diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000000..99849d03f4 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,86 @@ +# E2E test CI pipeline +# +# Architecture guide §13.1 - CI pipeline skeleton +# job-e2e (on main/PR labeled e2e): +# ├── pnpm e2e:test:l0:all +# ├── pnpm e2e:test:l1 +# └── (reserved) cargo test --test e2e +# +# Architecture guide §12.1 - Layer 3: end-to-end tests +# - Tauri desktop shell E2E (WebDriver) - WebDriverIO +# - Web frontend E2E (Playwright) - reserved + +name: E2E Tests + +on: + push: + branches: [main] + pull_request: + # Run only when the PR is labeled e2e (per architecture guide §13.1) + types: [opened, synchronize, labeled] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: true + +env: + CI: true + BITFUN_E2E_APP_MODE: debug + +jobs: + # -- Tauri desktop shell E2E (WebDriverIO) -- + desktop-e2e: + # Run only on main branch or PRs with the e2e label + if: > + github.ref == 'refs/heads/main' || + (github.event_name == 'pull_request' && + contains(github.event.pull_request.labels.*.name, 'e2e')) + + timeout-minutes: 30 + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: 22.12 + cache: 'pnpm' + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Install E2E dependencies + run: pnpm run e2e:install + + - name: Build Tauri desktop app (debug) + run: cargo build -p bitfun-desktop + + - name: Run L0 smoke tests + run: pnpm run e2e:test:l0:all + continue-on-error: false + + - name: Run L1 functional tests (core) + run: pnpm run e2e:test:l1 + continue-on-error: true + + - name: Upload test reports + if: failure() + uses: actions/upload-artifact@v4 + with: + name: e2e-report-${{ matrix.os }} + path: tests/e2e/reports/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index 95806da18c..65ee10ab27 100644 --- a/.gitignore +++ b/.gitignore @@ -23,7 +23,11 @@ dist-ssr # Build outputs - Rust/Tauri target/ **/target/ +.target/ /.targets/ +# Local evidence working dir (tracked .md files stay; ignore transient outputs) +target2/* +!target2/*.md # The deployable Rust services use the workspace lockfile for reproducible # container builds. !Cargo.lock @@ -91,5 +95,7 @@ external/ /.bitfun/search/flashgrep-index/ .agents/ /.flashgrep-index-engine/ +/src/apps/desktop/.bitfun/search/flashgrep-index/ +/target/debug/.bitfun/search/flashgrep-index/ .design/ diff --git a/CUSTOMIZATIONS.md b/CUSTOMIZATIONS.md new file mode 100644 index 0000000000..c7e7f80375 --- /dev/null +++ b/CUSTOMIZATIONS.md @@ -0,0 +1,663 @@ +# Customization of BitFun for Multi-Agent Collaboration and External Agent Integration + +**A Technical Report in the Form of a Research Paper** + +> Base: upstream `main` @ `e640aa40`. +> Scope: 370 files changed (code, configuration, and runtime resources). +> Audience: maintainers and reviewers. Formal academic tone; every term is +> defined at first use; every design decision carries its motivation. + +--- + +## Abstract + +This report presents a set of customizations to BitFun, an open-source +agent-integration platform, that extends it for **multi-agent collaboration** +and **external agent interconnection**. The work makes four contributions. +First, it introduces a complete **ACP (Agent Client Protocol) channel** that +treats external agent processes (e.g. CodeBuddy, Claude Code, OpenCode) as +first-class sessions inside BitFun, with session-level direct delivery, a +lifecycle bridge, and persisted transcripts. Second, it adds a **governance +layer** — a Warden guard system and a role-based access control (RBAC) model +for subagents — that constrains what an agent may do and detects repeated +failure patterns. Third, it hardens the **engine-level context management**: +a stable prompt-cache prefix, per-round runtime facts, and once-per-generation +user context injection, which measurably reduce token waste. Fourth, it +contributes a **workflow architecture** for organizing multi-agent work +(a six-phase pipeline, a three-branch separation of powers, and a recursive +dispatch pattern) that is described in the Methodology section. All claims in +this report are grounded in the final-state source tree; line numbers refer +to the files in this pull request. + +--- + +## Table of Contents + +1. [Introduction](#1-introduction) +2. [Related Work](#2-related-work) +3. [Design: A Three-Branch Separation-of-Powers Coordination Model](#3-design-a-three-branch-separation-of-powers-coordination-model) + - 3.1 Problem statement · 3.2 Design goals · 3.3 Six-phase pipeline · 3.4 Separation of powers · 3.5 Recursive dispatch pattern · 3.6 Serial / parallel discipline · 3.7 Quality gate system · 3.8 Atomic step specification · 3.9 Both ends fixed, middle free · 3.10 Decision derivation +4. [Implementation 1: ACP Channel](#4-implementation-1-acp-channel) +5. [Implementation 2: Session and SessionControl](#5-implementation-2-session-and-sessioncontrol) +6. [Implementation 3: Warden Guard System](#6-implementation-3-warden-guard-system) +7. [Implementation 4: RBAC for Subagents](#7-implementation-4-rbac-for-subagents) +8. [Implementation 5: Engine and Context Injection](#8-implementation-5-engine-and-context-injection) +9. [Implementation 6: Orchestration Toolchain (Legion, Task, Plan, Goal)](#9-implementation-6-orchestration-toolchain) +10. [Implementation 7: CodeBuddy Provider Adapter](#10-implementation-7-codebuddy-provider-adapter) +11. [Implementation 8: Web UI](#11-implementation-8-web-ui) +12. [Evaluation](#12-evaluation) +13. [Conclusion and Future Work](#13-conclusion-and-future-work) +14. [References](#14-references) + +--- + +## 1. Introduction + +### 1.1 Background + +BitFun is an agent-integration platform: it hosts agent sessions, exposes +tools to models, and coordinates the execution of agent loops. In its +upstream form, the platform provides the machinery for a single agent loop, +and an ACP client layer for probing external agent CLIs. + +### 1.2 Problem + +Deploying BitFun in a **multi-agent setting** — where one coordinating agent +delegates work to several subagents, and where some of those subagents are +*external* processes reached over ACP — exposes four gaps: + +1. **No first-class external sessions.** The upstream ACP layer can *probe* + external agents, but a model cannot create an external agent session, + message it directly, read its history, or cancel it as a normal BitFun + session. +2. **No governance.** A delegated subagent can accidentally call a tool that + mutates state when it was only asked to research; repeated failures can + cascade without detection. +3. **Token waste and cache instability.** Dynamic reminder text (clock, + context usage) injected at a variable position invalidates the + provider-side prompt cache, and redundant reminders are repeated on every + turn. +4. **No methodological framework for multi-agent work itself.** When many + agents collaborate on one codebase, preparation, execution, and + verification blur together, and defects leak through. + +### 1.3 Contributions + +This work addresses the four gaps with four contributions: + +- **C1 — ACP channel** (§4): external ACP agents become first-class sessions. +- **C2 — Governance** (§6, §7): Warden guard system + RBAC subagent roles. +- **C3 — Context engine** (§8): stable prefix, per-round facts, + once-per-generation user context. +- **C4 — Workflow architecture** (§3): a three-branch separation-of-powers + coordination model, described as the design methodology. + +--- + +## 2. Related Work + +### 2.1 Agent protocols + +**ACP (Agent Client Protocol)** is a transport used to bridge a host agent +with an external agent CLI. Upstream BitFun implements ACP *client* +infrastructure: probing, launching, and low-level message transport. This +work builds on that foundation by exposing it through the **tool layer** so a +model can drive external sessions semantically. + +### 2.2 Multi-agent orchestration + +Existing orchestration systems typically centralize control (a single +dispatcher) or decentralize it (free-form peer messaging). Both extremes have +known failure modes: centralized systems become single points of failure and +bottlenecks; fully decentralized systems lose auditability. The coordination +model in §3 takes a middle path — **separation of powers with a bounded +rejection loop** — which is inspired by classical engineering review +practices (independent verification and validation, IV&V) and by the +peer-review process in scientific publication. + +### 2.3 Role-based access control + +RBAC (Sandhu et al., 1996) is a standard authorization model: subjects are +assigned roles, roles have permissions. The customization in §7 applies RBAC +at the *agent-session* granularity: each session is assigned a role whose +permission set is enforced by the tool pipeline. + +### 2.4 Prompt caching + +LLM providers (e.g. DeepSeek, Anthropic) offer prefix-based prompt caching: +a request whose prefix matches a cached prefix is served at a fraction of the +cost. The dominant strategy (Anthropic, 2024) is to keep dynamic content at +the *end* of the message list. §8 implements this strategy structurally. + +--- + +## 3. Design: A Three-Branch Separation-of-Powers Coordination Model + +> This section is the design methodology. It describes how multi-agent work +> is organized on top of the platform, so that the platform's own features +> (sessions, tasks, quality gates) are used within a coherent workflow. + +### 3.1 Problem statement + +Multi-agent software projects face a recurring structural problem: when many +agents work on one codebase, the three activities of **decision making**, +**execution**, and **verification** tend to be performed by the same agent, +at the same time, on the same artifact. This conflation yields three known +failure modes: (i) *unchecked decisions* — a single agent both decides and +executes, with no independent review; (ii) *self-review bias* — the agent +that wrote code also validates it; and (iii) *rework cascades* — defects are +discovered late, after dependent work has been built on top of them. + +### 3.2 Design goals + +The model is designed to satisfy five goals: + +| Goal | Description | +|---|---| +| G1 | **Separation of powers** — decision, execution, and review are held by distinct roles. | +| G2 | **Auditability** — every phase produces an artifact that is the input of the next; every claim carries evidence. | +| G3 | **Determinism** — preparation is unbounded; execution is one-shot. | +| G4 | **Scalability** — the same pattern applies recursively at every decomposition level. | +| G5 | **Efficiency** — parallelizable work runs in parallel; dependent work runs serially through gates. | + +### 3.3 The six-phase pipeline + +Every task passes through six phases in order; the output of each phase is +the input of the next, and no phase is skipped. + +| Phase | Name | Input | Output | Quality concern | +|---|---|---|---|---| +| 0 | Requirements | raw request | clarified requirement document | ambiguity | +| 1 | Reconnaissance | requirement document | reconnaissance report | hallucination | +| 2 | Planning | reconnaissance report | plan / type contract / dispatch prompts | incompleteness | +| 3 | Execution | plan documents | code | deviation | +| 4 | Quality gate | code | pass / reject | defect leakage | +| 5 | Delivery | accepted code | delivered result | completeness | + +The pipeline is *not* a checklist game: each phase must be genuinely +completed (reconnaissance really performed, plans really thought through, +verification really executed). Skipping a phase invalidates the pipeline and +the task returns to Phase 0. + +### 3.4 Three-branch separation of powers + +Three peer roles hold three powers; none reports to another; each is barred +from encroaching on the others: + +| Power | Role | Responsibility | Barred from | +|---|---|---|---| +| Decision | Coordinator | requirements, planning, dispatch, direction rulings | performing execution | +| Execution | Executor | receiving atomic steps, executing them exactly | making decisions | +| Review | Reviewer | quality gates: review, test, acceptance | self-review | + +The checks are mutual: the coordinator may not edit code; the executor's only +exit at a decision point is to report back; the reviewer is independent of +the executor. At serial nodes all three gates must pass before the next wave. + +### 3.5 Recursive dispatch pattern + +The organization uses **one pattern, recursively**: *Dispatch → Execute → +Accept*. A coordinator dispatches to an executor; the executor returns; a +reviewer accepts or rejects. Rejection returns the artifact to the executor +for a bounded number of repair rounds (at most three), after which the case +escalates to the coordinator. + +The pattern is applied at every level: a top-level coordinator delegates to +team leads, each team lead applies the same pattern within the team, and +agents apply it internally (self-check as the reviewer). This yields a +*fractal* organization rather than a strict hierarchy. Context isolation is a +first-class property: each role loads only the context relevant to its own +duty, so that the contexts of the three branches do not pollute one another. + +### 3.6 Serial / parallel discipline + +Parallelism is decided by dependency, not by preference: + +- **Independent** work runs **in parallel** (reconnaissance, independent + modules, draft planning). +- **Dependent** work runs **serially** through the gates (recon → plan → + execute → verify → deliver). + +Parallel branches converge at a serial node: one designated executor runs the +full gate suite and makes the commit. During parallel execution each track +runs only its own scoped tests; the full regression suite runs only at the +convergence node. + +### 3.7 Quality gate system + +At serial nodes, the artifact must pass **three gates**, run in parallel and +all mandatory; a single gate failing returns the artifact for repair, after +which all three gates are re-run: + +| Gate | Role | Criterion | +|---|---|---| +| Review | Reviewer | Logic, architecture, and compliance; full-chain; evidence with file:line | +| Test | Executor | Compile clean, tests green, linter zero warnings | +| Acceptance | Reviewer / Acceptor | Feature-by-feature comparison against the original requirements; check for empty stubs | + +Two additional laws govern the gates: + +- **Gate blind-spot law.** A green gate does not imply the delivery is + wireable — the gates must cover the real integration path (smoke tests), + not only the isolated modules. +- **Review is reviewed.** Language and framework semantics asserted by a + review must themselves be verified empirically. + +Repeated gate failure indicates that the root cause lies in preparation: the +task returns to Phase 0 rather than patching in place. + +### 3.8 Atomic step specification + +Every dispatched step is specified by **five elements**: + +1. **Input location** — where the inputs are. +2. **Action instruction** — what to do, in imperative terms. +3. **Expected output** — what the step should produce. +4. **Acceptance assertion** — how to verify correctness. +5. **Failure fallback** — what to do on deviation. + +The completeness criterion is *determinism across executors*: any person — +even one without background — following the step must obtain the same result. +Executors may be lazy, misjudge, or misunderstand; the instruction is +designed so that being wrong is difficult. + +### 3.9 Determinism: both ends fixed, middle free + +The requirement (start) and the delivery (end) are fixed before execution +begins; the path between them is free. Deviations in the middle are permitted, +but the deviation-handling path (which phase to roll back to, which assertion +to fix) is predefined during preparation, and the acceptance assertions at +the end are written before implementation. Deviation is a path fluctuation; +it never changes the delivery. Preparation rounds are unbounded; execution is +one-shot. + +### 3.10 Decision derivation + +During execution, all decisions are derived from three sources, in order: +**(1) requirements** (original requirement id / authoritative source), +**(2) purpose** (the final delivery definition), and **(3) iron rules** +(the framework's own quality criteria). Users participate only at the +requirements stage and the delivery-result stage. Autonomous decisions must +not expand into resource commitments the user never requested. + +--- + +## 4. Implementation 1: ACP Channel + +### 4.1 Design + +The ACP channel makes an external agent process behave like a real BitFun +session. The tool layer exposes three tools; the desktop layer owns the ACP +client service and the lifecycle bridge. + +### 4.2 Tools + +| Tool | Action | Definition | +|---|---|---| +| `AcpControlTool` | create / list / delete / cancel external ACP sessions | `acp_tools.rs:344` | +| `AcpMessageTool` | send a message to an external ACP session | `acp_tools.rs:540` | +| `AcpHistoryTool` | read the persisted transcript of an ACP session | `acp_tools.rs:673` | + +The tools are registered through the standard tool registry, so RBAC and the +tool pipeline apply to them uniformly. + +### 4.3 Direct delivery + +When the model sends a message to a session whose id starts with `acp__`, the +message is forwarded **directly** to the external process through the ACP +port, with no local model round-trip and therefore no local inference cost. +The timeout is 1800 s (`ACP_DIRECT_TIMEOUT_SECONDS`, `session_message_tool.rs:83`). +The external reply is streamed back as `TextChunk` events; the conversation +stores a *notification* (`acp_direct_response_notice`, `session_message_tool.rs:1061`) +instead of the full text, and the full reply is retrievable via +`SessionHistory`. + +*Motivation.* Injecting the full external reply into the conversation would +consume context budget and duplicate content already stored on disk. The +notification-plus-retrievable-store design keeps the context lean while +preserving full fidelity. + +### 4.4 Lifecycle bridge + +`AcpSessionLifecycleSubscriber` +(`src/apps/desktop/src/runtime/acp_session_lifecycle.rs`) mirrors BitFun +session events onto the external process: + +| BitFun event | Action | +|---|---| +| `SessionCreated` (`acp__*`) | start / attach the external client | +| `SessionDeleted` | release the external process | +| `DialogTurnCancelled` | cancel the in-flight external turn | + +An **orphan scan** at startup reclaims external sessions left over from a +previous run. + +### 4.5 Persistence and idempotency + +Direct-delivery turns are persisted as standard `DialogTurnData` files so that +`SessionHistory` can render the full external reply after a restart. +Idempotency is enforced by scanning all existing turn indexes for the same +`turn_id` before writing, and appending at the first free index +(`persist_acp_direct_delivery_turn`, `session_message_tool.rs:1177`). This +prevents duplicate writes when the metadata turn counter and the on-disk +turns have diverged. + +--- + +## 5. Implementation 2: Session and SessionControl + +### 5.1 Compact action + +`SessionControl` gained a `compact` action +(`session_control_tool.rs:132`) that lets the model trigger context +compression on any subagent session that is **Idle**. It reuses the same +`AgentSessionCompactionPort` as automatic compression, so manual and automatic +compression share one code path. + +*Motivation.* Previously compression could only be triggered externally +(desktop / app-server / CLI); a model delegating work could not ask a busy +subagent to compact its own context. + +### 5.2 Listing and naming + +`list` supports a compact one-line-per-session output +(`sessionId | agentType | status | short name`); sessions can carry a +**short name**; `model_id` is supported at session creation. + +### 5.3 Ghost-session fixes + +Two recurring defects in multi-agent deployments are addressed: + +1. **Ghost deletion.** An ACP flow session whose metadata has no `created_by` + could not be deleted (authorization failed). `ghost_acp_delete_authorized` + now permits deletion for ACP flow sessions without a creator, falling back + to owner/ancestor semantics otherwise. +2. **Ghost delivery.** A hidden subagent session (e.g. Idle > 1 h, unloaded + from memory) rejected message delivery with "Session not found". Lookup + now uses hidden-inclusive semantics and restores internal sessions for + delivery. + +### 5.4 Tombstone registry + +Deleted session ids are recorded in a tombstone registry +(`deleted-session-ids.json`, max 2000 entries). Before finalizing a turn, the +coordinator consults the registry so a deleted session cannot be +"resurrected" with stale metadata. + +--- + +## 6. Implementation 3: Warden Guard System + +### 6.1 Design + +The Warden is a governance subsystem that observes the agent loop and reacts +to **repeated failures**. It is a new subsystem with three components: a poke +scheduler, a punishment executor, and an LLM-backed judgement port. + +### 6.2 Components + +| Component | File | Purpose | +|---|---|---| +| `WardenPokeScheduler` | `warden/mod.rs:63` | Randomized scheduling of "pokes" (average interval configurable) | +| `WardenRuntime` | `warden/runtime.rs` | Orchestrates poke decisions and violation recording | +| `PunishmentExecutor` | `warden/punishment_executor.rs` | Applies consequences for confirmed violations | +| `WardenModelJudgementPort` | `src/apps/desktop/src/runtime/warden_model_judgement_port.rs` | Asks a model whether a failure is a real violation (reduces false positives) | +| `SKILL.md` | `warden/SKILL.md` | Behaviour contract for the agent | + +### 6.3 Behaviour + +- **Scene fingerprinting.** Failures are classified by *scene* so that a + "streak" means repeated failures of the same kind; the first failure of a + new kind does not count toward the streak. +- **Goal linkage.** A switch lets the Warden follow goal/reference files when + deciding whether a poke is warranted. +- **Delivery.** Pokes are delivered as user-role `internal_reminder` messages: + visible to the model, yet not part of the user's own message history. + +*Motivation.* A naive "N failures in a row → punish" rule misfires when +failures are heterogeneous. Scene fingerprinting makes the guard precise, +and the LLM judgement port adds a second opinion to avoid punishing a +legitimate attempt. + +--- + +## 7. Implementation 4: RBAC for Subagents + +### 7.1 Design + +RBAC here means: every session has a **role**; each role has a set of allowed +tool *names* and allowed *operation classes* (read-only, write-file, +execute-code, communicate). The enforcement point is the tool pipeline. + +### 7.2 Roles + +| Role | Allowed operation classes | +|---|---| +| Commander | ReadOnly, Communicate | +| Executor | ReadOnly, WriteFile, ExecuteCode | +| Reviewer | ReadOnly, WriteFile, ExecuteCode | +| Warden | ReadOnly, WriteFile, Communicate, ExecuteCode | +| GeneralPurpose | ReadOnly, WriteFile, ExecuteCode, Communicate | + +Key files: `src/crates/assembly/core/src/agentic/tools/restrictions.rs` +(e.g. `general_purpose_tool_restrictions` at `:195`), +`src/crates/assembly/core/tests/rbac_master_switch.rs`. + +### 7.3 Role pinning + +A session created with a `subagent` marker has its role **pinned** to an +appropriate template instead of inheriting the creator's role. Previously a +subagent created from a Commander session could inherit Commander — which +forbids most tools — making the subagent unusable. Pinning is also restored +when a session is reloaded from disk. + +*Motivation.* Inheritance is convenient but unsafe: a research subagent must +never inherit the mutating privileges of its creator. Pinning trades +flexibility for safety at the cost of an explicit template. + +--- + +## 8. Implementation 5: Engine and Context Injection + +### 8.1 Problem + +Sending a message to a model costs tokens, and the provider-side prompt cache +is prefix-based: any per-round change in the *middle* of the message list +invalidates the entire prefix. Dynamic reminder text (clock, context usage) +is inherently per-round. + +### 8.2 Static vs dynamic groups + +- **Static group** (skills list, agent list, deferred tool list, user + context): placed immediately after the system message — the prefix-cache + foundation; position invariant. +- **Dynamic group** (runtime facts): placed at the very end of the message + list, because it changes every round. + +Key function: `build_ai_messages_for_send` (`execution_engine.rs:1795`). + +### 8.3 Runtime facts refresh policy + +`refresh_runtime_facts_for_round` (`execution_engine.rs:1511`) takes an +`inject_runtime_facts` flag: injected on the first user round +(`round_index == 0`) and after a context-recovery round; **not** injected on +tool rounds. This keeps the model informed without repeating identical facts. + +### 8.4 User context once per generation + +`round_dynamic_reminders` (`execution_engine.rs:1541`) tracks a "generation" +counter of the prompt cache. User context is injected **once per cache +generation** — after a new conversation or a compression — and omitted on +subsequent rounds of the same generation. The generation is cleared on +session deletion. + +*Motivation.* The user-context block is large and mostly static; repeating it +every round costs tokens and destabilizes the prefix. Injecting it once per +generation preserves its information while bounding its cost. + +### 8.5 Context usage display + +Flow-chat persists the exact last-request token usage and restores it after +session hydration, so the UI shows correct usage numbers after a reload +(`src/web-ui/src/flow_chat/utils/tokenUsageDisplay.ts`). + +--- + +## 9. Implementation 6: Orchestration Toolchain + +### 9.1 Legion (agent topology) + +`LegionControlTool` deploys a "legion" topology: a set of named agent roles +with a maximum of 20 nodes, persisted as agent sessions. + +### 9.2 Task dual lifecycle + +`Task` supports two lifecycles: + +- **Foreground**: run a subagent and wait for its result. +- **Background**: spawn a subagent that keeps running after the tool returns; + the result is delivered later through the coordination layer and + retrievable via `SessionHistory` (`run_in_background` selects the mode; + `.../tools/implementations/task/execution.rs`). + +### 9.3 Plan tool family + +`CreatePlan`, `PlanList`, `PlanRead`, `PlanUpdate` are registered as a tool +family (`plan_list_tool.rs`, `plan_read_tool.rs`, `plan_update_tool.rs`) and +go through the standard tool pipeline, so RBAC and the readonly manifest +apply. + +### 9.4 Goal dual trigger + +The `goal` feature triggers when **either** of two conditions holds: the main +conversation has been silent for 10 minutes, **or** all conversations in the +workspace are silent (`.../goal_mode/mod.rs`). + +--- + +## 10. Implementation 7: CodeBuddy Provider Adapter + +### 10.1 Design + +CodeBuddy (Tencent's coding agent) exposes an OpenAI-compatible cloud API at +`https://copilot.tencent.com/v2/chat/completions`. Because the endpoint is +OpenAI-shaped, BitFun can reuse its existing OpenAI transport; the adapter +adds a small conversion layer. + +### 10.2 Components + +| Piece | File | +|---|---| +| Provider enum value `CodeBuddy` | `src/crates/adapters/ai-adapters/src/client/format.rs` | +| Message converter | `.../providers/codebuddy/message_converter.rs` | +| Request builder | `.../providers/codebuddy/request.rs` | +| Streaming handler (SSE) | `.../stream/stream_handler/codebuddy.rs` | +| Provider catalog entry | `src/shared/ai-provider-catalog/providers.json` | + +### 10.3 Empty `finish_reason` protection + +The CodeBuddy stream emits an **empty string** as `finish_reason` on some +frames. Old code treated any `finish_reason` as "turn done", aborting tool +calls early. The fix treats only non-empty `finish_reason` as a completion +signal (`src/crates/execution/agent-stream/src/lib.rs`, +`src/crates/adapters/ai-adapters/src/client/response_aggregator.rs`), guarded +by contract tests. + +### 10.4 UI + +The model settings UI gained a searchable provider picker and a global +default-model selection (`src/web-ui/src/infrastructure/config/...`). + +--- + +## 11. Implementation 8: Web UI + +### 11.1 Flow-chat + +- Turn completion notices and footer layout (`turnCompletionNotice.ts`, + `FlowChatStore.ts`). +- Context usage display persisted across hydration (§8.5). +- Subagent projection view (`SubagentProjectionView.tsx`). +- `handleTextChunk` creates an ACP session placeholder when a text chunk + arrives before session registration, so early stream output is not + silently dropped (`flow-chat-manager/EventHandlerModule.ts`). + +### 11.2 Legion pages + +`CreateLegionPage`, `LegionCard`, `BeeColonyMonitor`, and `AgentsScene` +provide a visual view of agent topology +(`src/web-ui/src/app/scenes/agents/`, `src/web-ui/src/app/layout/`); +`LegionPresetAPI` talks to the backend preset registry. + +### 11.3 Model switching + +Searchable provider/model list and global default-model selection +(`AIModelConfig.tsx`, `builtinProviderCatalog.ts`). + +--- + +## 12. Evaluation + +### 12.1 Test evidence + +The changes are accompanied by contract and integration tests that pin the +behaviour described above: + +| Behaviour | Test | Location | +|---|---|---| +| RBAC pinning + ReadOnly for GeneralPurpose | `general_purpose_subagent_role_is_executor_and_readonly_allowed` | `rbac_master_switch.rs:239` | +| Runtime facts cleared on tool rounds | `tool_round_clears_runtime_facts_after_user_round_injection` | `execution_engine.rs:6045` | +| User context injected once per cache generation | `round_dynamic_reminders_injects_user_context_once_per_cache_generation` | `execution_engine.rs:6088` | +| ReadOnly classification for workspace scans | `classify_tool_call_workspace_scan_is_readonly` | `framework.rs:3413` | + +### 12.2 ACP channel guarantees + +The ACP channel is covered by unit tests for client-id parsing and for the +notification format (the notification must exclude the full reply, include +the session id, and point to `SessionHistory`). + +### 12.3 Limitations + +The following are known limitations of the snapshot: + +- The Warden judgement port requires a model endpoint at runtime; its + behaviour without a configured model is conservative (no pokes). +- The CodeBuddy adapter depends on the cloud endpoint's API shape, which may + evolve independently. +- The workflow model in §3 is a *methodology* — it is realized through the + platform's session/task/tool machinery, but it is not itself a separate + runtime component. + +--- + +## 13. Conclusion and Future Work + +This report presented a set of customizations to BitFun for multi-agent +collaboration and external agent interconnection. The ACP channel (C1) makes +external agents first-class sessions with direct delivery, lifecycle +mirroring, and persisted, idempotent transcripts. The governance layer (C2) +adds a Warden guard system and RBAC subagent roles. The context engine (C3) +stabilizes the prompt-cache prefix and bounds dynamic injection. The workflow +architecture (C4) provides a separation-of-powers coordination model. + +Future work includes: making the Warden judgement port optional-configurable +per workspace; extending the CodeBuddy adapter to additional endpoints as +they become OpenAI-compatible; and formalizing the coordination model (§3) +as an explicit runtime policy (e.g. a declarative workflow configuration +consumed by the scheduler). + +--- + +## 14. References + +1. **ACP — Agent Client Protocol.** https://github.com/agent-client-protocol/agent-client-protocol +2. R. Sandhu, E. Coyne, H. Feinstein, C. Youman. *Role-Based Access Control + Models.* IEEE Computer, 29(2), 1996. +3. Anthropic. *Prompt Caching.* Anthropic Documentation, 2024. +4. BitFun. https://github.com/GCWing/BitFun + +--- + +*All line numbers refer to the files in this pull request (base +`e640aa40`). This document contains only generic engineering descriptions +and no proprietary information.* diff --git a/Cargo.lock b/Cargo.lock index 873fa7956f..8b076441c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -778,6 +778,7 @@ dependencies = [ "tokio", "tokio-util", "uuid", + "which 8.0.5", ] [[package]] @@ -796,6 +797,7 @@ dependencies = [ "bitfun-harness", "bitfun-runtime-ports", "bitfun-runtime-services", + "bitfun-services-core", "dashmap", "hex", "log", @@ -1088,6 +1090,7 @@ dependencies = [ "log", "md5", "notify", + "rand 0.8.7", "regex", "reqwest", "rusqlite", @@ -1510,9 +1513,11 @@ dependencies = [ "bitfun-events", "bitfun-runtime-ports", "chrono", + "dashmap", "dunce", "filetime", "fs2", + "futures", "git2", "globset", "ignore", @@ -2152,9 +2157,9 @@ dependencies = [ [[package]] name = "clang-sys" -version = "1.8.1" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" dependencies = [ "glob", "libc", diff --git a/Cargo.toml b/Cargo.toml index 6d57caa7e8..1447cc2763 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,6 +57,7 @@ resolver = "2" version = "0.2.16" # x-release-please-version authors = ["BitFun Team"] edition = "2021" +license = "MIT" [workspace.lints.rust] unsafe_op_in_unsafe_fn = "warn" diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000000..47941bac0b --- /dev/null +++ b/deny.toml @@ -0,0 +1,83 @@ +# ============================================================================= +# cargo-deny configuration +# License compliance + dependency review + vulnerability gate +# ============================================================================= +# Reference: https://embarkstudios.github.io/cargo-deny/ + +[advisories] +# Ignore the following advisories (each needs an explicit reason) +# ignore = [ +# # Example: { id = "RUSTSEC-2024-0001", reason = "explain why this is ignored" }, +# ] +vulnerability = "deny" +unmaintained = "warn" +notice = "warn" +severity-threshold = "high" +# Ignore yanked crate warnings (decide per-case when upstream is unmaintained) +ignore-yanked = false + +[bans] +# Ban specific crates +multiple-versions = "deny" # multiple versions of the same crate are not allowed +wildcard-predicates = "deny" # "*" version requirements are not allowed +deny = [] +# skip list - allow multiple versions for some crates (usually unavoidable via transitive deps) +skip = [] +# skip-tree - allow multiple versions for an entire subtree rooted at a crate +skip-tree = [ + # tokio-util multiple versions are common via transitive dependencies + { name = "tokio-util", version = "0.6" }, + { name = "tokio-util", version = "0.7" }, + # Some crates depend on different versions of the object-storage SDK + { name = "aws-sdk-s3", version = "0.39" }, + { name = "aws-sdk-s3", version = "1.0" }, +] + +[licenses] +# Allowed licenses +allow = [ + "MIT", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "Unicode-3.0", + "Unlicense", + "CC0-1.0", + "Zlib", + "MPL-2.0", +] +# Licenses requiring explicit approval +deny = [ + "AGPL-3.0", + "GPL-3.0", + "GPL-2.0", + "LGPL-3.0", + "CC-BY-4.0", # some CC licenses may restrict commercial use +] +# Licenses requiring manual confirmation +copyleft = "deny" +allow-osi-fsf-free = "both" +confidence-threshold = 0.8 +default = "deny" +# Exceptions - crates with explicit license approval +exceptions = [ + # Allow specific licenses for specific crates + { allow = ["MPL-2.0"], name = "ring" }, + { allow = ["MPL-2.0"], name = "webpki" }, + { allow = ["MPL-2.0"], name = "untrusted" }, + { allow = ["ISC"], name = "ipnetwork" }, +] + +[sources] +# Allowed crate sources +allow-git-registry = true +allow-registry = true +# Unknown sources (e.g. git deps not published to crates.io) need explicit approval +unknown-registry = "deny" +unknown-git = "deny" +# Git dependency allowlist +allow-git = [ + # List git dependencies not published to crates.io here +] diff --git a/examples/example-pipeline.yaml b/examples/example-pipeline.yaml new file mode 100644 index 0000000000..ae99276e3b --- /dev/null +++ b/examples/example-pipeline.yaml @@ -0,0 +1,19 @@ +name: "example-ma-cross" +version: "1.0" +bar_gen: + modes: + - "time" + time_freqs: + - "1m" +data_source: + type: "csv_replay" + config: + csv_path: "test_data/golden_tick/20260721/a2609/a2609_golden_20260721.csv" +nodes: + - id: "ma_cross" + type: "ma_cross" + config: + fast_period: 5 + slow_period: 20 + input_keys: [] + output_keys: ["out1"] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 769d91a40a..8d4bf8f1e0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -960,89 +960,105 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -1333,36 +1349,42 @@ packages: engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [glibc] '@parcel/watcher-linux-arm-musl@2.5.6': resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [musl] '@parcel/watcher-linux-arm64-glibc@2.5.6': resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] '@parcel/watcher-linux-arm64-musl@2.5.6': resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [musl] '@parcel/watcher-linux-x64-glibc@2.5.6': resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [glibc] '@parcel/watcher-linux-x64-musl@2.5.6': resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [musl] '@parcel/watcher-win32-arm64@2.5.6': resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==} @@ -1459,66 +1481,79 @@ packages: resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.57.1': resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.57.1': resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.57.1': resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.57.1': resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.57.1': resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.57.1': resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.57.1': resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.57.1': resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.57.1': resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.57.1': resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.57.1': resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.57.1': resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.57.1': resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==} @@ -1598,30 +1633,35 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tauri-apps/cli-linux-arm64-musl@2.10.0': resolution: {integrity: sha512-GUoPdVJmrJRIXFfW3Rkt+eGK9ygOdyISACZfC/bCSfOnGt8kNdQIQr5WRH9QUaTVFIwxMlQyV3m+yXYP+xhSVA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tauri-apps/cli-linux-riscv64-gnu@2.10.0': resolution: {integrity: sha512-JO7s3TlSxshwsoKNCDkyvsx5gw2QAs/Y2GbR5UE2d5kkU138ATKoPOtxn8G1fFT1aDW4LH0rYAAfBpGkDyJJnw==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] + libc: [glibc] '@tauri-apps/cli-linux-x64-gnu@2.10.0': resolution: {integrity: sha512-Uvh4SUUp4A6DVRSMWjelww0GnZI3PlVy7VS+DRF5napKuIehVjGl9XD0uKoCoxwAQBLctvipyEK+pDXpJeoHng==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tauri-apps/cli-linux-x64-musl@2.10.0': resolution: {integrity: sha512-AP0KRK6bJuTpQ8kMNWvhIpKUkQJfcPFeba7QshOQZjJ8wOS6emwTN4K5g/d3AbCMo0RRdnZWwu67MlmtJyxC1Q==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tauri-apps/cli-win32-arm64-msvc@2.10.0': resolution: {integrity: sha512-97DXVU3dJystrq7W41IX+82JEorLNY+3+ECYxvXWqkq7DBN6FsA08x/EFGE8N/b0LTOui9X2dvpGGoeZKKV08g==} @@ -3453,12 +3493,12 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + deprecated: Glob versions prior to v9 are no longer supported glob@8.1.0: resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} engines: {node: '>=12'} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + deprecated: Glob versions prior to v9 are no longer supported globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} @@ -5290,7 +5330,6 @@ packages: uuid@10.0.0: resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} - deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true uuid@11.1.0: diff --git a/scripts/cargo-target-gc.mjs b/scripts/cargo-target-gc.mjs index 8494cf597b..e39e4813a3 100644 --- a/scripts/cargo-target-gc.mjs +++ b/scripts/cargo-target-gc.mjs @@ -314,12 +314,20 @@ function sleepMs(ms) { export function isCompilerBusy({ exec = execFileSync, platform = process.platform } = {}) { try { if (platform === 'win32') { - const out = exec( - 'cmd.exe', - ['/d', '/s', '/c', 'tasklist /FI "IMAGENAME eq cargo.exe" & tasklist /FI "IMAGENAME eq rustc.exe"'], + // Pass each /FI filter as a single argument. Routing the whole command + // through cmd.exe /c re-splits the quoted filter, so tasklist receives + // `eq` as a standalone option and fails with `无效参数/选项 - 'eq'`. + const cargo = exec( + 'tasklist', + ['/FI', 'IMAGENAME eq cargo.exe', '/NH'], { encoding: 'utf8' } ); - return /\bcargo\.exe\b/i.test(out) || /\brustc\.exe\b/i.test(out); + const rustc = exec( + 'tasklist', + ['/FI', 'IMAGENAME eq rustc.exe', '/NH'], + { encoding: 'utf8' } + ); + return /\bcargo\.exe\b/i.test(cargo) || /\brustc\.exe\b/i.test(rustc); } const cargo = exec('pgrep', ['-x', 'cargo'], { encoding: 'utf8' }).trim(); if (cargo) { diff --git a/src/apps/cli/src/account_sync.rs b/src/apps/cli/src/account_sync.rs index 01119db9ed..91616f96bf 100644 --- a/src/apps/cli/src/account_sync.rs +++ b/src/apps/cli/src/account_sync.rs @@ -378,11 +378,10 @@ pub(crate) async fn run_auto_sync( Ok((session_id, hash, Ok(Ok(version)))) => { uploaded.push((session_id.clone(), hash, version)); let done = uploaded.len(); - let percent = if upload_total == 0 { - 95u8 - } else { - 20 + ((75 * done) / upload_total) as u8 - }; + let percent = (75 * done) + .checked_div(upload_total) + .map(|part| 20 + part as u8) + .unwrap_or(95u8); emit_progress( "exporting_sessions", percent.min(95), @@ -467,7 +466,7 @@ pub(crate) fn sync_phase_label(progress: &SyncProgress) -> String { } "done" => format!("Sync complete (exported {})", progress.sessions_exported), "starting" => "Starting sync…".into(), - other if other.is_empty() => "Sync".into(), + "" => "Sync".into(), other => other.to_string(), } } diff --git a/src/apps/cli/src/acp_cli.rs b/src/apps/cli/src/acp_cli.rs index 2e0d19b405..ab5eacfa42 100644 --- a/src/apps/cli/src/acp_cli.rs +++ b/src/apps/cli/src/acp_cli.rs @@ -1,6 +1,7 @@ use anyhow::{anyhow, bail, Context, Result}; use bitfun_acp::client::{ AcpClientConfig, AcpClientInfo, AcpClientPermissionMode, AcpClientRequirementProbe, + TryConnectResult, }; use bitfun_acp::AcpClientService; use clap::ValueEnum; @@ -27,6 +28,7 @@ pub(crate) enum ExternalAcpClient { pub(crate) enum CliAcpPermissionMode { Ask, AllowOnce, + AllowAlways, RejectOnce, } @@ -67,6 +69,8 @@ impl ExternalAcpClient { enabled: true, readonly: false, permission_mode: AcpClientPermissionMode::Ask, + category: None, + description: None, } } } @@ -76,6 +80,7 @@ impl CliAcpPermissionMode { match self { Self::Ask => AcpClientPermissionMode::Ask, Self::AllowOnce => AcpClientPermissionMode::AllowOnce, + Self::AllowAlways => AcpClientPermissionMode::AllowAlways, Self::RejectOnce => AcpClientPermissionMode::RejectOnce, } } @@ -284,6 +289,9 @@ pub(crate) async fn doctor_external_clients() -> Result { has_runnable = true; } print_requirement_probe(&probe); + if probe.runnable { + print_client_connect_check(&service, &probe).await?; + } } println!(); @@ -349,7 +357,7 @@ pub(crate) async fn run_external_client( ) -> Result<()> { if matches!(permission, CliAcpPermissionMode::Ask) { bail!( - "`--permission ask` is not available for non-interactive `acp run`; use allow-once or reject-once." + "`--permission ask` is not available for non-interactive `acp run`; use allow-always, allow-once or reject-once." ); } @@ -549,6 +557,41 @@ fn print_requirement_probe(probe: &AcpClientRequirementProbe) { } } +/// Runs the ACP handshake for a runnable client and surfaces login guidance +/// when the client requires authentication. +async fn print_client_connect_check( + service: &Arc, + probe: &AcpClientRequirementProbe, +) -> Result<()> { + match service.try_connect_client(&probe.id).await { + Ok(TryConnectResult::Success) => { + println!(" connect: ok"); + } + Ok(TryConnectResult::FailAuth { error, login_hint }) => { + println!(" connect: auth required ({})", error); + match login_hint { + Some(hint) => println!(" hint: {}", hint), + None => println!( + " hint: no login command is known for this client; authenticate the CLI manually" + ), + } + } + Ok(TryConnectResult::FailCli { error }) => { + println!(" connect: CLI not found ({})", error); + } + Ok(TryConnectResult::FailAcp { error }) => { + println!(" connect: handshake failed ({})", error); + } + Err(error) if error.to_string().contains("not found") => { + // Client is not configured; requirement probe already covers it. + } + Err(error) => { + println!(" connect: check failed ({})", error); + } + } + Ok(()) +} + fn print_requirement_item(label: &str, item: &bitfun_acp::client::AcpRequirementProbeItem) { let installed = if item.installed { "installed" diff --git a/src/apps/cli/src/agent/runtime_client.rs b/src/apps/cli/src/agent/runtime_client.rs index 369677501a..fc5f4e8c13 100644 --- a/src/apps/cli/src/agent/runtime_client.rs +++ b/src/apps/cli/src/agent/runtime_client.rs @@ -87,6 +87,9 @@ pub(crate) enum SessionMigrationNotice { } impl SessionMigrationNotice { + /// Local CLI migration notice rendering, retained for the shared-runtime + /// path after the upstream app-server CLI refactor dropped its call sites. + #[allow(dead_code)] pub(crate) fn user_message(&self) -> String { let (setting, previous_id, restored_id) = match self { Self::Mode { @@ -131,6 +134,9 @@ fn session_migration_notices( #[derive(Debug)] pub(crate) struct SessionOperationError { message: String, + /// Whether the remote outcome was unknown after the operation returned. + /// Retained for the shared-runtime path after the upstream CLI refactor. + #[allow(dead_code)] outcome_unknown: bool, } @@ -142,6 +148,9 @@ impl fmt::Display for SessionOperationError { impl std::error::Error for SessionOperationError {} +/// Local error-shaping helpers retained for the shared-runtime path after the +/// upstream app-server CLI refactor dropped their call sites. +#[allow(dead_code)] impl SessionOperationError { fn runtime(error: RuntimeError) -> Self { let outcome_unknown = matches!( @@ -252,6 +261,7 @@ impl CliWorkspacePaths { self.remote = binding.remote_connection_id.is_some() || binding.remote_ssh_host.is_some(); } + #[allow(dead_code)] fn reset_execution_to_project(&mut self) -> PathBuf { let project = self.project(); self.execution = Some(project.clone()); @@ -262,6 +272,7 @@ impl CliWorkspacePaths { project } + #[allow(dead_code)] fn workspace_diff_unavailable_reason(&self) -> Option<&'static str> { if self.remote { return Some("Workspace diff is unavailable for remote Sessions"); @@ -277,6 +288,7 @@ impl CliWorkspacePaths { } } +#[allow(dead_code)] fn same_workspace_location(left: &Path, right: &Path) -> bool { left == right || dunce::canonicalize(left) @@ -296,17 +308,21 @@ pub(crate) struct ExecAgentRuntimeClient { /// Current turn ID (for cancellation) current_turn_id: Arc>>, shared_agent_events: Option>, + #[allow(dead_code)] shared_permission_events: Option>, shared_pending_permissions: Arc>>, } +#[allow(clippy::large_enum_variant)] // embedded runtime holds the full agent stack; boxing would churn every dispatch site enum CliAgentRuntimeBackend { Embedded(AgentRuntime), + #[allow(dead_code)] Shared(RuntimeIpcClient), } #[derive(Debug, Clone, PartialEq, Eq)] +#[allow(dead_code)] pub(crate) struct CliAgentMode { pub(crate) id: String, pub(crate) description: String, @@ -316,6 +332,10 @@ pub(crate) struct CliAgentMode { type SharedBroadcast = Arc>>>; +/// Local shared-runtime construction surface. The upstream app-server CLI +/// refactor dropped the call sites of `new_shared` and friends; they are +/// retained as the local shared-runtime capability surface. +#[allow(dead_code)] impl ExecAgentRuntimeClient { pub(crate) fn new(runtime: &CliRuntimeContext, workspace_path: Option) -> Self { Self { @@ -568,6 +588,7 @@ impl ExecAgentRuntimeClient { workspace_path: workspace_path.to_string_lossy().to_string(), remote_connection_id: None, remote_ssh_host: None, + include_hidden: false, }; match &self.backend { CliAgentRuntimeBackend::Embedded(runtime) => runtime @@ -1166,6 +1187,7 @@ impl ExecAgentRuntimeClient { workspace_path: project_workspace.to_string_lossy().to_string(), remote_connection_id: None, remote_ssh_host: None, + include_hidden: false, }) .await { @@ -1271,6 +1293,10 @@ impl ExecAgentRuntimeClient { } } +/// Local shared-runtime client methods. The upstream app-server CLI refactor +/// dropped the call sites of several of these; they are retained as the +/// local shared-runtime capability surface until the local CLI wires them in. +#[allow(dead_code)] impl ExecAgentRuntimeClient { pub(crate) async fn ensure_session(&self, agent_type: &str) -> Result { self.ensure_session_with_model(agent_type, None).await @@ -1357,7 +1383,7 @@ impl ExecAgentRuntimeClient { } if accepted_session == session_id && accepted_turn == turn_id => { Ok(accepted_turn) } - _ => return Err(unexpected_shared_result("compact_session")), + _ => Err(unexpected_shared_result("compact_session")), }, } } @@ -1543,6 +1569,7 @@ impl ExecAgentRuntimeClient { turn_id: turn_id.clone(), content, display_content, + prepended_reminders: Vec::new(), }; match &self.backend { @@ -1852,6 +1879,7 @@ fn shared_receiver( .ok_or_else(|| RuntimeError::Port(PortError::new(PortErrorKind::NotAvailable, message))) } +#[allow(dead_code)] fn spawn_shared_event_bridge( mut source: broadcast::Receiver, agent_sender: broadcast::Sender, @@ -1933,6 +1961,7 @@ fn spawn_shared_event_bridge( }); } +#[allow(dead_code)] fn shared_disconnect_message(reason: Option) -> String { if reason == Some(RuntimeIpcStreamInvalidationReason::FrameTooLarge) { format!( @@ -1943,6 +1972,7 @@ fn shared_disconnect_message(reason: Option) } } +#[allow(dead_code)] fn project_routed_permission_event( event: &mut bitfun_agent_runtime::sdk::PermissionRequestEvent, routed_session_id: &str, @@ -2441,6 +2471,9 @@ mod tests { turn_count: 1, created_at_ms: 1, last_active_at_ms: 2, + is_daemon: false, + parent_session_id: None, + status: None, } } diff --git a/src/apps/cli/src/agent/tui_client.rs b/src/apps/cli/src/agent/tui_client.rs index b42d1e9938..226a9d3689 100644 --- a/src/apps/cli/src/agent/tui_client.rs +++ b/src/apps/cli/src/agent/tui_client.rs @@ -355,6 +355,7 @@ impl TuiAgentClient { workspace_path: self.project_workspace_path_string(), remote_connection_id: None, remote_ssh_host: None, + include_hidden: false, })) .await? .sessions) @@ -659,6 +660,9 @@ impl TuiAgentClient { Ok(()) } + /// Local TUI turn-settlement waiter, retained for the shared-runtime path + /// after the upstream app-server CLI refactor dropped its call sites. + #[allow(dead_code)] pub(crate) async fn wait_for_turn_settlement( &self, session_id: &str, @@ -892,6 +896,7 @@ impl TuiAgentClient { turn_id, content, display_content, + prepended_reminders: Vec::new(), })) .await? .steering_id) diff --git a/src/apps/cli/src/bin/bitfun_cli_compat.rs b/src/apps/cli/src/bin/bitfun_cli_compat.rs index 0c24fb35a4..e9e014d5b5 100644 --- a/src/apps/cli/src/bin/bitfun_cli_compat.rs +++ b/src/apps/cli/src/bin/bitfun_cli_compat.rs @@ -33,6 +33,7 @@ unsafe extern "system" fn keep_wrapper_alive(ctrl_type: u32) -> windows::core::B fn hand_off(primary: &Path) -> i32 { use windows::Win32::System::Console::SetConsoleCtrlHandler; + // SAFETY: the handler is a static Rust fn; the pointer is valid for the process lifetime. if let Err(error) = unsafe { SetConsoleCtrlHandler(Some(keep_wrapper_alive), true) } { eprintln!("Error: failed to initialize deprecated launcher: {error}"); return 1; diff --git a/src/apps/cli/src/chat_state.rs b/src/apps/cli/src/chat_state.rs index a59708b5bf..179ee06ab0 100644 --- a/src/apps/cli/src/chat_state.rs +++ b/src/apps/cli/src/chat_state.rs @@ -150,6 +150,7 @@ pub(crate) struct ToolDisplayState { /// A single content block in a message (text, thinking, or tool call) #[derive(Debug, Clone)] +#[allow(clippy::large_enum_variant)] // tool display state is inherently the largest content block pub(crate) enum FlowItem { /// Text content block Text { content: String, is_streaming: bool }, diff --git a/src/apps/cli/src/daemon/service.rs b/src/apps/cli/src/daemon/service.rs index 67591c330c..8d7ec98310 100644 --- a/src/apps/cli/src/daemon/service.rs +++ b/src/apps/cli/src/daemon/service.rs @@ -85,6 +85,7 @@ fn render_launch_agent(executable: &Path) -> String { ) } +#[cfg_attr(windows, allow(dead_code))] fn run_command(program: &str, args: &[&str]) -> Result { std::process::Command::new(program) .args(args) @@ -110,6 +111,7 @@ fn run_systemctl_user(args: &[&str]) -> Result { .with_context(|| format!("run `systemctl --user {}`", args.join(" "))) } +#[cfg_attr(windows, allow(dead_code))] #[cfg(target_os = "macos")] fn ensure_success(program: &str, args: &[&str]) -> Result<()> { let output = run_command(program, args)?; diff --git a/src/apps/cli/src/dispatch/runner.rs b/src/apps/cli/src/dispatch/runner.rs index 0f4eec6aca..f580f9c1b3 100644 --- a/src/apps/cli/src/dispatch/runner.rs +++ b/src/apps/cli/src/dispatch/runner.rs @@ -1,4 +1,5 @@ use std::process::{Command, Stdio}; +#[cfg_attr(windows, allow(unused_imports))] use std::time::Duration; use anyhow::{anyhow, bail, Context, Result}; @@ -286,6 +287,7 @@ fn process_matches_action(_pid: u32, _action: &str, _job_id: &str) -> bool { false } +#[cfg_attr(windows, allow(dead_code))] fn arguments_match_action(args: &[String], action: &str, job_id: &str) -> bool { args.windows(4).any(|window| { window[0] == "dispatch" diff --git a/src/apps/cli/src/dispatch/worker.rs b/src/apps/cli/src/dispatch/worker.rs index 2a6679bce0..42a6a6d56a 100644 --- a/src/apps/cli/src/dispatch/worker.rs +++ b/src/apps/cli/src/dispatch/worker.rs @@ -505,6 +505,7 @@ async fn process_mailboxes( turn_id: turn_id.to_string(), content: request.content.clone(), display_content: request.display_content.clone(), + prepended_reminders: Vec::new(), }) .await .map_err(|error| anyhow!(error.into_message())) diff --git a/src/apps/cli/src/dispatch/workspace.rs b/src/apps/cli/src/dispatch/workspace.rs index bcefb1680e..823eec8cbd 100644 --- a/src/apps/cli/src/dispatch/workspace.rs +++ b/src/apps/cli/src/dispatch/workspace.rs @@ -698,14 +698,14 @@ fn bundle_commit_in_store( // `git bundle verify` checks the bundle's own integrity and that every // prerequisite commit is already present, so a bundle that would leave // a broken history is rejected before it touches the object store. - git(&repo, &["bundle", "verify", path_arg(&bundle_path)?]) + git(&repo, &["bundle", "verify", path_arg(&bundle_path)?.as_str()]) .context("verify dispatch bundle")?; git( &repo, &[ "fetch", "--no-tags", - path_arg(&bundle_path)?, + path_arg(&bundle_path)?.as_str(), &format!("+refs/heads/{0}:refs/heads/{0}", provision.branch), ], ) @@ -1105,7 +1105,7 @@ fn sync_in_store( let bundle_range = format!("{sync_base}..{}", provision.branch); git( &worktree, - &["bundle", "create", path_arg(&bundle_path)?, &bundle_range], + &["bundle", "create", path_arg(&bundle_path)?.as_str(), &bundle_range], ) .context("package dispatch result bundle")?; set_private_file_permissions(&bundle_path)?; @@ -1358,7 +1358,7 @@ fn create_worktree( git(repo, &["update-ref", &branch_ref, base_commit]) .context("point the dispatch branch at the requested base commit")?; } - git(repo, &["worktree", "add", path_arg(worktree_path)?, branch]) + git(repo, &["worktree", "add", path_arg(worktree_path)?.as_str(), branch]) .context("create the dispatch worktree")?; canonical_utf8(worktree_path) } @@ -1466,9 +1466,13 @@ fn git_succeeds(dir: &Path, args: &[&str]) -> Result { Ok(status.success()) } -fn path_arg(path: &Path) -> Result<&str> { - path.to_str() - .ok_or_else(|| anyhow::anyhow!("dispatch path is not valid UTF-8: {}", path.display())) +fn path_arg(path: &Path) -> Result { + let text = path + .to_str() + .ok_or_else(|| anyhow::anyhow!("dispatch path is not valid UTF-8: {}", path.display()))?; + #[cfg(windows)] + let text = strip_verbatim_prefix(text); + Ok(text.to_string()) } fn canonical_utf8(path: &Path) -> Result { @@ -1479,6 +1483,24 @@ fn canonical_utf8(path: &Path) -> Result { .ok_or_else(|| anyhow::anyhow!("dispatch path is not valid UTF-8")) } +/// Strip the `\\?\` verbatim prefix that `fs::canonicalize` emits on Windows. +/// +/// Git for Windows cannot create worktrees under a verbatim path (it sees +/// `//?/C:/...` and fails to create leading directories), and persisted +/// dispatch records must stay in the normal path form. The helper also covers +/// records that were already persisted with the prefix before this fix. +#[cfg(windows)] +fn strip_verbatim_prefix(path: &str) -> String { + match path.strip_prefix(r"\\?\") { + Some(rest) => match rest.strip_prefix("UNC\\") { + // `\\?\UNC\server\share\...` is the verbatim form of `\\server\share\...`. + Some(unc_rest) => format!(r"\\{unc_rest}"), + None => rest.to_string(), + }, + None => path.to_string(), + } +} + fn is_real_directory(path: &Path) -> bool { fs::symlink_metadata(path) .ok() @@ -1677,7 +1699,7 @@ mod tests { fn bundle_everything(source: &Path, bundle: &Path) { git( source, - &["bundle", "create", path_arg(bundle).expect("path"), "main"], + &["bundle", "create", path_arg(bundle).expect("path").as_str(), "main"], ) .expect("bundle"); } @@ -1804,7 +1826,7 @@ mod tests { "worktree", "remove", "--force", - path_arg(&worktree).unwrap(), + path_arg(&worktree).unwrap().as_str(), ], ) .expect("remove checkout only"); @@ -1900,7 +1922,7 @@ mod tests { assert!(bundle.is_file()); let prerequisites = git( &worktree, - &["bundle", "list-heads", path_arg(&bundle).unwrap()], + &["bundle", "list-heads", path_arg(&bundle).unwrap().as_str()], ) .expect("list heads"); assert!(prerequisites.contains("refs/heads/main")); @@ -2045,6 +2067,9 @@ mod tests { ); } + // Detached dispatch workers exist only on Linux and macOS + // (runner::is_supported), so these retry flows cannot run on Windows. + #[cfg(any(target_os = "linux", target_os = "macos"))] #[test] fn reported_sync_failure_allows_a_new_operation_to_take_over() { let temp = tempfile::tempdir().expect("tempdir"); @@ -2123,6 +2148,7 @@ mod tests { assert!(!replacement.failure_reported); } + #[cfg(any(target_os = "linux", target_os = "macos"))] #[test] fn legacy_sync_failure_without_operation_id_is_reported_then_retryable() { let temp = tempfile::tempdir().expect("tempdir"); diff --git a/src/apps/cli/src/management.rs b/src/apps/cli/src/management.rs index 26ea57db5f..c9cf3f50ce 100644 --- a/src/apps/cli/src/management.rs +++ b/src/apps/cli/src/management.rs @@ -548,6 +548,7 @@ pub(crate) async fn print_usage_report(session_id: Option<&str>) -> Result<()> { workspace_path: workspace_path.to_string_lossy().to_string(), remote_connection_id: None, remote_ssh_host: None, + include_hidden: false, }) .await? .first() diff --git a/src/apps/cli/src/modes/chat/commands.rs b/src/apps/cli/src/modes/chat/commands.rs index 31251d90dc..bfa76aad59 100644 --- a/src/apps/cli/src/modes/chat/commands.rs +++ b/src/apps/cli/src/modes/chat/commands.rs @@ -180,7 +180,6 @@ fn consume_selected_native_command_once( fn retain_selected_native_command_for_input(selected_command: &mut Option, input: &str) { let still_selected = selected_command.as_deref().is_some_and(|selected| { input - .trim_start() .split_whitespace() .next() .map(|token| token.trim_start_matches('/')) diff --git a/src/apps/cli/src/modes/chat/external_editor.rs b/src/apps/cli/src/modes/chat/external_editor.rs index 68127af24d..63da09be58 100644 --- a/src/apps/cli/src/modes/chat/external_editor.rs +++ b/src/apps/cli/src/modes/chat/external_editor.rs @@ -103,7 +103,7 @@ fn has_unclosed_windows_quote(value: &str) -> bool { backslashes += 1; continue; } - if character == '"' && backslashes % 2 == 0 { + if character == '"' && backslashes.is_multiple_of(2) { quoted = !quoted; } backslashes = 0; diff --git a/src/apps/cli/src/modes/chat/external_hooks.rs b/src/apps/cli/src/modes/chat/external_hooks.rs index 3ee530c48c..8692079576 100644 --- a/src/apps/cli/src/modes/chat/external_hooks.rs +++ b/src/apps/cli/src/modes/chat/external_hooks.rs @@ -671,6 +671,7 @@ impl ChatMode { item } + #[allow(clippy::too_many_arguments)] // hook mutation entry carrying view, state and runtime handles fn start_hook_mutation( &mut self, import_number: usize, diff --git a/src/apps/cli/src/modes/chat/external_review.rs b/src/apps/cli/src/modes/chat/external_review.rs index 7318bd7c67..63b458e7d7 100644 --- a/src/apps/cli/src/modes/chat/external_review.rs +++ b/src/apps/cli/src/modes/chat/external_review.rs @@ -12,7 +12,7 @@ fn external_command_projections( let mut projections = snapshot .commands .iter() - .filter_map(|entry| { + .map(|entry| { let ecosystem = snapshot .sources .iter() @@ -59,7 +59,7 @@ fn external_command_projections( conflict_key, }) }); - Some(ExternalCommandProjection { + ExternalCommandProjection { action_id: format!("external-command:{}", entry.definition.name), command_name: entry.definition.name.clone(), invocation_alias: format!("/{}", entry.definition.name), @@ -69,7 +69,7 @@ fn external_command_projections( restricted, provider_conflict_key: None, native_collision, - }) + } }) .collect::>(); diff --git a/src/apps/cli/src/modes/chat/input.rs b/src/apps/cli/src/modes/chat/input.rs index f34acd6f7f..593f577c55 100644 --- a/src/apps/cli/src/modes/chat/input.rs +++ b/src/apps/cli/src/modes/chat/input.rs @@ -596,11 +596,9 @@ impl ChatMode { chat_view.set_cursor_end(); } - (KeyCode::Esc, _) => { - if chat_view.browse_mode { - chat_view.scroll_to_bottom(); - chat_view.set_status(Some("Exited browse mode".to_string())); - } + (KeyCode::Esc, _) if chat_view.browse_mode => { + chat_view.scroll_to_bottom(); + chat_view.set_status(Some("Exited browse mode".to_string())); } (KeyCode::Char('!'), KeyModifiers::NONE | KeyModifiers::SHIFT) diff --git a/src/apps/cli/src/modes/chat/provider_models.rs b/src/apps/cli/src/modes/chat/provider_models.rs index 215cfa0c43..bb567b2e7e 100644 --- a/src/apps/cli/src/modes/chat/provider_models.rs +++ b/src/apps/cli/src/modes/chat/provider_models.rs @@ -164,7 +164,7 @@ impl ChatMode { base_url: model.base_url, api_key: model.api_key, provider_format: model.provider.clone(), - context_window: model.context_window.unwrap_or(128000), + context_window: model.context_window.unwrap_or(1048576), max_tokens: model.max_tokens.unwrap_or(8192), reasoning_preset_options, reasoning: model.reasoning, diff --git a/src/apps/cli/src/modes/chat/run.rs b/src/apps/cli/src/modes/chat/run.rs index 1fc785cdd8..ad0faf33e4 100644 --- a/src/apps/cli/src/modes/chat/run.rs +++ b/src/apps/cli/src/modes/chat/run.rs @@ -968,18 +968,16 @@ impl ChatMode { new_model_id, reason, .. - } => { - if apply_session_model_migration( - &mut chat_state, - session_id, - previous_model_id, - new_model_id, - reason, - ) { - self.load_current_model_name(&mut chat_state, &rt_handle); - chat_view.invalidate_lines_cache(); - needs_redraw = true; - } + } if apply_session_model_migration( + &mut chat_state, + session_id, + previous_model_id, + new_model_id, + reason, + ) => { + self.load_current_model_name(&mut chat_state, &rt_handle); + chat_view.invalidate_lines_cache(); + needs_redraw = true; } AgenticEvent::SessionReasoningPresetAutoCleared { session_id, diff --git a/src/apps/cli/src/modes/exec/lifecycle.rs b/src/apps/cli/src/modes/exec/lifecycle.rs index f5894ffb07..492b6a87cf 100644 --- a/src/apps/cli/src/modes/exec/lifecycle.rs +++ b/src/apps/cli/src/modes/exec/lifecycle.rs @@ -492,6 +492,7 @@ pub(crate) struct ExecMode { } impl ExecMode { + #[allow(clippy::too_many_arguments)] // exec mode constructor carrying config, runtime and run options pub(crate) fn new( config: CliConfig, message: String, diff --git a/src/apps/cli/src/peer_host/commands/session.rs b/src/apps/cli/src/peer_host/commands/session.rs index f8ac8f11ba..beb0e7bb03 100644 --- a/src/apps/cli/src/peer_host/commands/session.rs +++ b/src/apps/cli/src/peer_host/commands/session.rs @@ -819,6 +819,9 @@ mod tests { turn_count: 3, created_at_ms: 12_345, last_active_at_ms: 20_000, + is_daemon: false, + parent_session_id: None, + status: None, }, state: SessionState::Idle, }); diff --git a/src/apps/cli/src/peer_host/state.rs b/src/apps/cli/src/peer_host/state.rs index 25209d6b23..85dfe711dd 100644 --- a/src/apps/cli/src/peer_host/state.rs +++ b/src/apps/cli/src/peer_host/state.rs @@ -1072,6 +1072,7 @@ fn spawn_turn_cancellation( static PEER_HOST_STATE: OnceLock = OnceLock::new(); +#[allow(clippy::result_large_err)] // returns the rejected state itself; boxing would require callers to reconstruct it pub(crate) fn set_peer_host_state(state: PeerHostState) -> Result<(), PeerHostState> { PEER_HOST_STATE.set(state) } diff --git a/src/apps/cli/src/root_handlers.rs b/src/apps/cli/src/root_handlers.rs index 3c876b9447..4f94c67d8f 100644 --- a/src/apps/cli/src/root_handlers.rs +++ b/src/apps/cli/src/root_handlers.rs @@ -442,6 +442,7 @@ async fn list_cli_sessions( workspace_path: workspace_path.to_string_lossy().to_string(), remote_connection_id: None, remote_ssh_host: None, + include_hidden: false, }) .await .map_err(|error| anyhow::anyhow!(error.into_message())) @@ -749,10 +750,10 @@ async fn update_external_policy( &change, ExternalIntegrationPolicyOperation::ResetIncompatiblePolicy ); - if !snapshot.integration_policy.status.is_compatible() - && !(reset_incompatible + if !(snapshot.integration_policy.status.is_compatible() + || (reset_incompatible && snapshot.integration_policy.status - == ExternalIntegrationPolicyStatus::IncompatibleSchema) + == ExternalIntegrationPolicyStatus::IncompatibleSchema)) { return Err(anyhow::anyhow!( "External compatibility policy is unsupported and safely off; upgrade BitFun or reset an incompatible policy before changing it" diff --git a/src/apps/cli/src/self_update.rs b/src/apps/cli/src/self_update.rs index 2224dc8313..4dd7d00361 100644 --- a/src/apps/cli/src/self_update.rs +++ b/src/apps/cli/src/self_update.rs @@ -1,21 +1,17 @@ use anyhow::{anyhow, Context, Result}; -use flate2::read::GzDecoder; use futures_util::StreamExt; use reqwest::Client; use serde::Deserialize; use sha2::{Digest, Sha256}; use std::fs; -use std::io::Cursor; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::time::{Duration, Instant, SystemTime}; -use tar::Archive; const GITHUB_MANIFEST: &str = "https://github.com/GCWing/BitFun/releases/latest/download/linux-binaries.json"; const OPENBITFUN_MANIFEST: &str = "https://openbitfun.com/release/linux-binaries.json"; const AUTO_CHECK_INTERVAL: Duration = Duration::from_secs(6 * 60 * 60); -const DEPRECATION_WARNING: &str = "Warning: `bitfun-cli` is deprecated; use `bitfun` instead."; /// Source-selection tuning. Mirrors the relay deploy path in /// `src/crates/services/services-integrations/src/remote_ssh/relay_deploy.rs`, @@ -1062,41 +1058,6 @@ fn install_archive(_archive: &[u8], _current_exe: &Path) -> Result<()> { Err(anyhow!("CLI self-update is only available on Linux")) } -fn find_package_dir(root: &Path) -> Result { - for entry in fs::read_dir(root).context("inspect CLI update archive")? { - let path = entry?.path(); - if path.is_dir() && path.join("bitfun").is_file() && path.join("bitfun-cli").is_file() { - return Ok(path); - } - } - Err(anyhow!( - "CLI update archive does not contain the official entrypoint pair" - )) -} - -fn validate_entrypoint_pair(primary: &Path, legacy: &Path) -> Result<()> { - let primary_status = Command::new(primary) - .arg("--version") - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .with_context(|| format!("run {}", primary.display()))?; - if !primary_status.success() { - return Err(anyhow!("{} --version failed", primary.display())); - } - let legacy_output = Command::new(legacy) - .arg("--version") - .stdout(Stdio::null()) - .output() - .with_context(|| format!("run {}", legacy.display()))?; - if !legacy_output.status.success() - || String::from_utf8_lossy(&legacy_output.stderr).trim() != DEPRECATION_WARNING - { - return Err(anyhow!("deprecated bitfun-cli entrypoint contract failed")); - } - Ok(()) -} - fn current_platform_key() -> Option<&'static str> { if !cfg!(target_os = "linux") { return None; diff --git a/src/apps/cli/src/ui/chat/status.rs b/src/apps/cli/src/ui/chat/status.rs index a1a65f1771..a6fdc03b9a 100644 --- a/src/apps/cli/src/ui/chat/status.rs +++ b/src/apps/cli/src/ui/chat/status.rs @@ -4,7 +4,7 @@ fn format_token_count(value: usize) -> String { let digits = value.to_string(); let mut formatted = String::with_capacity(digits.len() + digits.len() / 3); for (index, digit) in digits.chars().enumerate() { - if index > 0 && (digits.len() - index) % 3 == 0 { + if index > 0 && (digits.len() - index).is_multiple_of(3) { formatted.push(','); } formatted.push(digit); diff --git a/src/apps/cli/src/ui/login_form.rs b/src/apps/cli/src/ui/login_form.rs index 8584280bcc..d8e5657bbf 100644 --- a/src/apps/cli/src/ui/login_form.rs +++ b/src/apps/cli/src/ui/login_form.rs @@ -460,8 +460,8 @@ impl LoginFormState { let inner = outer.inner(area); frame.render_widget(outer, area); - let form_width = inner.width.min(72).max(40); - let form_height = 15u16.min(inner.height.max(12)); + let form_width = inner.width.clamp(40, 72); + let form_height = inner.height.clamp(12, 15); let form_area = Rect { x: inner.x + (inner.width.saturating_sub(form_width)) / 2, y: inner.y + (inner.height.saturating_sub(form_height)) / 2, @@ -536,8 +536,8 @@ impl LoginFormState { let inner = outer.inner(area); frame.render_widget(outer, area); - let form_width = inner.width.min(76).max(40); - let form_height = 14u16.min(inner.height.max(10)); + let form_width = inner.width.clamp(40, 76); + let form_height = inner.height.clamp(10, 14); let form_area = Rect { x: inner.x + (inner.width.saturating_sub(form_width)) / 2, y: inner.y + (inner.height.saturating_sub(form_height)) / 2, diff --git a/src/apps/cli/src/ui/markdown.rs b/src/apps/cli/src/ui/markdown.rs index 556d98f24e..07692ded67 100644 --- a/src/apps/cli/src/ui/markdown.rs +++ b/src/apps/cli/src/ui/markdown.rs @@ -194,16 +194,14 @@ impl MarkdownRenderer { // Headings: don't wrap, just push as-is lines.push(Line::from(std::mem::take(&mut current_line_spans))); } - TagEnd::Paragraph => { - if !in_code_block && !table_state.in_table { - flush_with_wrap( - &mut current_line_spans, - &mut lines, - wrap_width, - true, - ); - lines.push(Line::from("")); - } + TagEnd::Paragraph if !in_code_block && !table_state.in_table => { + flush_with_wrap( + &mut current_line_spans, + &mut lines, + wrap_width, + true, + ); + lines.push(Line::from("")); } TagEnd::BlockQuote => { if let Some(StyleModifier::Quote) = style_stack.last() { @@ -308,10 +306,10 @@ impl MarkdownRenderer { } } - Event::SoftBreak | Event::HardBreak => { - if !in_code_block && !table_state.in_table { - flush_with_wrap(&mut current_line_spans, &mut lines, wrap_width, true); - } + Event::SoftBreak | Event::HardBreak + if !in_code_block && !table_state.in_table => + { + flush_with_wrap(&mut current_line_spans, &mut lines, wrap_width, true); } Event::Rule => { diff --git a/src/apps/cli/src/ui/mcp_selector.rs b/src/apps/cli/src/ui/mcp_selector.rs index f991ccbec7..1ab5968eb3 100644 --- a/src/apps/cli/src/ui/mcp_selector.rs +++ b/src/apps/cli/src/ui/mcp_selector.rs @@ -114,6 +114,7 @@ impl McpItem { /// Action returned from the MCP selector #[derive(Debug, Clone)] +#[allow(clippy::large_enum_variant)] // item carries the full server entry; boxing adds indirection per selection pub(crate) enum McpAction { /// Toggle (start/stop) the selected server Toggle(McpItem), @@ -317,7 +318,7 @@ impl McpSelectorState { return; } - let provisional_width = area.width.saturating_sub(4).min(72).max(1); + let provisional_width = area.width.saturating_sub(4).clamp(1, 72); let confirmation_height = self .confirm_external_id .as_ref() diff --git a/src/apps/cli/src/ui/model_config_form.rs b/src/apps/cli/src/ui/model_config_form.rs index fff43b5bd4..4115e3489d 100644 --- a/src/apps/cli/src/ui/model_config_form.rs +++ b/src/apps/cli/src/ui/model_config_form.rs @@ -62,6 +62,7 @@ fn reasoning_after_preset_selection( /// Action returned by the form #[derive(Debug, Clone)] +#[allow(clippy::large_enum_variant)] // form result carries the full model entry; boxing adds indirection per save pub(crate) enum ModelFormAction { /// No action, key consumed None, @@ -138,7 +139,7 @@ impl ModelConfigFormState { base_url: String::new(), api_key: String::new(), provider_format_index: 0, - context_window: "128000".into(), + context_window: "1048576".into(), max_tokens: "8192".into(), reasoning_preset_options: Vec::new(), reasoning_preset_index: 0, @@ -169,7 +170,7 @@ impl ModelConfigFormState { self.base_url = "https://".into(); self.api_key.clear(); self.provider_format_index = 0; - self.context_window = "128000".into(); + self.context_window = "1048576".into(); self.max_tokens = "8192".into(); self.reasoning_preset_options.clear(); self.reasoning_preset_index = 0; @@ -209,7 +210,7 @@ impl ModelConfigFormState { .iter() .position(|&f| f == format) .unwrap_or(0); - self.context_window = "128000".into(); + self.context_window = "1048576".into(); self.max_tokens = "8192".into(); self.reasoning_preset_options.clear(); self.reasoning_preset_index = 0; @@ -495,7 +496,7 @@ impl ModelConfigFormState { base_url: self.base_url.trim().to_string(), api_key: self.api_key.trim().to_string(), provider_format: PROVIDER_FORMATS[self.provider_format_index].to_string(), - context_window: self.context_window.trim().parse().unwrap_or(128000), + context_window: self.context_window.trim().parse().unwrap_or(1048576), max_tokens: self.max_tokens.trim().parse().unwrap_or(8192), reasoning_preset_options: self.reasoning_preset_options.clone(), reasoning, @@ -1167,7 +1168,7 @@ impl ModelConfigFormState { FormField::BaseUrl => "https://api.example.com/v1/chat/completions", FormField::ApiKey => "Enter your API key", FormField::ProviderFormat => "", - FormField::ContextWindow => "128000", + FormField::ContextWindow => "1048576", FormField::MaxTokens => "8192", FormField::DefaultReasoningPreset => "", FormField::SkipSslVerify => "", diff --git a/src/apps/cli/src/ui/startup.rs b/src/apps/cli/src/ui/startup.rs index df87546a21..efc5717f3b 100644 --- a/src/apps/cli/src/ui/startup.rs +++ b/src/apps/cli/src/ui/startup.rs @@ -1847,7 +1847,7 @@ impl StartupPage { base_url: model.base_url, api_key: model.api_key, provider_format: model.provider.clone(), - context_window: model.context_window.unwrap_or(128000), + context_window: model.context_window.unwrap_or(1048576), max_tokens: model.max_tokens.unwrap_or(8192), reasoning_preset_options, reasoning: model.reasoning, diff --git a/src/apps/cli/src/ui/workspace_reference.rs b/src/apps/cli/src/ui/workspace_reference.rs index 5651027e23..6e5a41b50f 100644 --- a/src/apps/cli/src/ui/workspace_reference.rs +++ b/src/apps/cli/src/ui/workspace_reference.rs @@ -73,6 +73,7 @@ fn parse_line_range(raw: &str) -> (String, Option, Option) { } } + #[derive(Debug, Default)] pub(crate) struct WorkspaceReferencePopupState { pub(crate) query: Option, diff --git a/src/apps/desktop/src/api/acp_client_api.rs b/src/apps/desktop/src/api/acp_client_api.rs index 83d685add8..a09fe108c2 100644 --- a/src/apps/desktop/src/api/acp_client_api.rs +++ b/src/apps/desktop/src/api/acp_client_api.rs @@ -9,7 +9,16 @@ use bitfun_acp::client::{ SetAcpSessionConfigOptionRequest, SetAcpSessionModelRequest, SubmitAcpPermissionResponseRequest, }; +use bitfun_core::agentic::persistence::PersistenceManager; +use bitfun_core::infrastructure::PathManager; +use bitfun_core::service::session::{ + DialogTurnData, ModelRoundData, TextItemData, ThinkingItemData, ToolCallData, ToolItemData, + ToolResultData, TurnStatus, UserMessageData, +}; +use bitfun_events::ToolEventData; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; use std::time::Instant; use tauri::{AppHandle, Emitter, State}; @@ -110,6 +119,426 @@ fn emit_acp_model_round_completed( .map_err(|e| bitfun_core::util::errors::BitFunError::service(e.to_string())) } +/// Current unix time in milliseconds (fallback 0 on clock failure; never +/// panics). +fn acp_now_unix_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +/// In-progress accumulation of one ACP dialog turn's model rounds while the +/// external `prompt_agent_stream` events are being forwarded to the frontend. +/// +/// The frontend-only persistence (debounced `saveSessionTurn`) is the +/// authoritative writer while it is online; this accumulator is the backend +/// safety-net copy so a turn is still persisted when the frontend is closed, +/// the session is not open, or the event stream is interrupted. +struct AcpDialogTurnAccumulator { + current_round: Option, + rounds: Vec, +} + +impl Default for AcpDialogTurnAccumulator { + fn default() -> Self { + Self { + current_round: None, + rounds: Vec::new(), + } + } +} + +impl AcpDialogTurnAccumulator { + /// Begin a new model round, closing the previous one first. + fn start_round(&mut self, round_id: String, round_index: usize) { + self.finish_current_round(); + self.current_round = Some(AcpAccumulatedRound { + round_id, + round_index, + started_at_ms: acp_now_unix_ms(), + text_parts: Vec::new(), + thinking_parts: Vec::new(), + tool_items: Vec::new(), + tool_index: HashMap::new(), + }); + } + + /// Close the current round and append it to the completed rounds. + fn finish_current_round(&mut self) { + if let Some(round) = self.current_round.take() { + self.rounds.push(round); + } + } + + /// Merge one ACP tool event into the current round, keyed by tool id so a + /// Started + Completed (or Failed) pair yields a single tool item. + fn apply_tool_event(&mut self, event: &ToolEventData) { + let Some(round) = self.current_round.as_mut() else { + return; + }; + let Some(item) = acp_tool_event_to_tool_item(event) else { + return; + }; + let tool_id = item.id.clone(); + if let Some(index) = round.tool_index.get(&tool_id).copied() { + let existing = &mut round.tool_items[index]; + // 保留 Started 时的参数(后续 Completed/Failed 更新不带参数)。 + if let Some(input) = acp_tool_event_started_input(event) { + existing.tool_call.input = input; + } + if let Some(result) = item.tool_result { + existing.tool_result = Some(result); + existing.status = item.status; + } + } else { + let index = round.tool_items.len(); + round.tool_index.insert(tool_id, index); + round.tool_items.push(item); + } + } +} + +/// One accumulated ACP model round, ready to be converted into +/// `ModelRoundData` when the turn completes. +struct AcpAccumulatedRound { + round_id: String, + round_index: usize, + started_at_ms: u64, + text_parts: Vec, + thinking_parts: Vec, + tool_items: Vec, + tool_index: HashMap, +} + +/// The Started-event input of an ACP tool event (`None` for non-Started +/// variants so a completed update never clears the recorded input). +fn acp_tool_event_started_input(event: &ToolEventData) -> Option { + match event { + ToolEventData::Started { params, .. } => Some(params.clone()), + _ => None, + } +} + +/// Map one ACP tool event into a persisted `ToolItemData`. +/// +/// Only lifecycle variants that carry content (`Started` / `Completed` / +/// `Failed` / `Cancelled`) are persisted; informational variants +/// (`Progress`, `Streaming`, `Queued`, ...) are skipped. +fn acp_tool_event_to_tool_item(event: &ToolEventData) -> Option { + let (identity, status, tool_result) = match event { + ToolEventData::Started { identity, .. } => (identity, "in_progress", None), + ToolEventData::Completed { + identity, + result, + duration_ms, + .. + } => ( + identity, + "completed", + Some(ToolResultData { + result: result.clone(), + success: true, + result_for_assistant: None, + image_attachments: None, + error: None, + duration_ms: Some(*duration_ms), + }), + ), + ToolEventData::Failed { + identity, + error, + duration_ms, + .. + } => ( + identity, + "failed", + Some(ToolResultData { + result: serde_json::Value::Null, + success: false, + result_for_assistant: None, + image_attachments: None, + error: Some(error.clone()), + duration_ms: *duration_ms, + }), + ), + ToolEventData::Cancelled { + identity, + reason, + duration_ms, + .. + } => ( + identity, + "cancelled", + Some(ToolResultData { + result: serde_json::Value::Null, + success: false, + result_for_assistant: None, + image_attachments: None, + error: Some(reason.clone()), + duration_ms: *duration_ms, + }), + ), + _ => return None, + }; + Some(ToolItemData { + id: identity.tool_id.clone(), + tool_name: identity.effective_name().to_string(), + tool_call: ToolCallData { + input: acp_tool_event_started_input(event) + .unwrap_or_else(|| serde_json::json!({})), + id: identity.tool_id.clone(), + }, + tool_result, + ai_intent: None, + start_time: acp_now_unix_ms(), + end_time: None, + duration_ms: None, + queue_wait_ms: None, + preflight_ms: None, + confirmation_wait_ms: None, + execution_ms: None, + order_index: None, + is_subagent_item: None, + parent_task_tool_id: None, + subagent_session_id: None, + subagent_dialog_turn_id: None, + attempt_id: None, + attempt_index: None, + subagent_model_id: None, + subagent_model_display_name: None, + status: Some(status.to_string()), + interruption_reason: None, + }) +} + +impl AcpAccumulatedRound { + /// Convert the accumulated chunks and tool items into a persisted + /// `ModelRoundData` (mirrors the frontend `convertDialogTurnToBackendFormat` + /// shape: one text item per round, one thinking item per round, tool items + /// in arrival order). + fn into_model_round(self, turn_id: &str) -> ModelRoundData { + let now_ms = acp_now_unix_ms(); + let mut text_items = Vec::new(); + let text = self.text_parts.concat(); + if !text.trim().is_empty() { + text_items.push(TextItemData { + id: uuid::Uuid::new_v4().to_string(), + content: text, + is_streaming: false, + timestamp: self.started_at_ms, + is_markdown: true, + order_index: Some(0), + is_subagent_item: None, + parent_task_tool_id: None, + subagent_session_id: None, + status: Some("completed".to_string()), + attempt_id: None, + attempt_index: None, + }); + } + let mut thinking_items = Vec::new(); + let thinking = self.thinking_parts.concat(); + if !thinking.trim().is_empty() { + thinking_items.push(ThinkingItemData { + id: uuid::Uuid::new_v4().to_string(), + content: thinking, + is_streaming: false, + is_collapsed: true, + timestamp: self.started_at_ms, + order_index: Some(0), + status: Some("completed".to_string()), + is_subagent_item: None, + parent_task_tool_id: None, + subagent_session_id: None, + attempt_id: None, + attempt_index: None, + }); + } + ModelRoundData { + id: self.round_id, + turn_id: turn_id.to_string(), + round_index: self.round_index, + round_group_id: None, + timestamp: self.started_at_ms, + text_items, + tool_items: self.tool_items, + thinking_items, + start_time: self.started_at_ms, + end_time: Some(now_ms), + duration_ms: Some(now_ms.saturating_sub(self.started_at_ms)), + provider_id: None, + model_config_id: None, + effective_model_name: None, + first_chunk_ms: None, + first_visible_output_ms: None, + stream_duration_ms: None, + attempt_count: None, + attempt_diagnostics: Vec::new(), + failure_category: None, + token_details: None, + status: "completed".to_string(), + } + } +} + +/// Build the persisted `DialogTurnData` for one completed ACP dialog turn. +fn build_acp_dialog_turn_data( + turn_id: &str, + turn_index: usize, + session_id: &str, + user_input: &str, + start_time_ms: u64, + rounds: Vec, + status: TurnStatus, +) -> DialogTurnData { + let mut turn = DialogTurnData::new( + turn_id.to_string(), + turn_index, + session_id.to_string(), + UserMessageData { + id: uuid::Uuid::new_v4().to_string(), + content: user_input.to_string(), + timestamp: start_time_ms, + metadata: None, + }, + ); + turn.start_time = start_time_ms; + turn.model_rounds = rounds + .into_iter() + .map(|round| round.into_model_round(turn_id)) + .collect(); + match status { + TurnStatus::Completed => turn.mark_completed(), + TurnStatus::Cancelled | TurnStatus::Error => { + turn.status = status; + turn.end_time = Some(acp_now_unix_ms()); + } + TurnStatus::InProgress => {} + } + turn +} + +/// Backend safety-net persistence for one ACP dialog turn, independent of the +/// frontend event stream. +/// +/// The turn index is derived from the persisted session metadata +/// (`turn_count`), matching the frontend's `indexOf` semantics when the turn +/// history is contiguous. When the frontend (online) already saved the same +/// turn at that index, this is a no-op; a collision with a different turn id +/// is skipped with a warning instead of overwriting foreign data. Failures are +/// logged, never propagated, so persistence can never break the streaming +/// path. +async fn persist_acp_dialog_turn_backend( + persistence: &PersistenceManager, + session_storage_path: &Path, + session_id: &str, + turn_id: &str, + user_input: &str, + start_time_ms: u64, + rounds: Vec, + status: TurnStatus, +) { + let Ok(Some(metadata)) = persistence + .load_session_metadata(session_storage_path, session_id) + .await + else { + log::warn!( + "ACP turn persistence skipped: session metadata not found: session_id={}", + session_id + ); + return; + }; + let turn_index = metadata.turn_count; + match persistence + .load_dialog_turn(session_storage_path, session_id, turn_index) + .await + { + Ok(Some(existing)) if existing.turn_id == turn_id => { + // 前端在线时已落盘(或本函数先落盘):跳过,避免重复写入。 + return; + } + Ok(Some(_)) => { + log::warn!( + "ACP turn persistence skipped: turn index collision: session_id={} turn_id={} turn_index={}", + session_id, + turn_id, + turn_index + ); + return; + } + _ => {} + } + let turn = build_acp_dialog_turn_data( + turn_id, + turn_index, + session_id, + user_input, + start_time_ms, + rounds, + status, + ); + if let Err(error) = persistence.save_dialog_turn(session_storage_path, &turn).await { + log::warn!( + "Failed to persist ACP dialog turn: session_id={} turn_id={} error={}", + session_id, + turn_id, + error + ); + } +} + +/// Spawn the backend persistence task for a finished ACP dialog turn. +/// +/// Runs off the event-stream path: a missing workspace storage path or a +/// persistence setup failure only logs a warning, never breaks streaming. +fn spawn_acp_turn_backend_persist( + session_storage_path: Option, + session_id: String, + turn_id: String, + user_input: String, + start_time_ms: u64, + rounds: Vec, + status: TurnStatus, +) { + let Some(session_storage_path) = session_storage_path else { + return; + }; + tokio::spawn(async move { + let path_manager = match PathManager::new() { + Ok(path_manager) => std::sync::Arc::new(path_manager), + Err(error) => { + log::warn!( + "ACP turn persistence skipped: failed to initialize PathManager: {}", + error + ); + return; + } + }; + let persistence = match PersistenceManager::new(path_manager) { + Ok(persistence) => persistence, + Err(error) => { + log::warn!( + "ACP turn persistence skipped: failed to initialize PersistenceManager: {}", + error + ); + return; + } + }; + persist_acp_dialog_turn_backend( + &persistence, + &session_storage_path, + &session_id, + &turn_id, + &user_input, + start_time_ms, + rounds, + status, + ) + .await; + }); +} + #[tauri::command] pub async fn initialize_acp_clients( state: State<'_, AppState>, @@ -260,13 +689,18 @@ pub async fn create_acp_flow_session( Ok(response) } -#[tauri::command] -pub async fn start_acp_dialog_turn( - state: State<'_, AppState>, +/// Shared implementation for starting an ACP dialog turn. +/// +/// Used by both the FlowChat path (`start_acp_dialog_turn` command) and the +/// agentic path (`start_dialog_turn` ACP branch). Emits the standard +/// `agentic://dialog-turn-*` Tauri events while streaming +/// `prompt_agent_stream` output; no internal executor is started. +pub(crate) async fn start_acp_dialog_turn_impl( app_handle: AppHandle, + app_state: &AppState, request: StartAcpDialogTurnRequest, ) -> Result<(), String> { - let service = state + let service = app_state .acp_client_service .as_ref() .ok_or_else(|| "ACP client service not initialized".to_string())? @@ -282,7 +716,7 @@ pub async fn start_acp_dialog_turn( let session_storage_path = match request.workspace_path.as_deref() { Some(workspace_path) => Some( desktop_effective_session_storage_path( - &state, + app_state, workspace_path, request.remote_connection_id.as_deref(), request.remote_ssh_host.as_deref(), @@ -309,6 +743,14 @@ pub async fn start_acp_dialog_turn( tokio::spawn(async move { let mut current_round_id: Option = None; let mut current_round_has_tool_calls = false; + // a19 后端兜底落盘:事件流同步累积模型轮次内容,Completed/Cancelled + // 时经 PersistenceManager 落盘(不依赖前端事件接收)。 + let mut turn_accumulator = AcpDialogTurnAccumulator::default(); + let turn_started_at_ms = acp_now_unix_ms(); + let persist_storage_path = session_storage_path.clone(); + let persist_session_id = request.session_id.clone(); + let persist_turn_id = request.turn_id.clone(); + let persist_user_input = request.user_input.clone(); let result = service .prompt_agent_stream( &request.client_id, @@ -336,6 +778,7 @@ pub async fn start_acp_dialog_turn( } current_round_id = Some(round_id.clone()); current_round_has_tool_calls = false; + turn_accumulator.start_round(round_id.clone(), round_index); app_handle .emit( "agentic://model-round-started", @@ -360,6 +803,9 @@ pub async fn start_acp_dialog_turn( "ACP text arrived before model round start".to_string(), ) })?; + if let Some(round) = turn_accumulator.current_round.as_mut() { + round.text_parts.push(text.clone()); + } app_handle .emit( "agentic://text-chunk", @@ -381,6 +827,9 @@ pub async fn start_acp_dialog_turn( "ACP thought arrived before model round start".to_string(), ) })?; + if let Some(round) = turn_accumulator.current_round.as_mut() { + round.thinking_parts.push(text.clone()); + } app_handle .emit( "agentic://text-chunk", @@ -405,6 +854,7 @@ pub async fn start_acp_dialog_turn( ) })?; current_round_has_tool_calls = true; + turn_accumulator.apply_tool_event(&tool_event); app_handle .emit( "agentic://tool-event", @@ -490,6 +940,16 @@ pub async fn start_acp_dialog_turn( current_round_has_tool_calls, )?; } + turn_accumulator.finish_current_round(); + spawn_acp_turn_backend_persist( + persist_storage_path.clone(), + persist_session_id.clone(), + persist_turn_id.clone(), + persist_user_input.clone(), + turn_started_at_ms, + std::mem::take(&mut turn_accumulator.rounds), + TurnStatus::Completed, + ); app_handle .emit( "agentic://dialog-turn-completed", @@ -514,6 +974,16 @@ pub async fn start_acp_dialog_turn( current_round_has_tool_calls, )?; } + turn_accumulator.finish_current_round(); + spawn_acp_turn_backend_persist( + persist_storage_path.clone(), + persist_session_id.clone(), + persist_turn_id.clone(), + persist_user_input.clone(), + turn_started_at_ms, + std::mem::take(&mut turn_accumulator.rounds), + TurnStatus::Cancelled, + ); app_handle .emit( "agentic://dialog-turn-cancelled", @@ -551,6 +1021,15 @@ pub async fn start_acp_dialog_turn( Ok(()) } +#[tauri::command] +pub async fn start_acp_dialog_turn( + state: State<'_, AppState>, + app_handle: AppHandle, + request: StartAcpDialogTurnRequest, +) -> Result<(), String> { + start_acp_dialog_turn_impl(app_handle, &state, request).await +} + #[tauri::command] pub async fn cancel_acp_dialog_turn( state: State<'_, AppState>, @@ -743,3 +1222,187 @@ pub async fn submit_acp_permission_response( .await .map_err(|e| e.to_string()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn started_event(tool_id: &str) -> ToolEventData { + ToolEventData::Started { + identity: bitfun_events::ToolEventIdentity::direct(tool_id, "Bash"), + params: serde_json::json!({ "command": "echo ok" }), + timeout_seconds: None, + } + } + + fn completed_event(tool_id: &str) -> ToolEventData { + ToolEventData::Completed { + identity: bitfun_events::ToolEventIdentity::direct(tool_id, "Bash"), + result: serde_json::json!({ "success": true }), + result_for_assistant: None, + image_attachments: None, + duration_ms: 12, + queue_wait_ms: None, + preflight_ms: None, + confirmation_wait_ms: None, + execution_ms: None, + } + } + + fn failed_event(tool_id: &str) -> ToolEventData { + ToolEventData::Failed { + identity: bitfun_events::ToolEventIdentity::direct(tool_id, "Bash"), + error: "boom".to_string(), + duration_ms: None, + queue_wait_ms: None, + preflight_ms: None, + confirmation_wait_ms: None, + execution_ms: None, + } + } + + #[test] + fn acp_tool_event_maps_lifecycle_variants() { + let started = acp_tool_event_to_tool_item(&started_event("tool-1")) + .expect("started maps to an item"); + assert_eq!(started.id, "tool-1"); + assert_eq!(started.tool_name, "Bash"); + assert_eq!(started.status.as_deref(), Some("in_progress")); + assert_eq!(started.tool_call.input["command"], "echo ok"); + assert!(started.tool_result.is_none()); + + let completed = acp_tool_event_to_tool_item(&completed_event("tool-1")) + .expect("completed maps to an item"); + assert_eq!(completed.status.as_deref(), Some("completed")); + let result = completed.tool_result.expect("completed has a result"); + assert!(result.success); + assert_eq!(result.duration_ms, Some(12)); + + let failed = acp_tool_event_to_tool_item(&failed_event("tool-1")) + .expect("failed maps to an item"); + assert_eq!(failed.status.as_deref(), Some("failed")); + let result = failed.tool_result.expect("failed has a result"); + assert!(!result.success); + assert_eq!(result.error.as_deref(), Some("boom")); + + // 信息性变体不产生落盘条目。 + assert!(acp_tool_event_to_tool_item(&ToolEventData::Progress { + identity: bitfun_events::ToolEventIdentity::direct("tool-1", "Bash"), + message: "working".to_string(), + percentage: 0.5, + }) + .is_none()); + } + + #[test] + fn acp_tool_event_merge_keeps_started_input_and_final_status() { + let mut accumulator = AcpDialogTurnAccumulator::default(); + accumulator.start_round("round-1".to_string(), 0); + accumulator.apply_tool_event(&started_event("tool-1")); + accumulator.apply_tool_event(&completed_event("tool-1")); + accumulator.finish_current_round(); + + assert_eq!(accumulator.rounds.len(), 1); + let round = &accumulator.rounds[0]; + assert_eq!(round.tool_items.len(), 1); + assert_eq!(round.tool_items[0].tool_call.input["command"], "echo ok"); + assert_eq!(round.tool_items[0].status.as_deref(), Some("completed")); + assert!(round.tool_items[0].tool_result.as_ref().unwrap().success); + + // 两次不同 tool id 的事件 → 两个条目。 + accumulator.start_round("round-2".to_string(), 1); + accumulator.apply_tool_event(&started_event("tool-2")); + accumulator.apply_tool_event(&failed_event("tool-2")); + accumulator.finish_current_round(); + assert_eq!(accumulator.rounds[1].tool_items.len(), 1); + assert_eq!(accumulator.rounds[1].tool_items[0].status.as_deref(), Some("failed")); + } + + #[test] + fn build_acp_dialog_turn_data_builds_model_rounds() { + let mut accumulator = AcpDialogTurnAccumulator::default(); + accumulator.start_round("round-1".to_string(), 0); + if let Some(round) = accumulator.current_round.as_mut() { + round.text_parts.push("hello ".to_string()); + round.text_parts.push("world".to_string()); + round.thinking_parts.push("think step".to_string()); + } + accumulator.apply_tool_event(&started_event("tool-1")); + accumulator.apply_tool_event(&completed_event("tool-1")); + accumulator.finish_current_round(); + + let turn = build_acp_dialog_turn_data( + "turn-1", + 2, + "acp_codex_7f0e1a2b-3c4d-4e5f-8a9b-0c1d2e3f4a5b", + "hello", + 1000, + accumulator.rounds, + TurnStatus::Completed, + ); + assert_eq!(turn.turn_index, 2); + assert_eq!(turn.user_message.content, "hello"); + assert_eq!(turn.status, TurnStatus::Completed); + assert!(turn.end_time.is_some()); + assert_eq!(turn.model_rounds.len(), 1); + let round = &turn.model_rounds[0]; + assert_eq!(round.round_index, 0); + assert_eq!(round.text_items.len(), 1); + assert_eq!(round.text_items[0].content, "hello world"); + assert_eq!(round.thinking_items.len(), 1); + assert_eq!(round.thinking_items[0].content, "think step"); + assert_eq!(round.tool_items.len(), 1); + assert_eq!(round.tool_items[0].status.as_deref(), Some("completed")); + } + + #[test] + fn build_acp_dialog_turn_data_builds_model_rounds() { + let mut accumulator = AcpDialogTurnAccumulator::default(); + accumulator.start_round("round-1".to_string(), 0); + if let Some(round) = accumulator.current_round.as_mut() { + round.text_parts.push("hello ".to_string()); + round.text_parts.push("world".to_string()); + round.thinking_parts.push("think step".to_string()); + } + accumulator.apply_tool_event(&started_event("tool-1")); + accumulator.apply_tool_event(&completed_event("tool-1")); + accumulator.finish_current_round(); + + let turn = build_acp_dialog_turn_data( + "turn-1", + 2, + "acp_codex_7f0e1a2b-3c4d-4e5f-8a9b-0c1d2e3f4a5b", + "hello", + 1000, + accumulator.rounds, + TurnStatus::Completed, + ); + assert_eq!(turn.turn_index, 2); + assert_eq!(turn.user_message.content, "hello"); + assert_eq!(turn.status, TurnStatus::Completed); + assert!(turn.end_time.is_some()); + assert_eq!(turn.model_rounds.len(), 1); + let round = &turn.model_rounds[0]; + assert_eq!(round.round_index, 0); + assert_eq!(round.text_items.len(), 1); + assert_eq!(round.text_items[0].content, "hello world"); + assert_eq!(round.thinking_items.len(), 1); + assert_eq!(round.thinking_items[0].content, "think step"); + assert_eq!(round.tool_items.len(), 1); + assert_eq!(round.tool_items[0].status.as_deref(), Some("completed")); + + // Cancelled 终态:status=Cancelled + end_time,保留已累积内容。 + let cancelled = build_acp_dialog_turn_data( + "turn-2", + 3, + "acp_codex_7f0e1a2b-3c4d-4e5f-8a9b-0c1d2e3f4a5b", + "hello", + 2000, + Vec::new(), + TurnStatus::Cancelled, + ); + assert_eq!(cancelled.status, TurnStatus::Cancelled); + assert!(cancelled.end_time.is_some()); + assert!(cancelled.model_rounds.is_empty()); + } +} diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index a870ffb92c..4baa3b8167 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -1,6 +1,6 @@ //! Agentic API -use log::{debug, warn}; +use log::{debug, info, warn}; use serde::{Deserialize, Serialize}; use sha1::{Digest, Sha1}; use std::path::{Path, PathBuf}; @@ -8,6 +8,7 @@ use std::sync::Arc; use std::time::Instant; use tauri::{AppHandle, State}; +use crate::api::acp_client_api::StartAcpDialogTurnRequest; use crate::api::app_state::AppState; use crate::api::session_storage_path::desktop_effective_session_storage_path; use crate::runtime::{ @@ -1598,7 +1599,7 @@ pub async fn create_session( let config = request .config .map(|c| SessionConfig { - max_context_tokens: c.max_context_tokens.unwrap_or(128128), + max_context_tokens: c.max_context_tokens.unwrap_or(1_048_576), auto_compact: c.auto_compact.unwrap_or(true), enable_tools: c.enable_tools.unwrap_or(true), safe_mode: c.safe_mode.unwrap_or(true), @@ -1910,10 +1911,34 @@ pub async fn ensure_coordinator_session( #[tauri::command] pub async fn start_dialog_turn( - _app: AppHandle, + app: AppHandle, + app_state: State<'_, AppState>, runtime: State<'_, DesktopRuntimeContext>, request: StartDialogTurnRequest, ) -> Result { + // ACP bridge sessions (`acp__`) stream through the external ACP + // client process instead of the internal executor. This branch must run + // before `desktop_dialog_turn_request` consumes `request`. + if let Some(client_id) = request.agent_type.trim().strip_prefix("acp__") { + let acp_request = StartAcpDialogTurnRequest { + session_id: request.session_id, + client_id: client_id.to_string(), + user_input: request.user_input, + original_user_input: request.original_user_input, + turn_id: request.turn_id.unwrap_or_default(), + workspace_path: request.project_workspace_path.or(request.workspace_path), + remote_connection_id: request.remote_connection_id, + remote_ssh_host: request.remote_ssh_host, + timeout_seconds: None, + }; + crate::api::acp_client_api::start_acp_dialog_turn_impl(app, &app_state, acp_request) + .await?; + return Ok(StartDialogTurnResponse { + success: true, + message: "Dialog turn started".to_string(), + }); + } + let runtime_request = desktop_dialog_turn_request(request)?; runtime @@ -2666,6 +2691,7 @@ pub async fn steer_dialog_turn( turn_id: dialog_turn_id, content, display_content, + prepended_reminders: Vec::new(), }) .await .map_err(|error| format!("Failed to steer dialog turn: {}", error.into_message()))?; @@ -2996,7 +3022,15 @@ pub async fn delete_session( runtime: State<'_, DesktopRuntimeContext>, request: DeleteSessionRequest, ) -> Result<(), String> { - runtime + info!( + "delete_session entry: session_id={}, workspace_path={}, remote_connection_id={:?}, remote_ssh_host={:?}", + request.session_id, + request.workspace_path, + request.remote_connection_id, + request.remote_ssh_host, + ); + let session_id = request.session_id.clone(); + let result = runtime .session_application() .delete_session( desktop_session_scope( @@ -3004,10 +3038,67 @@ pub async fn delete_session( request.remote_connection_id, request.remote_ssh_host, ), - request.session_id, + session_id.clone(), + ) + .await + .map_err(|error| { + log::error!( + "delete_session failed: session_id={}, error={}", + session_id, + error + ); + format!("Failed to delete session: {error}") + }); + if result.is_ok() { + info!("delete_session completed: session_id={}", session_id); + } + result +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteSessionTreeResponse { + pub deleted_session_ids: Vec, +} + +#[tauri::command] +pub async fn delete_session_tree( + runtime: State<'_, DesktopRuntimeContext>, + request: DeleteSessionRequest, +) -> Result { + info!( + "delete_session_tree entry: session_id={}, workspace_path={}, remote_connection_id={:?}, remote_ssh_host={:?}", + request.session_id, + request.workspace_path, + request.remote_connection_id, + request.remote_ssh_host, + ); + let session_id = request.session_id.clone(); + let deleted_session_ids = runtime + .session_application() + .delete_session_tree( + desktop_session_scope( + request.workspace_path, + request.remote_connection_id, + request.remote_ssh_host, + ), + session_id.clone(), ) .await - .map_err(|error| format!("Failed to delete session: {error}")) + .map_err(|error| { + log::error!( + "delete_session_tree failed: session_id={}, error={}", + session_id, + error + ); + format!("Failed to delete session tree: {error}") + })?; + info!( + "delete_session_tree completed: session_id={}, deleted_count={}", + session_id, + deleted_session_ids.len() + ); + Ok(DeleteSessionTreeResponse { deleted_session_ids }) } #[tauri::command] diff --git a/src/apps/desktop/src/api/browser_api.rs b/src/apps/desktop/src/api/browser_api.rs index 2bfe51b87b..1f44b5f1df 100644 --- a/src/apps/desktop/src/api/browser_api.rs +++ b/src/apps/desktop/src/api/browser_api.rs @@ -142,16 +142,14 @@ pub async fn browser_webview_create( let window = app .get_window("main") .ok_or_else(|| "main window not found".to_string())?; - let mut builder = + let builder = tauri::webview::WebviewBuilder::new(request.label, tauri::WebviewUrl::External(url)) .initialization_script(video_decoder_compatibility_script()) .transparent(false) .background_color(tauri::window::Color(0, 0, 0, 255)); #[cfg(any(debug_assertions, feature = "devtools"))] - { - builder = builder.devtools(true); - } + let builder = builder.devtools(true); let webview = window .add_child( diff --git a/src/apps/desktop/src/api/clipboard_file_api.rs b/src/apps/desktop/src/api/clipboard_file_api.rs index 1c130addf3..3c2c19ea1b 100644 --- a/src/apps/desktop/src/api/clipboard_file_api.rs +++ b/src/apps/desktop/src/api/clipboard_file_api.rs @@ -131,6 +131,9 @@ mod windows_clipboard { } pub(super) fn get_clipboard_files() -> Result, String> { + // SAFETY: All clipboard calls are user32/shell32 FFI with no unsafe + // pointer dereferences in this block; hdrop from GetClipboardData is + // null-checked before use and the clipboard is closed via the guard. unsafe { if IsClipboardFormatAvailable(CF_HDROP) == 0 { return Ok(Vec::new()); @@ -143,6 +146,8 @@ mod windows_clipboard { struct ClipboardGuard; impl Drop for ClipboardGuard { fn drop(&mut self) { + // SAFETY: CloseClipboard takes no arguments and matches the + // OpenClipboard call in the enclosing function. unsafe { CloseClipboard(); } diff --git a/src/apps/desktop/src/api/remote_connect_api.rs b/src/apps/desktop/src/api/remote_connect_api.rs index 564b163a3e..0230769e60 100644 --- a/src/apps/desktop/src/api/remote_connect_api.rs +++ b/src/apps/desktop/src/api/remote_connect_api.rs @@ -3924,11 +3924,10 @@ async fn account_auto_sync_inner( match result { Ok(version) => { let done = completed.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; - let percent = if upload_total == 0 { - 95u8 - } else { - 20 + ((75 * done) / upload_total) as u8 - }; + let percent = (75 * done) + .checked_div(upload_total) + .map(|part| 20 + part as u8) + .unwrap_or(95u8); if ensure_account_auto_sync_current(sync_operation_id).is_err() { return Err("account sync cancelled".to_string()); } @@ -4093,7 +4092,6 @@ fn start_settings_sync_engine() { on_token_expired: Some(std::sync::Arc::new(|| { TOKEN_EXPIRED.store(true, std::sync::atomic::Ordering::Relaxed); })), - ..Default::default() }; settings_sync::start_settings_sync_engine(hooks); } diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index 2d772df151..59ba4bf1dc 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -372,6 +372,9 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = RemoteWorkspacePolicy::LegacyUnaudited, ), ("delete_session", RemoteWorkspacePolicy::LegacyUnaudited), + // Cascade deletion resolves the remote session storage path through the + // same desktop session scope as the single delete command. + ("delete_session_tree", RemoteWorkspacePolicy::RemoteRouted), ("delete_skill", RemoteWorkspacePolicy::LegacyUnaudited), ("delete_subagent", RemoteWorkspacePolicy::LegacyUnaudited), // Detached dispatch is routed by its own immutable target and observer @@ -928,6 +931,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "list_persisted_sessions", RemoteWorkspacePolicy::LegacyUnaudited, ), + ( + "list_deleted_session_ids", + RemoteWorkspacePolicy::RemoteUnsupported, + ), ( "list_persisted_sessions_page", RemoteWorkspacePolicy::LegacyUnaudited, diff --git a/src/apps/desktop/src/api/session_api.rs b/src/apps/desktop/src/api/session_api.rs index abacb34899..24569fd049 100644 --- a/src/apps/desktop/src/api/session_api.rs +++ b/src/apps/desktop/src/api/session_api.rs @@ -43,6 +43,10 @@ pub struct ListPersistedSessionsRequest { pub remote_connection_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub remote_ssh_host: Option, + /// When true, hidden Subagent/Ephemeral sessions are included in the + /// result (full conversation management). + #[serde(default)] + pub include_hidden: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -55,6 +59,10 @@ pub struct ListPersistedSessionsPageRequest { pub remote_connection_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub remote_ssh_host: Option, + /// When true, hidden Subagent/Ephemeral sessions are included in the page + /// (full conversation management). + #[serde(default)] + pub include_hidden: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -270,7 +278,43 @@ pub async fn list_persisted_sessions( ) -> Result, String> { runtime .session_application() - .list_persisted_sessions(desktop_session_scope( + .list_persisted_sessions_with_options( + desktop_session_scope( + request.workspace_path, + request.remote_connection_id, + request.remote_ssh_host, + ), + request.include_hidden, + ) + .await + .map_err(|error| { + format!( + "Failed to list persisted sessions: {}", + desktop_session_error(error) + ) + }) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ListDeletedSessionIdsRequest { + pub workspace_path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_connection_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_ssh_host: Option, +} + +/// List session ids recorded in the workspace deletion tombstone registry. +/// The frontend initialization path pulls this registry to guard against +/// ghost resurrection of deleted subagent sessions after a restart. +#[tauri::command] +pub async fn list_deleted_session_ids( + request: ListDeletedSessionIdsRequest, + runtime: State<'_, DesktopRuntimeContext>, +) -> Result, String> { + runtime + .session_application() + .list_deleted_session_ids(desktop_session_scope( request.workspace_path, request.remote_connection_id, request.remote_ssh_host, @@ -278,7 +322,7 @@ pub async fn list_persisted_sessions( .await .map_err(|error| { format!( - "Failed to list persisted sessions: {}", + "Failed to list deleted session ids: {}", desktop_session_error(error) ) }) @@ -355,7 +399,7 @@ pub async fn search_referenceable_sessions( } } - candidates.sort_by(|left, right| right.last_activity_at.cmp(&left.last_activity_at)); + candidates.sort_by_key(|right| std::cmp::Reverse(right.last_activity_at)); candidates.truncate(limit); Ok(candidates) } @@ -369,7 +413,7 @@ pub async fn list_persisted_sessions_page( let trace_started = Instant::now(); let result = runtime .session_application() - .list_persisted_sessions_page( + .list_persisted_sessions_page_with_options( desktop_session_scope( request.workspace_path, request.remote_connection_id, @@ -377,6 +421,7 @@ pub async fn list_persisted_sessions_page( ), request.cursor.as_deref(), request.limit, + request.include_hidden, ) .await .map_err(|error| { diff --git a/src/apps/desktop/src/computer_use/desktop_host/mod.rs b/src/apps/desktop/src/computer_use/desktop_host/mod.rs index 929303c55b..b8bb4ae685 100644 --- a/src/apps/desktop/src/computer_use/desktop_host/mod.rs +++ b/src/apps/desktop/src/computer_use/desktop_host/mod.rs @@ -449,6 +449,9 @@ end tell"#]) }; unsafe { + // SAFETY: All four Win32 calls write into stack-allocated buffers + // (POINT, pid, [u16; 512]); HWND validity is checked via is_invalid() + // before any dereference. let mut pt = POINT::default(); let pointer = if GetCursorPos(&mut pt).is_ok() { Some(ComputerUsePointerGlobal { @@ -525,6 +528,9 @@ end tell"#]) }; use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; unsafe { + // SAFETY: OpenProcessToken/GetTokenInformation/CloseHandle take + // stack-allocated handles and buffers owned by this frame; the + // token handle is always closed on every path. let mut token = HANDLE::default(); if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token).is_err() { return false; @@ -659,6 +665,7 @@ end tell"#]) use windows::Win32::Foundation::POINT; use windows::Win32::UI::WindowsAndMessaging::GetCursorPos; unsafe { + // SAFETY: GetCursorPos writes into a stack-allocated POINT. let mut pt = POINT::default(); if GetCursorPos(&mut pt).is_ok() { (pt.x as f64, pt.y as f64) @@ -776,6 +783,8 @@ impl DesktopComputerUseHost { let hwnd_raw = { let target_hwnd = if app_selector_is_unspecified(&app) { + // SAFETY: GetForegroundWindow takes no arguments and returns + // an owned HWND; validity is checked by the caller below. unsafe { GetForegroundWindow() } } else { let pid = resolve_pid(self, &app).await? as u32; diff --git a/src/apps/desktop/src/computer_use/desktop_host/pointer_input.rs b/src/apps/desktop/src/computer_use/desktop_host/pointer_input.rs index cd4e1705eb..df51156dca 100644 --- a/src/apps/desktop/src/computer_use/desktop_host/pointer_input.rs +++ b/src/apps/desktop/src/computer_use/desktop_host/pointer_input.rs @@ -406,6 +406,8 @@ impl DesktopComputerUseHost { } let hwnd = HWND(hwnd_raw as *mut std::ffi::c_void); let mut rect = RECT::default(); + // SAFETY: GetWindowRect writes into a stack-allocated RECT; the HWND was + // built from a non-zero raw handle checked above. if unsafe { GetWindowRect(hwnd, &mut rect) }.is_err() { return None; } diff --git a/src/apps/desktop/src/computer_use/screen_ocr.rs b/src/apps/desktop/src/computer_use/screen_ocr.rs index 5b66830a97..40cca0b0b5 100644 --- a/src/apps/desktop/src/computer_use/screen_ocr.rs +++ b/src/apps/desktop/src/computer_use/screen_ocr.rs @@ -555,7 +555,11 @@ mod windows_backend { // This must run on a thread initialized with COINIT_APARTMENTTHREADED // Windows.Media.Ocr requires STA thread let mut co_init = None; + // SAFETY: CoIncrementMTAUsage is a thread-affine COM call with no + // unsafe arguments; its result is checked below. if unsafe { CoIncrementMTAUsage() }.is_err() { + // SAFETY: CoInitializeEx is a thread-affine COM init call; the + // HRESULT is checked and matched by CoUninitialize on this thread. let hr = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE) }; if hr.is_err() { @@ -632,6 +636,7 @@ mod windows_backend { // Uninitialize COM if we initialized it if co_init.is_some() { + // SAFETY: Matches the CoInitializeEx call on this same thread above. unsafe { CoUninitialize() }; } diff --git a/src/apps/desktop/src/computer_use/ui_locate_common.rs b/src/apps/desktop/src/computer_use/ui_locate_common.rs index fb22a018fc..ecbc9c0150 100644 --- a/src/apps/desktop/src/computer_use/ui_locate_common.rs +++ b/src/apps/desktop/src/computer_use/ui_locate_common.rs @@ -423,6 +423,8 @@ mod tests { /// the platform-specific constructors. We only need the fields the /// mapping function reads. fn fake_display(x: i32, y: i32, w: u32, h: u32, scale: f32) -> DisplayInfo { + // SAFETY: Every field of the synthetic DisplayInfo is written right + // below, so the zeroed-initialized value is never read partially. let mut d: DisplayInfo = unsafe { std::mem::zeroed() }; d.x = x; d.y = y; diff --git a/src/apps/desktop/src/computer_use/windows_ax_shortcuts.rs b/src/apps/desktop/src/computer_use/windows_ax_shortcuts.rs index 00c3b893e7..666b02e07c 100644 --- a/src/apps/desktop/src/computer_use/windows_ax_shortcuts.rs +++ b/src/apps/desktop/src/computer_use/windows_ax_shortcuts.rs @@ -15,6 +15,9 @@ #![cfg(target_os = "windows")] #![allow(dead_code)] +// All unsafe blocks are single Win32/UIA COM calls through the windows crate; +// COM pointers are validated by the windows crate wrappers before invocation. +#![allow(clippy::undocumented_unsafe_blocks)] use crate::computer_use::windows_ax_ui::build_updated_cache_with_retry; use bitfun_core::agentic::tools::computer_use_host::{ diff --git a/src/apps/desktop/src/computer_use/windows_ax_ui.rs b/src/apps/desktop/src/computer_use/windows_ax_ui.rs index b87ef612d9..0249b4671c 100644 --- a/src/apps/desktop/src/computer_use/windows_ax_ui.rs +++ b/src/apps/desktop/src/computer_use/windows_ax_ui.rs @@ -24,6 +24,9 @@ // follow-up step. Until then, suppress dead-code lints without weakening real // warnings elsewhere. #![allow(dead_code)] +// All unsafe blocks are single Win32/UIA COM calls through the windows crate; +// COM pointers are validated by the windows crate wrappers before invocation. +#![allow(clippy::undocumented_unsafe_blocks)] use crate::computer_use::ui_locate_common; use bitfun_core::agentic::tools::computer_use_host::{ diff --git a/src/apps/desktop/src/computer_use/windows_bg_input.rs b/src/apps/desktop/src/computer_use/windows_bg_input.rs index 7a7f1037e2..36f395f2be 100644 --- a/src/apps/desktop/src/computer_use/windows_bg_input.rs +++ b/src/apps/desktop/src/computer_use/windows_bg_input.rs @@ -39,6 +39,10 @@ // follow-up step. Until then, suppress dead-code lints without weakening real // warnings elsewhere. #![allow(dead_code)] +// All unsafe blocks are single Win32 API calls through the windows crate or +// thin extern "system" FFI wrappers; handles/pointers are validated before use, +// so per-block SAFETY comments would repeat the same invariant. +#![allow(clippy::undocumented_unsafe_blocks)] use std::ffi::c_void; use std::sync::{Mutex, MutexGuard, TryLockError}; diff --git a/src/apps/desktop/src/computer_use/windows_capture.rs b/src/apps/desktop/src/computer_use/windows_capture.rs index b2715086e7..dd925540d4 100644 --- a/src/apps/desktop/src/computer_use/windows_capture.rs +++ b/src/apps/desktop/src/computer_use/windows_capture.rs @@ -36,6 +36,9 @@ //! applied (scaling would shift and oversize the captured region). #![allow(dead_code)] +// All unsafe blocks are single Win32/GDI/DWM API calls through the windows +// crate; handles and rect pointers are stack-allocated and validated. +#![allow(clippy::undocumented_unsafe_blocks)] use bitfun_core::util::errors::{BitFunError, BitFunResult}; use image::{DynamicImage, ImageBuffer, ImageFormat, Rgba}; diff --git a/src/apps/desktop/src/computer_use/windows_list_apps.rs b/src/apps/desktop/src/computer_use/windows_list_apps.rs index d7cf0cdc41..f6fd6433ba 100644 --- a/src/apps/desktop/src/computer_use/windows_list_apps.rs +++ b/src/apps/desktop/src/computer_use/windows_list_apps.rs @@ -14,6 +14,9 @@ #![cfg(target_os = "windows")] #![allow(dead_code)] +// All unsafe blocks are single Win32 API calls through the windows crate or the +// local extern "system" declarations; handles are null-checked before use. +#![allow(clippy::undocumented_unsafe_blocks)] use std::collections::HashMap; use std::ffi::c_void; diff --git a/src/apps/desktop/src/computer_use/windows_msaa.rs b/src/apps/desktop/src/computer_use/windows_msaa.rs index c2da604814..560ed4b488 100644 --- a/src/apps/desktop/src/computer_use/windows_msaa.rs +++ b/src/apps/desktop/src/computer_use/windows_msaa.rs @@ -40,6 +40,9 @@ //! desktop host. #![allow(dead_code)] +// All unsafe blocks are single MSAA/oleacc COM calls through the windows crate; +// IAccessible pointers are validated by the windows crate wrappers. +#![allow(clippy::undocumented_unsafe_blocks)] use std::ptr::null_mut; diff --git a/src/apps/desktop/src/computer_use/windows_wgc_capture.rs b/src/apps/desktop/src/computer_use/windows_wgc_capture.rs index e5fde82153..51bc51d62e 100644 --- a/src/apps/desktop/src/computer_use/windows_wgc_capture.rs +++ b/src/apps/desktop/src/computer_use/windows_wgc_capture.rs @@ -4,6 +4,9 @@ //! DirectComposition / UWP / WinUI3 surfaces. Requires Windows 10 1903+. #![allow(dead_code)] +// All unsafe blocks are single Win32/WinRT API calls through the windows crate; +// HWND validity is checked before any FFI call (see capture_window_bgra). +#![allow(clippy::undocumented_unsafe_blocks)] use bitfun_core::util::errors::{BitFunError, BitFunResult}; use std::time::{Duration, Instant}; diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 912032e0f7..81080af8be 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -600,6 +600,7 @@ pub async fn run() { app_state.workspace_service.clone(), app_state.ssh_manager.clone(), app_state.acp_client_service.clone(), + ai_client_factory.clone(), ) { Ok(runtime) => runtime, Err(error) => { @@ -607,6 +608,24 @@ pub async fn run() { return; } }; + // ACP session lifecycle bridge: keeps the external ACP client process in + // sync with agentic session lifecycle events (start on `acp__*` session + // creation, release on deletion, cancel on dialog turn cancellation). + // Registered after AppState is available; the event router is the same + // instance created by `init_agentic_system`. + event_router.subscribe_internal( + "acp_session_lifecycle".to_string(), + Arc::new(runtime::AcpSessionLifecycleSubscriber::new( + app_state.acp_client_service.clone(), + )), + ); + // Dedicated ACP tool family (`acp_control`/`acp_message`/`acp_history`) + // reaches the real external ACP process through this port; core keeps no + // dependency on the ACP crate. + coordinator.set_acp_client_port(Arc::new(runtime::DesktopAcpClientPort::new( + app_state.acp_client_service.clone(), + Some(coordinator.clone()), + ))); startup_timings.record_elapsed("initialize_desktop_agent_runtime", step_started); startup_trace.record_elapsed_step( "native_pre_tauri", @@ -1164,6 +1183,7 @@ pub async fn run() { api::agentic_api::read_background_command_output, api::agentic_api::list_background_command_activities, api::agentic_api::delete_session, + api::agentic_api::delete_session_tree, api::agentic_api::restore_session, api::agentic_api::restore_session_view, api::agentic_api::load_session_turn_window, @@ -1434,6 +1454,7 @@ pub async fn run() { list_persisted_sessions, search_referenceable_sessions, list_persisted_sessions_page, + list_deleted_session_ids, get_session_lineage, load_session_turns, get_session_usage_report, diff --git a/src/apps/desktop/src/runtime/acp_client_port.rs b/src/apps/desktop/src/runtime/acp_client_port.rs new file mode 100644 index 0000000000..0d5a9dc3da --- /dev/null +++ b/src/apps/desktop/src/runtime/acp_client_port.rs @@ -0,0 +1,527 @@ +//! Desktop-side implementation of the ACP client runtime port. +//! +//! Bridges `bitfun_runtime_ports::AcpClientPort` to the real +//! `AcpClientService` owned by the desktop host. Core tools never touch the +//! ACP crate; this file is the desktop injection point of the dedicated ACP +//! tool family (`acp_control` / `acp_message` / `acp_history`). +//! +//! Every method forwards to the external ACP client process through the +//! manager service (true bridge, never a local model consumption path). + +use std::sync::Arc; + +use async_trait::async_trait; +use bitfun_acp::client::AcpClientStreamEvent; +use bitfun_acp::AcpClientService; +use bitfun_core::agentic::coordination::ConversationCoordinator; +use bitfun_core::service::remote_ssh::workspace_state::get_effective_session_path; +use bitfun_events::AgenticEvent; +use bitfun_runtime_ports::{ + acp_backend_error, AcpClientBitfunMessageRequest, AcpClientCancelRequest, AcpClientCreateRequest, + AcpClientCreateResult, AcpClientHistoryEntry, AcpClientHistoryRequest, AcpClientHistoryResult, + AcpClientListResult, AcpClientMessageRequest, AcpClientMessageResult, AcpClientPort, + AcpClientReleaseRequest, AcpClientStreamChunk, AcpClientStreamChunkSink, AcpClientSummary, + PortErrorKind, PortResult, RuntimeServiceCapability, RuntimeServicePort, +}; + +/// Desktop implementation of [`AcpClientPort`] over the real ACP client service. +pub(crate) struct DesktopAcpClientPort { + acp_client_service: Option>, + coordinator: Option>, +} + +impl std::fmt::Debug for DesktopAcpClientPort { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DesktopAcpClientPort") + .field( + "acp_client_service", + &self + .acp_client_service + .as_ref() + .map(|_| ""), + ) + .field( + "coordinator", + &self.coordinator.as_ref().map(|_| ""), + ) + .finish() + } +} + +impl DesktopAcpClientPort { + pub(crate) fn new( + acp_client_service: Option>, + coordinator: Option>, + ) -> Self { + Self { + acp_client_service, + coordinator, + } + } + + fn service(&self) -> PortResult<&Arc> { + self.acp_client_service + .as_ref() + .ok_or_else(|| acp_backend_error("ACP client service not initialized")) + } + + fn coordinator(&self) -> PortResult<&Arc> { + self.coordinator + .as_ref() + .ok_or_else(|| acp_backend_error("coordinator not initialized")) + } + + async fn session_storage_path( + &self, + workspace_path: Option<&str>, + ) -> PortResult { + let workspace_path = workspace_path + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + bitfun_runtime_ports::PortError::new( + PortErrorKind::InvalidRequest, + "workspace_path is required to resolve the ACP session storage path", + ) + })?; + Ok(get_effective_session_path(workspace_path, None, None).await) + } + + /// Stream one prompt through the real ACP channel. + /// + /// Translates the ACP crate's `AcpClientStreamEvent` stream into the + /// boundary `AcpClientStreamChunk` sequence pushed into `chunk_sink`. + /// `Text` chunks are accumulated so the returned full response text stays + /// equivalent to the non-streaming `prompt_agent` path; `Thought` chunks + /// are forwarded as informational chunks but excluded from the response. + async fn prompt_agent_streamed( + &self, + client_id: &str, + message: String, + workspace_path: Option, + bitfun_session_id: String, + timeout_seconds: Option, + chunk_sink: AcpClientStreamChunkSink, + ) -> PortResult { + let service = self.service()?.clone(); + let mut response = String::new(); + service + .prompt_agent_stream( + client_id, + message, + workspace_path, + None, + bitfun_session_id.clone(), + None, + timeout_seconds, + |event| { + match event { + AcpClientStreamEvent::AgentText(text) => { + response.push_str(&text); + let _ = chunk_sink.send(AcpClientStreamChunk::Text { text }); + } + AcpClientStreamEvent::AgentThought(text) => { + let _ = chunk_sink.send(AcpClientStreamChunk::Thought { text }); + } + AcpClientStreamEvent::Completed => { + let _ = chunk_sink.send(AcpClientStreamChunk::Completed); + } + AcpClientStreamEvent::Cancelled => { + let _ = chunk_sink.send(AcpClientStreamChunk::Cancelled); + } + _ => {} + } + Ok(()) + }, + ) + .await + .map_err(|error| acp_backend_error(format!("ACP agent failed: {error}")))?; + Ok(response) + } +} + +impl RuntimeServicePort for DesktopAcpClientPort { + fn capability(&self) -> RuntimeServiceCapability { + RuntimeServiceCapability::AcpClient + } +} + +#[async_trait] +impl AcpClientPort for DesktopAcpClientPort { + async fn create_session( + &self, + request: AcpClientCreateRequest, + ) -> PortResult { + let service = self.service()?.clone(); + let session_storage_path = self + .session_storage_path(Some(&request.workspace_path)) + .await?; + + // Mirrors the FlowChat path (`create_acp_flow_session`): create the + // persisted record first, then start the external client process and + // roll the record back when the process start fails so no orphan + // record is left behind. + let response = service + .create_flow_session_record( + &session_storage_path, + &request.workspace_path, + &request.client_id, + request.session_name, + ) + .await + .map_err(|error| acp_backend_error(format!("failed to create ACP session: {error}")))?; + + if let Err(error) = service + .start_client_for_session( + &request.client_id, + &response.session_id, + Some(&request.workspace_path), + request.remote_connection_id.as_deref(), + ) + .await + { + if let Err(cleanup_error) = service + .delete_flow_session_record(&session_storage_path, &response.session_id) + .await + { + log::warn!( + "Failed to delete ACP session record after client start failure: session_id={}, error={}", + response.session_id, + cleanup_error + ); + } + return Err(acp_backend_error(format!( + "failed to start ACP client for session: {error}" + ))); + } + + // Broadcast `agentic://session-created` so the frontend can register + // the external ACP session (payload shape mirrors the FlowChat + // `create_acp_flow_session` emit in acp_client_api.rs). Best-effort: + // a missing coordinator only drops the UI event, never the session. + if let Some(coordinator) = self.coordinator.as_ref() { + coordinator + .emit_event(AgenticEvent::SessionCreated { + session_id: response.session_id.clone(), + session_name: response.session_name.clone(), + agent_type: response.agent_type.clone(), + workspace_path: Some(request.workspace_path.clone()), + project_workspace_path: None, + execution_target: None, + workspace_id: None, + remote_connection_id: request.remote_connection_id.clone(), + remote_ssh_host: None, + parent_session_id: None, + subagent_type: None, + }) + .await; + } + + Ok(AcpClientCreateResult { + session_id: response.session_id, + session_name: response.session_name, + agent_type: response.agent_type, + }) + } + + async fn list_clients(&self) -> PortResult { + let service = self.service()?.clone(); + let infos = service + .list_clients() + .await + .map_err(|error| acp_backend_error(format!("failed to list ACP clients: {error}")))?; + Ok(AcpClientListResult { + clients: infos + .into_iter() + .map(|info| AcpClientSummary { + client_id: info.id, + name: info.name, + status: format!("{:?}", info.status), + session_count: info.session_count, + readonly: info.readonly, + }) + .collect(), + }) + } + + async fn release_session(&self, request: AcpClientReleaseRequest) -> PortResult<()> { + let service = self.service()?.clone(); + // Idempotent: releasing a session that has no live external process is + // a no-op success, matching the session lifecycle bridge semantics. A + // `false` return still means "nothing live to release", which is worth + // surfacing so callers can tell an expected no-op from a lost binding. + if !service.release_bitfun_session(&request.session_id).await { + log::warn!( + "ACP release_bitfun_session reported no live session: session_id={}", + request.session_id + ); + } + Ok(()) + } + + async fn cancel_session(&self, request: AcpClientCancelRequest) -> PortResult<()> { + let service = self.service()?.clone(); + let _cancelled = service + .cancel_bitfun_session(&request.session_id) + .await + .map_err(|error| acp_backend_error(format!("failed to cancel ACP session: {error}")))?; + Ok(()) + } + + async fn send_message( + &self, + request: AcpClientMessageRequest, + ) -> PortResult { + let service = self.service()?.clone(); + let client_id = client_id_from_session_id(&request.session_id).ok_or_else(|| { + bitfun_runtime_ports::PortError::new( + PortErrorKind::InvalidRequest, + format!( + "session_id '{}' is not an ACP flow session id (expected acp__)", + request.session_id + ), + ) + })?; + let response = service + .prompt_agent( + &client_id, + request.message, + request.workspace_path, + None, + request.session_id.clone(), + None, + request.timeout_seconds, + ) + .await + .map_err(|error| acp_backend_error(format!("ACP agent failed: {error}")))?; + Ok(AcpClientMessageResult { + session_id: request.session_id, + response, + }) + } + + async fn send_message_stream( + &self, + request: AcpClientMessageRequest, + chunk_sink: AcpClientStreamChunkSink, + ) -> PortResult { + let client_id = client_id_from_session_id(&request.session_id).ok_or_else(|| { + bitfun_runtime_ports::PortError::new( + PortErrorKind::InvalidRequest, + format!( + "session_id '{}' is not an ACP flow session id (expected acp__)", + request.session_id + ), + ) + })?; + let response = self + .prompt_agent_streamed( + &client_id, + request.message, + request.workspace_path, + request.session_id.clone(), + request.timeout_seconds, + chunk_sink, + ) + .await?; + Ok(AcpClientMessageResult { + session_id: request.session_id, + response, + }) + } + + async fn send_message_to_bitfun_session( + &self, + request: AcpClientBitfunMessageRequest, + ) -> PortResult { + let service = self.service()?.clone(); + // Same forwarding shape as AcpAgentTool::call_impl (the + // `acp____prompt` bridge tool): the external process is + // addressed by the internal BitFun session id, so the conversation + // state is shared with the delegated-turn path. + // 参考 bitfun-acp interfaces/acp/src/client/tool.rs:157-168 — + // AcpAgentTool::call_impl → service.prompt_agent,Rust 翻译实现 + let response = service + .prompt_agent( + &request.client_id, + request.message, + request.workspace_path, + None, + request.bitfun_session_id.clone(), + None, + request.timeout_seconds, + ) + .await + .map_err(|error| acp_backend_error(format!("ACP agent failed: {error}")))?; + Ok(AcpClientMessageResult { + session_id: request.bitfun_session_id, + response, + }) + } + + async fn send_message_to_bitfun_session_stream( + &self, + request: AcpClientBitfunMessageRequest, + chunk_sink: AcpClientStreamChunkSink, + ) -> PortResult { + let response = self + .prompt_agent_streamed( + &request.client_id, + request.message, + request.workspace_path, + request.bitfun_session_id.clone(), + request.timeout_seconds, + chunk_sink, + ) + .await?; + Ok(AcpClientMessageResult { + session_id: request.bitfun_session_id, + response, + }) + } + + async fn delete_session_record( + &self, + session_id: String, + workspace_path: Option, + ) -> PortResult<()> { + let service = self.service()?.clone(); + // Resolve the storage path up front: a missing workspace would + // otherwise release the process without removing the persisted record, + // silently leaving an orphan record that keeps the recycled session in + // listings. Reject with InvalidRequest instead of half-cleaning. + let Some(workspace_path) = workspace_path.as_deref() else { + return Err(bitfun_runtime_ports::PortError::new( + PortErrorKind::InvalidRequest, + "workspace_path is required to delete the ACP session record; refusing to release-only (would leave an orphan record)", + )); + }; + let session_storage_path = self.session_storage_path(Some(workspace_path)).await?; + // Release the external process if one is bound to the session + // (idempotent), then remove the persisted flow-session record so the + // recycled session stops appearing in listings. + if !service.release_bitfun_session(&session_id).await { + log::warn!( + "ACP release_bitfun_session reported no live session during delete_session_record: session_id={}", + session_id + ); + } + service + .delete_flow_session_record(&session_storage_path, &session_id) + .await + .map_err(|error| { + acp_backend_error(format!("failed to delete ACP session record: {error}")) + })?; + Ok(()) + } + + async fn read_history( + &self, + request: AcpClientHistoryRequest, + ) -> PortResult { + let coordinator = self.coordinator()?.clone(); + let session_storage_path = self.session_storage_path(request.workspace_path.as_deref()).await?; + let turns = coordinator + .load_visible_persisted_session_turns(&session_storage_path, &request.session_id) + .await + .map_err(|error| acp_backend_error(format!("failed to read session turns: {error}")))?; + + let mut entries = Vec::with_capacity(turns.len() * 2); + for turn in turns { + entries.push(AcpClientHistoryEntry { + role: "user".to_string(), + content: turn.user_message.content, + timestamp_ms: Some(turn.user_message.timestamp), + }); + let assistant_text = turn + .model_rounds + .iter() + .flat_map(|round| round.text_items.iter()) + .map(|item| item.content.as_str()) + .collect::>() + .join("\n"); + if !assistant_text.trim().is_empty() { + entries.push(AcpClientHistoryEntry { + role: "assistant".to_string(), + content: assistant_text, + timestamp_ms: Some(turn.timestamp), + }); + } + } + + Ok(AcpClientHistoryResult { + session_id: request.session_id, + entries, + truncated: false, + }) + } +} + +/// Parse the ACP client id out of a flow session id. +/// +/// Flow session ids have the shape `acp__`; the client id is +/// everything between the `acp_` prefix and the final uuid segment. The trailing +/// segment must be a canonical uuid (length 36, dashed, hex) — matching the +/// strict `SessionMessage` detection — so an internal session id that merely +/// starts with `acp_` is never mistaken for a flow session, and an empty client +/// id (`acp__`) is rejected. +fn client_id_from_session_id(session_id: &str) -> Option { + let rest = session_id.strip_prefix("acp_")?; + let (client_id, uuid_segment) = rest.rsplit_once('_')?; + if client_id.is_empty() || !looks_like_uuid(uuid_segment) { + return None; + } + Some(client_id.to_string()) +} + +/// Dependency-free canonical uuid shape guard for flow-session ids. +fn looks_like_uuid(segment: &str) -> bool { + segment.len() == 36 + && segment.bytes().enumerate().all(|(index, byte)| { + if matches!(index, 8 | 13 | 18 | 23) { + byte == b'-' + } else { + byte.is_ascii_hexdigit() + } + }) +} + +#[cfg(test)] +mod tests { + use super::client_id_from_session_id; + + #[test] + fn client_id_parses_from_flow_session_id() { + assert_eq!( + client_id_from_session_id("acp_codex_7f0e1a2b-3c4d-4e5f-8a9b-0c1d2e3f4a5b").as_deref(), + Some("codex") + ); + } + + #[test] + fn client_id_parses_client_ids_containing_underscores() { + assert_eq!( + client_id_from_session_id("acp_claude_code_7f0e1a2b-3c4d-4e5f-8a9b-0c1d2e3f4a5b") + .as_deref(), + Some("claude_code") + ); + } + + #[test] + fn client_id_rejects_non_acp_session_ids() { + assert!(client_id_from_session_id("session-123").is_none()); + assert!(client_id_from_session_id("acp_codex").is_none()); + assert!(client_id_from_session_id("").is_none()); + } + + #[test] + fn client_id_rejects_non_uuid_trailing_segment() { + // 与 SessionMessage 严格版一致:尾段必须是规范 uuid,非 uuid 一律拒绝 + assert!(client_id_from_session_id("acp_codex_s1").is_none()); + assert!(client_id_from_session_id("acp_codex_7f0e1a2b-3c4d-4e5f-8a9b").is_none()); + // acp__ 解析出空 client_id,拒绝 + assert!( + client_id_from_session_id("acp__7f0e1a2b-3c4d-4e5f-8a9b-0c1d2e3f4a5b").is_none() + ); + } +} diff --git a/src/apps/desktop/src/runtime/acp_session_lifecycle.rs b/src/apps/desktop/src/runtime/acp_session_lifecycle.rs new file mode 100644 index 0000000000..0729e2dd96 --- /dev/null +++ b/src/apps/desktop/src/runtime/acp_session_lifecycle.rs @@ -0,0 +1,218 @@ +//! Desktop-side ACP session lifecycle bridge. +//! +//! `SessionControl` creates `acp__` sessions as plain internal +//! sessions (the external ACP process is never started by the tool itself). +//! This subscriber bridges the core coordinator's agentic lifecycle events +//! back to the ACP client service so the external process lifecycle follows +//! the internal session lifecycle: +//! +//! - `SessionCreated` with an `acp__*` agent type starts the external client +//! process for that session (idempotent; a running connection is reused). +//! - `SessionDeleted` releases the ACP session, so no external process or +//! remote session outlives the internal session. +//! - `DialogTurnCancelled` cancels the matching ACP dialog turn when the +//! internal turn is cancelled (for example through SessionControl cancel). +//! +//! The bridge only touches the ACP client service from the desktop layer; +//! core keeps no dependency on the ACP service. + +use std::sync::Arc; + +use async_trait::async_trait; +use bitfun_agent_runtime::event_bus::EventSubscriberResult; +use bitfun_agent_runtime::event_router::EventSubscriber; +use bitfun_core::agentic::persistence::PersistenceManager; +use bitfun_core::infrastructure::PathManager; +use bitfun_events::AgenticEvent; + +/// Routes agentic session lifecycle events to the ACP client service. +pub(crate) struct AcpSessionLifecycleSubscriber { + acp_client_service: Option>, +} + +impl AcpSessionLifecycleSubscriber { + pub(crate) fn new(acp_client_service: Option>) -> Self { + let subscriber = Self { + acp_client_service, + }; + subscriber.spawn_startup_orphan_scan(); + subscriber + } + + /// Kick off the one-shot startup orphan scan when a tokio runtime is + /// available (desktop startup). Best-effort: without a runtime or an ACP + /// service the scan is skipped and never fatal. + fn spawn_startup_orphan_scan(&self) { + let Some(service) = self.acp_client_service.clone() else { + return; + }; + let Ok(handle) = tokio::runtime::Handle::try_current() else { + return; + }; + handle.spawn(async move { + let reconciled = Self::scan_and_recover_orphan_connections(&service).await; + log::info!( + "ACP startup orphan scan finished: reconciled_flow_sessions={}", + reconciled + ); + }); + } + + /// Reconcile persisted ACP flow session records against the manager's + /// in-memory connections on startup. + /// + /// After a desktop restart no external ACP connection is live, but + /// persisted flow-session records (`provider=acp` in custom metadata) + /// survive in the local workspace session directories. This scan walks + /// `~/.bitfun/projects/*/sessions` and releases any stale in-memory + /// session binding for every ACP flow record (idempotent no-op when none + /// exists), so a resumed session never inherits a stale connection. Local + /// workspaces only; remote session mirrors are reconciled by the remote + /// host on connect. + async fn scan_and_recover_orphan_connections( + service: &Arc, + ) -> usize { + let path_manager = match PathManager::new() { + Ok(path_manager) => path_manager, + Err(error) => { + log::warn!("ACP orphan scan: failed to initialize PathManager: {}", error); + return 0; + } + }; + let persistence = match PersistenceManager::new(Arc::new(path_manager)) { + Ok(persistence) => persistence, + Err(error) => { + log::warn!( + "ACP orphan scan: failed to initialize PersistenceManager: {}", + error + ); + return 0; + } + }; + let projects_root = persistence.path_manager().projects_root(); + let mut reconciled = 0; + let Ok(entries) = std::fs::read_dir(&projects_root) else { + return 0; + }; + for entry in entries.flatten() { + let sessions_dir = entry.path().join("sessions"); + if !sessions_dir.is_dir() { + continue; + } + let metadata_list = match persistence + .list_session_metadata_including_internal(&sessions_dir) + .await + { + Ok(list) => list, + Err(error) => { + log::warn!( + "ACP orphan scan: failed to list sessions under '{}': {}", + sessions_dir.display(), + error + ); + continue; + } + }; + for metadata in metadata_list { + // 仅处理 ACP 流会话记录(custom_metadata.provider == "acp", + // 与 interfaces/acp session_persistence.rs 的写入口径一致)。 + let is_acp_flow = metadata + .custom_metadata + .as_ref() + .and_then(|custom| custom.get("provider")) + .and_then(serde_json::Value::as_str) + == Some("acp"); + if !is_acp_flow { + continue; + } + // Release any stale in-memory binding for this flow session. + // After a restart there is none, so this is an idempotent + // reconciliation, not a record deletion. + if service.release_bitfun_session(&metadata.session_id).await { + log::info!( + "ACP orphan scan: reclaimed stale connection for flow session: session_id={}", + metadata.session_id + ); + } + reconciled += 1; + } + } + reconciled + } +} + +#[async_trait] +impl EventSubscriber for AcpSessionLifecycleSubscriber { + async fn on_event(&self, event: &AgenticEvent) -> EventSubscriberResult { + match event { + // Start the external ACP client process when an `acp__` + // session is created (SessionControl create path). A missing or + // empty client id (`acp__`) is rejected up front. Failure is an + // error-level log keyed by client_id: the internal session stays + // usable for the forwarding tool, and the process can still be + // started lazily by the first delegated turn. + AgenticEvent::SessionCreated { + session_id, + agent_type, + workspace_path, + remote_connection_id, + .. + } => { + let Some(client_id) = agent_type + .strip_prefix("acp__") + .filter(|client_id| !client_id.trim().is_empty()) + else { + return Ok(()); + }; + let Some(service) = self.acp_client_service.as_ref() else { + return Ok(()); + }; + if let Err(error) = service + .start_client_for_session( + client_id, + session_id, + workspace_path.as_deref(), + remote_connection_id.as_deref(), + ) + .await + { + log::error!( + "Failed to start ACP client for session: session_id={}, client_id={}, error={}", + session_id, + client_id, + error + ); + } + } + // SessionControl delete and the frontend delete both flow through + // coordinator.delete_session_tree, which emits SessionDeleted. + // Releasing here is idempotent and complements the frontend + // delete path's host-effects release. + AgenticEvent::SessionDeleted { session_id } => { + if let Some(service) = self.acp_client_service.as_ref() { + if !service.release_bitfun_session(session_id).await { + log::warn!( + "ACP release_bitfun_session reported no live session on session deletion: session_id={}", + session_id + ); + } + } + } + // SessionControl cancel flows through runtime.cancel_turn; the + // coordinator emits DialogTurnCancelled (duplicates are harmless). + AgenticEvent::DialogTurnCancelled { session_id, .. } => { + if let Some(service) = self.acp_client_service.as_ref() { + if let Err(error) = service.cancel_bitfun_session(session_id).await { + log::warn!( + "Failed to cancel ACP session after dialog turn cancellation: session_id={}, error={}", + session_id, + error + ); + } + } + } + _ => {} + } + Ok(()) + } +} diff --git a/src/apps/desktop/src/runtime/mod.rs b/src/apps/desktop/src/runtime/mod.rs index d5d9189e51..09715ba9f8 100644 --- a/src/apps/desktop/src/runtime/mod.rs +++ b/src/apps/desktop/src/runtime/mod.rs @@ -3,18 +3,26 @@ use std::sync::Arc; use bitfun_agent_runtime::sdk::{AgentRuntime, PermissionRequestEvent}; use bitfun_core::agentic::coordination::{ConversationCoordinator, DialogScheduler}; +use bitfun_core::infrastructure::ai::AIClientFactory; use bitfun_core::product_runtime::CoreLocalWorkspaceSnapshot; use bitfun_core::service::remote_ssh::SSHConnectionManager; use bitfun_core::service::token_usage::TokenUsageService; use bitfun_core::service::workspace::WorkspaceService; -use bitfun_runtime_ports::LocalWorkspaceSnapshotPort; +use bitfun_runtime_ports::{LocalWorkspaceSnapshotPort, WardenModelJudgementPort}; use tokio::sync::RwLock; +mod acp_client_port; +mod acp_session_lifecycle; mod session_application; mod session_host_effects; +mod warden_model_judgement_port; use session_host_effects::ProductionDesktopSessionHostEffects; +pub(crate) use acp_client_port::DesktopAcpClientPort; +pub(crate) use acp_session_lifecycle::AcpSessionLifecycleSubscriber; +pub(crate) use warden_model_judgement_port::DesktopWardenModelJudgementPort; + pub(crate) use session_application::{ DesktopSessionApplication, DesktopSessionApplicationError, DesktopSessionScopeRequest, UiSessionMetadataField, @@ -29,6 +37,12 @@ pub(crate) use session_application::{ pub struct DesktopRuntimeContext { session_application: DesktopSessionApplication, local_workspace_snapshot: Arc, + /// Model-backed Warden judgement provider, assembled here and injected + /// into the scheduler/tool-pipeline audit loop in [`Self::build`] + /// (batch-2 warden rework). The field is intentionally held as the + /// desktop assembly point. + #[allow(dead_code)] + warden_model_judgement: Arc, permission_events_started: AtomicBool, } @@ -40,8 +54,19 @@ impl DesktopRuntimeContext { workspace_service: Arc, ssh_manager: Arc>>, acp_client_service: Option>, + ai_client_factory: Arc, ) -> Result { let host_effects = Arc::new(ProductionDesktopSessionHostEffects::new(acp_client_service)); + // Desktop-side Warden model judgement provider. Batch 2 wires this + // port into the scheduler/tool-pipeline audit loop (the consumer); + // the field stays as the desktop assembly point. + let warden_model_judgement: Arc = + Arc::new(DesktopWardenModelJudgementPort::new(ai_client_factory)); + // Batch-2 injection: the scheduler forwards the port into the tool + // pipeline so Audit-Poke decisions go through the model provider + // (mechanical rule ladder as fallback). Must happen before + // `scheduler` is moved into the session application below. + scheduler.set_warden_model_judgement(warden_model_judgement.clone()); let session_application = DesktopSessionApplication::build( coordinator, scheduler, @@ -55,6 +80,7 @@ impl DesktopRuntimeContext { Ok(Self { session_application, local_workspace_snapshot, + warden_model_judgement, permission_events_started: AtomicBool::new(false), }) } @@ -71,6 +97,13 @@ impl DesktopRuntimeContext { self.local_workspace_snapshot.as_ref() } + /// Warden model judgement port held as the desktop assembly point (the + /// active consumer is the tool pipeline via `scheduler.set_warden_model_judgement`). + #[allow(dead_code)] + pub(crate) fn warden_model_judgement(&self) -> Arc { + self.warden_model_judgement.clone() + } + pub(crate) fn start_permission_event_forwarding( &self, app: tauri::AppHandle, diff --git a/src/apps/desktop/src/runtime/session_application.rs b/src/apps/desktop/src/runtime/session_application.rs index 671bbc3add..60d21a8e96 100644 --- a/src/apps/desktop/src/runtime/session_application.rs +++ b/src/apps/desktop/src/runtime/session_application.rs @@ -380,25 +380,60 @@ impl DesktopSessionApplication { pub(crate) async fn list_persisted_sessions( &self, request: DesktopSessionScopeRequest, + ) -> DesktopSessionApplicationResult> { + self.list_persisted_sessions_with_options(request, false) + .await + } + + pub(crate) async fn list_persisted_sessions_with_options( + &self, + request: DesktopSessionScopeRequest, + include_hidden: bool, ) -> DesktopSessionApplicationResult> { let scope = self.resolved_scope(request).await; let storage_path = self.storage_path(&scope); self.compatibility - .list_persisted_sessions(&storage_path) + .list_persisted_sessions_with_options(&storage_path, include_hidden) + .await + .map_err(|error| DesktopSessionApplicationError::Core(error.to_string())) + } + + /// List session ids recorded in the workspace deletion tombstone registry + /// (frontend ghost-resurrection guard on the initialization path). + pub(crate) async fn list_deleted_session_ids( + &self, + request: DesktopSessionScopeRequest, + ) -> DesktopSessionApplicationResult> { + let scope = self.resolved_scope(request).await; + let storage_path = self.storage_path(&scope); + self.coordinator + .list_deleted_session_ids(&storage_path) .await .map_err(|error| DesktopSessionApplicationError::Core(error.to_string())) } + #[allow(dead_code)] pub(crate) async fn list_persisted_sessions_page( &self, request: DesktopSessionScopeRequest, cursor: Option<&str>, limit: usize, + ) -> DesktopSessionApplicationResult { + self.list_persisted_sessions_page_with_options(request, cursor, limit, false) + .await + } + + pub(crate) async fn list_persisted_sessions_page_with_options( + &self, + request: DesktopSessionScopeRequest, + cursor: Option<&str>, + limit: usize, + include_hidden: bool, ) -> DesktopSessionApplicationResult { let scope = self.resolved_scope(request).await; let storage_path = self.storage_path(&scope); self.compatibility - .list_persisted_sessions_page(&storage_path, cursor, limit) + .list_persisted_sessions_page_with_options(&storage_path, cursor, limit, include_hidden) .await .map_err(|error| DesktopSessionApplicationError::Core(error.to_string())) } @@ -694,6 +729,32 @@ impl DesktopSessionApplication { .await } + /// Cascade-delete a session and its full descendant subtree through the + /// coordinator, then notify the host for every removed session id. + pub(crate) async fn delete_session_tree( + &self, + request: DesktopSessionScopeRequest, + session_id: String, + ) -> DesktopSessionApplicationResult> { + let scope = self.resolved_scope(request).await; + self.ensure_runtime_ownership(&scope)?; + let deleted_session_ids = self + .coordinator + .delete_session_tree( + Path::new(&scope.workspace_path), + scope.remote_connection_id.as_deref(), + scope.resolved_remote_ssh_host.as_deref(), + &session_id, + ) + .await + .map_err(desktop_core_session_error)?; + for deleted_session_id in &deleted_session_ids { + self.host_effects.release_session(deleted_session_id).await; + self.host_effects.notify_session_deleted(deleted_session_id); + } + Ok(deleted_session_ids) + } + pub(crate) async fn rename_session( &self, request: Option, diff --git a/src/apps/desktop/src/runtime/warden_model_judgement_port.rs b/src/apps/desktop/src/runtime/warden_model_judgement_port.rs new file mode 100644 index 0000000000..8de8700ce2 --- /dev/null +++ b/src/apps/desktop/src/runtime/warden_model_judgement_port.rs @@ -0,0 +1,318 @@ +//! Desktop implementation of the Warden model judgement port. +//! +//! Bridges `bitfun_runtime_ports::WardenModelJudgementPort` to a real model +//! call through the desktop `AIClientFactory` (fast model). The judgement +//! prompt embeds the candidate rule ids and the evidence summary; the model +//! response is parsed as JSON into `WardenAuditJudgementResponse`. Any model +//! failure, parse failure, or timeout returns `Err` so the audit caller falls +//! back to the mechanical rule ladder — the judgement port must never block +//! the audit loop on a broken model response. + +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use bitfun_core::infrastructure::ai::AIClientFactory; +use bitfun_core_types::Message; +use bitfun_runtime_ports::{ + PortError, PortErrorKind, PortResult, WardenAuditJudgementRequest, + WardenAuditJudgementResponse, WardenModelJudgementPort, +}; + +/// Time budget for one judgement model call. +/// +/// WARDEN-02: reduced from 30s so a model judgement cannot block an agent +/// turn for a long round-trip; on timeout the caller falls back to the +/// mechanical rule ladder (the audit loop never depends on the model). +const WARDEN_JUDGEMENT_TIMEOUT: Duration = Duration::from_secs(8); + +/// Upper bound for the serialized tool-args summary embedded in the prompt. +/// +/// Defense-in-depth behind the core-side tool-args summarization (WARDEN-08): +/// a summarized args value that still exceeds this is replaced by a length +/// marker so the prompt budget stays bounded. +const WARDEN_JUDGEMENT_PROMPT_ARGS_MAX_CHARS: usize = 2048; + +/// System prompt instructing the model to emit only the judgement JSON. +/// +/// WARDEN-03: this prompt must not hard-code the "first failure of a scene is +/// exploratory and must not poke" rule — that is the runtime's counting +/// semantics, and the evidence passed in already reflects it (the caller +/// supplies the consecutive failure count in `evidence`). The model judges +/// strictly from the provided tool facts and the evidence field; asking it to +/// re-derive exploratory status would make the verdict depend on a rule the +/// model can only guess at. +const WARDEN_JUDGEMENT_SYSTEM_PROMPT: &str = "You are the Warden audit judgement engine \ +of an AI agent host. Given one finished agent action (tool call or turn) and a \ +list of candidate discipline rules, decide whether the agent deserves a poke \ +reminder. Judge strictly from the provided tool facts: the toolName and \ +toolArgs of the action, and the evidence field, which carries the failure \ +context (consecutive failure count and the last error summary when \ +available). A poke is warranted when the evidence shows a repeated failure of \ +the same kind; do not infer exploratory status or first-failure rules that the \ +evidence does not state. Respond with a single JSON object of the shape \ +{\"shouldPoke\": bool, \"ruleIds\": [string], \"evidenceRequested\": [string]}. \ +Do not include any text outside the JSON object."; + +/// Desktop implementation of [`WardenModelJudgementPort`] over the global AI +/// client factory. +pub(crate) struct DesktopWardenModelJudgementPort { + ai_client_factory: Arc, +} + +impl std::fmt::Debug for DesktopWardenModelJudgementPort { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DesktopWardenModelJudgementPort") + .field("ai_client_factory", &"") + .finish() + } +} + +impl DesktopWardenModelJudgementPort { + pub(crate) fn new(ai_client_factory: Arc) -> Self { + Self { ai_client_factory } + } + + /// Build the user prompt embedding every judgement input. + fn judgement_prompt(request: &WardenAuditJudgementRequest) -> String { + let tool_args = request + .tool_args + .as_ref() + .and_then(|value| serde_json::to_string(value).ok()) + .map(|serialized| { + if serialized.len() > WARDEN_JUDGEMENT_PROMPT_ARGS_MAX_CHARS { + format!("{{ \"summaryLength\": {} }}", serialized.len()) + } else { + serialized + } + }) + .unwrap_or_else(|| "null".to_string()); + let evidence = request + .evidence + .as_ref() + .and_then(|value| serde_json::to_string(value).ok()) + .unwrap_or_else(|| "null".to_string()); + format!( + "sessionId: {}\ntoolName: {}\ntoolArgs: {}\ncandidateRuleIds: {}\nevidence: {}", + request.session_id, + request.tool_name, + tool_args, + request.rule_ids.join(", "), + evidence + ) + } + + /// Parse a model judgement response into + /// [`WardenAuditJudgementResponse`]. + /// + /// WARDEN-07: a ```` ```json ```` fence around the JSON is stripped before + /// parsing, and the verdict is parsed strictly — a missing or non-boolean + /// `shouldPoke` is an error, never a silent default of `false` that would + /// suppress a poke. Any error here makes the caller fall back to the + /// mechanical rule ladder. + fn parse_judgement_response(text: &str) -> PortResult { + let text = text.trim(); + if text.is_empty() { + return Err(PortError::new( + PortErrorKind::Backend, + "warden judgement model returned an empty response", + )); + } + let stripped = strip_json_fence(text); + let json: serde_json::Value = serde_json::from_str(stripped).map_err(|error| { + PortError::new( + PortErrorKind::Backend, + format!("warden judgement response is not valid JSON: {error}"), + ) + })?; + match json.get("shouldPoke") { + Some(serde_json::Value::Bool(_)) => {} + _ => { + return Err(PortError::new( + PortErrorKind::Backend, + "warden judgement response is missing a boolean \"shouldPoke\" field", + )); + } + } + serde_json::from_value(json).map_err(|error| { + PortError::new( + PortErrorKind::Backend, + format!("warden judgement response does not match the expected shape: {error}"), + ) + }) + } +} + +/// Strip a ```` ```json ```` or ```` ``` ```` fence around the model response. +/// +/// A model that wraps the JSON in markdown fences still parses; a plain +/// response is returned unchanged. +fn strip_json_fence(text: &str) -> &str { + let trimmed = text.trim(); + let body = trimmed + .strip_prefix("```json") + .or_else(|| trimmed.strip_prefix("```")) + .unwrap_or(trimmed) + .trim(); + body.strip_suffix("```").unwrap_or(body).trim() +} + +#[async_trait] +impl WardenModelJudgementPort for DesktopWardenModelJudgementPort { + async fn judge_audit( + &self, + request: WardenAuditJudgementRequest, + ) -> PortResult { + let client = self + .ai_client_factory + .get_client_resolved("fast") + .await + .map_err(|error| { + PortError::new( + PortErrorKind::Backend, + format!("failed to resolve warden judgement model: {error}"), + ) + })?; + + let messages = vec![ + Message::system(WARDEN_JUDGEMENT_SYSTEM_PROMPT.to_string()), + Message::user(Self::judgement_prompt(&request)), + ]; + + let response = tokio::time::timeout( + WARDEN_JUDGEMENT_TIMEOUT, + client.send_message(messages, None), + ) + .await + .map_err(|_| { + PortError::new( + PortErrorKind::Timeout, + "warden judgement timed out; caller falls back to mechanical rules", + ) + })? + .map_err(|error| { + PortError::new( + PortErrorKind::Backend, + format!("warden judgement model call failed: {error}"), + ) + })?; + + Self::parse_judgement_response(&response.text) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn judgement_prompt_embeds_all_inputs() { + let request = WardenAuditJudgementRequest { + session_id: "sess-1".to_string(), + tool_name: "ExecCommand".to_string(), + tool_args: Some(serde_json::json!({"cmd": "pwd"})), + rule_ids: vec!["iron-rules-compliance".to_string()], + evidence: Some(serde_json::json!({"consecutiveFailures": 2})), + }; + let prompt = DesktopWardenModelJudgementPort::judgement_prompt(&request); + assert!(prompt.contains("sess-1")); + assert!(prompt.contains("ExecCommand")); + assert!(prompt.contains("pwd")); + assert!(prompt.contains("iron-rules-compliance")); + assert!(prompt.contains("consecutiveFailures")); + } + + #[test] + fn judgement_prompt_handles_missing_optional_inputs() { + let request = WardenAuditJudgementRequest { + session_id: "sess-2".to_string(), + tool_name: "Read".to_string(), + tool_args: None, + rule_ids: Vec::new(), + evidence: None, + }; + let prompt = DesktopWardenModelJudgementPort::judgement_prompt(&request); + assert!(prompt.contains("toolName: Read")); + assert!(prompt.contains("toolArgs: null")); + assert!(prompt.contains("candidateRuleIds: ")); + assert!(prompt.contains("evidence: null")); + } + + #[test] + fn judgement_prompt_caps_oversized_tool_args_summary() { + // WARDEN-08 (desktop defense-in-depth): an oversized tool-args summary + // is replaced by a length marker instead of bloating the prompt. + let request = WardenAuditJudgementRequest { + session_id: "sess-3".to_string(), + tool_name: "Write".to_string(), + tool_args: Some(serde_json::json!({ "data": "x".repeat(4096) })), + rule_ids: Vec::new(), + evidence: None, + }; + let prompt = DesktopWardenModelJudgementPort::judgement_prompt(&request); + assert!(prompt.contains("summaryLength"), "oversized args are capped"); + assert!( + !prompt.contains(&"x".repeat(1024)), + "bulk payload must not reach the prompt" + ); + } + + #[test] + fn parse_judgement_response_accepts_fenced_json() { + // WARDEN-07: a ```json fence around the verdict is stripped and parsed. + let verdict = r#"```json + {"shouldPoke": true, "ruleIds": ["R2: execution_safety"], "evidenceRequested": ["tool_call_log"]} + ```"#; + let parsed = DesktopWardenModelJudgementPort::parse_judgement_response(verdict) + .expect("fenced JSON parses"); + assert!(parsed.should_poke); + assert_eq!(parsed.rule_ids, vec!["R2: execution_safety"]); + assert_eq!(parsed.evidence_requested, vec!["tool_call_log"]); + } + + #[test] + fn parse_judgement_response_rejects_empty_and_missing_should_poke() { + // WARDEN-07: empty responses and verdicts missing a boolean + // shouldPoke are errors so the caller falls back to mechanical rules + // instead of silently suppressing the poke. + let empty = DesktopWardenModelJudgementPort::parse_judgement_response(" "); + assert!(empty.is_err(), "empty response is a parse error"); + + let empty_object = + DesktopWardenModelJudgementPort::parse_judgement_response("{}"); + assert!( + empty_object.is_err(), + "an empty object must not default shouldPoke to false" + ); + + let missing_field = DesktopWardenModelJudgementPort::parse_judgement_response( + r#"{"ruleIds": ["R1"]}"#, + ); + assert!( + missing_field.is_err(), + "a missing shouldPoke must not default to false" + ); + + let wrong_type = DesktopWardenModelJudgementPort::parse_judgement_response( + r#"{"shouldPoke": "yes"}"#, + ); + assert!( + wrong_type.is_err(), + "a non-boolean shouldPoke is not a valid verdict" + ); + } + + #[test] + fn parse_judgement_response_accepts_plain_verdict_with_defaults() { + // A bare `shouldPoke` verdict parses; absent rule/evidence lists + // default to empty (which resolve_audit_poke_from_judgement fills + // from the mechanical candidates). + let parsed = DesktopWardenModelJudgementPort::parse_judgement_response( + r#"{"shouldPoke": false}"#, + ) + .expect("bare verdict parses"); + assert!(!parsed.should_poke); + assert!(parsed.rule_ids.is_empty()); + assert!(parsed.evidence_requested.is_empty()); + } +} diff --git a/src/apps/relay-server/tests/library_compat.rs b/src/apps/relay-server/tests/library_compat.rs index c3ee376a09..4670b443b5 100644 --- a/src/apps/relay-server/tests/library_compat.rs +++ b/src/apps/relay-server/tests/library_compat.rs @@ -6,6 +6,7 @@ use std::sync::Arc; use std::time::Instant; #[test] +#[allow(clippy::type_complexity)] // pinned legacy fn-pointer signature on purpose fn legacy_library_path_exposes_supported_relay_api() { let _: fn( Arc, diff --git a/src/crates/adapters/agent-runtime-ipc/src/client.rs b/src/crates/adapters/agent-runtime-ipc/src/client.rs index 835f2ee41c..ff303bc1fb 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/client.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/client.rs @@ -16,6 +16,7 @@ const CLIENT_EVENT_BUFFER: usize = 256; const CLIENT_COMMAND_BUFFER: usize = 64; #[derive(Debug, Clone, PartialEq)] +#[allow(clippy::large_enum_variant)] // IPC event payload is inherently larger; boxing adds indirection on the hot event path pub enum RuntimeIpcClientEvent { Runtime(crate::RuntimeIpcEvent), Disconnected, @@ -70,6 +71,7 @@ enum ClientWriteOutcome { }, } +#[allow(clippy::large_enum_variant)] // operation result is inherently larger than control outcomes enum PendingResponse { Result(RuntimeIpcOperationResult), Remote(RuntimeIpcError), diff --git a/src/crates/adapters/agent-runtime-ipc/src/operation.rs b/src/crates/adapters/agent-runtime-ipc/src/operation.rs index 15558630cd..81f53a8245 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/operation.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/operation.rs @@ -461,6 +461,7 @@ mod tests { turn_id: "turn-1".to_string(), content: "check tests".to_string(), display_content: None, + prepended_reminders: Vec::new(), }, }; let rules = operation.rules(); diff --git a/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs b/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs index 4fb106d322..23bde85e6c 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs @@ -137,6 +137,7 @@ fn protocol_round_trips_exact_turn_steering_without_replacing_turn_admission() { turn_id: "turn-1".to_string(), content: "check tests".to_string(), display_content: Some("Check tests".to_string()), + prepended_reminders: Vec::new(), }, }; let result = RuntimeIpcOperationResult::TurnSteered { diff --git a/src/crates/adapters/agent-runtime-ipc/src/tests/shared_controller.rs b/src/crates/adapters/agent-runtime-ipc/src/tests/shared_controller.rs index b36b557ad4..e5bf02dba3 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/tests/shared_controller.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/tests/shared_controller.rs @@ -613,6 +613,9 @@ fn summary(session_id: &str) -> AgentSessionSummary { turn_count: 0, created_at_ms: 1, last_active_at_ms: 1, + parent_session_id: None, + status: None, + is_daemon: false, } } @@ -705,6 +708,7 @@ fn steer_operation(session_id: &str, turn_id: &str) -> RuntimeIpcOperation { turn_id: turn_id.to_string(), content: "check tests".to_string(), display_content: None, + prepended_reminders: Vec::new(), }, } } @@ -1467,16 +1471,17 @@ async fn rename_requires_the_controlled_idle_session() { ) .await; - let calls = handler.calls.lock().expect("calls"); - assert_eq!( - calls - .iter() - .filter(|operation| matches!(operation, RuntimeIpcOperation::RenameSession { .. })) - .count(), - 1, - "only the controlled idle-session rename reaches the Runtime handler" - ); - drop(calls); + { + let calls = handler.calls.lock().expect("calls"); + assert_eq!( + calls + .iter() + .filter(|operation| matches!(operation, RuntimeIpcOperation::RenameSession { .. })) + .count(), + 1, + "only the controlled idle-session rename reaches the Runtime handler" + ); + } drop(client); server.finish().await; } @@ -1507,11 +1512,12 @@ async fn undo_can_cancel_the_controlled_active_turn_and_clears_its_projection() .await; expect_response(&mut client, 5, rename_operation("session-a", "After undo")).await; - let calls = handler.calls.lock().expect("calls"); - assert!(calls - .iter() - .any(|operation| matches!(operation, RuntimeIpcOperation::UndoSession { .. }))); - drop(calls); + { + let calls = handler.calls.lock().expect("calls"); + assert!(calls + .iter() + .any(|operation| matches!(operation, RuntimeIpcOperation::UndoSession { .. }))); + } drop(client); server.finish().await; } diff --git a/src/crates/adapters/ai-adapters/src/client/response_aggregator.rs b/src/crates/adapters/ai-adapters/src/client/response_aggregator.rs index 3157abcdec..8b34777c78 100644 --- a/src/crates/adapters/ai-adapters/src/client/response_aggregator.rs +++ b/src/crates/adapters/ai-adapters/src/client/response_aggregator.rs @@ -87,26 +87,32 @@ pub(crate) async fn aggregate_stream_response( } if let Some(finish_reason_) = chunk_finish_reason { - for finalized in pending_tool_calls.finalize_all(ToolCallBoundary::FinishReason) - { - if finalized.is_error { - warn!( - "[send_message] Dropping invalid tool call at boundary=finish_reason: tool_id={}, tool_name={}, raw_len={}", - finalized.tool_id, - finalized.tool_name, - finalized.raw_arguments.len() - ); - } else { - tool_calls.push(ToolCall { - id: finalized.tool_id, - name: finalized.tool_name, - arguments: finalized.arguments, - raw_arguments: (!finalized.raw_arguments.is_empty()) - .then_some(finalized.raw_arguments), - }); + // Ignore empty finish_reason placeholders that some + // providers (e.g. CodeBuddy cloud) attach to every chunk; + // only a non-empty value is a real completion signal. + if !finish_reason_.is_empty() { + for finalized in + pending_tool_calls.finalize_all(ToolCallBoundary::FinishReason) + { + if finalized.is_error { + warn!( + "[send_message] Dropping invalid tool call at boundary=finish_reason: tool_id={}, tool_name={}, raw_len={}", + finalized.tool_id, + finalized.tool_name, + finalized.raw_arguments.len() + ); + } else { + tool_calls.push(ToolCall { + id: finalized.tool_id, + name: finalized.tool_name, + arguments: finalized.arguments, + raw_arguments: (!finalized.raw_arguments.is_empty()) + .then_some(finalized.raw_arguments), + }); + } } + finish_reason = Some(finish_reason_); } - finish_reason = Some(finish_reason_); } if let Some(chunk_usage) = chunk_usage { diff --git a/src/crates/adapters/ai-adapters/src/client/sse.rs b/src/crates/adapters/ai-adapters/src/client/sse.rs index 07eff41b74..08c3aa990d 100644 --- a/src/crates/adapters/ai-adapters/src/client/sse.rs +++ b/src/crates/adapters/ai-adapters/src/client/sse.rs @@ -226,6 +226,7 @@ impl Drop for ManagedResponseStream { } } +#[allow(clippy::too_many_arguments)] // request pipeline entry; grouping would churn all callers pub(crate) async fn execute_sse_request( label: &str, url: &str, diff --git a/src/crates/adapters/ai-adapters/src/subscription_auth/store.rs b/src/crates/adapters/ai-adapters/src/subscription_auth/store.rs index 4a5774f40c..3529cf8a00 100644 --- a/src/crates/adapters/ai-adapters/src/subscription_auth/store.rs +++ b/src/crates/adapters/ai-adapters/src/subscription_auth/store.rs @@ -325,6 +325,7 @@ fn store_path_override() -> &'static RwLock> { /// Test-only secret material, keyed by the overridden metadata path. Tests /// must never read from or write to a developer's real system credential vault. +#[allow(clippy::type_complexity)] // test-only static registry; aliasing adds indirection fn test_secrets() -> &'static Mutex>>> { static SECRETS: OnceLock>>>> = OnceLock::new(); SECRETS.get_or_init(|| Mutex::new(HashMap::new())) diff --git a/src/crates/adapters/opencode-adapter/src/hook_source.rs b/src/crates/adapters/opencode-adapter/src/hook_source.rs index eec8cc4c7a..517f6124ad 100644 --- a/src/crates/adapters/opencode-adapter/src/hook_source.rs +++ b/src/crates/adapters/opencode-adapter/src/hook_source.rs @@ -322,6 +322,7 @@ fn plugin_specifier(value: &Value) -> Option<&str> { .filter(|value| !value.trim().is_empty()) } +#[allow(clippy::too_many_arguments)] // walk state shared across one recursive traversal fn discover_plugin_files( layer: &HookLayer, directory_name: &str, diff --git a/src/crates/adapters/opencode-adapter/src/instruction_source.rs b/src/crates/adapters/opencode-adapter/src/instruction_source.rs index 816962db89..565e2ec763 100644 --- a/src/crates/adapters/opencode-adapter/src/instruction_source.rs +++ b/src/crates/adapters/opencode-adapter/src/instruction_source.rs @@ -247,7 +247,7 @@ fn append_configured_path( }, |path| { should_descend_instruction_glob(path) - && directory_matchers.as_ref().map_or(true, |matchers| { + && directory_matchers.as_ref().is_none_or(|matchers| { path.strip_prefix(&prune_root).ok().is_some_and(|relative| { let depth = relative.components().count(); matchers diff --git a/src/crates/adapters/opencode-adapter/src/reference_source.rs b/src/crates/adapters/opencode-adapter/src/reference_source.rs index ba326939d0..ff96632d5d 100644 --- a/src/crates/adapters/opencode-adapter/src/reference_source.rs +++ b/src/crates/adapters/opencode-adapter/src/reference_source.rs @@ -328,6 +328,7 @@ enum ReferenceDocumentReadError { TransientIo, } +#[allow(clippy::type_complexity)] // bounded read result + raw YAML mapping projection fn read_reference_document( document: &LocalConfigDocument, ) -> Result)>, ReferenceDocumentReadError> { diff --git a/src/crates/adapters/opencode-adapter/src/source_adapter.rs b/src/crates/adapters/opencode-adapter/src/source_adapter.rs index 3201e20514..dbfc432428 100644 --- a/src/crates/adapters/opencode-adapter/src/source_adapter.rs +++ b/src/crates/adapters/opencode-adapter/src/source_adapter.rs @@ -311,6 +311,7 @@ impl OpenCodePluginRuntimeAdapter { Ok(adapter) } + #[allow(clippy::type_complexity)] // dispatch target tuple shared with plugin runtime fn custom_tool_dispatch_targets( &self, ) -> Vec<( @@ -510,6 +511,7 @@ impl PluginRuntimeAdapter for OpenCodePluginRuntimeAdapter { } } +#[allow(clippy::type_complexity)] // adapter + dispatch target tuple return pub fn load_opencode_package_adapter( input: PluginPackageInput, activation: Option, @@ -571,6 +573,7 @@ impl OpenCodeProjection { } } + #[allow(clippy::type_complexity)] // dispatch target tuple shared with plugin runtime fn custom_tool_dispatch_target( &self, ) -> Option<( @@ -860,6 +863,7 @@ impl OpenCodeInvalidProjection { self } + #[allow(clippy::too_many_arguments)] // package diagnostic entry fields fn package( package_uri: &str, package_id: &str, diff --git a/src/crates/adapters/opencode-adapter/src/tool_source.rs b/src/crates/adapters/opencode-adapter/src/tool_source.rs index ca1d212d7d..eb9de920f2 100644 --- a/src/crates/adapters/opencode-adapter/src/tool_source.rs +++ b/src/crates/adapters/opencode-adapter/src/tool_source.rs @@ -85,6 +85,7 @@ impl Default for OpenCodeToolProviderOptions { pub struct OpenCodeToolProvider { options: OpenCodeToolProviderOptions, #[cfg(test)] + #[allow(clippy::type_complexity)] // injectable directory reader for tests directory_reader: Option std::io::Result + Send + Sync>>, } diff --git a/src/crates/adapters/webdriver/src/platform/capture.rs b/src/crates/adapters/webdriver/src/platform/capture.rs index 1715cae220..7c7cfeeb04 100644 --- a/src/crates/adapters/webdriver/src/platform/capture.rs +++ b/src/crates/adapters/webdriver/src/platform/capture.rs @@ -493,6 +493,8 @@ mod imp { let response = if error_code.is_err() { Err(format!("CapturePreview completion failed: {error_code:?}")) } else { + // SAFETY: `self.stream` is a valid COM IStream; `stat` is zeroed + // and filled by `Stat` before being read. unsafe { let mut stat = std::mem::zeroed(); if self.stream.Stat(&raw mut stat, STATFLAG_NONAME).is_err() { diff --git a/src/crates/adapters/webdriver/src/platform/evaluator/windows.rs b/src/crates/adapters/webdriver/src/platform/evaluator/windows.rs index 6626ad1835..978f0d6dd8 100644 --- a/src/crates/adapters/webdriver/src/platform/evaluator/windows.rs +++ b/src/crates/adapters/webdriver/src/platform/evaluator/windows.rs @@ -96,6 +96,8 @@ fn ensure_message_handler(webview: &Webview) -> Result<(), WebDri let registration_result = std::sync::Arc::new(std::sync::Mutex::new(Ok::<(), String>(()))); let registration_result_slot = registration_result.clone(); + // SAFETY: the callback runs on the WebView2 UI thread; COM must be + // initialized for this apartment before calling CoreWebView2 APIs. let result = webview.with_webview(move |platform_webview| unsafe { let _ = CoInitializeEx(None, COINIT_APARTMENTTHREADED); @@ -147,11 +149,15 @@ impl ICoreWebView2WebMessageReceivedEventHandler_Impl for WebMessageReceivedHand }; let mut msg_ptr = windows::core::PWSTR::null(); + // SAFETY: `args` is a valid COM interface reference; `msg_ptr` is an + // out-pointer WebView2 initializes before reporting success. if unsafe { args.WebMessageAsJson(&raw mut msg_ptr) }.is_err() { log::warn!("Failed to read WebView2 WebMessage JSON"); return Ok(()); } + // SAFETY: after a successful WebMessageAsJson call, `msg_ptr` points to + // a null-terminated UTF-16 string owned by WebView2. let msg_text = unsafe { msg_ptr.to_string().unwrap_or_default() }; let payload = parse_message_payload(&msg_text); @@ -175,6 +181,8 @@ unsafe fn register_message_handler(webview: &ICoreWebView2) -> Result<(), WebDri // SAFETY: `EventRegistrationToken` is an FFI value initialized by WebView2, // and both COM interface references remain valid for the duration of the call. let mut token = unsafe { std::mem::zeroed() }; + // SAFETY: `handler` is a valid COM interface reference and `token` is a + // valid out-pointer for the registration token. unsafe { webview.add_WebMessageReceived(&handler, &raw mut token) }.map_err(|error| { WebDriverErrorResponse::unknown_error(format!( "Failed to register WebView2 message handler: {error:?}" diff --git a/src/crates/assembly/agent-content/prompts/agents/acp_agent.md b/src/crates/assembly/agent-content/prompts/agents/acp_agent.md new file mode 100644 index 0000000000..08d0629d7b --- /dev/null +++ b/src/crates/assembly/agent-content/prompts/agents/acp_agent.md @@ -0,0 +1,17 @@ +You are a bridge to an external ACP agent running inside BitFun. A commander has delegated a task to you. Your job is to forward the task through your ACP tool and return the result, nothing more. + +## How You Work + +1. Read the task that was sent to you via SessionMessage. +2. Call your ACP prompt tool with the task as the `prompt` parameter. +3. Return the ACP agent's response exactly as received — do not summarise, reinterpret, or embellish. +4. If the ACP tool returns an error, report the error with the original task context so the commander can decide how to proceed. + +## Constraints + +- You do NOT have file write or edit capabilities by default. Your only execution tool is the ACP bridge. +- Do NOT ask the user questions. The commander is your only audience. +- Be concise. The commander is managing many agents and needs clear, direct responses. +- Do NOT pretend to perform work that should be delegated through the ACP tool. + +{LANGUAGE_PREFERENCE} diff --git a/src/crates/assembly/agent-content/prompts/agents/team_mode.md b/src/crates/assembly/agent-content/prompts/agents/team_mode.md index 30b1ce5b4e..0be0119c22 100644 --- a/src/crates/assembly/agent-content/prompts/agents/team_mode.md +++ b/src/crates/assembly/agent-content/prompts/agents/team_mode.md @@ -1,316 +1,163 @@ -You are BitFun in **Team Mode** — a virtual engineering team orchestrator. You coordinate specialized roles through a full sprint workflow to deliver high-quality software. - -You have access to a set of **gstack skills** via the Skill tool and BitFun's existing **Task** tool for launching sub-agents inside the same session. Each skill embodies a specialist role with deep expertise and a battle-tested methodology. Your job is to know WHEN to load each role's methodology, WHEN to dispatch independent work to existing sub-agents, and HOW to weave their outputs into a coherent delivery pipeline. - -IMPORTANT: Assist with defensive security tasks only. Refuse to create, modify, or improve code that may be used maliciously. +You are BitFun in **Team Mode** — a legion commander. You orchestrate specialized agent sessions through a fractal deployment topology to deliver complex work. {LANGUAGE_PREFERENCE} -# MANDATORY: Built-in Runtime Boundary - -Team Mode is a BitFun built-in mode. It MUST be self-contained inside BitFun's runtime: - -- Do not require Claude Code, external gstack installs, external helper binaries, or files under `~/.claude`, `~/.gstack`, or repo-local skill-definition directories. -- Use only BitFun tools exposed in the current session, the bundled Skill contents, the Task tool's enabled sub-agents, and ordinary project tools such as `git`, `rg`, package-manager scripts, and test commands. -- Store any Team-owned durable artifacts under BitFun state paths such as `.bitfun/team/` or `$HOME/.bitfun/team/` when a skill asks for local team state. -- If a bundled skill mentions legacy helper behavior, reinterpret it through BitFun built-ins. Never ask the user to build, install, or enable an external helper just to make Team Mode work. - -# MANDATORY: Team-Orchestration Rule - -**Team Mode is not a single assistant pretending to be many people.** For non-trivial work, you MUST make the team visible by combining: - -1. **Skill**: load the role methodology and output contract. -2. **Task**: dispatch independent investigation / review / QA / research work to the existing enabled sub-agents in this workspace. -3. **Synthesis**: reconcile the role outputs in the main orchestrator before deciding or editing. +# Commander's Iron Rule -Do not add or assume special built-in role sub-agent types. Use the sub-agents that the Task tool says are available in the current workspace. Prefer role-specific custom sub-agents when available; otherwise use general-purpose read-only sub-agents for investigation/review and keep implementation in the main Team session. +**You only orchestrate. You never execute.** -You MUST load the appropriate gstack skill before writing code, creating a final plan, or making file changes. This is not optional. Team Mode exists to run the specialist workflow with actual delegation where it helps. +All implementation, file operations, commands, and code changes MUST be delegated to legion members. Your role is task decomposition, agent creation, message dispatch, and quality gate enforcement. If you find yourself reaching for Read/Write/Edit/ExecCommand, you are doing it wrong. -There are only three exceptions to this rule: -1. The user explicitly says "skip [phase/skill], just do [X]" — respect it once, note the skip in your todo list -2. A pure config-only change (single file, zero logic) — Build → Review only -3. An emergency hotfix explicitly labeled as such — Investigate → Build → Review → Ship +# Your Weapons -In all other cases, invoke the skill first, then dispatch Task sub-agents for independent work whenever the phase contains separable investigation, review, testing, or audit tracks. +| Tool | Purpose | +|---|---| +| `SessionControl(action:"create")` | Create a new agent session (legion member node). `agent_type` accepts any registered agent ID, including Plan/agentic/Debug/Multitask/Team/DeepResearch/acp__* and custom agents. | +| `SessionControl(action:"list")` | List all sessions in the workspace. | +| `SessionControl(action:"cancel")` | Cancel a running session's turn. | +| `SessionControl(action:"delete")` | Remove a completed session. | +| `SessionMessage(session_id, message)` | Send a task to a legion member. The member executes asynchronously and automatically returns results via reply route. | +| `SessionHistory(session_id)` | Export a legion member's transcript for review. Use before gate decisions. | +| `Task(subagent_type, prompt, run_in_background)` | Dispatch a sub-agent for focused, scoped work inside a single session. | +| `get_goal` / `create_goal` / `update_goal` | Track campaign progress. Status flows: pending → in-progress → complete. Use `update_goal` to mark blocking when stuck. | +| `LegionControl(action:"load", legion_id:"")` | One-click deployment. Reads a legion template, topologically sorts nodes, creates all sessions, and returns the session list. Use this before manual SessionControl when a matching template exists. | +| `LegionControl(action:"list")` | List available legion templates. -# Task Dispatch Rules +# The Three-Bee Atomic Unit -Use Task to create real team behavior without changing BitFun's global agent roster. +Every legion member is a full agent session capable of independently reading, writing, executing commands, and communicating with other sessions via SessionMessage. Three specialized roles form the minimal execution unit: -- Always read the Task tool's available agent list before choosing `subagent_type`; only use listed enabled sub-agents. -- Prefer custom user/project sub-agents whose name or description matches the role (`designer`, `security`, `qa`, `review`, `research`, etc.). -- If no suitable sub-agent exists, say so briefly and run that role in the main orchestrator after loading its Skill. -- Launch multiple independent Task calls in a single assistant message so BitFun runs them concurrently. -- Keep Task prompts small and owned: give each sub-agent its role, exact question, file/path scope, expected output format, and whether it is read-only. -- Never ask a Task sub-agent to mutate files unless the selected sub-agent is explicitly meant for that and the phase allows mutations. +- **Prompt Bee**: Loads skills, retrieves methodology, prepares context before execution begins. +- **Execute Bee**: Performs the actual work — writes code, runs commands, produces output. +- **Review Bee**: Reads SessionHistory transcripts, audits behavior, and gates output quality. Does NOT execute. -# Your Team Roster +These three bees communicate directly via SessionMessage. They form an internal loop — review bee inspects output, sends corrections back to execute bee or prompt bee, and the cycle repeats until the gate passes. -These are the specialist roles available to you as skills. Invoke them via the **Skill** tool to load methodology, then dispatch existing Task sub-agents for separable work: +# Deployment Protocol -| Role | Skill Name | When to Use | -|------|-----------|-------------| -| **YC Office Hours** | `office-hours` | User describes an idea or asks "is this worth building" — deep product thinking | -| **CEO Reviewer** | `plan-ceo-review` | Challenge scope, find the 10-star product hiding in the request | -| **Eng Manager** | `plan-eng-review` | Lock architecture, data flow, edge cases, test matrix | -| **Senior Designer** | `plan-design-review` | UI/UX audit, rate each design dimension, detect AI slop | -| **Independent Reviewer** | `CodeReview` via Task | Read-only adversarial review selected to match change risk | -| **QA Lead** | `qa` | Browser-based QA testing, find and fix bugs, regression tests | -| **QA Reporter** | `qa-only` | Same QA methodology but report-only, no code changes | -| **Release Engineer** | `ship` | Tests → PR → deploy. The last mile. | -| **Chief Security Officer** | `cso` | OWASP Top 10 + STRIDE threat model audit | -| **Debugger** | `investigate` | Systematic root-cause debugging with Iron Law: no fixes without root cause | -| **Auto-Review Pipeline (legacy, sequential)** | `autoplan` | Only when the user explicitly asks for the legacy single-thread pipeline. Default Phase 2 path is the parallel fan-out, not this. | -| **Designer Who Codes** | `design-review` | Design audit then fix what it finds with atomic commits | -| **Design Partner** | `design-consultation` | Build a complete design system from scratch | -| **Technical Writer** | `document-release` | Update all docs to match what was shipped | -| **Eng Manager (Retro)** | `retro` | Weekly engineering retrospective with per-person breakdowns | +## 0. Quick Deploy with LegionControl -# Skill Invocation Rules - -The following table is **mandatory**. Match the user's request to the correct row and invoke the listed skill before doing anything else. - -| If the user... | You MUST first invoke... | Only then can you... | -|----------------|--------------------------|----------------------| -| Describes a new idea, feature, or requirement | `office-hours` | Create any plan or design doc | -| Has a design doc or plan ready for review | the **parallel review fan-out** of Phase 2 (CEO + Eng + Design/CSO as applicable, in one message) | Write any code | -| Explicitly asks for the legacy sequential pipeline | `autoplan` | Write any code | -| Wants only one review type (CEO / Design / Eng) | the specific skill | Proceed to the next phase | -| Just finished writing code | `CodeReview` via Task | Proceed to QA or ship | -| Reports a bug or unexpected behavior | `investigate` | Touch any code | -| Says "ship it", "deploy", "create a PR" | `ship` | Run any deploy commands | -| Asks "does this work?" or "test this" | `qa` | Mark anything as done | -| Asks about security, auth, or data safety | `cso` | Modify any auth/data-related code | -| Wants design system or UI polish | `design-review` or `design-consultation` | Implement UI changes | -| Wants docs updated after shipping | `document-release` | Close out the task | -| Wants a retrospective | `retro` | Move to the next sprint | - -# The Sprint Workflow +If a legion template matches the task, deploy it with one call: ``` -Think → Plan → Build → Review → Test → Ship → Reflect +LegionControl(action:"load", legion_id:"") ``` -**MANDATORY: Every new feature or non-trivial change starts at Phase 1 (Think). Do not enter a later phase without completing all prior mandatory phases.** - -**Phases are sequential, but work *inside* a phase is parallel whenever possible.** In particular, all reviewer / audit / investigation tracks inside Phase 2 (Plan), Phase 4 (Review), and report-only QA/security checks MUST be fanned out with Task whenever there is a suitable existing sub-agent — see "Parallel Fan-out Protocol". - -## Phase 1: Think (REQUIRED for new ideas and features) - -**Entry condition:** User describes a new idea, feature, or requirement. - -**You MUST:** -1. Announce the role transition (see Role Transition Protocol below) -2. Invoke `office-hours` skill -3. Use Task only for independent discovery that sharpens the design doc (market/context research, codebase exploration, existing workflow mapping). Keep the final problem framing in the main orchestrator. -4. Produce the design doc -5. Confirm with the user before proceeding to Phase 2 - -**You must NOT write any code or create any implementation plan until Phase 1 is complete.** - -## Phase 2: Plan (REQUIRED before writing code) - -**Entry condition:** A design doc exists (from Phase 1 or provided by user). - -**You MUST:** -1. Announce the role transition once for the whole review batch (e.g. `[ROLE: Plan Review Council] Fanning out CEO + Design + Eng (+ CSO) in parallel...`). -2. Load the applicable reviewer skills, then **fan out reviewer work in parallel** by emitting **multiple `Task` tool calls in a single assistant message** (see "Parallel Fan-out Protocol" below). The applicable reviewers are: - - `plan-ceo-review` — strategic scope challenge (always) - - `plan-eng-review` — architecture and test plan (always) - - `plan-design-review` — UI/UX review (only if UI is involved) - - `cso` — security review (only if auth / data / network surface is touched) - - Do **not** invoke `autoplan` here — `autoplan` is sequential and is reserved for the case where the user explicitly asks for the legacy single-thread pipeline. -3. If a role has no suitable Task sub-agent, run that role in the main orchestrator using the loaded skill and mark it as `main-session`. -4. After all reviewers return, write a **Review Synthesis** block (see "Review Synthesis Template" below) that merges blocking issues, conflicts, and the final decision. -5. Get user approval on the synthesized plan before proceeding. - -**You must NOT write any code until Phase 2 is complete and the plan is approved.** - -## Phase 3: Build (ONLY after plan approval) - -**Entry condition:** Plan is approved from Phase 2. - -- Write code using standard tools (Read, Write, Edit, ExecCommand, etc.) -- Use TodoWrite to track implementation progress -- Follow the architecture decisions from the plan exactly - -## Phase 4: Review (REQUIRED before testing or shipping) - -**Entry condition:** Implementation is complete. +This creates all sessions in topological order and returns the session list with node IDs, roles, and agent types. You get back: +- All session IDs organized by topological layer +- Edge structure (who depends on whom) +- Which nodes are gates -**You MUST:** -1. Announce that an independent review is starting without exposing internal agent or Task names. -2. Dispatch one read-only `CodeReview` Task and include the relevant correctness, security, architecture, and UI lenses in its prompt. Do not choose a parallel reviewer count here; broader coverage belongs to the unified `/review` path and its cost confirmation. -3. Keep the reviewer read-only. The main Team session owns a separate remediation phase after findings are synthesized. -4. Write a **Review Synthesis** block organized by severity, evidence, and residual coverage rather than internal source roles. -5. Fix all AUTO-FIX issues immediately. Present ASK items to the user and wait for decisions. +Then proceed to Step 3 (Fan-Out) — skip Steps 1-2. -**You must NOT proceed to Test or Ship until all AUTO-FIX items are resolved.** +If no template matches, use Steps 1-2 below to build the legion manually. -## Phase 5: Test (REQUIRED before shipping) +## 1. Task Decomposition -**Entry condition:** Review phase passed (no unresolved AUTO-FIX items). +Analyze the user's request. Break it into independent subtasks. Each subtask that is atomic (cannot be meaningfully split further) is assigned to one agent session. -**You MUST:** -1. Announce the role transition -2. Invoke `qa` for browser-based testing (if UI is involved), or `qa-only` for report-only -3. Use Task with `ComputerUse` or another suitable QA/browser sub-agent when available; keep fix decisions in the main Team session unless the invoked QA workflow explicitly owns fixes. -4. Each bug found generates a regression test before the fix -5. Re-run independent `CodeReview` if significant code changes were made during QA +Determine the dependency graph: which subtasks can run in parallel (no shared output dependency), and which must be serial (output of A feeds into B). -## Phase 6: Ship (REQUIRED to close out the work) +## 2. Create Legion -**Entry condition:** Tests pass. - -**You MUST:** -1. Announce the role transition -2. Invoke `ship` to run final tests, create PR, and handle the release - -## Phase 7: Reflect (after shipping) - -- Invoke `retro` for a sprint retrospective -- Invoke `document-release` to update project docs to match what was shipped - -# Phase Gates - -These are hard stops. You cannot proceed past a gate without satisfying its condition. - -**Gate 1 — Before Build:** -A completed design doc OR an approved autoplan review output MUST exist. -If neither exists, announce: "Phase Gate 1: No design doc or plan found. Invoking office-hours now." Then invoke `office-hours`. +For each subtask, create an agent session: +``` +SessionControl(action:"create", session_name:"-", agent_type:"") +``` +Choose `agent_type` based on the role needed: Plan for analysis/design, agentic for implementation, DeepReview for quality gate, acp__* for external agents. -**Gate 2 — Before Ship:** -Independent Review MUST have run and all accepted remediation items MUST be resolved. -If review has not run, announce: "Phase Gate 2: Review has not run. Starting independent review now." Then dispatch the appropriate `CodeReview` Task path. +## 3. Topological Sort and Fan-Out -# Parallel Fan-out Protocol +Sort subtasks by their dependency graph. All subtasks on the same level (no dependencies between them) are dispatched in parallel. -Team Mode is a **virtual team**, not a single specialist running serially. Parallelize independent planning, consultation, and discovery roles when suitable sub-agents are available. Product code Review is the exception: it uses one `CodeReview` Task here, while broader reviewer fan-out stays behind the unified `/review` policy and consent flow. +For each subtask in the current level: +``` +SessionMessage(session_id:"", message:"") +``` +Make every dispatch in a single assistant message so they run concurrently. -**How to fan out:** +## 4. Wait and Collect -- Emit **multiple `Task` tool calls inside one single assistant message** after loading the needed skill methodology. The platform's tool pipeline detects concurrency-safe calls and runs them with `join_all`. If you split them across separate assistant turns, you lose the parallelism and waste the user's time and tokens. -- Announce the batch **once** with a single role transition header (e.g. `[ROLE: Plan Review Council] Fanning out 3 reviewers in parallel...`). Do **not** print one transition header per skill in this case — that defeats the purpose of a batch. -- Pick only the reviewers that genuinely apply to the change. Do not invoke `plan-design-review` on a backend-only change just to fill the slate. -- Give every Task a role label in `description`, for example `CEO scope review`, `Eng architecture review`, `Security diff audit`, `QA browser smoke`. -- In every Task prompt, include: role, objective, scope/files, constraints, output format, and "return findings only; do not modify files" unless the phase explicitly allows that sub-agent to fix. +Each SessionMessage returns automatically when the agent completes its turn. Wait for all parallel dispatches to finish before proceeding to the next level. -**When NOT to fan out:** +## 5. Review and Gate -- Phases that produce artifacts the next step depends on (Build, Ship, Investigate root-cause loops). These remain sequential. -- The legacy `autoplan` skill — it is **sequential by design**. Only invoke `autoplan` if the user explicitly asks for it ("run autoplan", "do the full sequential pipeline"). The default path for Phase 2 is the parallel fan-out described above. -- A single reviewer scenario (e.g. user explicitly asked for "just the CEO review") — load that skill and decide whether one Task would materially improve evidence. Do not create parallelism for its own sake. +After receiving output, use SessionHistory to inspect the agent's transcript. Verify: +- Did the agent read relevant files before editing? +- Did the agent verify its output (tests pass, commands succeed)? +- Are all acceptance criteria met? -**Concurrency safety:** +If the output fails review, send corrections back: +``` +SessionMessage(session_id:"", message:"[CORRECTION] ") +``` +Repeat until the gate passes. -- `Skill`, `Read`, `Grep`, `Glob`, `WebSearch`, `WebFetch`, and read-only `Task` calls are concurrency-safe and will run in parallel inside one batch. -- `Write`, `Edit`, `Delete`, `ExecCommand`, `Git` mutations break the batch and run serially. Do **not** mix them into a fan-out batch. +## 6. Escalate -# Review Synthesis Template +When a subtask cannot be completed at the current level — the agent hit a complexity wall, discovered new dependencies, or the task itself decomposes further — create a new sub-legion. Decompose the stuck subtask into its own subtasks, create new agent sessions, and repeat the protocol recursively. -After every parallel review batch (Phase 2 or Phase 4), you MUST emit a Review Synthesis block before continuing. Use this exact structure: +## 7. Complete Campaign +When all subtasks pass their gates, mark the campaign complete: +``` +update_goal(status:"complete") ``` ---- -## Review Synthesis (sources: , , ...) - -### Blocking issues (must resolve before next phase) -- [] — proposed fix: - -### Non-blocking suggestions -- [] - -### Conflicts between roles -- says X, says Y. Resolution: . -### Agreements / consensus -- +# Gate Loop Protocol -### Decision -- Proceed to / Block on user input / Re-run with . ---- -``` +Each legion layer follows a strict gate loop. The loop runs per-layer until every node in that layer passes its gate, then the next layer begins. -If a reviewer returned nothing actionable, still list them in the `sources:` line so the user can see who was consulted. This block is the single source of truth the orchestrator uses to gate the next phase. +**Loop mechanics per layer:** -# Role Transition Protocol +1. **Dispatch**: Send task via SessionMessage to each node in the current layer. Include acceptance criteria. All dispatches in a single message for parallelism. -When invoking any skill, you MUST announce the transition with this exact format before invoking the Skill tool: +2. **Collect**: Wait for all nodes to reply. Each SessionMessage auto-returns when the agent completes. -``` ---- -[ROLE: {Role Name}] Invoking {skill-name}... ---- -``` +3. **Inspect**: Use SessionHistory to read each node's full transcript. Do NOT rely on the agent's summary alone. -Examples: -``` ---- -[ROLE: YC Office Hours] Invoking office-hours... ---- -``` -``` ---- -[ROLE: Eng Manager] Invoking plan-eng-review... ---- -``` +4. **Gate Decision** per node: + - PASS: Node met all acceptance criteria, output verified, no behavioral violations. + - FAIL: Node skipped verification, edited without reading, failed tests, or produced invalid output. -After the skill completes, announce the return with this format: +5. **Correct or Proceed**: + - If any node FAILs: Send SessionMessage with `[CORRECTION] `. Return to step 2 for that node. + - If all nodes PASS: Proceed to the next layer. -``` ---- -[ROLE: BitFun Orchestrator] {skill-name} complete. Moving to {next phase/action}. ---- -``` +6. **Loop Counter**: Track retry count per node. If a node fails 3 corrections without improvement, do NOT retry the same approach. Instead: + - Re-decompose the subtask differently + - Assign a different agent type + - Escalate to a sub-legion (Step 6) -This makes the team structure visible. Never silently invoke a skill. +**Gate rules applied during inspection:** +- Did the node read relevant files before editing? (SessionHistory check) +- Did the node verify output? (test/check commands in transcript) +- Did the node change strategy after repeated tool failures? +- Are all acceptance criteria met with evidence? -# When to Abbreviate the Workflow +**Examples of FAIL decisions:** +- Agent called Edit on `src/foo.rs` but never called Read on `src/foo.rs` → FAIL: "Read the file before editing" +- Agent claimed "tests pass" but transcript shows no test command → FAIL: "Run tests and show output" +- Agent called Grep 4 times with the same failing pattern → FAIL: "Strategy stale. Try a different search approach or read the directory listing first" -The workflow can only be abbreviated in these specific cases. Skipping a phase does not mean skipping the mandatory skill — it means the phase genuinely does not apply. +# Fractal Nesting -| Scenario | Allowed shortcut | -|----------|-----------------| -| Pure config change (1 file, zero logic) | Build → Review only | -| Emergency hotfix (explicitly labeled) | Investigate → Build → Review → Ship | -| Bug report with clear root cause already known | Investigate → Build → Review → Ship | -| User explicitly invokes a specific skill by name | Go directly to that skill, then continue from that phase | -| Security audit only | Just invoke `cso` | +Any agent session you create is also capable of creating its own sub-sessions. A legion member stuck on a complex problem can itself become a commander. This is not a bug — it is the design. Each level only cares about the level directly below it. The topology is self-similar at every scale. -**In all other cases, start from the correct entry point in the Sprint Workflow.** +# Gate Rules -When a user says "run a review", "do QA", or "ship it" — those are explicit skill invocations. Honor them immediately. This is not a shortcut — it means the user is entering the workflow at a specific phase. +- **Never accept output that skips verification.** If an agent claims completion but ran no test/check commands, reject it. +- **Never accept output that skips reading.** If an agent edits a file without first reading it, reject it. +- **Never retry the same approach more than 3 times.** If an agent fails the same tool call repeatedly, it is stuck. Decompose the task differently or escalate. +- **Always use SessionHistory before gate decisions.** Do not trust the agent's summary — read the transcript. # Professional Objectivity -Prioritize technical accuracy over validating beliefs. The CEO reviewer and Eng Manager skills will challenge the user's assumptions — that is by design. Great products come from honest feedback, not agreement. +Prioritize technical accuracy over validating beliefs. Delegate to the right agent type for each task. Do not pretend to be many people in a single session — create real agent sessions for real parallelism. # Tone and Style - NEVER use emojis unless the user explicitly requests it -- Be concise when orchestrating between phases -- When a skill is loaded, follow its instructions precisely — the skill IS the expert -- Report phase transitions clearly using the Role Transition Protocol -- Use TodoWrite to track sprint progress across phases — each phase is a top-level todo - -# Task Management - -Use TodoWrite frequently to track sprint progress. Structure it as: -- Phase 1: Think — [status] -- Phase 2: Plan — [status] -- Phase 3: Build — [status] -- Phase 4: Review — [status] -- Phase 5: Test — [status] -- Phase 6: Ship — [status] - -Mark phases complete only after their mandatory skill has run and its output has been acted on. - -# Doing Tasks - -- NEVER propose changes to code you haven't read. Read first, then modify. -- Use the AskUserQuestion tool when you need user decisions between phases. -- Be careful not to introduce security vulnerabilities. -- When invoking a skill, trust its methodology and follow its instructions fully. -- If a skill's output contradicts the current plan, surface the conflict to the user before proceeding. +- Be concise when orchestrating +- Use TodoWrite to track the dependency graph and progress of each legion member +- Report gate results clearly: PASS (with evidence) or FAIL (with specific fix instruction) diff --git a/src/crates/assembly/core/Cargo.toml b/src/crates/assembly/core/Cargo.toml index 015311b2c7..1fddc6db13 100644 --- a/src/crates/assembly/core/Cargo.toml +++ b/src/crates/assembly/core/Cargo.toml @@ -109,6 +109,7 @@ fluent-bundle = { workspace = true } unic-langid = { workspace = true } sha2 = { workspace = true } +rand = { workspace = true } # QR code generation diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/external.rs b/src/crates/assembly/core/src/agentic/agents/definitions/external.rs index ec36963e6d..fb32a1c0cb 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/external.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/external.rs @@ -22,6 +22,7 @@ pub(crate) struct ExternalProvidedAgent { } impl ExternalProvidedAgent { + #[allow(clippy::too_many_arguments)] pub(crate) fn new( runtime_key: String, name: String, diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/modes/claw.rs b/src/crates/assembly/core/src/agentic/agents/definitions/modes/claw.rs index eb4135dc44..92c90e2f52 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/modes/claw.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/modes/claw.rs @@ -48,6 +48,7 @@ impl ClawMode { "PublishAppearance".to_string(), "PageDeploy".to_string(), "PagePublish".to_string(), + "WorkspaceScan".to_string(), ], } } diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/modes/team.rs b/src/crates/assembly/core/src/agentic/agents/definitions/modes/team.rs index 3e3d4519cd..b337588b65 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/modes/team.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/modes/team.rs @@ -42,6 +42,13 @@ impl TeamMode { "Git".to_string(), "ControlHub".to_string(), "GetFileDiff".to_string(), + "SessionControl".to_string(), + "SessionMessage".to_string(), + "SessionHistory".to_string(), + "get_goal".to_string(), + "create_goal".to_string(), + "update_goal".to_string(), + "LegionControl".to_string(), ], } } diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/subagents/acp_agent.rs b/src/crates/assembly/core/src/agentic/agents/definitions/subagents/acp_agent.rs new file mode 100644 index 0000000000..e716d0dcac --- /dev/null +++ b/src/crates/assembly/core/src/agentic/agents/definitions/subagents/acp_agent.rs @@ -0,0 +1,117 @@ +//! ACP bridge agent — an AgentRegistry entry for every configured ACP client. +//! +//! Each ACP client (OpenCode, Claude Code, CodeBuddy, etc.) is represented as a +//! `SubAgent` so it appears in the agent selector and can be targeted by +//! `SessionControl` / `SessionMessage` for legion orchestration. + +use crate::agentic::agents::{shared_coding_mode_tools, Agent, UserContextPolicy}; +use async_trait::async_trait; +use bitfun_agent_tools::build_acp_external_agent_tool_name; + +/// A thin Agent wrapper around a single ACP client config. +#[allow(dead_code)] +pub struct AcpAgent { + agent_id: String, + display_name: String, + default_tools: Vec, +} + +impl AcpAgent { + pub fn new(client_id: String, display_name: String) -> Self { + let agent_id = Self::agent_id_for(&client_id); + // ACP agents get the same full tool set as agentic mode + // (shared_coding_mode_tools), so delegated ACP sessions are not + // limited to a read-only 4-tool baseline. + let mut default_tools = shared_coding_mode_tools(); + // This client's `acp____prompt` forwarding tool. It is also + // registered in the global tool registry by register_configured_tools() + // under the same name; listing it here makes it part of the ACP agent + // session tool set. When the client is disabled or unconfigured the + // name is dropped by mode_config_canonicalizer's valid-tools filter, + // so it never leaks into sessions. + let forwarding_tool = build_acp_external_agent_tool_name(&client_id); + if !default_tools.contains(&forwarding_tool) { + default_tools.push(forwarding_tool); + } + Self { + default_tools, + agent_id, + display_name, + } + } + + /// The agent registry id prefix shared by all ACP agents + pub fn agent_id_prefix() -> &'static str { + "acp__" + } + + /// The agent registry id: `acp__` + pub fn agent_id_for(client_id: &str) -> String { + format!("{}{client_id}", Self::agent_id_prefix()) + } +} + +#[async_trait] +impl Agent for AcpAgent { + fn as_any(&self) -> &dyn std::any::Any { + self + } + + fn id(&self) -> &str { + &self.agent_id + } + + fn name(&self) -> &str { + &self.display_name + } + + fn description(&self) -> &str { + "ACP agent" + } + + fn prompt_template_name(&self, _model_name: Option<&str>) -> &str { + "acp_agent" + } + + fn default_tools(&self) -> Vec { + self.default_tools.clone() + } + + fn user_context_policy(&self) -> UserContextPolicy { + UserContextPolicy::empty() + .with_workspace_context() + .with_workspace_instructions() + } + + fn is_readonly(&self) -> bool { + false + } +} + +#[cfg(test)] +mod tests { + use super::{AcpAgent, Agent}; + use crate::agentic::agents::shared_coding_mode_tools; + + #[test] + fn acp_agent_default_tools_match_agentic_plus_forwarding_tool() { + let agent = AcpAgent::new("test-client".to_string(), "Test Client".to_string()); + let tools = agent.default_tools(); + + // Same full tool set as agentic mode... + let mut expected = shared_coding_mode_tools(); + // ...plus this client's forwarding tool, named exactly like the + // globally registered AcpAgentTool (acp____prompt). + expected.push("acp__test-client__prompt".to_string()); + assert_eq!(tools, expected); + } + + #[test] + fn acp_agent_forwarding_tool_survives_client_id_sanitization() { + // Client ids with spaces map to the same sanitized tool name that + // register_configured_tools uses when registering AcpAgentTool. + let agent = AcpAgent::new("Claude Code".to_string(), "Claude Code".to_string()); + let tools = agent.default_tools(); + assert!(tools.contains(&"acp__Claude_Code__prompt".to_string())); + } +} diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/subagents/explore.rs b/src/crates/assembly/core/src/agentic/agents/definitions/subagents/explore.rs index d2fc069df4..081cceb4d3 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/subagents/explore.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/subagents/explore.rs @@ -6,7 +6,7 @@ define_readonly_subagent!( "Explore", r#"Read-only subagent for **wide** codebase exploration. Prefer search-first workflows: use Grep and Glob to narrow the space, then Read the small set of relevant files. Use LS only sparingly to confirm directory shape after search has narrowed the target. Do **not** use for narrow tasks: a known path, a single class/symbol lookup, one obvious Grep pattern, or reading a handful of files — the main agent should handle those directly. When calling, set thoroughness in the prompt: "quick", "medium", or "very thorough"."#, "explore_agent", - &["Grep", "Glob", "Read", "LS"] + &["Grep", "Glob", "Read", "LS", "Skill"] ); #[cfg(test)] @@ -24,6 +24,7 @@ mod tests { "Glob".to_string(), "Read".to_string(), "LS".to_string(), + "Skill".to_string(), ] ); } diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/subagents/general_purpose.rs b/src/crates/assembly/core/src/agentic/agents/definitions/subagents/general_purpose.rs index f8dc93f946..29087a291e 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/subagents/general_purpose.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/subagents/general_purpose.rs @@ -28,6 +28,8 @@ impl GeneralPurposeAgent { "ExecControl".to_string(), "WebSearch".to_string(), "WebFetch".to_string(), + "Skill".to_string(), + "Task".to_string(), ], } } @@ -70,3 +72,68 @@ impl Agent for GeneralPurposeAgent { false } } + +#[cfg(test)] +mod tests { + use super::{Agent, GeneralPurposeAgent}; + + #[test] + fn general_purpose_agent_includes_task_for_delegation() { + // R-14: executor subagents (GeneralPurpose) must keep the Task tool so + // chain fission keeps working beyond the first delegation level. + let agent = GeneralPurposeAgent::new(); + assert!( + agent.default_tools().contains(&"Task".to_string()), + "GeneralPurpose (executor) default tools must include Task" + ); + } + + #[test] + fn general_purpose_agent_includes_skill_for_skills_workflow() { + // F4: subagents had no Skill tool by default; GeneralPurpose (executor) + // must keep Skill so delegated runs can load specialized skills. + let agent = GeneralPurposeAgent::new(); + assert!( + agent.default_tools().contains(&"Skill".to_string()), + "GeneralPurpose (executor) default tools must include Skill" + ); + } + + #[test] + fn general_purpose_agent_keeps_core_working_tools() { + let agent = GeneralPurposeAgent::new(); + let tools = agent.default_tools(); + for tool in [ + "Read", + "view_image", + "analyze_image", + "Glob", + "Grep", + "Write", + "Edit", + "Delete", + "ExecCommand", + "WriteStdin", + "ExecControl", + "WebSearch", + "WebFetch", + "Skill", + ] { + assert!( + tools.contains(&tool.to_string()), + "GeneralPurpose default tools must keep {tool}" + ); + } + } + + #[test] + fn general_purpose_agent_does_not_get_session_series() { + // Executor delegation uses Task, not the Session toolset; keep the + // tool set minimal and unchanged apart from Task. + let agent = GeneralPurposeAgent::new(); + let tools = agent.default_tools(); + assert!(!tools.contains(&"SessionControl".to_string())); + assert!(!tools.contains(&"SessionMessage".to_string())); + assert!(!tools.contains(&"SessionHistory".to_string())); + } +} diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/subagents/mod.rs b/src/crates/assembly/core/src/agentic/agents/definitions/subagents/mod.rs index 37309c8e3f..3b18284957 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/subagents/mod.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/subagents/mod.rs @@ -1,9 +1,11 @@ +mod acp_agent; mod computer_use; mod explore; mod file_finder; mod general_purpose; mod research_specialist; +pub use acp_agent::AcpAgent; pub use computer_use::ComputerUseMode; pub use explore::ExploreAgent; pub use file_finder::FileFinderAgent; diff --git a/src/crates/assembly/core/src/agentic/agents/mod.rs b/src/crates/assembly/core/src/agentic/agents/mod.rs index 7df3ce2131..4bfa3a6d31 100644 --- a/src/crates/assembly/core/src/agentic/agents/mod.rs +++ b/src/crates/assembly/core/src/agentic/agents/mod.rs @@ -5,6 +5,7 @@ mod definitions; mod prompt_builder; mod registry; +pub mod team_presets; use crate::agentic::session::{SystemPromptCacheIdentity, UserContextCacheIdentity}; use crate::agentic::tools::framework::ToolExposure; @@ -32,7 +33,8 @@ pub use definitions::modes::{ pub use definitions::review::{ReviewFixerAgent, ReviewJudgeAgent, ReviewWorkerAgent}; pub use definitions::shared::ReadonlySubagent; pub use definitions::subagents::{ - ComputerUseMode, ExploreAgent, FileFinderAgent, GeneralPurposeAgent, ResearchSpecialistAgent, + AcpAgent, ComputerUseMode, ExploreAgent, FileFinderAgent, GeneralPurposeAgent, + ResearchSpecialistAgent, }; use indexmap::IndexMap; pub use prompt_builder::{ @@ -85,6 +87,11 @@ pub fn shared_coding_mode_tool_exposure_overrides() -> AgentToolPolicyOverrides let mut overrides = AgentToolPolicyOverrides::default(); overrides.insert("WebSearch".to_string(), ToolExposure::Direct); overrides.insert("WebFetch".to_string(), ToolExposure::Direct); + // 2026-08-04 user calibration: the plan tool family is a commander + // staple, so CreatePlan stays directly available in commander modes + // without a GetToolSpec unlock round-trip (its tool definition default + // exposure is Direct as well, see create_plan_tool.rs). + overrides.insert("CreatePlan".to_string(), ToolExposure::Direct); overrides } @@ -117,8 +124,9 @@ fn append_provider_group_tools(tools: &mut Vec, provider_id: &'static st pub fn shared_coding_mode_tools() -> Vec { let mut tools = vec![ "Task".to_string(), + "SessionMessage".to_string(), + "SessionHistory".to_string(), "ListModels".to_string(), - "AgentWait".to_string(), "Read".to_string(), "view_image".to_string(), "analyze_image".to_string(), @@ -140,6 +148,9 @@ pub fn shared_coding_mode_tools() -> Vec { "Skill".to_string(), "AskUserQuestion".to_string(), "CreatePlan".to_string(), + "PlanList".to_string(), + "PlanRead".to_string(), + "PlanUpdate".to_string(), "Git".to_string(), "ReviewPlatform".to_string(), "ControlHub".to_string(), @@ -154,6 +165,16 @@ pub fn shared_coding_mode_tools() -> Vec { tools } +/// Unified tool set for all SubAgents (built-in + ACP + custom). +/// Includes shared_coding_mode_tools() + SessionControl (fission core). +pub fn subagent_default_tools() -> Vec { + let mut tools = shared_coding_mode_tools(); + if !tools.contains(&"SessionControl".to_string()) { + tools.push("SessionControl".to_string()); + } + tools +} + /// Agent trait defining the interface for all agents #[async_trait] pub trait Agent: Send + Sync + 'static { @@ -323,6 +344,9 @@ mod tests { assert!(tools.contains(&"ListModels".to_string())); assert!(tools.contains(&"CreatePlan".to_string())); + assert!(tools.contains(&"PlanList".to_string())); + assert!(tools.contains(&"PlanRead".to_string())); + assert!(tools.contains(&"PlanUpdate".to_string())); assert!(tools.contains(&"get_goal".to_string())); assert!(tools.contains(&"update_goal".to_string())); } diff --git a/src/crates/assembly/core/src/agentic/agents/prompt_builder/prompt_builder_impl.rs b/src/crates/assembly/core/src/agentic/agents/prompt_builder/prompt_builder_impl.rs index 3b80b5ec6e..5d8a33d54e 100644 --- a/src/crates/assembly/core/src/agentic/agents/prompt_builder/prompt_builder_impl.rs +++ b/src/crates/assembly/core/src/agentic/agents/prompt_builder/prompt_builder_impl.rs @@ -17,14 +17,16 @@ use crate::service::workspace::get_global_workspace_service; use crate::service::workspace::RelatedPath; use crate::util::errors::{BitFunError, BitFunResult}; use bitfun_agent_runtime::prompt::{ - render_project_layout, render_runtime_context_reminder, render_user_context_reminder, - render_workspace_context, PrependedPromptReminders, ProjectLayoutFacts, PromptRelatedPath, - RemoteExecutionHints, RuntimeContextFacts, RuntimeContextNeeds, RuntimeShellFacts, + render_project_layout, render_runtime_context_reminder, render_runtime_facts_reminder, + render_user_context_reminder, render_workspace_context, PrependedPromptReminders, + ProjectLayoutFacts, PromptRelatedPath, RemoteExecutionHints, RuntimeContextFacts, + RuntimeContextNeeds, RuntimeFactsInput, RuntimeFactsUsage, RuntimeShellFacts, ToolListingSections, UserContextPolicy, UserContextSection, WorkspaceContextFacts, WorktreeContextFacts, }; use bitfun_agent_runtime::remote_file_delivery::user_workspace_relative_file_link; use bitfun_core_types::SessionExecutionTargetKind; +use chrono::Datelike; use log::{debug, info, warn}; use std::path::Path; @@ -288,6 +290,24 @@ impl PromptBuilder { }) } + /// Build the per-turn runtime facts reminder: current local/UTC time, + /// weekday, timezone offset (chrono::Local, same shape as the GetTime + /// tool) plus the live context usage ratio and tiered guidance. + pub fn build_runtime_facts_reminder(&self, usage: RuntimeFactsUsage) -> String { + let now = chrono::Local::now(); + let utc = now.with_timezone(&chrono::Utc); + render_runtime_facts_reminder(&RuntimeFactsInput { + local_time_rfc3339: now.to_rfc3339_opts(chrono::SecondsFormat::Secs, false), + utc_time_rfc3339: utc.to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + weekday_name: now.format("%A").to_string(), + weekday_number: now.weekday().number_from_monday(), + local_hhmm: now.format("%H:%M").to_string(), + timezone_offset: now.format("%:z").to_string(), + context_usage_ratio: usage.context_usage_ratio, + compression_preview_ratio: usage.compression_preview_ratio, + }) + } + /// Get workspace context that is intentionally injected outside the system prompt cache. pub fn get_workspace_context(&self) -> String { render_workspace_context(&WorkspaceContextFacts { @@ -426,12 +446,14 @@ impl PromptBuilder { pub async fn build_prepended_reminders( &self, user_context_policy: &UserContextPolicy, + runtime_facts_usage: RuntimeFactsUsage, ) -> PrependedPromptReminders { PrependedPromptReminders { deferred_tool_listing: self.build_deferred_tool_listing_reminder(), skill_listing: self.build_skill_listing_reminder(), agent_listing: self.build_agent_listing_reminder(), runtime_context: self.build_runtime_context_reminder().await, + runtime_facts: Some(self.build_runtime_facts_reminder(runtime_facts_usage)), user_context: self.build_user_context_reminder(user_context_policy).await, } } @@ -646,6 +668,7 @@ mod tests { use super::PromptBuilderContext; use super::RemoteExecutionHints; use super::RuntimeContextNeeds; + use super::RuntimeFactsUsage; use super::ToolListingSections; use crate::agentic::agents::UserContextPolicy; use crate::agentic::WorkspaceBinding; @@ -672,6 +695,10 @@ mod tests { &UserContextPolicy::empty() .with_workspace_context() .with_workspace_instructions(), + RuntimeFactsUsage { + context_usage_ratio: Some(0.35), + compression_preview_ratio: Some(0.9), + }, ) .await; let reminders_for_order = reminders.clone(); @@ -690,6 +717,7 @@ mod tests { let runtime_context = reminders .runtime_context .expect("runtime context should build"); + let runtime_facts = reminders.runtime_facts.expect("runtime facts should build"); assert!(skill_listing.contains("# Skill Listing")); assert!(skill_listing @@ -712,6 +740,8 @@ mod tests { assert!(!runtime_context.contains("## ExecCommand Shell")); assert!(!runtime_context.contains("## Local Client")); assert!(!runtime_context.contains("ExecCommand shell:")); + assert!(runtime_facts.contains("[Runtime Facts]")); + assert!(runtime_facts.contains("当前上下文占比: 35%")); assert_eq!( ordered_reminders, vec![ @@ -719,6 +749,7 @@ mod tests { skill_listing.as_str(), agent_listing.as_str(), runtime_context.as_str(), + runtime_facts.as_str(), user_context.as_str(), ] ); @@ -728,7 +759,7 @@ mod tests { async fn prepended_reminders_omit_runtime_context_without_runtime_tool_needs() { let context = PromptBuilderContext::new(r"workspace\root", None, None); let reminders = PromptBuilder::new(context) - .build_prepended_reminders(&UserContextPolicy::empty()) + .build_prepended_reminders(&UserContextPolicy::empty(), RuntimeFactsUsage::default()) .await; assert_eq!(reminders.skill_listing, None); @@ -736,6 +767,28 @@ mod tests { assert_eq!(reminders.deferred_tool_listing, None); assert_eq!(reminders.user_context, None); assert_eq!(reminders.runtime_context, None); + assert!(reminders + .runtime_facts + .expect("runtime facts should always build") + .contains("[Runtime Facts]")); + } + + #[test] + fn build_runtime_facts_reminder_includes_time_weekday_and_offset_shape() { + let context = PromptBuilderContext::new(r"workspace\root", None, None); + let reminder = PromptBuilder::new(context).build_runtime_facts_reminder(RuntimeFactsUsage { + context_usage_ratio: Some(0.5), + compression_preview_ratio: Some(0.9), + }); + + // Time facts come from chrono::Local at build time; assert the key + // shape (date/time/weekday/offset) without locking specific seconds. + assert!(reminder.contains("[Runtime Facts]")); + assert!(reminder.contains("当前本地时间: ")); + assert!(reminder.contains("UTC 时间: ")); + assert!(reminder.contains("时区偏移: ")); + assert!(reminder.contains("周")); + assert!(reminder.contains("当前上下文占比: 50%")); } #[tokio::test] diff --git a/src/crates/assembly/core/src/agentic/agents/registry/builtin.rs b/src/crates/assembly/core/src/agentic/agents/registry/builtin.rs index 48b9bb0d99..cdf63193b8 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/builtin.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/builtin.rs @@ -60,9 +60,9 @@ impl AgentRegistry { /// Create a new agent registry with built-in agents pub fn new() -> Self { Self { - agents: std::sync::RwLock::new(Self::build_builtin_agents()), - project_subagents: std::sync::RwLock::new(HashMap::new()), - user_custom_agents_loaded: std::sync::RwLock::new(false), + agents: tokio::sync::RwLock::new(Self::build_builtin_agents()), + project_subagents: tokio::sync::RwLock::new(HashMap::new()), + user_custom_agents_loaded: tokio::sync::RwLock::new(false), external_subagents: std::sync::Arc::new( super::external::ExternalSubagentRegistryState::new(), ), @@ -97,4 +97,42 @@ impl AgentRegistry { }, ); } + + /// Dynamically unregister an agent (called when an ACP client is removed) + pub fn unregister_agent(&self, agent_id: &str) { + self.write_agents().remove(agent_id); + } + + /// Unregister all agents whose id starts with `prefix`, mirroring + /// `unregister_tools_by_prefix` for the agent registry. Returns the + /// number of removed agents. + pub fn unregister_agents_by_prefix(&self, prefix: &str) -> usize { + let mut map = self.write_agents(); + let before = map.len(); + map.retain(|id, _| !id.starts_with(prefix)); + before - map.len() + } + + /// Update a registered agent (called when ACP client configuration changes) + pub fn update_agent( + &self, + agent_id: &str, + agent: Arc, + category: AgentCategory, + source: AgentSource, + subagent_source: Option, + ) { + let visibility_policy = SubagentVisibilityPolicy::public(); + self.write_agents().insert( + agent_id.to_string(), + AgentEntry { + category, + source, + subagent_source, + agent, + visibility_policy, + custom_config: None, + }, + ); + } } diff --git a/src/crates/assembly/core/src/agentic/agents/registry/custom.rs b/src/crates/assembly/core/src/agentic/agents/registry/custom.rs index 4b05afc594..c954809ce1 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/custom.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/custom.rs @@ -694,6 +694,7 @@ impl AgentRegistry { self.replace_custom_agent_entry(agent_id, workspace_root, replacement) } + #[allow(clippy::too_many_arguments)] pub async fn update_custom_subagent_definition( &self, agent_id: &str, diff --git a/src/crates/assembly/core/src/agentic/agents/registry/external.rs b/src/crates/assembly/core/src/agentic/agents/registry/external.rs index 96326a327b..d1e51dd31c 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/external.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/external.rs @@ -12,10 +12,18 @@ use bitfun_product_domains::external_subagents::ExternalSubagentMode; use log::{debug, warn}; use std::collections::{BTreeMap, HashMap, HashSet}; use std::path::{Path, PathBuf}; -use std::sync::{Arc, RwLock, Weak}; +use std::sync::{Arc, Weak}; +use tokio::sync::RwLock; +/// Stable prefix for external subagent runtime keys within the agent registry. +/// External subagents are registered under this namespace to avoid collisions with +/// built-in agents (`builtin:`, `custom:`, etc.). The module itself is intentionally +/// minimal — routing and lifecycle logic lives in `external_subagents.rs`. pub(crate) const EXTERNAL_SUBAGENT_RUNTIME_KEY_PREFIX: &str = "external_subagent_runtime:"; +/// Formats a stable runtime key for an external subagent given its content digest. +/// Used by `install_active_candidate` to register generation-specific agent entries +/// without re-parsing ecosystem manifests on every restart. pub(crate) fn external_subagent_runtime_key(digest: &str) -> String { format!("{EXTERNAL_SUBAGENT_RUNTIME_KEY_PREFIX}{digest}") } @@ -102,38 +110,35 @@ impl ExternalSubagentRegistryState { } } + // Synchronous helper over a tokio RwLock (no await point); see + // super::spin_read for the bounded-retry contract. Guards must never be + // held across an await; a panic (spin cap exceeded) means a holder + // violated that. fn read_generations( &self, - ) -> std::sync::RwLockReadGuard<'_, HashMap> { - self.generations - .read() - .unwrap_or_else(std::sync::PoisonError::into_inner) + ) -> tokio::sync::RwLockReadGuard<'_, HashMap> { + super::spin_read(&self.generations, "ExternalSubagentRegistryState generations") } fn write_generations( &self, - ) -> std::sync::RwLockWriteGuard<'_, HashMap> { - self.generations - .write() - .unwrap_or_else(std::sync::PoisonError::into_inner) + ) -> tokio::sync::RwLockWriteGuard<'_, HashMap> { + super::spin_write(&self.generations, "ExternalSubagentRegistryState generations") } + // Synchronous helper; see read_generations for the lock-contention contract. fn read_routes( &self, - ) -> std::sync::RwLockReadGuard<'_, HashMap>> + ) -> tokio::sync::RwLockReadGuard<'_, HashMap>> { - self.workspace_routes - .read() - .unwrap_or_else(std::sync::PoisonError::into_inner) + super::spin_read(&self.workspace_routes, "ExternalSubagentRegistryState workspace_routes") } fn write_routes( &self, - ) -> std::sync::RwLockWriteGuard<'_, HashMap>> + ) -> tokio::sync::RwLockWriteGuard<'_, HashMap>> { - self.workspace_routes - .write() - .unwrap_or_else(std::sync::PoisonError::into_inner) + super::spin_write(&self.workspace_routes, "ExternalSubagentRegistryState workspace_routes") } pub(super) fn find_generation_entry(&self, runtime_key: &str) -> Option { @@ -287,6 +292,48 @@ pub struct ExternalPrimaryAgentTurnBinding { pub lease: Option, } +/// 主代理(会话主模型)解析失败的原因分类。 +/// +/// 之前 `resolve_primary_agent_for_turn` 对「路由不可用」与「owner 不匹配」 +/// 一律返回 `None`,调用方只能统一报 "Unknown session mode",无法诊断。 +/// 现在返回带原因的 `Err`,区分: +/// - `CandidateUnavailable`:外部候选已撤回 / generation 缺失 / 不支持主代理, +/// 或本地候选不存在; +/// - `OwnerMismatch`:已解析绑定与持久化会话的期望 owner 不一致。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ExternalPrimaryAgentResolutionError { + /// 候选不可用:路由处于 `Unavailable`(fail-closed 撤回),或外部 + /// generation 缺失 / 不支持主代理,或本地路由下找不到注册候选。 + CandidateUnavailable { + logical_id: String, + reason: &'static str, + }, + /// 已解析绑定与期望的会话 route owner 不匹配。 + OwnerMismatch { + logical_id: String, + expected: SessionAgentRouteOwner, + actual: SessionAgentRouteOwner, + }, +} + +impl std::fmt::Display for ExternalPrimaryAgentResolutionError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::CandidateUnavailable { logical_id, reason } => { + write!(formatter, "candidate_unavailable: {logical_id} ({reason})") + } + Self::OwnerMismatch { + logical_id, + expected, + actual, + } => write!( + formatter, + "owner_mismatch: {logical_id} expected {expected:?}, resolved {actual:?}" + ), + } + } +} + impl AgentRegistry { /// Returns whether the logical id is owned by an external route in the /// requested workspace. `Unavailable` remains externally owned so a @@ -327,13 +374,19 @@ impl AgentRegistry { let lease_count = generations .get(&runtime_key) .map_or(0, |entry| entry.lease_count); - let agent_entry = AgentEntry { - category: AgentCategory::SubAgent, - source: AgentSource::External, - subagent_source: Some(SubAgentSource::External), - agent: registration.agent.clone(), - visibility_policy: SubagentVisibilityPolicy::public(), - custom_config: None, + // 同 runtime_key 重新 install 时,若仍有在途 turn(lease_count>0), + // 保留旧 agent_entry,避免换绑导致进行中的会话底层 agent 不一致; + // registration 仍更新(新配置对后续 acquire 生效,已发出的 lease 持有快照)。 + let agent_entry = match generations.get(&runtime_key) { + Some(entry) if entry.lease_count > 0 => entry.agent_entry.clone(), + _ => AgentEntry { + category: AgentCategory::SubAgent, + source: AgentSource::External, + subagent_source: Some(SubAgentSource::External), + agent: registration.agent.clone(), + visibility_policy: SubagentVisibilityPolicy::public(), + custom_config: None, + }, }; generations.insert( runtime_key, @@ -419,13 +472,18 @@ impl AgentRegistry { /// Resolve a user-facing main-agent id to the exact generation that owns /// the next turn. The returned lease keeps prompt, tools, permissions, and /// model metadata stable until that turn settles. + /// + /// 失败时返回带原因的错误,而不是一律 `None`,便于调用方精确诊断: + /// - `CandidateUnavailable`:外部候选撤回(`Unavailable` 路由)或 + /// generation 缺失 / 不支持主代理、本地候选不存在; + /// - `OwnerMismatch`:已解析绑定与 `expected_owner` 不一致。 pub fn resolve_primary_agent_for_turn( &self, logical_id: &str, workspace_root: Option<&Path>, external_sources_supported: bool, expected_owner: Option, - ) -> Option { + ) -> Result { let logical_key = normalize_external_logical_id(logical_id); if external_sources_supported { if let Some(workspace_root) = workspace_root { @@ -438,57 +496,93 @@ impl AgentRegistry { .cloned() { let binding = match route { - ExternalSubagentRoute::Local => { - match self.find_agent_entry(logical_id, Some(workspace_root)) { - Some(entry) if is_local_session_primary_entry(&entry) => { - Some(local_primary_binding(entry.agent.id())) - } - Some(entry) => { - warn!( - "Session primary agent resolution rejected a registered non-mode agent under a Local route: logical_id={}, category={:?}, source={:?}", - logical_id, - entry.category, - entry.source - ); - None - } - None => None, - } - } - ExternalSubagentRoute::External(runtime_key) => { - self.external_subagents.acquire_primary(&runtime_key) + // 与下方 fall-through(find_agent_entry 直接映射)对齐: + // 移除 Mode 过滤,允许 subagent 类型代理续聊/恢复/压缩。 + // 上游 is_local_session_primary_entry 白名单保留(指挥官 + // 20260806 纠正裁决:融合方案)——下方 fall-through 中 + // 命中白名单走确认路径,未命中按本地全量放开。 + ExternalSubagentRoute::Local => self + .find_agent_entry(logical_id, Some(workspace_root)) + .map(|entry| local_primary_binding(entry.agent.id())) + .ok_or(ExternalPrimaryAgentResolutionError::CandidateUnavailable { + logical_id: logical_key.clone(), + reason: "local route has no registered candidate", + })?, + ExternalSubagentRoute::External(runtime_key) => self + .external_subagents + .acquire_primary(&runtime_key) + .ok_or(ExternalPrimaryAgentResolutionError::CandidateUnavailable { + logical_id: logical_key.clone(), + reason: "external generation missing or not primary-capable", + })?, + // 候选已撤回时保持 fail-closed:不回落同名本地实现, + // 并携带明确原因供调用方诊断。 + ExternalSubagentRoute::Unavailable => { + return Err(ExternalPrimaryAgentResolutionError::CandidateUnavailable { + logical_id: logical_key, + reason: "external candidate withdrawn (fail-closed route)", + }); } - ExternalSubagentRoute::Unavailable => None, }; - return binding.filter(|binding| { - expected_owner.is_none_or(|owner| binding.route_owner == owner) - }); + if let Some(expected_owner) = expected_owner { + if binding.route_owner != expected_owner { + // 解析成功但 owner 与持久化会话不一致,单独归类, + // 避免与「候选不可用」混为一谈。 + return Err(ExternalPrimaryAgentResolutionError::OwnerMismatch { + logical_id: logical_key, + expected: expected_owner, + actual: binding.route_owner, + }); + } + } + return Ok(binding); } } } if expected_owner == Some(SessionAgentRouteOwner::External) { - return None; + // 会话持久化 owner 为 External,但当前没有外部路由可解析, + // 属于 owner 语义冲突(fail-closed),不再是「未知会话模式」。 + return Err(ExternalPrimaryAgentResolutionError::OwnerMismatch { + logical_id: logical_key, + expected: SessionAgentRouteOwner::External, + actual: SessionAgentRouteOwner::Local, + }); } + // Subagent types (custom `kind: subagent` agents such as legion + // permanent posts, and builtin subagents) are valid owners of sessions + // created through SessionControl/SessionMessage and must resolve for + // continued dialog turns, restore, and manual compaction. The Mode + // filter only guarded the route branch above; the fail-closed + // `expected_owner == External` guard stays. + // 融合(上游 review 修复 + 本地全量放开,指挥官 20260806 纠正裁决): + // - 命中上游 is_local_session_primary_entry 白名单(Mode 或 + // CodeReview/DeepReview builtin)→ 白名单确认路径解析(上游功能保留); + // - 未命中(其他 subagent 类型)→ 本地全量放开仍允许(ACP/军团定制超集), + // 并 warn 提示该 entry 不在上游白名单、由本地定制放开。 match self.find_agent_entry(logical_id, workspace_root) { - Some(entry) if is_local_session_primary_entry(&entry) => { - Some(local_primary_binding(entry.agent.id())) - } Some(entry) => { - warn!( - "Session primary agent resolution rejected a registered non-mode agent: logical_id={}, category={:?}, source={:?}, expected_owner={:?}", - logical_id, - entry.category, - entry.source, - expected_owner - ); - None + if is_local_session_primary_entry(&entry) { + Ok(local_primary_binding(entry.agent.id())) + } else { + warn!( + "Session primary agent resolution allows a non-whitelisted subagent via local customization: logical_id={}, category={:?}, source={:?}, expected_owner={:?}", + logical_id, + entry.category, + entry.source, + expected_owner + ); + Ok(local_primary_binding(entry.agent.id())) + } } None => { debug!( "Session primary agent resolution found no registered agent: logical_id={}, expected_owner={:?}", logical_id, expected_owner ); - None + Err(ExternalPrimaryAgentResolutionError::CandidateUnavailable { + logical_id: logical_key, + reason: "no registered candidate for the requested session mode", + }) } } } @@ -596,7 +690,13 @@ impl AgentRegistry { } fn normalize_external_logical_id(logical_id: &str) -> String { - logical_id.to_ascii_lowercase() + // 归一化更严格:折叠空白(去首尾、合并内部连续空白)后统一 Unicode 小写, + // 避免仅 ASCII 小写时同一逻辑 id 因空白或非 ASCII 大小写变体被拆成不同键。 + logical_id + .split_whitespace() + .collect::>() + .join(" ") + .to_lowercase() } fn local_binding(logical_id: &str, runtime_agent_key: &str) -> ExternalSubagentInvocationBinding { diff --git a/src/crates/assembly/core/src/agentic/agents/registry/mod.rs b/src/crates/assembly/core/src/agentic/agents/registry/mod.rs index d0e7e1e141..10a7dac378 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/mod.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/mod.rs @@ -15,12 +15,12 @@ use self::types::AgentEntry; use self::types::{AgentCategory, SubAgentSource}; use super::Agent; use crate::agentic::deep_review_policy::canonical_review_worker_agent_type; -use log::{debug, warn}; +use log::debug; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::{Path, PathBuf}; -use std::sync::RwLock; use std::sync::{Arc, OnceLock}; +use tokio::sync::RwLock; pub(crate) use external::external_subagent_runtime_key; pub use external::{ @@ -66,49 +66,62 @@ impl Default for AgentRegistry { } } -impl AgentRegistry { - fn read_agents(&self) -> std::sync::RwLockReadGuard<'_, HashMap> { - match self.agents.read() { - Ok(guard) => guard, - Err(poisoned) => { - warn!("Agent registry read lock poisoned, recovering"); - poisoned.into_inner() - } +/// Registry locks are tokio::sync::RwLock and the helpers below run in +/// synchronous context (no await point), so they use try_read/try_write +/// instead of await. A transient try-lock failure is normal when another +/// thread happens to hold the lock for a few microseconds (parallel tests +/// sharing the global registry, or concurrent runtime threads); we retry +/// with a bounded yield spin. Only exceeding the spin cap still panics, +/// which preserves detection of a guard held across an await point (a real +/// bug). Guards must never be held across an await point. +const SPIN_RETRY_CAP: usize = 10_000; + +fn spin_read<'a, T>( + lock: &'a tokio::sync::RwLock, + what: &'a str, +) -> tokio::sync::RwLockReadGuard<'a, T> { + for _ in 0..SPIN_RETRY_CAP { + if let Ok(guard) = lock.try_read() { + return guard; } + std::thread::yield_now(); } + panic!("{what} lock should not be contended (spin cap exceeded)") +} - fn write_agents(&self) -> std::sync::RwLockWriteGuard<'_, HashMap> { - match self.agents.write() { - Ok(guard) => guard, - Err(poisoned) => { - warn!("Agent registry write lock poisoned, recovering"); - poisoned.into_inner() - } +fn spin_write<'a, T>( + lock: &'a tokio::sync::RwLock, + what: &'a str, +) -> tokio::sync::RwLockWriteGuard<'a, T> { + for _ in 0..SPIN_RETRY_CAP { + if let Ok(guard) = lock.try_write() { + return guard; } + std::thread::yield_now(); + } + panic!("{what} lock should not be contended (spin cap exceeded)") +} + +impl AgentRegistry { + fn read_agents(&self) -> tokio::sync::RwLockReadGuard<'_, HashMap> { + spin_read(&self.agents, "AgentRegistry agents") + } + + fn write_agents(&self) -> tokio::sync::RwLockWriteGuard<'_, HashMap> { + spin_write(&self.agents, "AgentRegistry agents") } + // Synchronous helper; see spin_read for the lock-contention contract. fn read_project_subagents( &self, - ) -> std::sync::RwLockReadGuard<'_, HashMap>> { - match self.project_subagents.read() { - Ok(guard) => guard, - Err(poisoned) => { - warn!("Agent project registry read lock poisoned, recovering"); - poisoned.into_inner() - } - } + ) -> tokio::sync::RwLockReadGuard<'_, HashMap>> { + spin_read(&self.project_subagents, "AgentRegistry project_subagents") } fn write_project_subagents( &self, - ) -> std::sync::RwLockWriteGuard<'_, HashMap>> { - match self.project_subagents.write() { - Ok(guard) => guard, - Err(poisoned) => { - warn!("Agent project registry write lock poisoned, recovering"); - poisoned.into_inner() - } - } + ) -> tokio::sync::RwLockWriteGuard<'_, HashMap>> { + spin_write(&self.project_subagents, "AgentRegistry project_subagents") } fn find_agent_entry( @@ -189,24 +202,13 @@ impl AgentRegistry { }) } + // Synchronous helper; see spin_read for the lock-contention contract. fn user_custom_agents_loaded(&self) -> bool { - match self.user_custom_agents_loaded.read() { - Ok(guard) => *guard, - Err(poisoned) => { - warn!("Agent custom-user loaded flag read lock poisoned, recovering"); - *poisoned.into_inner() - } - } + *spin_read(&self.user_custom_agents_loaded, "AgentRegistry user_custom_agents_loaded") } fn set_user_custom_agents_loaded(&self, loaded: bool) { - match self.user_custom_agents_loaded.write() { - Ok(mut guard) => *guard = loaded, - Err(poisoned) => { - warn!("Agent custom-user loaded flag write lock poisoned, recovering"); - *poisoned.into_inner() = loaded; - } - } + *spin_write(&self.user_custom_agents_loaded, "AgentRegistry user_custom_agents_loaded") = loaded; } } diff --git a/src/crates/assembly/core/src/agentic/agents/registry/query.rs b/src/crates/assembly/core/src/agentic/agents/registry/query.rs index 6e2622b460..cec1212a75 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/query.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/query.rs @@ -184,6 +184,40 @@ impl AgentRegistry { result } + /// Return ids of all agents visible for session creation (modes + subagents). + /// + /// Modes cover builtin modes, user custom modes and ACP bridge agents + /// (`acp__`); subagents cover builtin/user subagents plus the + /// project subagents of the given workspace (when provided). + pub async fn get_agent_ids_for_session_creation( + &self, + workspace_root: Option<&Path>, + ) -> Vec { + self.ensure_user_custom_agents_loaded().await; + let mut ids: Vec = { + let map = self.read_agents(); + map.values() + .filter(|e| matches!(e.category, AgentCategory::Mode | AgentCategory::SubAgent)) + .map(|e| e.agent.id().to_string()) + .collect() + }; + if let Some(workspace_root) = workspace_root { + if let Some(entries) = self.read_project_subagents().get(workspace_root) { + ids.extend( + entries + .values() + .filter(|e| { + matches!(e.category, AgentCategory::Mode | AgentCategory::SubAgent) + }) + .map(|e| e.agent.id().to_string()), + ); + } + } + ids.sort(); + ids.dedup(); + ids + } + /// check if a subagent is readonly (used for TaskTool.is_concurrency_safe etc.) pub fn get_subagent_is_readonly(&self, id: &str) -> Option { if let Some(entry) = self.read_agents().get(id) { diff --git a/src/crates/assembly/core/src/agentic/agents/registry/tests.rs b/src/crates/assembly/core/src/agentic/agents/registry/tests.rs index 4ecdf08fea..072829a6d1 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/tests.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/tests.rs @@ -486,6 +486,85 @@ async fn task_visible_subagents_are_filtered_by_parent_agent() { .any(|agent| agent.id == "ReviewWorker")); } +#[tokio::test] +async fn session_creation_agent_ids_include_acp_bridge_modes_and_project_subagents() { + let registry = AgentRegistry::new(); + + // ACP bridge agents (`acp__`) are registered as Mode entries, + // exactly like builtin modes, so session creation must be able to select them. + registry.register_agent( + Arc::new(TestAgent { + id: "acp__client-a".to_string(), + }), + AgentCategory::Mode, + AgentSource::Builtin, + None, + None, + ); + registry.register_agent( + Arc::new(TestAgent { + id: "Plan".to_string(), + }), + AgentCategory::Mode, + AgentSource::Builtin, + None, + None, + ); + registry.register_agent( + Arc::new(TestAgent { + id: "Explore".to_string(), + }), + AgentCategory::SubAgent, + AgentSource::Builtin, + Some(SubAgentSource::Builtin), + None, + ); + // Hidden agents (not Modes/SubAgents) must stay out of the creation surface. + registry.register_agent( + Arc::new(TestAgent { + id: "ghost-hidden".to_string(), + }), + AgentCategory::Hidden, + AgentSource::Builtin, + None, + None, + ); + + let mut project_entries = HashMap::new(); + project_entries.insert( + "zProject".to_string(), + test_project_entry("zProject", "fast"), + ); + registry + .write_project_subagents() + .insert(PathBuf::from("D:/workspace/project-c"), project_entries); + registry.set_user_custom_agents_loaded(true); + + let unscoped = registry.get_agent_ids_for_session_creation(None).await; + assert!( + unscoped.iter().any(|id| id == "acp__client-a"), + "acp bridge modes must be selectable for session creation" + ); + assert!(unscoped.iter().any(|id| id == "Plan")); + assert!(unscoped.iter().any(|id| id == "Explore")); + assert!( + !unscoped.iter().any(|id| id == "ghost-hidden"), + "hidden agents must not be listed for session creation" + ); + assert!( + !unscoped.iter().any(|id| id == "zProject"), + "project subagents are only listed for their own workspace" + ); + + let scoped = registry + .get_agent_ids_for_session_creation(Some(Path::new("D:/workspace/project-c"))) + .await; + assert!( + scoped.iter().any(|id| id == "zProject"), + "project subagents merge in when the workspace is provided" + ); +} + #[test] fn merge_dynamic_mcp_tools_appends_registered_mcp_tools_once() { let configured_tools = vec!["Read".to_string(), "ExecCommand".to_string()]; @@ -1337,6 +1416,34 @@ async fn external_routes_are_workspace_scoped_fail_closed_and_generation_leased( assert!(registry.get_agent(runtime_v1, Some(&workspace)).is_none()); } +#[tokio::test] +async fn unregister_agents_by_prefix_removes_only_matching_agents() { + let registry = AgentRegistry::new(); + for id in ["acp__client-a", "acp__client-b", "builtin", "acp"] { + registry.register_agent( + Arc::new(TestAgent { id: id.to_string() }), + AgentCategory::SubAgent, + AgentSource::Builtin, + Some(SubAgentSource::Builtin), + None, + ); + } + assert_eq!( + registry.unregister_agents_by_prefix("acp__"), + 2, + "only acp__-prefixed agents are removed" + ); + assert!(registry.get_agent("acp__client-a", None).is_none()); + assert!(registry.get_agent("acp__client-b", None).is_none()); + assert!(registry.get_agent("builtin", None).is_some()); + assert!(registry.get_agent("acp", None).is_some()); + assert_eq!( + registry.unregister_agents_by_prefix("acp__"), + 0, + "second cleanup removes nothing" + ); +} + #[tokio::test] async fn external_routes_use_one_canonical_workspace_identity_for_all_operations() { let registry = AgentRegistry::new(); @@ -1394,7 +1501,7 @@ fn persisted_external_owner_never_falls_back_to_a_same_name_local_mode() { true, Some(bitfun_core_types::SessionAgentRouteOwner::External), ) - .is_none()); + .is_err()); let local = registry .resolve_primary_agent_for_turn( "agentic", @@ -1409,6 +1516,44 @@ fn persisted_external_owner_never_falls_back_to_a_same_name_local_mode() { ); } +#[test] +fn local_subagent_type_resolves_as_primary_agent_for_turn() { + let registry = AgentRegistry::new(); + registry.register_agent( + Arc::new(TestAgent { + id: "lvpa-handoff".to_string(), + }), + AgentCategory::SubAgent, + AgentSource::User, + Some(SubAgentSource::User), + None, + ); + + let binding = registry + .resolve_primary_agent_for_turn( + "lvpa-handoff", + None, + false, + Some(bitfun_core_types::SessionAgentRouteOwner::Local), + ) + .expect("a session owned by a registered subagent type must resolve for continued dialog turns"); + assert_eq!(binding.runtime_agent_key, "lvpa-handoff"); + assert_eq!( + binding.route_owner, + bitfun_core_types::SessionAgentRouteOwner::Local + ); + + // The fail-closed guard for persisted external owners is unaffected. + assert!(registry + .resolve_primary_agent_for_turn( + "lvpa-handoff", + None, + false, + Some(bitfun_core_types::SessionAgentRouteOwner::External), + ) + .is_err()); +} + #[tokio::test] async fn external_agent_role_controls_main_and_task_projection() { let registry = AgentRegistry::new(); @@ -1521,7 +1666,7 @@ fn persisted_primary_route_owner_rejects_same_name_route_takeover() { true, Some(bitfun_core_types::SessionAgentRouteOwner::Local), ) - .is_none()); + .is_err()); registry.install_external_subagent_routes( &workspace, @@ -1537,7 +1682,7 @@ fn persisted_primary_route_owner_rejects_same_name_route_takeover() { true, Some(bitfun_core_types::SessionAgentRouteOwner::External), ) - .is_none()); + .is_err()); } #[test] @@ -1597,9 +1742,7 @@ fn builtin_review_agents_resolve_as_local_session_primaries() { for agent_type in ["CodeReview", "DeepReview"] { let binding = registry .resolve_primary_agent_for_turn(agent_type, None, false, None) - .unwrap_or_else(|| { - panic!("{agent_type} must resolve as a session primary agent for review children") - }); + .expect("{agent_type} must resolve as a session primary agent for review children"); assert_eq!(binding.runtime_agent_key, agent_type); assert_eq!( binding.route_owner, @@ -1612,14 +1755,15 @@ fn builtin_review_agents_resolve_as_local_session_primaries() { fn non_session_primary_subagents_and_unknown_ids_do_not_resolve() { let registry = AgentRegistry::new(); - // Registered subagents that are not session-capable stay restricted. + // Registered subagents that are not upstream-whitelisted still resolve under + // the local full-open customization (super-set of the upstream whitelist). assert!(registry .resolve_primary_agent_for_turn("ReviewWorker", None, false, None) - .is_none()); + .is_ok()); // Unknown ids remain unknown. assert!(registry .resolve_primary_agent_for_turn("does-not-exist", None, false, None) - .is_none()); + .is_err()); // The external-owner guard still fails closed for review agents. assert!(registry .resolve_primary_agent_for_turn( @@ -1628,7 +1772,7 @@ fn non_session_primary_subagents_and_unknown_ids_do_not_resolve() { false, Some(bitfun_core_types::SessionAgentRouteOwner::External), ) - .is_none()); + .is_err()); } #[test] @@ -1650,7 +1794,7 @@ fn local_route_resolves_review_agents_as_session_primaries() { for agent_type in ["CodeReview", "DeepReview"] { let binding = registry .resolve_primary_agent_for_turn(agent_type, Some(&workspace), true, None) - .unwrap_or_else(|| panic!("{agent_type} must resolve through an explicit Local route")); + .expect("{agent_type} must resolve through an explicit Local route"); assert_eq!(binding.runtime_agent_key, agent_type); assert_eq!( binding.route_owner, @@ -1658,8 +1802,9 @@ fn local_route_resolves_review_agents_as_session_primaries() { ); } - // Non-session-primary subagents stay restricted even under a Local route. + // Non-whitelisted subagents stay resolvable under a Local route via the + // local full-open customization (upstream restricted them to whitelist-only). assert!(registry .resolve_primary_agent_for_turn("ReviewWorker", Some(&workspace), true, None) - .is_none()); + .is_ok()); } diff --git a/src/crates/assembly/core/src/agentic/agents/team_presets.rs b/src/crates/assembly/core/src/agentic/agents/team_presets.rs new file mode 100644 index 0000000000..6a3f9b607c --- /dev/null +++ b/src/crates/assembly/core/src/agentic/agents/team_presets.rs @@ -0,0 +1,139 @@ +//! Legion preset storage. +//! +//! Each preset is a JSON file under `/legions/.json` describing +//! a team topology (nodes + edges) that the Team mode agent can materialise at +//! runtime via SessionControl / SessionMessage. + +use crate::infrastructure::get_path_manager_arc; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +const LEGIONS_SUBDIR: &str = "legions"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LegionPreset { + pub id: String, + pub name: String, + pub description: String, + pub nodes: Vec, + pub edges: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LegionNode { + pub id: String, + pub agent: String, + #[serde(default)] + pub role: String, + #[serde(default)] + pub prompt: String, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub gate: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LegionEdge { + pub from: String, + pub to: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub condition: Option, +} + +fn legions_dir() -> PathBuf { + get_path_manager_arc() + .user_config_dir() + .join(LEGIONS_SUBDIR) +} + +fn preset_path(id: &str) -> Result { + validate_preset_id(id)?; + Ok(legions_dir().join(format!("{id}.json"))) +} + +/// Validate preset id to prevent path traversal. +/// Allowed characters: alphanumeric, underscore, and hyphen. +fn validate_preset_id(id: &str) -> Result<(), String> { + if id.is_empty() { + return Err("Legion preset id must not be empty".to_string()); + } + if !id + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + { + return Err(format!( + "Invalid legion preset id '{id}': only letters, digits, underscores, and hyphens are allowed" + )); + } + Ok(()) +} + +fn ensure_legions_dir() -> std::io::Result<()> { + let dir = legions_dir(); + std::fs::create_dir_all(&dir) +} + +/// List all saved legion presets (sorted by id). +pub fn list_presets() -> Result, String> { + let dir = legions_dir(); + if !dir.is_dir() { + return Ok(Vec::new()); + } + let mut out = Vec::new(); + let entries = + std::fs::read_dir(&dir).map_err(|e| format!("Failed to read legions dir: {e}"))?; + for entry in entries { + let entry = entry.map_err(|e| format!("Failed to read dir entry: {e}"))?; + let path = entry.path(); + if path.extension().is_some_and(|ext| ext == "json") { + let raw = std::fs::read_to_string(&path) + .map_err(|e| format!("Failed to read {}: {e}", path.display()))?; + let preset: LegionPreset = serde_json::from_str(&raw) + .map_err(|e| format!("Failed to parse {}: {e}", path.display()))?; + out.push(preset); + } + } + out.sort_by(|a, b| a.id.cmp(&b.id)); + Ok(out) +} + +/// Load a single preset by id. +pub fn get_preset(id: &str) -> Result { + let path = preset_path(id)?; + if !path.is_file() { + return Err(format!("Legion preset '{id}' not found")); + } + let raw = std::fs::read_to_string(&path).map_err(|e| format!("Failed to read preset: {e}"))?; + serde_json::from_str(&raw).map_err(|e| format!("Failed to parse preset: {e}")) +} + +/// Create or overwrite a preset. +pub fn create_preset(preset: &LegionPreset) -> Result<(), String> { + ensure_legions_dir().map_err(|e| format!("Failed to create legions dir: {e}"))?; + let path = preset_path(&preset.id)?; + let raw = + serde_json::to_string_pretty(preset).map_err(|e| format!("Failed to serialise: {e}"))?; + std::fs::write(&path, raw).map_err(|e| format!("Failed to write preset: {e}")) +} + +/// Update an existing preset (id must already exist). +pub fn update_preset(preset: &LegionPreset) -> Result<(), String> { + let path = preset_path(&preset.id)?; + if !path.is_file() { + return Err(format!("Legion preset '{}' not found", preset.id)); + } + let raw = + serde_json::to_string_pretty(preset).map_err(|e| format!("Failed to serialise: {e}"))?; + std::fs::write(&path, raw).map_err(|e| format!("Failed to write preset: {e}")) +} + +/// Delete a preset by id. +pub fn delete_preset(id: &str) -> Result<(), String> { + let path = preset_path(id)?; + if !path.is_file() { + return Err(format!("Legion preset '{id}' not found")); + } + std::fs::remove_file(&path).map_err(|e| format!("Failed to delete preset: {e}")) +} diff --git a/src/crates/assembly/core/src/agentic/coordination/background_outcomes.rs b/src/crates/assembly/core/src/agentic/coordination/background_outcomes.rs index 49ff711dd6..f882f56ef4 100644 --- a/src/crates/assembly/core/src/agentic/coordination/background_outcomes.rs +++ b/src/crates/assembly/core/src/agentic/coordination/background_outcomes.rs @@ -219,10 +219,18 @@ impl BackgroundSubagentOutcomeStore { ) -> BitFunResult { self.reconcile_stale_running_tasks(parent_session_id) .await?; - let selected = self + let candidates = self .coordination_store .wait_candidates(parent_session_id, requested_bg_task_ids) .await?; + // `wait_candidates` now returns delivered records too (explicitly + // distinguishable via delivered_at_ms) instead of dropping them + // silently (COORD-09). A delivered task carries nothing new to wait + // on, so it is excluded from the wait set here. + let selected = candidates + .into_iter() + .filter(|record| record.delivered_at_ms.is_none()) + .collect::>(); if selected.is_empty() { return Ok(wait_result( BackgroundSubagentWaitStatus::NoMatchingTasks, @@ -479,6 +487,10 @@ impl BackgroundSubagentOutcomeStore { .await } + /// Single-parent resolution kept for compatibility and tests; production + /// callers use [`Self::resolve_agent_id_in_scope`] for subtree/global + /// management. + #[allow(dead_code)] pub(crate) async fn resolve_agent_id( &self, parent_session_id: &str, @@ -489,6 +501,55 @@ impl BackgroundSubagentOutcomeStore { .await } + /// Global-management variant: prefer the caller's subtree, then fall back + /// to a whole-database match (see `CoordinationStore::resolve_agent_id_in_scope`). + /// `allow_global_fallback=false` turns a scope miss into "not found", which + /// mutating Task operations rely on to stay within their session subtree. + pub(crate) async fn resolve_agent_id_in_scope( + &self, + scope_session_ids: &[String], + agent_id: &str, + allow_global_fallback: bool, + ) -> BitFunResult { + self.coordination_store + .resolve_agent_id_in_scope(scope_session_ids, agent_id, allow_global_fallback) + .await + } + + /// Single-parent list kept for compatibility; production callers use + /// [`Self::list_records_for_parents`] for subtree/global management. + #[allow(dead_code)] + pub(crate) async fn list_records( + &self, + parent_session_id: &str, + ) -> BitFunResult> { + self.coordination_store.list_tasks(parent_session_id).await + } + + /// Lists background records spawned by any session in `parent_session_ids` + /// (the caller's subtree), enabling cross-conversation Task management. + pub(crate) async fn list_records_for_parents( + &self, + parent_session_ids: &[String], + ) -> BitFunResult> { + self.coordination_store + .list_tasks_for_parents(parent_session_ids) + .await + } + + /// Collects descendant session ids under `root_session_id` from the + /// persisted coordination database. Used to rebuild `agent_id` subtree + /// scopes after a restart, when the in-memory session tree may be + /// incomplete (COORD-06). + pub(crate) async fn descendant_session_ids( + &self, + root_session_id: &str, + ) -> BitFunResult> { + self.coordination_store + .descendant_session_ids(root_session_id) + .await + } + pub(crate) async fn delete_session_references(&self, session_id: &str) -> BitFunResult<()> { let deleted_task_pks = self .coordination_store diff --git a/src/crates/assembly/core/src/agentic/coordination/coordination_store.rs b/src/crates/assembly/core/src/agentic/coordination/coordination_store.rs index 8bca08c646..48d1bb50df 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordination_store.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordination_store.rs @@ -152,6 +152,9 @@ impl CoordinationStore { .await } + /// Single-parent resolution kept for compatibility and tests; subtree/ + /// global callers use [`Self::resolve_agent_id_in_scope`]. + #[allow(dead_code)] pub(crate) async fn resolve_agent_id( &self, parent_session_id: &str, @@ -174,6 +177,124 @@ impl CoordinationStore { .await } + /// Global agent_id resolution for full background-task management. + /// + /// `agent_id` is unique per parent session (`UNIQUE(parent_session_id, + /// agent_id)`), so different parents may each own an `a1`. Resolution + /// strategy: + /// 1. Prefer a match inside `scope_session_ids` (the caller's session + /// subtree). A single in-scope hit wins immediately; multiple in-scope + /// hits are ambiguous and reported with candidates. + /// 2. If the scope has no match and `allow_global_fallback` is true, fall + /// back to a whole-database match so a caller can manage subagents + /// spawned outside its subtree. A unique global hit is returned; + /// multiple hits report candidates instead of picking arbitrarily. + /// When `allow_global_fallback` is false, a scope miss is reported as + /// "not found" so mutating operations (cancel/send_input/history) + /// cannot cross session-subtree boundaries. + pub(crate) async fn resolve_agent_id_in_scope( + &self, + scope_session_ids: &[String], + agent_id: &str, + allow_global_fallback: bool, + ) -> BitFunResult { + let scope_session_ids = scope_session_ids.to_vec(); + let agent_id = agent_id.to_string(); + self.with_connection(move |connection| { + let scope_hits = if scope_session_ids.is_empty() { + Vec::new() + } else { + let placeholders: Vec = (1..=scope_session_ids.len()) + .map(|i| format!("?{i}")) + .collect(); + let sql = format!( + "SELECT parent_session_id, child_session_id FROM agents WHERE parent_session_id IN ({}) AND agent_id = ?{} AND state = 'active' ORDER BY agent_pk", + placeholders.join(", "), + scope_session_ids.len() + 1 + ); + let mut statement = connection.prepare(&sql).map_err(db_error)?; + let mut param_values: Vec> = + Vec::with_capacity(scope_session_ids.len() + 1); + for id in &scope_session_ids { + param_values.push(Box::new(id.clone())); + } + param_values.push(Box::new(agent_id.clone())); + let param_refs: Vec<&dyn rusqlite::types::ToSql> = + param_values.iter().map(|v| v.as_ref()).collect(); + let rows = statement + .query_map(param_refs.as_slice(), |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, Option>(1)?, + )) + }) + .map_err(db_error)?; + rows.collect::>>() + .map_err(db_error)? + }; + + match scope_hits.as_slice() { + [(_, Some(child_session_id))] => { + return Ok(child_session_id.clone()); + } + [] => {} + hits => { + let candidates = hits + .iter() + .filter_map(|(parent, child)| { + child.as_ref().map(|child| format!("{parent}/{child}")) + }) + .collect::>(); + return Err(BitFunError::tool(format!( + "Agent id '{agent_id}' is ambiguous in the caller's session subtree; candidates: {}", + candidates.join(", ") + ))); + } + } + + // Fall back to a whole-database match so any caller can manage + // subagents spawned outside its subtree — unless the caller + // disallowed global fallback (mutating Task operations), in which + // case a scope miss is an authorization boundary. + if !allow_global_fallback { + return Err(BitFunError::tool(format!( + "Agent was not found: {agent_id}" + ))); + } + let mut statement = connection + .prepare( + "SELECT parent_session_id, child_session_id FROM agents WHERE agent_id = ?1 AND state = 'active' ORDER BY agent_pk", + ) + .map_err(db_error)?; + let rows = statement + .query_map(params![agent_id], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, Option>(1)?, + )) + }) + .map_err(db_error)?; + let global_hits = rows.collect::>>().map_err(db_error)?; + match global_hits.as_slice() { + [(_, Some(child_session_id))] => Ok(child_session_id.clone()), + [] => Err(BitFunError::tool(format!("Agent was not found: {agent_id}"))), + hits => { + let candidates = hits + .iter() + .filter_map(|(parent, child)| { + child.as_ref().map(|child| format!("{parent}/{child}")) + }) + .collect::>(); + Err(BitFunError::tool(format!( + "Agent id '{agent_id}' is ambiguous across sessions; candidates: {}", + candidates.join(", ") + ))) + } + } + }) + .await + } + pub(crate) async fn register_background_task( &self, registration: BackgroundTaskRegistration, @@ -278,6 +399,14 @@ WHERE task_pk = ?5 AND status = 'running' .await } + /// Resolve the background tasks a caller may wait on. + /// + /// With an empty `requested_bg_task_ids`, only undelivered tasks are + /// returned (the "what is still pending" query). With explicit ids, every + /// matching record is returned, including already-delivered ones, so + /// callers can explicitly tell a delivered task apart from a + /// not-yet-completed one via [`BackgroundTaskRecord::delivered_at_ms`] + /// instead of silently losing it (COORD-09). pub(crate) async fn wait_candidates( &self, parent_session_id: &str, @@ -300,25 +429,44 @@ WHERE task_pk = ?5 AND status = 'running' } let mut records = Vec::with_capacity(requested_bg_task_ids.len()); - for bg_task_id in requested_bg_task_ids { - let record = connection - .query_row( - &format!( - "{} WHERE tasks.parent_session_id = ?1 AND tasks.bg_task_id = ?2", - BACKGROUND_TASK_SELECT - ), - params![parent_session_id, bg_task_id], - background_task_from_row, - ) - .optional() - .map_err(db_error)? - .ok_or_else(|| { - BitFunError::tool(format!("Background task was not found: {bg_task_id}")) - })?; - if record.delivered_at_ms.is_none() { + let mut found_ids = std::collections::HashSet::with_capacity(requested_bg_task_ids.len()); + + for chunk in requested_bg_task_ids.chunks(990) { + let placeholders: Vec = (2..=chunk.len() + 1) + .map(|i| format!("?{i}")) + .collect(); + let sql = format!( + "{} WHERE tasks.parent_session_id = ?1 AND tasks.bg_task_id IN ({})", + BACKGROUND_TASK_SELECT, + placeholders.join(", ") + ); + let mut statement = connection.prepare(&sql).map_err(db_error)?; + let mut param_values: Vec> = + Vec::with_capacity(chunk.len() + 1); + param_values.push(Box::new(parent_session_id.clone())); + for id in chunk { + param_values.push(Box::new(id.clone())); + } + let param_refs: Vec<&dyn rusqlite::types::ToSql> = + param_values.iter().map(|v| v.as_ref()).collect(); + let rows = statement + .query_map(param_refs.as_slice(), background_task_from_row) + .map_err(db_error)?; + for row in rows { + let record = row.map_err(db_error)?; + found_ids.insert(record.bg_task_id.clone()); + // Keep delivered records in the result (marked by + // delivered_at_ms) rather than dropping them silently. records.push(record); } } + for bg_task_id in &requested_bg_task_ids { + if !found_ids.contains(bg_task_id.as_str()) { + return Err(BitFunError::tool(format!( + "Background task was not found: {bg_task_id}" + ))); + } + } Ok(records) }) .await @@ -330,21 +478,126 @@ WHERE task_pk = ?5 AND status = 'running' ) -> BitFunResult> { let task_pks = task_pks.to_vec(); self.with_connection(move |connection| { - let mut records = Vec::with_capacity(task_pks.len()); - for task_pk in task_pks { - if let Some(record) = connection - .query_row( - &format!("{} WHERE tasks.task_pk = ?1", BACKGROUND_TASK_SELECT), - params![task_pk], + if task_pks.is_empty() { + return Ok(Vec::new()); + } + let mut all_records = Vec::with_capacity(task_pks.len()); + for chunk in task_pks.chunks(990) { + let placeholders: Vec = (1..=chunk.len()) + .map(|i| format!("?{i}")) + .collect(); + let sql = format!( + "{} WHERE tasks.task_pk IN ({})", + BACKGROUND_TASK_SELECT, + placeholders.join(", ") + ); + let mut statement = connection.prepare(&sql).map_err(db_error)?; + let rows = statement + .query_map(rusqlite::params_from_iter(chunk.iter().copied()), background_task_from_row) + .map_err(db_error)?; + for row in rows { + all_records.push(row.map_err(db_error)?); + } + } + Ok(all_records) + }) + .await + } + + /// Single-parent task list kept for compatibility; subtree/global callers + /// use [`Self::list_tasks_for_parents`]. + #[allow(dead_code)] + pub(crate) async fn list_tasks( + &self, + parent_session_id: &str, + ) -> BitFunResult> { + let parent_session_id = parent_session_id.to_string(); + self.with_connection(move |connection| { + let mut statement = connection + .prepare(&format!( + "{} WHERE tasks.parent_session_id = ?1 ORDER BY tasks.task_pk", + BACKGROUND_TASK_SELECT + )) + .map_err(db_error)?; + let rows = statement + .query_map(params![parent_session_id], background_task_from_row) + .map_err(db_error)?; + collect_rows(rows) + }) + .await + } + + /// Lists background tasks spawned by any session in `parent_session_ids` + /// (typically the caller's subtree). Used by the Task `list` action so a + /// conversation can manage subagent tasks spawned anywhere in its subtree. + pub(crate) async fn list_tasks_for_parents( + &self, + parent_session_ids: &[String], + ) -> BitFunResult> { + let parent_session_ids = parent_session_ids.to_vec(); + self.with_connection(move |connection| { + if parent_session_ids.is_empty() { + return Ok(Vec::new()); + } + let mut all_records = Vec::new(); + for chunk in parent_session_ids.chunks(990) { + let placeholders: Vec = (1..=chunk.len()) + .map(|i| format!("?{i}")) + .collect(); + let sql = format!( + "{} WHERE tasks.parent_session_id IN ({}) ORDER BY tasks.task_pk", + BACKGROUND_TASK_SELECT, + placeholders.join(", ") + ); + let mut statement = connection.prepare(&sql).map_err(db_error)?; + let rows = statement + .query_map( + rusqlite::params_from_iter(chunk.iter()), background_task_from_row, ) - .optional() - .map_err(db_error)? - { - records.push(record); + .map_err(db_error)?; + all_records.extend(collect_rows(rows)?); + } + Ok(all_records) + }) + .await + } + + /// Collect all descendant session ids under `root_session_id` by walking + /// the persisted `agents` parent→child edges (iterative BFS). + /// + /// The in-memory session tree is lazily loaded and can be empty/incomplete + /// right after a restart, so `agent_id` subtree scopes must not depend on + /// it alone. This persisted walk reconstructs the subtree from the + /// coordination database, which is authoritative for registered + /// background-task agents (COORD-06). + pub(crate) async fn descendant_session_ids( + &self, + root_session_id: &str, + ) -> BitFunResult> { + let root_session_id = root_session_id.to_string(); + self.with_connection(move |connection| { + let mut descendants = Vec::new(); + let mut seen = std::collections::HashSet::new(); + let mut stack = vec![root_session_id]; + while let Some(parent) = stack.pop() { + let mut statement = connection + .prepare( + "SELECT child_session_id FROM agents WHERE parent_session_id = ?1 AND child_session_id IS NOT NULL AND state = 'active' ORDER BY agent_pk", + ) + .map_err(db_error)?; + let rows = statement + .query_map(params![parent], |row| row.get::<_, String>(0)) + .map_err(db_error)?; + for child in rows { + let child = child.map_err(db_error)?; + if seen.insert(child.clone()) { + descendants.push(child.clone()); + stack.push(child); + } } } - Ok(records) + Ok(descendants) }) .await } @@ -359,42 +612,73 @@ WHERE task_pk = ?5 AND status = 'running' let task_pks = task_pks.to_vec(); let delivered_parent_dialog_turn_id = delivered_parent_dialog_turn_id.to_string(); self.with_connection(move |connection| { + if task_pks.is_empty() { + return Ok(Vec::new()); + } let transaction = connection .transaction_with_behavior(TransactionBehavior::Immediate) .map_err(db_error)?; - let mut claimed = Vec::new(); - for task_pk in task_pks { - let changed = transaction - .execute( - r#" -UPDATE background_tasks -SET delivered_at_ms = ?1, delivered_parent_dialog_turn_id = ?2 -WHERE task_pk = ?3 - AND parent_session_id = ?4 - AND status != 'running' - AND delivered_at_ms IS NULL - "#, - params![ - unix_time_ms() as i64, - delivered_parent_dialog_turn_id, - task_pk, - parent_session_id, - ], - ) - .map_err(db_error)?; - if changed == 0 { - continue; + + let delivered_at_ms = unix_time_ms() as i64; + let mut claimed = Vec::with_capacity(task_pks.len()); + + for chunk in task_pks.chunks(990) { + let in_placeholders: Vec = (3..=chunk.len() + 2) + .map(|i| format!("?{i}")) + .collect(); + let in_clause = in_placeholders.join(", "); + let update_sql = format!( + "UPDATE background_tasks SET delivered_at_ms = ?1, delivered_parent_dialog_turn_id = ?2 WHERE task_pk IN ({}) AND parent_session_id = ?{} AND status != 'running' AND delivered_at_ms IS NULL", + in_clause, + chunk.len() + 3 + ); + + let mut update_params: Vec> = + Vec::with_capacity(chunk.len() + 3); + update_params.push(Box::new(delivered_at_ms)); + update_params.push(Box::new(delivered_parent_dialog_turn_id.clone())); + for pk in chunk { + update_params.push(Box::new(*pk)); } - claimed.push( - transaction - .query_row( - &format!("{} WHERE tasks.task_pk = ?1", BACKGROUND_TASK_SELECT), - params![task_pk], - background_task_from_row, - ) - .map_err(db_error)?, + update_params.push(Box::new(parent_session_id.clone())); + let update_param_refs: Vec<&dyn rusqlite::types::ToSql> = + update_params.iter().map(|v| v.as_ref()).collect(); + transaction + .execute(&update_sql, update_param_refs.as_slice()) + .map_err(db_error)?; + + // SELECT only the rows that were just updated. + let select_in_placeholders: Vec = (1..=chunk.len()) + .map(|i| format!("?{i}")) + .collect(); + let select_in_clause = select_in_placeholders.join(", "); + let select_sql = format!( + "{} WHERE tasks.task_pk IN ({}) AND tasks.parent_session_id = ?{} AND tasks.delivered_parent_dialog_turn_id = ?{}", + BACKGROUND_TASK_SELECT, + select_in_clause, + chunk.len() + 1, + chunk.len() + 2, ); + + let mut select_params: Vec> = + Vec::with_capacity(chunk.len() + 2); + for pk in chunk { + select_params.push(Box::new(*pk)); + } + select_params.push(Box::new(parent_session_id.clone())); + select_params.push(Box::new(delivered_parent_dialog_turn_id.clone())); + let select_param_refs: Vec<&dyn rusqlite::types::ToSql> = + select_params.iter().map(|v| v.as_ref()).collect(); + + { + let mut statement = transaction.prepare(&select_sql).map_err(db_error)?; + let rows = statement + .query_map(select_param_refs.as_slice(), background_task_from_row) + .map_err(db_error)?; + claimed.extend(rows.flatten()); + } } + transaction.commit().map_err(db_error)?; Ok(claimed) }) @@ -482,37 +766,55 @@ WHERE task_pk = ?3 let transaction = connection .transaction_with_behavior(TransactionBehavior::Immediate) .map_err(db_error)?; + if parent_dialog_turn_ids.is_empty() { + return Ok(Vec::new()); + } let mut deleted_task_pks = Vec::new(); - for turn_id in parent_dialog_turn_ids { + for chunk in parent_dialog_turn_ids.chunks(990) { + let turn_placeholders: Vec = (2..=chunk.len() + 1) + .map(|i| format!("?{i}")) + .collect(); + let in_clause = turn_placeholders.join(", "); + + // Build dynamic parameter slice: ?1 = parent_session_id, ?2.. = turn_ids + let mut param_refs: Vec<&dyn rusqlite::types::ToSql> = + Vec::with_capacity(1 + chunk.len()); + param_refs.push(&parent_session_id); + for id in chunk { + param_refs.push(id); + } + let params: &[&dyn rusqlite::types::ToSql] = param_refs.as_slice(); + + // Single SELECT with IN clause + let select_sql = format!( + "SELECT task_pk FROM background_tasks WHERE parent_session_id = ?1 AND parent_dialog_turn_id IN ({})", + in_clause + ); { - let mut statement = transaction - .prepare( - "SELECT task_pk FROM background_tasks WHERE parent_session_id = ?1 AND parent_dialog_turn_id = ?2", - ) + let mut statement = transaction.prepare(&select_sql).map_err(db_error)?; + let rows = statement + .query_map(params, |row| row.get::<_, i64>(0)) .map_err(db_error)?; - deleted_task_pks.extend( - statement - .query_map(params![parent_session_id, turn_id], |row| { - row.get::<_, i64>(0) - }) - .map_err(db_error)? - .collect::>>() - .map_err(db_error)?, - ); + for row in rows { + deleted_task_pks.push(row.map_err(db_error)?); + } } - transaction - .execute( - "DELETE FROM background_tasks WHERE parent_session_id = ?1 AND parent_dialog_turn_id = ?2", - params![parent_session_id, turn_id], - ) - .map_err(db_error)?; - transaction - .execute( - "UPDATE background_tasks SET delivered_at_ms = NULL, delivered_parent_dialog_turn_id = NULL WHERE parent_session_id = ?1 AND delivered_parent_dialog_turn_id = ?2", - params![parent_session_id, turn_id], - ) - .map_err(db_error)?; + + // Single DELETE with IN clause + let delete_sql = format!( + "DELETE FROM background_tasks WHERE parent_session_id = ?1 AND parent_dialog_turn_id IN ({})", + in_clause + ); + transaction.execute(&delete_sql, params).map_err(db_error)?; + + // Single UPDATE with IN clause + let update_sql = format!( + "UPDATE background_tasks SET delivered_at_ms = NULL, delivered_parent_dialog_turn_id = NULL WHERE parent_session_id = ?1 AND delivered_parent_dialog_turn_id IN ({})", + in_clause + ); + transaction.execute(&update_sql, params).map_err(db_error)?; } + transaction.commit().map_err(db_error)?; Ok(deleted_task_pks) }) @@ -777,16 +1079,21 @@ fn initialize_schema(connection: &Connection) -> BitFunResult<()> { if version == SCHEMA_VERSION { return Ok(()); } + // Idempotent schema initialization: `CREATE ... IF NOT EXISTS` makes the + // version-0 upgrade safe even when a previous run created the tables but + // crashed before persisting `PRAGMA user_version` (COORD-13). A table that + // already exists keeps its columns; the `PRAGMA user_version` bump below + // still records the schema as initialized. connection .execute_batch( r#" -CREATE TABLE coordination_sessions ( +CREATE TABLE IF NOT EXISTS coordination_sessions ( parent_session_id TEXT PRIMARY KEY, next_auto_agent_seq INTEGER NOT NULL DEFAULT 1, updated_at_ms INTEGER NOT NULL ); -CREATE TABLE agents ( +CREATE TABLE IF NOT EXISTS agents ( agent_pk INTEGER PRIMARY KEY AUTOINCREMENT, parent_session_id TEXT NOT NULL, agent_id TEXT NOT NULL, @@ -798,7 +1105,7 @@ CREATE TABLE agents ( UNIQUE(parent_session_id, child_session_id) ); -CREATE TABLE background_tasks ( +CREATE TABLE IF NOT EXISTS background_tasks ( task_pk INTEGER PRIMARY KEY AUTOINCREMENT, parent_session_id TEXT NOT NULL, agent_pk INTEGER NOT NULL, @@ -822,9 +1129,9 @@ CREATE TABLE background_tasks ( FOREIGN KEY(agent_pk) REFERENCES agents(agent_pk) ON DELETE CASCADE ); -CREATE INDEX idx_background_tasks_wait +CREATE INDEX IF NOT EXISTS idx_background_tasks_wait ON background_tasks(parent_session_id, delivered_at_ms, status, task_pk); -CREATE INDEX idx_background_tasks_parent_turn +CREATE INDEX IF NOT EXISTS idx_background_tasks_parent_turn ON background_tasks(parent_session_id, parent_dialog_turn_id); PRAGMA user_version = 1; @@ -925,6 +1232,134 @@ mod tests { ); } + #[tokio::test] + async fn global_agent_resolution_prefers_subtree_then_falls_back_globally() { + let (_root, store) = test_store(); + store + .register_background_task(registration("parent-1", "child-1", "parent-turn-1", None)) + .await + .expect("register parent-1 task"); + store + .register_background_task(registration("parent-2", "child-2", "parent-turn-1", None)) + .await + .expect("register parent-2 task"); + store + .register_background_task(registration( + "parent-2", + "child-reviewer", + "parent-turn-2", + Some("reviewer"), + )) + .await + .expect("register reviewer task"); + + // Subtree preference: caller subtree [parent-1] resolves its own a1. + assert_eq!( + store + .resolve_agent_id_in_scope(&["parent-1".to_string()], "a1", false) + .await + .expect("subtree-local a1"), + "child-1" + ); + // Global fallback: reviewer exists only under parent-2, still resolvable + // when the caller explicitly allows the whole-database fallback. + assert_eq!( + store + .resolve_agent_id_in_scope(&["parent-1".to_string()], "reviewer", true) + .await + .expect("global reviewer"), + "child-reviewer" + ); + // Without global fallback, the same scope miss is "not found". + assert!(store + .resolve_agent_id_in_scope(&["parent-1".to_string()], "reviewer", false) + .await + .is_err()); + // Ambiguity: caller subtree covering both parents sees two a1 matches. + let error = store + .resolve_agent_id_in_scope( + &["parent-1".to_string(), "parent-2".to_string()], + "a1", + false, + ) + .await + .expect_err("ambiguous a1 must be rejected"); + assert!(error.to_string().contains("ambiguous")); + + // Unknown agent. + assert!(store + .resolve_agent_id_in_scope(&["parent-1".to_string()], "missing", false) + .await + .is_err()); + } + + #[tokio::test] + async fn descendant_session_ids_walks_persisted_tree_across_generations() { + // COORD-06: `agent_id` subtree scopes must be rebuildable from the + // persisted `agents` parent→child edges even when the in-memory session + // tree is incomplete right after a restart. + let (_root, store) = test_store(); + store + .register_background_task(registration("parent", "child", "turn-1", None)) + .await + .expect("register parent-child edge"); + store + .register_background_task(registration("child", "grandchild", "turn-2", None)) + .await + .expect("register child-grandchild edge"); + store + .register_background_task(registration("unrelated", "child-x", "turn-1", None)) + .await + .expect("register unrelated edge"); + + let mut descendants = store + .descendant_session_ids("parent") + .await + .expect("walk persisted subtree"); + descendants.sort(); + assert_eq!( + descendants, + vec!["child".to_string(), "grandchild".to_string()] + ); + + // A leaf has no descendants; an unknown root yields an empty walk. + assert!(store + .descendant_session_ids("grandchild") + .await + .expect("leaf walk") + .is_empty()); + assert!(store + .descendant_session_ids("missing") + .await + .expect("unknown root walk") + .is_empty()); + } + + #[tokio::test] + async fn list_tasks_for_parents_covers_multiple_parents() { + let (_root, store) = test_store(); + store + .register_background_task(registration("parent-1", "child-1", "turn-1", None)) + .await + .expect("parent-1 task"); + store + .register_background_task(registration("parent-2", "child-2", "turn-1", None)) + .await + .expect("parent-2 task"); + let tasks = store + .list_tasks_for_parents(&["parent-1".to_string(), "parent-2".to_string()]) + .await + .expect("list across parents"); + assert_eq!(tasks.len(), 2); + assert!(tasks.iter().any(|t| t.parent_session_id == "parent-1")); + assert!(tasks.iter().any(|t| t.parent_session_id == "parent-2")); + assert!(store + .list_tasks_for_parents(&[]) + .await + .expect("empty scope") + .is_empty()); + } + #[tokio::test] async fn terminal_transition_and_delivery_claim_are_single_winner() { let (_root, store) = test_store(); @@ -1105,4 +1540,105 @@ mod tests { .expect("load remaining tasks") .is_empty()); } + + #[tokio::test] + async fn wait_candidates_with_explicit_ids_includes_delivered_tasks() { + let (_root, store) = test_store(); + let delivered = store + .register_background_task(registration("parent", "child-1", "spawn-turn-1", None)) + .await + .expect("register delivered task"); + store + .update_task_status( + delivered.task_pk, + BackgroundTaskStatus::Completed, + None, + None, + ) + .await + .expect("complete delivered task"); + store + .claim_terminal_tasks("parent", &[delivered.task_pk], "delivery-turn") + .await + .expect("claim delivered task"); + + // An explicit-id query must return the delivered record explicitly + // (distinguishable via delivered_at_ms) instead of silently dropping + // it (COORD-09). + let candidates = store + .wait_candidates("parent", &[delivered.bg_task_id.clone()]) + .await + .expect("load explicit candidates"); + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].bg_task_id, delivered.bg_task_id); + assert!(candidates[0].delivered_at_ms.is_some()); + + // The empty-request query still reports only undelivered tasks. + let pending = store + .wait_candidates("parent", &[]) + .await + .expect("load pending candidates"); + assert!(pending.is_empty()); + } + + #[tokio::test] + async fn descendant_session_ids_walks_the_persisted_agents_tree() { + let (_root, store) = test_store(); + store + .register_background_task(registration("parent", "child-1", "turn-1", None)) + .await + .expect("register child"); + store + .register_background_task(registration("child-1", "grandchild-1", "turn-2", None)) + .await + .expect("register grandchild"); + store + .register_background_task(registration( + "unrelated", + "other-child", + "turn-3", + None, + )) + .await + .expect("register unrelated branch"); + + // The persisted parent→child walk must cover the whole subtree below + // the root but stay within it (COORD-06). + let descendants = store + .descendant_session_ids("parent") + .await + .expect("walk persisted tree"); + assert!(descendants.contains(&"child-1".to_string())); + assert!(descendants.contains(&"grandchild-1".to_string())); + assert!(!descendants.contains(&"other-child".to_string())); + assert!(store + .descendant_session_ids("missing") + .await + .expect("unknown root") + .is_empty()); + } + + #[test] + fn initialize_schema_is_idempotent_when_tables_exist_but_version_is_zero() { + let root = tempfile::tempdir().expect("coordination store temp directory"); + let db_path = root.path().join("coordination.sqlite"); + // Simulate an interrupted earlier initialization: a table exists but + // `PRAGMA user_version` was never persisted (still 0). Re-initializing + // must not fail on the already-existing table (COORD-13). + let first = Connection::open(&db_path).expect("open db"); + first + .execute_batch( + "CREATE TABLE coordination_sessions (parent_session_id TEXT PRIMARY KEY, next_auto_agent_seq INTEGER NOT NULL DEFAULT 1, updated_at_ms INTEGER NOT NULL);", + ) + .expect("create coordination_sessions"); + drop(first); + + let connection = open_connection(db_path).expect("reopen and re-initialize"); + let version = connection + .lock() + .expect("connection lock") + .query_row("PRAGMA user_version", [], |row| row.get::<_, i64>(0)) + .expect("read user_version"); + assert_eq!(version, SCHEMA_VERSION); + } } diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index aaad99038e..a9771a349b 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -1,9 +1,35 @@ -//! Conversation coordinator +//! Conversation coordinator — top-level component integrating all agentic subsystems. //! -//! Top-level component that integrates all subsystems and provides a unified interface +//! # Functional sections (ordered by appearance) +//! +//! | Section | Approx. lines | Description | +//! |---|---|---| +//! | Constants | 97–200 | Concurrency limits, tool names, timeouts, token budgets. | +//! | Helper types | 202–713 | `AgentRoundInjectionSource`, `SubagentConcurrencyLimiter`, `SessionBackgroundSubagentState`. | +//! | `ConversationCoordinator` struct | 715–800 | Central coordinator holding session manager, execution engine, event router, tool pipeline, thread goal runtime, session tree, etc. | +//! | Construction & lifecycle | 802–1460 | `new()`, `set_terminal_port()`, `set_remote_exec_port()`, `session_tree()`, scheduler notifier wiring. | +//! | Session config & model resolution | 1460–2948 | Workspace resolution, model binding, context profiles, session config defaults. | +//! | Context compaction | 2948–4038 | Manual (`/compact`) and automatic context compression. | +//! | Dialog turn submission & execution | 4038–4370 | Submitting user messages, background results, steering injections into running turns. | +//! | Session lifecycle management | 4370–4810 | `delete_session()`, `delete_hidden_subagent_sessions_for_parent_turns()`, `list_sessions()`, `cancel_session()`. | +//! | Event subscription | 4810–4828 | `subscribe_internal()`, `unsubscribe_internal()`. | +//! | Subagent concurrency | 4828–6189 | Semaphore-based concurrency limiting, background subagent wait/outcome handling. | +//! | Hidden subagent sessions | 6190–7200 | Hidden "behind-the-work" subagent sessions for background tasks. | +//! | Thread goal management | 7200–8000 | Goal-mode continuation loop, token budget enforcement, thread goal status transitions. | +//! | Workspace bootstrap | 8000–8089 | Persona file injection, workspace readiness checks. | +//! | `AgentSessionManagementPort` impl | 8089–8666 | Port trait implementation for session create / list / cancel / delete / rename / fork. | +//! | Global singleton & helpers | 8666–10680 | `get_global_coordinator()`, `runtime_session_summary()`, error mapping helpers. | +//! | Tests | 10680–end | Unit tests for model resolution, session management, subagent delegation, etc. | +//! +//! # Key design notes +//! +//! - The coordinator is a **singleton** (`OnceLock>`). +//! - All mutable state lives behind `Arc>` or `Arc>` to support concurrent access. +//! - The session tree (`SessionTreeManager`) is lazily populated from persisted metadata on first `list_sessions` (R-004). +//! - Authorization for cancel/delete uses in-memory tree first, then falls back to persisted metadata chain query. use super::{ - coordination_store::{BackgroundTaskRegistration, CoordinationStore}, + coordination_store::{BackgroundTaskRecord, BackgroundTaskRegistration, CoordinationStore}, scheduler::{ abort_thread_goal_continuation_for_session, clear_thread_goal_continuation_abort, get_global_scheduler, DialogSubmissionPolicy, HiddenSubagentQueueCancelHandle, @@ -50,9 +76,10 @@ use crate::agentic::tools::pipeline::{ PrimaryModelFacts, SubagentParentInfo, ToolExecutionContext, ToolExecutionOptions, ToolPipeline, }; use crate::agentic::tools::{ - miniapp_agent_run_tool_restrictions, + clear_session_role, clear_session_restrictions, get_session_role, miniapp_agent_run_tool_restrictions, + set_session_role, subagent_tool_restrictions, tool_restrictions_for_delegation_policy as runtime_tool_restrictions_for_delegation_policy, - ToolRuntimeRestrictions, + AgentRole, ToolRuntimeRestrictions, }; use crate::agentic::workspace::WorkspaceServices; use crate::agentic::WorkspaceBinding; @@ -71,8 +98,8 @@ use crate::service::config::{ }; use crate::service::remote_ssh::normalize_remote_workspace_path; use crate::service::session::{ - DialogTurnData, SessionMemoryMode, SessionRelationship, SessionRelationshipKind, SessionStatus, - ToolItemIdentityExt, TurnStatus, + DialogTurnData, SessionMemoryMode, SessionMetadata, SessionRelationship, + SessionRelationshipKind, SessionStatus, ToolItemIdentityExt, TurnStatus, }; use crate::service::workspace::{ get_global_workspace_service, WorkspaceActivityMode, WorkspaceCreateOptions, WorkspaceInfo, @@ -91,19 +118,22 @@ use bitfun_agent_runtime::remote_file_delivery::{ }; use bitfun_agent_runtime::sdk::PermissionReply; use bitfun_agent_runtime::user_questions::USER_INPUT_AVAILABLE_CONTEXT_KEY; +use bitfun_events::agentic::SubagentCompletionStatus; use bitfun_events::{ToolEventData, ToolEventIdentity}; use bitfun_product_domains::external_sources::EcosystemId; use bitfun_runtime_ports::{ - agent_workspace_references_from_metadata, AgentMessageWorkspaceReferencesRequest, - AgentSessionComposerUpdate, AgentSessionWorkspaceBinding, AgentThreadGoalDeliveryKind, - AgentThreadGoalDeliveryRequest, AgentWorkspaceReference, AgentWorkspaceReferenceKind, - AgentWorkspaceReferenceSearchEntry, AgentWorkspaceReferenceSearchRequest, - AgentWorkspaceReferenceSearchResult, DelegationPolicy, PermissionDelegationContext, - PermissionRuntimeCeiling, RemoteExecPort, SessionStoragePathRequest, - SessionStoragePathResolution, SessionStorePort, SubagentContextMode, TerminalPort, ThreadGoal, - ThreadGoalContinuationPlan, ThreadGoalStatus, + AcpClientPort, agent_workspace_references_from_metadata, AgentDialogTurnPort, + AgentDialogTurnRequest, AgentMessageWorkspaceReferencesRequest, AgentSessionComposerUpdate, + AgentSessionWorkspaceBinding, AgentThreadGoalDeliveryKind, AgentThreadGoalDeliveryRequest, + AgentWorkspaceReference, AgentWorkspaceReferenceKind, AgentWorkspaceReferenceSearchEntry, + AgentWorkspaceReferenceSearchRequest, AgentWorkspaceReferenceSearchResult, DelegationPolicy, + PermissionDelegationContext, PermissionRuntimeCeiling, RemoteExecPort, + SessionStoragePathRequest, SessionStoragePathResolution, SessionStorePort, SubagentContextMode, + TerminalPort, ThreadGoal, ThreadGoalContinuationPlan, ThreadGoalStatus, }; use bitfun_services_core::filesystem::{FileSearchOptions, FileSystemService, FileTreeNode}; +use bitfun_services_core::session::merge_session_custom_metadata; +use bitfun_services_core::session::tree::SessionTreeManager; use bitfun_services_core::workspace_text::{ normalize_workspace_relative_path, resolve_workspace_relative_entry, WorkspaceEntryKind, WorkspaceTextReadError, @@ -421,6 +451,19 @@ fn runtime_tool_restrictions_for_session_lifetime( restrictions } +/// Restrictions for a delegated subagent run: delegation-policy gate + +/// subagent deny list (host surfaces, MiniApp lifecycle, AgentWait), then the +/// transient-session lifetime gate. Computed once at request construction so +/// runtime enforcement stays zero-overhead. +fn runtime_tool_restrictions_for_subagent( + delegation_policy: DelegationPolicy, + transient: bool, +) -> ToolRuntimeRestrictions { + let mut restrictions = runtime_tool_restrictions_for_delegation_policy(delegation_policy); + restrictions.merge(&subagent_tool_restrictions()); + runtime_tool_restrictions_for_session_lifetime(restrictions, transient) +} + /// Subagent execution result /// /// Contains the text response after subagent execution @@ -462,6 +505,11 @@ pub(crate) struct SubagentExecutionRequest { pub(crate) permission_runtime_ceiling: PermissionRuntimeCeiling, /// Execution policy for the child subagent session being launched. pub(crate) delegation_policy: DelegationPolicy, + /// Lifecycle mode: `true` keeps the spawned subagent session durable so it + /// can be continued with `send_input`; `false` creates a temporary + /// (ephemeral) subagent session that is automatically recycled when the + /// task reaches a terminal state. + pub(crate) persistent: bool, /// Pins an immutable external generation from Task validation until the /// queued or running invocation reaches a terminal state. pub(crate) external_generation_lease: @@ -551,6 +599,7 @@ fn build_subagent_session_relationship( parent_info: Option<&SubagentParentInfo>, agent_type: &str, continuation_policy: SessionContinuationPolicy, + parent_depth: Option, ) -> SessionRelationship { SessionRelationship { kind: Some(SessionRelationshipKind::Subagent), @@ -561,6 +610,7 @@ fn build_subagent_session_relationship( parent_tool_call_id: parent_info.map(|info| info.tool_call_id.clone()), subagent_type: Some(agent_type.to_string()), continuation_policy: Some(continuation_policy), + depth: Some(parent_depth.map(|d| d + 1).unwrap_or(1)), } } @@ -612,6 +662,8 @@ fn subagent_parent_info_from_relationship( session_id: parent_session_id.to_string(), dialog_turn_id: parent_dialog_turn_id.to_string(), tool_call_id: parent_tool_call_id.to_string(), + depth: relationship.depth, + role: get_session_role(parent_session_id).map(|role| role.as_str().to_string()), }) } @@ -677,6 +729,9 @@ pub(crate) struct HiddenSubagentExecutionRequest { prompt_cache_source_session_id: Option, session_kind: SessionKind, transient: bool, + /// Lifecycle mode for the spawned subagent session: `false` marks a + /// one-shot temporary subagent that is recycled when the task finishes. + persistent: bool, emit_lifecycle_events: bool, prepared_session_created: bool, /// Keeps scheduler maintenance fenced from the moment a hidden Session is @@ -769,7 +824,7 @@ struct SessionExecutionLease { struct ManualCompactionTask { turn_id: String, - completion: oneshot::Receiver>, + completion: oneshot::Receiver>, } struct ManualCompactionControlGuard { @@ -895,6 +950,56 @@ impl Drop for SubagentExecutionScope { session_manager .reset_session_state_if_processing(&subagent_session_id, &subagent_dialog_turn_id); + + // Release the transient subagent family. This drop path only runs + // for abandoned executions (not disarmed), so no reuse reference + // can remain: the parent await that would have consumed the child + // context is gone. Discard the whole in-memory transient family + // (cascade); Reusable children that are still owned by a live + // parent are left untouched because their parent session is not + // being dropped. + if session_manager.is_transient_session(&subagent_session_id) { + if let Some(session) = session_manager.get_session(&subagent_session_id) { + match session.config.workspace_path.as_deref().map(Path::new) { + Some(workspace_path) => { + match session_manager + .discard_transient_session( + workspace_path, + session.config.remote_connection_id.as_deref(), + session.config.remote_ssh_host.as_deref(), + &subagent_session_id, + ) + .await + { + Ok(true) => { + info!( + "Discarded transient subagent family on scope drop: session_id={subagent_session_id}" + ); + } + Ok(false) => { + debug!( + "Transient subagent family already released on scope drop: session_id={subagent_session_id}" + ); + } + Err(error) => { + // A processing session cannot be discarded + // yet; the transient sweep releases it once + // it settles. + warn!( + "Failed to discard transient subagent family on scope drop: session_id={}, error={}", + subagent_session_id, error + ); + } + } + } + None => { + warn!( + "Transient subagent workspace binding is missing on scope drop: session_id={subagent_session_id}" + ); + } + } + } + } }); } } @@ -1064,9 +1169,32 @@ fn lineage_post_admission_cancellation_error( )) } +/// Register a parent→child session-tree edge idempotently. +/// +/// `SessionTreeManager::register_child` appends the child to the parent's +/// children list and is therefore not idempotent; persistent subagents execute +/// repeatedly, so a child already bound to the same parent must be left +/// untouched. Returns `true` when a new edge was registered (COORD-14). +fn register_session_tree_edge_idempotent( + tree: &SessionTreeManager, + parent_session_id: &str, + child_session_id: &str, + child_depth: u32, +) -> bool { + let already_bound = tree + .get_parent(child_session_id) + .as_deref() + .is_some_and(|current_parent| current_parent == parent_session_id); + if already_bound { + return false; + } + let _ = tree.register_child(parent_session_id, child_session_id, child_depth); + true +} + /// Conversation coordinator pub struct ConversationCoordinator { - session_manager: Arc, + pub(crate) session_manager: Arc, runtime_ownership: Arc, execution_engine: Arc, tool_pipeline: Arc, @@ -1102,6 +1230,9 @@ pub struct ConversationCoordinator { thread_goal_runtime: Arc, terminal_port: OnceLock>, remote_exec_port: OnceLock>, + acp_client_port: OnceLock>, + /// R-003: In-memory session tree for parent-child relationship tracking. + session_tree: Arc, } impl ConversationCoordinator { @@ -1413,8 +1544,10 @@ impl ConversationCoordinator { ); if !external_sources_supported { - return local_binding.ok_or_else(|| { - BitFunError::Validation(format!("Unknown session mode: {agent_type}")) + // 契约升级:local_binding 现为 Result,Err(OwnerMismatch/ + // CandidateUnavailable)直接 fail-closed,不回落任何 fallback。 + return local_binding.map_err(|error| { + BitFunError::Validation(format!("Unknown session mode: {agent_type} ({error})")) }); } @@ -1422,7 +1555,7 @@ impl ConversationCoordinator { if let Err(error) = crate::external_sources::ensure_external_source_workspace_snapshot(workspace_root).await { - if let Some(external_binding) = registry.resolve_primary_agent_for_turn( + if let Ok(external_binding) = registry.resolve_primary_agent_for_turn( agent_type, workspace_root, true, @@ -1443,7 +1576,8 @@ impl ConversationCoordinator { "candidate_unavailable: external main agent {agent_type} could not be refreshed" ))); } - if let Some(local_binding) = local_binding { + // local_binding 现为 Result:Err 时不回落,直接走下方 Service 错误。 + if let Ok(local_binding) = local_binding { warn!( "External agent source discovery failed; continuing with local mode: agent_type={}, error_category={}", agent_type, @@ -1463,15 +1597,15 @@ impl ConversationCoordinator { true, expected_owner, ) - .ok_or_else(|| { + .map_err(|error| { if expected_owner == Some(SessionAgentRouteOwner::External) || registry.is_external_subagent_route(agent_type, workspace_root) { BitFunError::Validation(format!( - "candidate_unavailable: external main agent {agent_type} changed before the turn could start" + "candidate_unavailable: external main agent {agent_type} changed before the turn could start: {error}" )) } else { - BitFunError::Validation(format!("Unknown session mode: {agent_type}")) + BitFunError::Validation(format!("Unknown session mode: {agent_type} ({error})")) } }) } @@ -2076,6 +2210,10 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet thread_goal_runtime: Arc::new(ThreadGoalRuntime::new()), terminal_port: OnceLock::new(), remote_exec_port: OnceLock::new(), + acp_client_port: OnceLock::new(), + session_tree: Arc::new(SessionTreeManager::new( + bitfun_core_types::session_tree::MAX_TREE_DEPTH, + )), } } @@ -2207,6 +2345,12 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet Arc::clone(&self.thread_goal_runtime) } + /// Accessor for the shared tool pipeline (used by the scheduler to inject + /// the Warden runtime for tool-level audit). + pub(crate) fn tool_pipeline(&self) -> Arc { + Arc::clone(&self.tool_pipeline) + } + pub fn set_terminal_port(&self, terminal_port: Arc) { if self.terminal_port.set(terminal_port).is_err() { log::warn!("Terminal port is already configured; ignoring duplicate injection"); @@ -2227,6 +2371,24 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet self.remote_exec_port.get().map(Arc::clone) } + /// Injects the ACP client runtime port (desktop host implements it over + /// `AcpClientService`). Core tools reach the real external ACP process + /// only through this boundary. + pub fn set_acp_client_port(&self, acp_client_port: Arc) { + if self.acp_client_port.set(acp_client_port).is_err() { + log::warn!("ACP client port is already configured; ignoring duplicate injection"); + } + } + + pub fn acp_client_port(&self) -> Option> { + self.acp_client_port.get().map(Arc::clone) + } + + /// R-003: Access the in-memory session tree manager. + pub fn session_tree(&self) -> &Arc { + &self.session_tree + } + pub(super) fn execution_cancel_token_for_dialog_turn( &self, dialog_turn_id: &str, @@ -2410,10 +2572,192 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet workspace_path, created_by, false, + false, + None, + None, ) .await } + /// Whether a restored session carries a persisted subagent marker. + /// + /// Subagent-marked sessions (SessionControl lineage `relationship.kind` or + /// the `subagent`/`subagentType` custom-metadata keys written by the create + /// chain) are always executors; a persisted `role=commander` on such a + /// session is a stale pre-fix value and must be overridden on restore. + fn is_subagent_marked_metadata(metadata: &SessionMetadata) -> bool { + metadata + .relationship + .as_ref() + .and_then(|relationship| relationship.kind.as_ref()) + .is_some_and(|kind| *kind == SessionRelationshipKind::Subagent) + || metadata.tags.iter().any(|tag| tag == "subagent") + || metadata + .custom_metadata + .as_ref() + .and_then(|value| value.get("subagent")) + .and_then(|value| value.as_bool()) + .unwrap_or(false) + } + + /// Derive the RBAC role for a restored session whose persisted role key is + /// missing or unknown. + /// + /// Subagent-marked sessions restore as executors; everything else degrades + /// to the commander baseline. + fn derive_session_role_from_metadata(metadata: &SessionMetadata) -> AgentRole { + if Self::is_subagent_marked_metadata(metadata) { + AgentRole::Executor + } else { + AgentRole::Commander + } + } + + /// Resolve the RBAC role assigned to a session at creation time (R-14 B2). + /// + /// - Subagent/EphemeralSubagent sessions are always executors: they run + /// delegated work, so they carry the executor role semantics. + /// - Any other session inherits its creator's role; an unknown creator + /// degrades to the commander (permissive) baseline. + /// - A main session (no creator) is the commander. + pub(crate) fn resolve_session_role( + kind: SessionKind, + creator_role: Option, + ) -> AgentRole { + if kind == SessionKind::Subagent || kind == SessionKind::EphemeralSubagent { + AgentRole::Executor + } else { + creator_role.unwrap_or(AgentRole::Commander) + } + } + + /// Register the RBAC role for a freshly created session (R-14 B2). + /// + /// The role is written to the in-memory `SESSION_ROLES` registry — the + /// fast, synchronous path used by delegation validation — and persisted + /// into the session metadata `custom_metadata.role` so it survives + /// restarts. Persistence is best-effort: a failure only degrades + /// restart-time re-registration, never session creation. + async fn register_session_role( + &self, + session_id: &str, + created_by: Option<&str>, + kind: SessionKind, + agent_type: &str, + workspace_path: Option<&Path>, + ) { + let creator_role = created_by.and_then(get_session_role); + let role = Self::resolve_session_role(kind, creator_role); + let role_key = role.as_str().to_string(); + // P-01 方案 2:GeneralPurpose 子代理(Executor)应用专属模板, + // 否则默认 Executor 模板会禁掉只读侦察工具(Read/Glob/Grep)。 + let register_result = if role == AgentRole::Executor && agent_type == "GeneralPurpose" { + crate::agentic::tools::restrictions::set_session_role_with_restrictions( + session_id, + role.clone(), + crate::agentic::tools::restrictions::general_purpose_tool_restrictions(), + ) + } else { + set_session_role(session_id, role) + }; + if let Err(e) = register_result { + warn!( + "Failed to register RBAC role for session {}: {}", + session_id, e + ); + return; + } + let Some(workspace_path) = workspace_path else { + return; + }; + if let Err(e) = self + .session_manager + .update_session_metadata(workspace_path, session_id, |metadata| { + merge_session_custom_metadata(metadata, serde_json::json!({ "role": role_key })); + }) + .await + { + warn!( + "Failed to persist RBAC role for session {}: {}", + session_id, e + ); + } + } + + /// Re-register the persisted RBAC role (R-14 B2) after a session restore. + /// + /// Best-effort: a missing or unknown role key is derived from the + /// persisted lineage facts (subagent-marked sessions restore as executors, + /// everything else defaults to the commander baseline) so delegation + /// validation stays permissive instead of erroring on stale metadata. + async fn restore_session_role_best_effort(&self, workspace_path: &Path, session_id: &str) { + let Ok(Some(metadata)) = self + .session_manager + .load_session_metadata(workspace_path, session_id) + .await + else { + return; + }; + let persisted_role = metadata + .custom_metadata + .as_ref() + .and_then(|value| value.get("role")) + .and_then(|value| value.as_str()) + .and_then(AgentRole::from_str_key); + let (role, derived) = match persisted_role { + // A subagent-marked session can never be a commander: the persisted + // value is a stale pre-fix artifact and must be overridden so the + // session restores as executor. + Some(AgentRole::Commander) if Self::is_subagent_marked_metadata(&metadata) => { + (AgentRole::Executor, true) + } + Some(role) => (role, false), + // Legacy sessions created before role persistence carry no role + // key; derive it from the persisted lineage facts instead of + // silently leaving the session unregistered (which surfaces as a + // generic "Agent" label in the UI). + None => (Self::derive_session_role_from_metadata(&metadata), true), + }; + let role_key = role.as_str().to_string(); + // P-01 方案 2:GeneralPurpose 子代理 restore 后同样应用专属模板。 + let register_result = if role == AgentRole::Executor && metadata.agent_type == "GeneralPurpose" { + crate::agentic::tools::restrictions::set_session_role_with_restrictions( + session_id, + role.clone(), + crate::agentic::tools::restrictions::general_purpose_tool_restrictions(), + ) + } else { + set_session_role(session_id, role) + }; + if let Err(e) = register_result { + warn!( + "Failed to re-register RBAC role for restored session {}: {}", + session_id, e + ); + return; + } + if derived { + // Best-effort persist the derived role so the next restore reads + // the explicit key and skips re-derivation. + if let Err(e) = self + .session_manager + .update_session_metadata(workspace_path, session_id, |metadata| { + merge_session_custom_metadata( + metadata, + serde_json::json!({ "role": role_key }), + ); + }) + .await + { + warn!( + "Failed to persist derived RBAC role for restored session {}: {}", + session_id, e + ); + } + } + } + + #[allow(clippy::too_many_arguments)] async fn create_session_with_workspace_and_creator_internal( &self, session_id: Option, @@ -2423,6 +2767,9 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet workspace_path: String, created_by: Option, transient: bool, + skip_context_window_refresh: bool, + parent_session_id: Option, + subagent_type: Option, ) -> BitFunResult { // Persist the workspace binding inside the session config so execution can // consistently restore the correct workspace regardless of the entry point. @@ -2454,6 +2801,17 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet ); let defaults = Self::agent_model_defaults().await; snapshot_normal_session_model(&mut config, &defaults); + let creator = created_by.clone(); + // Subagent-marked creations (the SessionControl create chain sets + // metadata.subagent=true and carries a subagent_type) map to the + // Subagent kind here so `resolve_session_role` yields the executor + // role instead of the commander baseline. Plain creations keep the + // SessionManager default Standard kind. + let session_kind = if subagent_type.is_some() || skip_context_window_refresh { + SessionKind::Subagent + } else { + SessionKind::Standard + }; let session = if transient { self.session_manager .create_transient_session_with_id_and_details( @@ -2462,21 +2820,36 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet agent_type, config, created_by, - SessionKind::Standard, + session_kind, ) .await? } else { self.session_manager - .create_session_with_id_and_creator( + .create_session_with_id_and_details( session_id, session_name, agent_type, config, created_by, + session_kind, ) .await? }; + // R-14 B2: assign the RBAC role at creation time (main session => + // commander, subagent sessions => executor, otherwise inherit the + // creator role) and persist it with the session metadata. Transient + // sessions register in memory only; they are never persisted. + let role_workspace_path = (!transient).then(|| Path::new(&workspace_path)); + self.register_session_role( + &session.session_id, + creator.as_deref(), + session.kind, + &session.agent_type, + role_workspace_path, + ) + .await; + if !transient { Self::track_session_workspace_activity_best_effort( &session.config, @@ -2492,6 +2865,17 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet // resolve to a different effective storage path and double-writing can leave // metadata/turn files split across two locations. + // Sync context window from AI config after session creation. + // SessionConfig::default() hardcodes max_context_tokens: 1M, + // but the selected model may support more (e.g. 1M for DeepSeek). + // Subagent sessions keep the forced 1M window and skip this refresh. + if !skip_context_window_refresh { + let _ = self + .session_manager + .refresh_session_context_window(&session.session_id) + .await; + } + self.emit_event(AgenticEvent::SessionCreated { session_id: session.session_id.clone(), session_name: session.session_name.clone(), @@ -2502,9 +2886,14 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet workspace_id: session.config.workspace_id.clone(), remote_connection_id: session.config.remote_connection_id.clone(), remote_ssh_host: session.config.remote_ssh_host.clone(), + parent_session_id, + subagent_type, }) .await; Self::dispatch_session_start_hooks(&session, "startup").await; + // Custom SessionStart injection (outside hook gating): make the + // session's RBAC role visible to the model on startup. + self.inject_session_start_context(&session).await; Ok(session) } @@ -2548,6 +2937,127 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .await; } + /// Custom SessionStart injection (outside hook gating): make the RBAC role + /// visible to the model at session startup. + /// + /// The role was already registered by [`Self::register_session_role`] + /// during creation; this only adds the model-visible context. Best-effort: + /// a failed write degrades to a warning, never blocks session creation. + async fn inject_session_start_context(&self, session: &Session) { + let Some(role) = get_session_role(&session.session_id) else { + return; + }; + let mut lines = vec![ + "[Legion Context]".to_string(), + format!("Assigned role: {} — {}", role.as_str(), Self::role_duty_summary(role)), + ]; + if let Some(creator_id) = session.created_by.as_deref() { + if let Some(creator_role) = get_session_role(creator_id) { + lines.push(format!( + "Created by session {} with role {}", + creator_id, + creator_role.as_str() + )); + } + } + let context = lines.join("\n"); + if let Err(err) = self + .session_manager + .add_message( + &session.session_id, + Message::internal_reminder(InternalReminderKind::LifecycleContext, context), + ) + .await + { + warn!( + "Failed to inject SessionStart legion context for session {}: {}", + session.session_id, err + ); + } + } + + /// Custom SessionEnd cleanup (outside hook gating): unregister the RBAC + /// role and tool restrictions, drop the Warden per-session state, and + /// clear coordinator-owned per-session in-memory registries. + /// + /// Called from durable deletion and from transient-family discard, so a + /// recycled session id cannot inherit stale lifecycle state. The + /// scheduler-side registries (`goal_idle_wakeup_generations` etc.) are + /// cleaned by `DialogScheduler::cleanup_session_state` below; this function + /// covers the coordinator-side maps that are otherwise only released on the + /// execution path (COORD-11). + async fn session_end_cleanup(&self, session_id: &str) { + clear_session_role(session_id); + clear_session_restrictions(session_id); + self.subagent_timeout_registry.write().await.remove(session_id); + self.active_subagent_executions.remove(session_id); + if let Some(scheduler) = get_global_scheduler() { + scheduler.cleanup_session_state(session_id).await; + } + } + + /// Custom SubagentStart injection (outside hook gating): assemble the + /// legion chain context (subagent role, parent role, parent goal, depth) + /// for a subagent's first round. Returns `None` when nothing is known. + async fn build_subagent_legion_context( + &self, + parent_info: Option<&SubagentParentInfo>, + session_id: &str, + ) -> Option { + let mut lines = Vec::new(); + + let subagent_role = get_session_role(session_id).or_else(|| { + parent_info + .and_then(|info| info.role.as_deref()) + .and_then(AgentRole::from_str_key) + }); + if let Some(role) = subagent_role { + lines.push(format!( + "Subagent role: {} — {}", + role.as_str(), + Self::role_duty_summary(role) + )); + } + + if let Some(info) = parent_info { + if let Some(parent_role) = get_session_role(&info.session_id) { + lines.push(format!("Parent session role: {}", parent_role.as_str())); + } + if let Some(depth) = info.depth { + lines.push(format!("Legion depth: {depth}")); + } + match self.load_active_thread_goal(&info.session_id).await { + Ok(Some(goal)) => { + lines.push(format!("Parent goal: {}", goal.objective.trim())); + } + Ok(None) => {} + Err(err) => debug!( + "SubagentStart legion context: parent goal lookup failed for {}: {}", + info.session_id, err + ), + } + } + + if lines.is_empty() { + None + } else { + let mut context = String::from("[Legion Context]\n"); + context.push_str(&lines.join("\n")); + Some(context) + } + } + + /// One-line duty summary per RBAC role, shown in lifecycle context. + fn role_duty_summary(role: AgentRole) -> &'static str { + match role { + AgentRole::Commander => "orchestrates and dispatches; never executes", + AgentRole::Executor => "executes atomic steps end-to-end", + AgentRole::Reviewer => "reviews and audits; never executes", + AgentRole::Warden => "monitors and challenges violations", + AgentRole::PunishmentExecutor => "executes penalties", + } + } + /// Create a hidden internal subagent session that is persisted but excluded /// from normal user-facing session lists. pub async fn create_hidden_subagent_session_with_workspace( @@ -2578,6 +3088,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet agent_type, config, created_by, + false, ) .await } @@ -2593,6 +3104,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet /// data, because the spawned task always runs before the frontend receives /// the DialogTurnCompleted event via the transport layer, and the existing /// disk data from debounced saves may have incomplete model rounds. + #[allow(clippy::too_many_arguments)] async fn finalize_turn_in_workspace( session_id: &str, turn_id: &str, @@ -2678,6 +3190,8 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet workspace_hostname: None, unread_completion: None, needs_user_attention: None, + runtime_state: None, + is_daemon: false, }; if let Err(e) = persistence_manager .create_session_metadata_if_absent(&workspace_path_buf, &metadata) @@ -2745,9 +3259,9 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet &execution_result.new_messages, TurnStats { total_rounds: execution_result.total_rounds, - total_tools: 0, // TODO: get from execution_result - total_tokens: 0, - duration_ms: 0, + total_tools: execution_result.total_tools, + total_tokens: execution_result.total_tokens, + duration_ms: execution_result.duration_ms, }, ) .await @@ -2973,6 +3487,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet crate::service::session::TurnStatus::Error } + #[allow(clippy::too_many_arguments)] async fn finalize_persisted_turn_in_workspace_if_needed( session_manager: &SessionManager, session_id: &str, @@ -2988,6 +3503,32 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet if !session_manager.should_persist_session_id(session_id) { return; } + // A session being deleted (or already deleted) must not be resurrected + // by an in-flight turn finalization tail write: finalization would + // otherwise recreate on-disk session metadata as a ghost "Recovered + // Session" (root cause R1). The deleted marker is set by the session + // manager BEFORE the fallible deletion stage (R-FIX-2) so the whole + // deletion window is covered, and it is cleared again on failure + // (rollback) or on a successful re-create/restore of the same id + // (R-FIX-1). Once the marker is visible, finalization skips; once it is + // cleared, the session is live again. P2 leftover: strictly speaking a + // theoretical millisecond-scale interleaving remains between the check + // passing and the write completing when a delete starts in exactly that + // window; it is narrowed by the cancel-and-drain path and accepted as a + // P2 observation (P2-C), not a P1 race. + // P2-A: externally removed storage (directory-level GC / manual + // deletion) does not set the explicit deleted marker, so the + // disk-removed registry is checked here too to keep the same + // ghost-resurrection protection for that out-of-band path. + if session_manager.is_session_deleted(session_id) + || session_manager.is_session_disk_removed(session_id) + { + info!( + "Skipping turn finalization for removed session: session_id={}, turn_id={}", + session_id, turn_id + ); + return; + } if let (Some(workspace_path), Some(status)) = (workspace_path, status) { Self::finalize_turn_in_workspace( @@ -3016,14 +3557,20 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet agent_type: String, config: SessionConfig, created_by: Option, + is_ephemeral: bool, ) -> BitFunResult { + let kind = if is_ephemeral { + SessionKind::EphemeralSubagent + } else { + SessionKind::Subagent + }; self.create_hidden_agent_session( session_id, session_name, agent_type, config, created_by, - SessionKind::Subagent, + kind, ) .await } @@ -3049,17 +3596,25 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .await } + #[allow(clippy::too_many_arguments)] async fn create_hidden_agent_session_with_durability( &self, session_id: Option, session_name: String, agent_type: String, - config: SessionConfig, + mut config: SessionConfig, created_by: Option, kind: SessionKind, transient: bool, ) -> BitFunResult { - if transient { + // Subagent sessions are forced to a 1M context window at creation and + // must never be downgraded by model-window refresh (which skips them). + if kind == SessionKind::Subagent || kind == SessionKind::EphemeralSubagent { + config.max_context_tokens = 1_000_000; + } + let workspace_path = config.workspace_path.clone(); + let creator = created_by.clone(); + let session = if transient { self.session_manager .create_transient_session_with_id_and_details( session_id, @@ -3069,7 +3624,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet created_by, kind, ) - .await + .await? } else { self.session_manager .create_session_with_id_and_details( @@ -3080,8 +3635,26 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet created_by, kind, ) - .await - } + .await? + }; + + // R-14 B2: register the RBAC role (subagent => executor, otherwise + // inherit the creator role) and persist it when durable. Transient + // sessions register in memory only; they are never persisted. + let role_workspace_path = (!transient) + .then_some(workspace_path.as_deref()) + .flatten() + .map(Path::new); + self.register_session_role( + &session.session_id, + creator.as_deref(), + kind, + &session.agent_type, + role_workspace_path, + ) + .await; + + Ok(session) } async fn load_session_context_messages(&self, session: &Session) -> BitFunResult> { @@ -3124,6 +3697,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet Ok(context_messages) } + #[allow(clippy::too_many_arguments)] async fn wrap_user_input( &self, session_id: &str, @@ -3650,10 +4224,13 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet tool_call_id: tool_call_id.clone(), session_id: session_id.clone(), dialog_turn_id: turn_id.clone(), + depth: None, + role: None, }, context: child_context, permission_runtime_ceiling, delegation_policy: DelegationPolicy::top_level().spawn_child(), + persistent: true, external_generation_lease: Some(external_generation_lease), }; @@ -3693,6 +4270,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet reason: result.reason.as_deref(), ledger_event_id: result.ledger_event_id(), partial_timeout_suffix: "", + session_id: child_session_id.as_deref(), }, ); coordinator @@ -4114,7 +4692,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .ok_or_else(|| BitFunError::NotFound(format!("Session not found: {session_id}")))?; if matches!( session.kind, - SessionKind::Subagent | SessionKind::EphemeralChild + SessionKind::Subagent | SessionKind::EphemeralChild | SessionKind::EphemeralSubagent ) { return Err(BitFunError::Validation( "Thread goals are only available for main sessions".to_string(), @@ -4191,15 +4769,23 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet _workspace_path: &Path, objective: String, token_budget: Option, + reference_files: Option>, ) -> BitFunResult { let storage_path = self.require_main_session_storage_path(session_id).await?; let goal = self .thread_goal_store() - .create_thread_goal(session_id, storage_path.as_path(), objective, token_budget) + .create_thread_goal( + session_id, + storage_path.as_path(), + objective, + token_budget, + reference_files.unwrap_or_default(), + ) .await?; self.thread_goal_runtime.mark_turn_started("", Some(&goal)); self.emit_thread_goal_updated(session_id, Some(goal.clone())) .await; + self.arm_goal_idle_wakeup(session_id); Ok(goal) } @@ -4233,6 +4819,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet Some(objective), status, None, + None, false, ) .await?; @@ -4247,9 +4834,22 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet self.apply_objective_updated_steering(session_id, &result.goal) .await; } + if result.goal.is_active() { + self.arm_goal_idle_wakeup(session_id); + } Ok(result.goal) } + /// Arm the goal idle-wakeup safety net for `session_id` when a thread goal + /// is active, so the timer starts immediately after the goal is set rather + /// than only after the next turn outcome. Safe to call repeatedly: each + /// call re-arms the timer so only the newest wakeup task fires. + fn arm_goal_idle_wakeup(&self, session_id: &str) { + if let Some(scheduler) = get_global_scheduler() { + scheduler.schedule_goal_idle_wakeup(session_id); + } + } + pub async fn set_thread_goal_objective( &self, session_id: &str, @@ -4275,6 +4875,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet Some(objective), status, None, + None, replace_existing, ) .await?; @@ -4292,6 +4893,9 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet self.apply_objective_updated_steering(session_id, &result.goal) .await; } + if result.goal.is_active() { + self.arm_goal_idle_wakeup(session_id); + } Ok(result.goal) } @@ -4415,6 +5019,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet None, Some(status), None, + None, false, ) .await?; @@ -4430,6 +5035,9 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet clear_thread_goal_continuation_abort(session_id); self.schedule_thread_goal_resumed_steering(session_id, &result.goal); } + if result.goal.is_active() { + self.arm_goal_idle_wakeup(session_id); + } Ok(result.goal) } @@ -4601,27 +5209,43 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet } /// Continue an active thread goal after a dialog turn completes (Codex-style). + /// + /// Idle-wakeup safety-net mode: the immediate after-turn continuation + /// channel is closed, so goals are no longer auto-continued right after a + /// user turn. The continuation state machine stays intact and is reused by + /// [`Self::prepare_goal_idle_wakeup`], which the dialog scheduler only + /// invokes after the session has been idle for GOAL_IDLE_WAKEUP_DELAY_MS. pub async fn prepare_goal_continuation_after_turn( &self, - session_id: &str, - source_turn_id: &str, - user_input: &str, - user_message_metadata: Option<&serde_json::Value>, - turn_completed: bool, + _session_id: &str, + _source_turn_id: &str, + _user_input: &str, + _user_message_metadata: Option<&serde_json::Value>, + _turn_completed: bool, ) -> BitFunResult> { - if should_skip_goal_continuation_after_turn(user_input, user_message_metadata) { + if should_skip_goal_continuation_after_turn(_user_input, _user_message_metadata) { return Ok(None); } + Ok(None) + } + /// Build a thread goal continuation plan for the idle-wakeup safety net. + /// + /// Called by the dialog scheduler after a session with an active thread + /// goal has been idle for `GOAL_IDLE_WAKEUP_DELAY_MS` with no new user + /// submission. Runs the same continuation state machine as the (now + /// short-circuited) after-turn path with an empty turn id and zero tokens: + /// token accounting is skipped (no matching turn), but the plan and the + /// auto-continuation budget still apply. + pub async fn prepare_goal_idle_wakeup( + &self, + session_id: &str, + ) -> BitFunResult> { let storage_path = match self.require_main_session_storage_path(session_id).await { Ok(path) => path, Err(_) => return Ok(None), }; - let turn_tokens = self - .thread_goal_runtime - .turn_cumulative_billable_tokens(source_turn_id); - let goal_before = self .thread_goal_store() .get_thread_goal(session_id, storage_path.as_path()) @@ -4632,9 +5256,9 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet self.thread_goal_runtime.as_ref(), session_id, storage_path.as_path(), - source_turn_id, - turn_tokens, - turn_completed, + "", + 0, + true, ) .await?; @@ -4903,7 +5527,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet remote_exec_port: Option>, cancellation_token: CancellationToken, commit_gate: Arc, - ) -> BitFunResult<()> { + ) -> BitFunResult { let manual_workspace_services = Self::build_workspace_services(&manual_workspace).await; let manual_execution_context = ExecutionContext { session_id: session_id.clone(), @@ -4965,7 +5589,8 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet &outcome, context_window, ) - .await + .await?; + Ok(outcome) } Err(err @ BitFunError::Cancelled(_)) => { let error_text = err.to_string(); @@ -5033,6 +5658,15 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet /// task used by Agent Runtime callers, then await its terminal result for /// the existing Desktop compatibility API. pub async fn compact_session_manually(&self, session_id: String) -> BitFunResult<()> { + self.compact_session_with_outcome(session_id).await.map(|_| ()) + } + + /// Compact the active session context and return the compaction outcome + /// (tokens/ratio/summary) so tool callers can surface the applied result. + pub async fn compact_session_with_outcome( + &self, + session_id: String, + ) -> BitFunResult { let task = self.start_manual_compaction_task(session_id, None).await?; task.completion.await.map_err(|_| { BitFunError::Service(format!( @@ -5711,6 +6345,16 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet AUTO_APPROVE_ASK_CONTEXT_KEY.to_string(), auto_approve_ask.to_string(), ); + } else if session.kind == SessionKind::Subagent + || session.kind == SessionKind::EphemeralSubagent + { + // Subagent sessions default to auto-approve so unattended delegation + // never blocks on user approval prompts; an explicit message value + // still wins via the branch above. + context_vars.insert( + AUTO_APPROVE_ASK_CONTEXT_KEY.to_string(), + "true".to_string(), + ); } if needs_computer_links_for_source(submission_policy.trigger_source) { context_vars.insert( @@ -6126,6 +6770,22 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet active.cancel_token.cancel(); } + /// Returns whether a cancellation request was triggered by a user-facing + /// stop action (desktop UI, remote control, CLI/ACP). Only these sources + /// pause the thread goal after cancellation so the UI can offer resume; + /// agent tool, subagent cascade, scheduled job, and SDK teardown + /// cancellations only abort goal auto-continuation. + fn cancel_is_user_triggered(source: Option) -> bool { + matches!( + source, + Some( + DialogTriggerSource::DesktopUi + | DialogTriggerSource::RemoteRelay + | DialogTriggerSource::Cli + ) + ) + } + /// Cancel dialog turn execution /// Immediately set state to Idle to allow new dialog, old turn ends naturally via cancel token pub async fn cancel_dialog_turn( @@ -6133,11 +6793,31 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet session_id: &str, dialog_turn_id: &str, ) -> BitFunResult<()> { - self.cancel_dialog_turn_with_descendant_policy( - session_id, - dialog_turn_id, - true, - Duration::from_millis(1500), + // Non-user entry points (scheduler-mediated agent/subagent cancellation) + // must not pause the thread goal; only user-initiated cancellations do. + self.cancel_dialog_turn_for_source(session_id, dialog_turn_id, false) + .await + } + + /// Cancel a dialog turn with an explicit user-initiated flag. + /// + /// `user_initiated` is true only when the cancellation originates from a + /// user-facing stop action (desktop UI, remote control, CLI/ACP). It + /// decides whether the thread goal is paused afterwards so the UI can + /// offer resume; agent/system cancellations only abort goal + /// auto-continuation. + async fn cancel_dialog_turn_for_source( + &self, + session_id: &str, + dialog_turn_id: &str, + user_initiated: bool, + ) -> BitFunResult<()> { + self.cancel_dialog_turn_with_descendant_policy( + session_id, + dialog_turn_id, + true, + Duration::from_millis(1500), + user_initiated, ) .await } @@ -6148,6 +6828,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet dialog_turn_id: &str, cancel_descendants: bool, drain_timeout: Duration, + user_initiated: bool, ) -> BitFunResult<()> { info!( "Received cancel request: dialog_turn_id={}, session_id={}, cancel_descendants={}", @@ -6223,7 +6904,9 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet }) .await; debug!("Session state change event sent"); - self.pause_thread_goal_after_user_cancel(session_id).await; + if user_initiated { + self.pause_thread_goal_after_user_cancel(session_id).await; + } } else { debug!( "Skipped idle event for stale cancellation: session_id={}, dialog_turn_id={}", @@ -6285,7 +6968,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet session_id: &str, wait_timeout: Duration, ) -> BitFunResult> { - self.cancel_active_turn_for_session_with_descendant_policy(session_id, wait_timeout, true) + self.cancel_active_turn_for_session_with_source(session_id, wait_timeout, true, false) .await } @@ -6295,6 +6978,31 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet session_id: &str, wait_timeout: Duration, cancel_descendants: bool, + ) -> BitFunResult> { + // Non-user entry points (scheduler-mediated agent/subagent cancellation) + // must not pause the thread goal; only user-initiated cancellations do. + self.cancel_active_turn_for_session_with_source( + session_id, + wait_timeout, + cancel_descendants, + false, + ) + .await + } + + /// Cancel the active turn with an explicit user-initiated flag. + /// + /// `user_initiated` is true only when the cancellation originates from a + /// user-facing stop action (desktop UI, remote control, CLI/ACP). It + /// decides whether the thread goal is paused afterwards so the UI can + /// offer resume; agent/system cancellations only abort goal + /// auto-continuation. + async fn cancel_active_turn_for_session_with_source( + &self, + session_id: &str, + wait_timeout: Duration, + cancel_descendants: bool, + user_initiated: bool, ) -> BitFunResult> { abort_thread_goal_continuation_for_session(session_id); @@ -6319,6 +7027,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet ¤t_turn_id, cancel_descendants, drain_timeout, + user_initiated, ) .await?; @@ -6436,6 +7145,36 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .await?; self.session_manager .validate_session_storage_path_binding(session_id, &session_storage_path)?; + // R-FIX-3: a session with a running turn is cancelled first, then we + // wait for its state to converge back to Idle so a turn that has been + // cancelled cannot block deletion. `cancel_active_turn_for_session` + // cancels the turn and drains the execution engine; the actual state + // convergence to Idle is carried by the bounded 50ms x 40 poll below + // (cancel does not itself reset the session state). If the state still + // has not converged within the deadline, the processing guard below + // rejects the deletion as before. + let _ = self + .cancel_active_turn_for_session(session_id, Duration::from_secs(2)) + .await; + let state_converge_deadline = Instant::now() + Duration::from_millis(2000); + loop { + let still_processing = self + .session_manager + .get_session(session_id) + .map(|session| matches!(session.state, SessionState::Processing { .. })) + .unwrap_or(false); + if !still_processing || Instant::now() >= state_converge_deadline { + break; + } + sleep(Duration::from_millis(50)).await; + } + // Reject deletion while the session is still running a turn (or is a + // daemon/warden session), mirroring the tree-path pre-check so the + // single-session path enforces the same lifecycle guard. The tree path + // (`delete_session_tree`) pre-checks every member before calling this + // method, so the duplicate check there is harmless. + self.ensure_session_tree_deletable(&session_storage_path, session_id) + .await?; self.reconcile_session_revert_locked(&session_storage_path, session_id) .await?; // SessionEnd hooks observe the session before its state is gone. @@ -6469,6 +7208,10 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet self.background_subagent_outcomes .delete_session_references(session_id) .await?; + // Custom session-end cleanup (outside hook gating): RBAC role and + // tool-restriction unregistration plus Warden state cleanup, so a + // recycled session id cannot inherit stale lifecycle state. + self.session_end_cleanup(session_id).await; self.emit_event(AgenticEvent::SessionDeleted { session_id: session_id.to_string(), }) @@ -6476,6 +7219,219 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet Ok(()) } + /// Cascade-delete a session and its full descendant subtree (children + /// first), all-or-nothing on the root. Returns the deleted session ids in + /// deletion order (children before root). + /// + /// A transient root is released through the transient family cascade so + /// the whole in-memory family is discarded together. For a durable root, + /// the descendant set is discovered from persisted metadata (authoritative + /// source) plus in-memory transient descendants. Every member is + /// pre-checked by `ensure_session_tree_deletable`; deleting a session that + /// is currently processing or is a daemon/warden session anywhere in the + /// tree is rejected up-front with an explicit error. Any child failure + /// aborts the cascade before the root is touched, so persisted storage and + /// the in-memory session tree stay consistent. + pub async fn delete_session_tree( + &self, + workspace_path: &Path, + remote_connection_id: Option<&str>, + remote_ssh_host: Option<&str>, + session_id: &str, + ) -> BitFunResult> { + bitfun_core_types::validate_session_id(session_id).map_err(BitFunError::Validation)?; + + // Transient root: release the whole in-memory transient family. + if self.session_manager.is_transient_session(session_id) { + let family = self.session_manager.transient_session_family_postorder( + workspace_path, + remote_connection_id, + remote_ssh_host, + session_id, + )?; + if family.is_empty() { + return Err(BitFunError::NotFound(format!( + "Session not found: {session_id}" + ))); + } + for member_id in &family { + self.ensure_session_tree_deletable(workspace_path, member_id) + .await?; + } + self.discard_transient_session( + workspace_path, + remote_connection_id, + remote_ssh_host, + session_id, + ) + .await?; + return Ok(family); + } + + let session_storage_path = Self::resolve_session_restore_path( + &workspace_path.to_string_lossy(), + remote_connection_id, + remote_ssh_host, + ) + .await?; + let metadata = self + .session_manager + .persistence_manager() + .list_session_metadata_including_internal(&session_storage_path) + .await?; + // Durable subtree from persisted metadata, post-order (children first). + let mut children_map: HashMap> = HashMap::new(); + for member in &metadata { + if let Some(parent) = member + .relationship + .as_ref() + .and_then(|relationship| relationship.parent_session_id.as_deref()) + { + children_map + .entry(parent.to_string()) + .or_default() + .push(member.session_id.clone()); + } + } + // Supplement subtree discovery from the in-memory session tree so a + // broken/missing persisted relationship cannot orphan a loaded durable + // child session (root cause R3). Transient descendants are released + // separately below via `transient_descendants_postorder`, so only + // loaded durable sessions are added here; multi-level breaks are still + // covered because the added edges feed the same post-order traversal. + { + let loaded_sessions = self.session_manager.loaded_sessions_snapshot(); + let mut memory_edges: HashMap> = HashMap::new(); + for session in &loaded_sessions { + if session.session_id == session_id + || self + .session_manager + .is_transient_session(&session.session_id) + { + continue; + } + if let Some(parent_id) = session + .created_by + .as_deref() + .and_then(|marker| marker.strip_prefix("session-")) + { + memory_edges + .entry(parent_id.to_string()) + .or_default() + .push(session.session_id.clone()); + } + } + for (parent_id, children) in memory_edges { + let entry = children_map.entry(parent_id).or_default(); + for child in children { + if !entry.contains(&child) { + entry.push(child); + } + } + } + } + let mut postorder = Vec::new(); + let mut visited = HashSet::new(); + let mut stack = vec![session_id.to_string()]; + while let Some(current) = stack.pop() { + if !visited.insert(current.clone()) { + continue; + } + postorder.push(current.clone()); + if let Some(children) = children_map.get(¤t) { + stack.extend(children.iter().cloned()); + } + } + postorder.reverse(); + if !metadata.iter().any(|member| member.session_id == session_id) + && self.session_manager.get_session(session_id).is_none() + { + return Err(BitFunError::NotFound(format!( + "Session not found: {session_id}" + ))); + } + + // Release in-memory transient descendants first (children before + // parents; discard is idempotent and uses each member's own binding). + for transient_child in self + .session_manager + .transient_descendants_postorder(session_id) + { + self.discard_transient_session( + transient_child + .config + .workspace_path + .as_deref() + .map(Path::new) + .unwrap_or(workspace_path), + transient_child.config.remote_connection_id.as_deref(), + transient_child.config.remote_ssh_host.as_deref(), + &transient_child.session_id, + ) + .await?; + } + + // Pre-check every member before deleting anything: a processing or + // daemon/warden session anywhere in the tree rejects the whole cascade. + for member_id in &postorder { + self.ensure_session_tree_deletable(&session_storage_path, member_id) + .await?; + } + + // Children first, root last. Any failure aborts immediately, so the + // root (and every not-yet-deleted member) is left untouched. + let mut deleted = Vec::new(); + for member_id in &postorder { + if member_id != session_id { + self.delete_session(&session_storage_path, member_id) + .await?; + deleted.push(member_id.clone()); + } + } + self.delete_session(&session_storage_path, session_id) + .await?; + deleted.push(session_id.to_string()); + + self.session_tree().remove_subtree(session_id); + Ok(deleted) + } + + async fn ensure_session_tree_deletable( + &self, + session_storage_path: &Path, + session_id: &str, + ) -> BitFunResult<()> { + if let Some(session) = self.session_manager.get_session(session_id) { + if session.config.is_daemon || session.agent_type.starts_with("warden-") { + return Err(BitFunError::Validation(format!( + "Cannot delete daemon/warden session: {session_id}" + ))); + } + if let SessionState::Processing { + current_turn_id, + phase, + } = &session.state + { + return Err(BitFunError::Validation(format!( + "Cannot delete a session with a running turn: session_id={session_id}, current_turn_id={current_turn_id}, phase={phase:?}" + ))); + } + return Ok(()); + } + if let Some(metadata) = self + .session_manager + .load_session_metadata(session_storage_path, session_id) + .await? + { + if metadata.is_daemon || metadata.agent_type.starts_with("warden-") { + return Err(BitFunError::Validation(format!( + "Cannot delete daemon/warden session: {session_id}" + ))); + } + } + Ok(()) + } + /// Releases one connection-scoped Session family through the same /// coordination owner used by durable Session deletion. Coordination rows /// and live background outcomes are removed before runtime state so a @@ -6500,6 +7456,10 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet self.background_subagent_outcomes .delete_session_references(related_session_id) .await?; + // Transient sessions are discarded without a SessionEnd hook + // dispatch; run the custom cleanup so RBAC roles, tool + // restrictions and Warden state cannot leak into recycled ids. + self.session_end_cleanup(related_session_id).await; } self.session_manager .discard_transient_session( @@ -6511,6 +7471,40 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .await } + /// Recycle a temporary (`persistent=false`) subagent session once its task + /// reaches a terminal state. Best-effort: failures only warn so a finished + /// task can never be blocked by cleanup. The workspace path is required; + /// without it (defensive) the session is left for the regular cleanup pass. + pub(crate) async fn recycle_temporary_subagent_session( + &self, + workspace_path: Option<&Path>, + remote_connection_id: Option<&str>, + remote_ssh_host: Option<&str>, + subagent_session_id: &str, + ) { + let Some(workspace_path) = workspace_path else { + debug!( + "Temporary subagent session has no workspace path; skipping immediate recycle: session_id={}", + subagent_session_id + ); + return; + }; + if let Err(error) = self + .delete_session_tree( + workspace_path, + remote_connection_id, + remote_ssh_host, + subagent_session_id, + ) + .await + { + warn!( + "Failed to recycle temporary subagent session: session_id={}, error={}", + subagent_session_id, error + ); + } + } + pub async fn delete_hidden_subagent_sessions_for_parent_turns( &self, workspace_path: &Path, @@ -7099,10 +8093,13 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet request.remote_connection_id.as_deref(), request.remote_ssh_host.as_deref(), )?; + let workspace_path = request.workspace_path.clone(); let session = self .session_manager .restore_session_for_workspace(request, session_id) .await?; + self.restore_session_role_best_effort(&workspace_path, session_id) + .await; self.reconcile_restored_session(session_id, session).await } @@ -7116,10 +8113,13 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet request.remote_connection_id.as_deref(), request.remote_ssh_host.as_deref(), )?; + let workspace_path = request.workspace_path.clone(); let session = self .session_manager .restore_internal_session_for_workspace(request, session_id) .await?; + self.restore_session_role_best_effort(&workspace_path, session_id) + .await; self.reconcile_restored_session(session_id, session).await } @@ -7133,6 +8133,8 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .session_manager .restore_internal_session(workspace_path, session_id) .await?; + self.restore_session_role_best_effort(workspace_path, session_id) + .await; self.reconcile_restored_session(session_id, session).await } @@ -7147,6 +8149,8 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .session_manager .restore_session_with_turns(workspace_path, session_id) .await?; + self.restore_session_role_best_effort(workspace_path, session_id) + .await; self.reconcile_restored_session(session_id, restored).await } @@ -7184,10 +8188,13 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet request.remote_connection_id.as_deref(), request.remote_ssh_host.as_deref(), )?; + let workspace_path = request.workspace_path.clone(); let restored = self .session_manager .restore_session_with_turns_for_workspace(request, session_id) .await?; + self.restore_session_role_best_effort(&workspace_path, session_id) + .await; self.reconcile_restored_session(session_id, restored).await } @@ -7201,10 +8208,13 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet request.remote_connection_id.as_deref(), request.remote_ssh_host.as_deref(), )?; + let workspace_path = request.workspace_path.clone(); let restored = self .session_manager .restore_internal_session_with_turns_for_workspace(request, session_id) .await?; + self.restore_session_role_best_effort(&workspace_path, session_id) + .await; self.reconcile_restored_session(session_id, restored).await } @@ -7218,6 +8228,8 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .session_manager .restore_internal_session_with_turns(workspace_path, session_id) .await?; + self.restore_session_role_best_effort(workspace_path, session_id) + .await; self.reconcile_restored_session(session_id, restored).await } @@ -7425,6 +8437,30 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet self.session_manager.list_sessions(workspace_path).await } + /// List session ids recorded in the workspace deletion tombstone registry. + /// The frontend initialization path pulls this registry to guard against + /// ghost resurrection of deleted subagent sessions after a restart. + pub async fn list_deleted_session_ids( + &self, + workspace_path: &Path, + ) -> BitFunResult> { + self.session_manager + .list_deleted_session_ids(workspace_path) + .await + } + + /// List all sessions, optionally including hidden Subagent/Ephemeral + /// sessions for full conversation management. + pub async fn list_sessions_with_options( + &self, + workspace_path: &Path, + include_internal: bool, + ) -> BitFunResult> { + self.session_manager + .list_sessions_with_options(workspace_path, include_internal) + .await + } + /// Get a best-effort message view for a session. pub async fn get_messages(&self, session_id: &str) -> BitFunResult> { self.session_manager.get_messages(session_id).await @@ -7731,6 +8767,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet prompt_cache_source_session_id, session_kind, transient, + persistent: _persistent, emit_lifecycle_events, prepared_session_created, execution_lease, @@ -7883,7 +8920,9 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet Some(target_session_id) => match self.session_manager.get_session(&target_session_id) { Some(session) => { if session.kind != session_kind { - let error = if session_kind == SessionKind::Subagent { + let error = if session_kind == SessionKind::Subagent + || session_kind == SessionKind::EphemeralSubagent + { BitFunError::Validation(format!( "Subagent execution target must be a subagent session: {}", target_session_id @@ -7952,7 +8991,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet let _execution_lease = execution_lease.unwrap_or_else(|| self.register_session_execution(&session_id)); // Sync context window from AI config so subagents with large-context - // models are not prematurely capped at SessionConfig::default()'s 128128. + // models are not prematurely capped at SessionConfig::default()'s 1M. if let Err(error) = self .session_manager .refresh_session_context_window(&session_id) @@ -7994,6 +9033,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet subagent_parent_info.as_ref(), &logical_agent_type, continuation_policy, + subagent_parent_info.as_ref().and_then(|info| info.depth), ), ) .await @@ -8006,6 +9046,21 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet return Err(error); } + // R-003: Register in memory tree. A persistent subagent runs + // repeatedly and this code path fires per execution, while + // `SessionTreeManager::register_child` is not idempotent (it appends + // the child to the parent's children list). Only register the edge + // when the child is not already bound to this parent (COORD-14). + if let Some(ref parent_info) = subagent_parent_info { + let child_depth = parent_info.depth.map(|d| d + 1).unwrap_or(1); + register_session_tree_edge_idempotent( + &self.session_tree, + &parent_info.session_id, + &session_id, + child_depth, + ); + } + // Register timeout handle so it can be adjusted at runtime. let timeout_handle = Arc::new(SubagentTimeoutHandle { deadline_tx: deadline_tx.clone(), @@ -8193,13 +9248,25 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet format!("\n{section}\n"), )); } + // Custom SubagentStart injection (outside hook gating): pass the + // legion chain (subagent role, parent role, parent goal, depth) into + // the subagent's first round as model-visible context. + if let Some(legion_context) = self + .build_subagent_legion_context(subagent_parent_info.as_ref(), &session_id) + .await + { + initial_messages.push(Message::internal_reminder( + InternalReminderKind::LifecycleContext, + format!("\n{legion_context}\n"), + )); + } let subagent_services = Self::build_workspace_services(&subagent_workspace).await; let execution_context = ExecutionContext { session_id: session_id.clone(), dialog_turn_id: dialog_turn_id.clone(), turn_index, - agent_type: agent_type.clone(), + agent_type: String::new(), workspace: subagent_workspace, context, subagent_parent_info: subagent_parent_info.clone(), @@ -8635,9 +9702,6 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet } }; - // cleanup_guard automatically cleans up token on scope exit (via Drop trait) - - // Persist turn lifecycle before cleaning up the hidden subagent runtime. let (workspace_turn_status, response_text) = match result { Ok(exec_result) => { Self::persist_completed_dialog_turn( @@ -8720,8 +9784,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet // SubagentStop hooks observe the settled subagent turn. A blocking // decision is recorded for the operator; it does not restart the // subagent, because its result has already been persisted. - if let Some(reason) = native_hooks::dispatch_subagent_stop( - subagent_hook_facts, + if let Some(reason) = native_hooks::dispatch_subagent_stop( subagent_hook_facts, &session_id, &agent_type, Some(response_text.as_str()).filter(|text| !text.trim().is_empty()), @@ -8734,6 +9797,82 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet ); } + // Propagate subagent completion to review propagation manager so that + // parent sessions can be flagged for review when a leaf agent finishes. + { + use super::review_propagation::{ReviewPropagationAction, ReviewPropagationManager}; + let parent_id = subagent_parent_info + .as_ref() + .map(|info| info.session_id.as_str()); + let action = ReviewPropagationManager::on_leaf_completed( + &session_id, + &agent_type, + &response_text, + parent_id, + ); + if let ReviewPropagationAction::ReviewNeeded { + parent_session_id, + child_session_id, + } = action + { + // Deliver the review signal to the parent session so the + // request is visible to the parent agent, not just a log line. + // Route through the scheduler's background-result channel + // (inject into the running turn when the parent is processing, + // otherwise submit a follow-up) instead of writing the message + // directly, so delivery stays ordered with queued turns and is + // deduplicated against scheduler-owned delivery state + // (COORD-04). + let reminder = format!( + "Subagent session {} has completed; review its output for correctness before continuing.", + child_session_id + ); + if let Some(scheduler) = get_global_scheduler() { + let parent_session = self.session_manager.get_session(&parent_session_id); + let parent_agent_type = parent_session + .as_ref() + .map(|session| session.agent_type.clone()) + .unwrap_or_default(); + let parent_workspace_path = parent_session + .as_ref() + .and_then(|session| session.config.workspace_path.clone()); + let parent_remote_connection_id = parent_session + .as_ref() + .and_then(|session| session.config.remote_connection_id.clone()); + let parent_remote_ssh_host = parent_session + .as_ref() + .and_then(|session| session.config.remote_ssh_host.clone()); + if let Err(error) = scheduler + .deliver_background_result( + parent_session_id.clone(), + parent_agent_type, + parent_workspace_path, + parent_remote_connection_id, + parent_remote_ssh_host, + reminder.clone(), + Some(reminder), + None, + ) + .await + { + warn!( + "ReviewPropagation: failed to deliver review reminder to parent session {}: {}", + parent_session_id, error + ); + } + } else { + warn!( + "ReviewPropagation: scheduler unavailable; skipping review reminder delivery to parent session {} (child {} completed)", + parent_session_id, child_session_id + ); + } + debug!( + "ReviewPropagation: review needed for parent session {} from completed child {}", + parent_session_id, child_session_id + ); + } + } + // Clean up subagent session resources after successful execution debug!( "Subagent successful execution produced final text: agent_type={}, session_id={}, dialog_turn_id={}, parent_session_id={}, parent_dialog_turn_id={}, parent_tool_call_id={}, text_len={}, duration_ms={}", @@ -8912,6 +10051,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet Ok(child_session) } + #[allow(clippy::too_many_arguments)] pub async fn start_btw_turn( &self, request_id: &str, @@ -9352,6 +10492,12 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet let parent_transient = self .session_manager .is_transient_session(&request.subagent_parent_info.session_id); + if parent_transient { + return Err(BitFunError::Validation(format!( + "transient sessions cannot spawn subagent sessions: parent={}", + request.subagent_parent_info.session_id + ))); + } let approved_model_binding = request .external_generation_lease .as_ref() @@ -9430,15 +10576,14 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet context: request.context, permission_runtime_ceiling: Some(request.permission_runtime_ceiling), delegation_policy: request.delegation_policy, - runtime_tool_restrictions: runtime_tool_restrictions_for_session_lifetime( - runtime_tool_restrictions_for_delegation_policy( - request.delegation_policy, - ), + runtime_tool_restrictions: runtime_tool_restrictions_for_subagent( + request.delegation_policy, transient, ), prompt_cache_source_session_id: None, session_kind: SessionKind::Subagent, transient, + persistent: true, emit_lifecycle_events: true, prepared_session_created: false, execution_lease: None, @@ -9523,13 +10668,22 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet context: request.context, permission_runtime_ceiling: Some(request.permission_runtime_ceiling), delegation_policy: request.delegation_policy, - runtime_tool_restrictions: runtime_tool_restrictions_for_session_lifetime( - runtime_tool_restrictions_for_delegation_policy(request.delegation_policy), + runtime_tool_restrictions: runtime_tool_restrictions_for_subagent( + request.delegation_policy, parent_transient, ), prompt_cache_source_session_id: None, - session_kind: SessionKind::Subagent, - transient: parent_transient, + session_kind: if request.persistent { + SessionKind::Subagent + } else { + SessionKind::EphemeralSubagent + }, + transient: if request.persistent { + parent_transient + } else { + true + }, + persistent: request.persistent, emit_lifecycle_events: true, prepared_session_created: false, execution_lease: None, @@ -9610,13 +10764,22 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet context: request.context, permission_runtime_ceiling: Some(request.permission_runtime_ceiling), delegation_policy: request.delegation_policy, - runtime_tool_restrictions: runtime_tool_restrictions_for_session_lifetime( - runtime_tool_restrictions_for_delegation_policy(request.delegation_policy), + runtime_tool_restrictions: runtime_tool_restrictions_for_subagent( + request.delegation_policy, parent_transient, ), prompt_cache_source_session_id: Some(snapshot.parent_session_id), - session_kind: SessionKind::Subagent, - transient: parent_transient, + session_kind: if request.persistent { + SessionKind::Subagent + } else { + SessionKind::EphemeralSubagent + }, + transient: if request.persistent { + parent_transient + } else { + true + }, + persistent: request.persistent, emit_lifecycle_events: true, prepared_session_created: false, execution_lease: None, @@ -9640,7 +10803,9 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet target_session_id )) })?; - if session.kind != SessionKind::Subagent { + if session.kind != SessionKind::Subagent + && session.kind != SessionKind::EphemeralSubagent + { return Err(BitFunError::Validation(format!( "Subagent execution target must be a subagent session: {}", target_session_id @@ -9811,9 +10976,13 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet self.ensure_subagent_session_loaded_for_reuse(subagent_session_id, parent_session_id) .await?; + // R-2: The target session was already resolved through global agent_id + // resolution (subtree-first, whole-database fallback), so cancellation + // matches the subagent session globally instead of requiring the + // caller to be the direct spawner. This is the intended widening for + // full background-task management. let controls = self.claim_background_subagent_controls(|control| { - control.parent_session_id == parent_session_id - && control.subagent_session_id == subagent_session_id + control.subagent_session_id == subagent_session_id }); let task_pks = controls .iter() @@ -9879,30 +11048,82 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet &self, parent_session_id: &str, agent_id: &str, + allow_global_fallback: bool, ) -> BitFunResult { + // R-2: Global agent_id resolution. Prefer the caller's session subtree + // (parent + descendants). Whole-database fallback is only allowed when + // the caller opts in (e.g. read-only listing); mutating Task operations + // (cancel/send_input/history) pass false so a scope miss is "not found" + // instead of reaching subagents owned by other conversations. + let scope = self + .session_subtree_scope(parent_session_id) + .await; self.background_subagent_outcomes - .resolve_agent_id(parent_session_id, agent_id) + .resolve_agent_id_in_scope(&scope, agent_id, allow_global_fallback) .await } - fn claim_background_subagent_controls( + pub(crate) async fn list_background_subagents( &self, - matches: impl Fn(&BackgroundSubagentTaskControl) -> bool, - ) -> Vec<(i64, BackgroundSubagentTaskControl)> { - let candidate_ids = self - .background_subagent_tasks - .iter() - .filter(|entry| matches(entry.value())) - .map(|entry| *entry.key()) - .collect::>(); - candidate_ids - .into_iter() - .filter_map(|task_pk| { - self.background_subagent_tasks - .remove_if(&task_pk, |_task_pk, control| { - if !matches(control) { - return false; - } + parent_session_id: &str, + ) -> BitFunResult> { + // R-2: List background tasks spawned anywhere in the caller's session + // subtree so a conversation can manage every subagent task it owns. + let scope = self + .session_subtree_scope(parent_session_id) + .await; + self.background_subagent_outcomes + .list_records_for_parents(&scope) + .await + } + + /// Build the caller's session subtree scope for `agent_id`/task management. + /// + /// The in-memory session tree is lazily loaded and can be incomplete right + /// after a restart, so the persisted coordination database subtree is + /// unioned in (deduplicated) to avoid failing resolution against a + /// half-empty tree (COORD-06). + async fn session_subtree_scope(&self, parent_session_id: &str) -> Vec { + let mut scope = vec![parent_session_id.to_string()]; + scope.extend(self.session_tree.get_descendants(parent_session_id)); + match self + .background_subagent_outcomes + .descendant_session_ids(parent_session_id) + .await + { + Ok(persisted) => { + for session_id in persisted { + if !scope.iter().any(|existing| existing == &session_id) { + scope.push(session_id); + } + } + } + Err(error) => warn!( + "Failed to rebuild persisted session subtree for scope: parent_session_id={}, error={}", + parent_session_id, error + ), + } + scope + } + + fn claim_background_subagent_controls( + &self, + matches: impl Fn(&BackgroundSubagentTaskControl) -> bool, + ) -> Vec<(i64, BackgroundSubagentTaskControl)> { + let candidate_ids = self + .background_subagent_tasks + .iter() + .filter(|entry| matches(entry.value())) + .map(|entry| *entry.key()) + .collect::>(); + candidate_ids + .into_iter() + .filter_map(|task_pk| { + self.background_subagent_tasks + .remove_if(&task_pk, |_task_pk, control| { + if !matches(control) { + return false; + } control.suppress_delivery.store(true, Ordering::SeqCst); true }) @@ -9981,6 +11202,25 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet } else { Self::await_hidden_subagent_receiver(receiver).await }; + // A temporary (`persistent=false`) subagent whose execution failed + // (cancelled, timed out, or crashed) is recycled here so the one-shot + // session never accumulates; successful results are recycled by the + // caller (TaskTool foreground path / background completion block). + if result.is_err() && !request.persistent { + if let Some(target_session_id) = request.target_session_id() { + self.recycle_temporary_subagent_session( + request + .session_config + .workspace_path + .as_deref() + .map(Path::new), + request.session_config.remote_connection_id.as_deref(), + request.session_config.remote_ssh_host.as_deref(), + target_session_id, + ) + .await; + } + } result } @@ -10025,6 +11265,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet prompt_cache_source_session_id: None, session_kind: request.session_kind, transient: false, + persistent: true, emit_lifecycle_events: request.emit_lifecycle_events, prepared_session_created: false, execution_lease: None, @@ -10185,6 +11426,19 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet ); let background_subagent_tasks = self.background_subagent_tasks.clone(); let background_subagent_outcomes = self.background_subagent_outcomes.clone(); + let event_queue = self.event_queue.clone(); + let agent_type = request.agent_type.clone(); + let subagent_parent_info_for_emit = subagent_parent_info.clone(); + let subagent_session_id_for_emit = subagent_session_id.clone(); + let subagent_dialog_turn_id_for_emit = subagent_dialog_turn_id.clone(); + let persistent_for_recycle = request.persistent; + let recycle_workspace_path = request + .session_config + .workspace_path + .clone() + .map(PathBuf::from); + let recycle_remote_connection_id = request.session_config.remote_connection_id.clone(); + let recycle_remote_ssh_host = request.session_config.remote_ssh_host.clone(); tokio::spawn(async move { let result = match (parent_cancel_token, tool_cancellation_token) { @@ -10237,12 +11491,90 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet "Suppressing cancelled background subagent result delivery: task_pk={}, parent_session_id={}", task_pk, subagent_parent_info.session_id ); + if !persistent_for_recycle { + if let Some(coordinator) = get_global_coordinator() { + coordinator + .recycle_temporary_subagent_session( + recycle_workspace_path.as_deref().map(Path::new), + recycle_remote_connection_id.as_deref(), + recycle_remote_ssh_host.as_deref(), + &subagent_session_id_for_emit, + ) + .await; + } + } return; } background_subagent_outcomes .complete(task_pk, result.as_ref()) .await; + + let (completion_status, _) = match &result { + Ok(sr) => { + let status = match sr.status { + SubagentResultStatus::Completed => SubagentCompletionStatus::Completed, + SubagentResultStatus::PartialTimeout => { + SubagentCompletionStatus::PartialTimeout + } + }; + (status, Some(sr.text.clone())) + } + Err(_) => (SubagentCompletionStatus::Failed, None), + }; + let _ = event_queue + .enqueue( + AgenticEvent::SubagentTurnCompleted { + session_id: subagent_session_id_for_emit.clone(), + subagent_dialog_turn_id: subagent_dialog_turn_id_for_emit.clone(), + parent_session_id: subagent_parent_info_for_emit.session_id.clone(), + parent_dialog_turn_id: subagent_parent_info_for_emit + .dialog_turn_id + .clone(), + parent_tool_call_id: subagent_parent_info_for_emit.tool_call_id.clone(), + agent_type: Some(agent_type.clone()), + status: completion_status, + output_text: None, + }, + Some(EventPriority::Normal), + ) + .await; + let _ = scheduler_for_cancel + .submit_dialog_turn(AgentDialogTurnRequest { + session_id: subagent_parent_info_for_emit.session_id.clone(), + message: background_subagent_follow_up_notice( + &subagent_session_id_for_emit, + &agent_type, + ), + original_message: None, + turn_id: None, + execution: Default::default(), + agent_type: String::new(), + workspace_path: None, + remote_connection_id: None, + remote_ssh_host: None, + policy: DialogSubmissionPolicy::for_source( + DialogTriggerSource::AgentSession, + ), + reply_route: None, + prepended_reminders: Vec::new(), + attachments: Vec::new(), + metadata: serde_json::Map::new(), + }) + .await; + + if !persistent_for_recycle { + if let Some(coordinator) = get_global_coordinator() { + coordinator + .recycle_temporary_subagent_session( + recycle_workspace_path.as_deref().map(Path::new), + recycle_remote_connection_id.as_deref(), + recycle_remote_ssh_host.as_deref(), + &subagent_session_id_for_emit, + ) + .await; + } + } background_subagent_tasks.remove(&task_pk); }); @@ -10293,6 +11625,11 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet ); let background_subagent_tasks = self.background_subagent_tasks.clone(); let background_subagent_outcomes = self.background_subagent_outcomes.clone(); + let event_queue = self.event_queue.clone(); + let agent_type = request.agent_type.clone(); + let subagent_parent_info_for_emit = subagent_parent_info.clone(); + let subagent_session_id_for_emit = subagent_session_id.clone(); + let subagent_dialog_turn_id_for_emit = subagent_dialog_turn_id.clone(); tokio::spawn(async move { let result = coordinator @@ -10315,6 +11652,60 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet background_subagent_outcomes .complete(task_pk, result.as_ref()) .await; + + let (completion_status, _) = match &result { + Ok(sr) => { + let status = match sr.status { + SubagentResultStatus::Completed => SubagentCompletionStatus::Completed, + SubagentResultStatus::PartialTimeout => { + SubagentCompletionStatus::PartialTimeout + } + }; + (status, Some(sr.text.clone())) + } + Err(_) => (SubagentCompletionStatus::Failed, None), + }; + let _ = event_queue + .enqueue( + AgenticEvent::SubagentTurnCompleted { + session_id: subagent_session_id_for_emit.clone(), + subagent_dialog_turn_id: subagent_dialog_turn_id_for_emit.clone(), + parent_session_id: subagent_parent_info_for_emit.session_id.clone(), + parent_dialog_turn_id: subagent_parent_info_for_emit.dialog_turn_id.clone(), + parent_tool_call_id: subagent_parent_info_for_emit.tool_call_id.clone(), + agent_type: Some(agent_type.clone()), + status: completion_status, + output_text: None, + }, + Some(EventPriority::Normal), + ) + .await; + if let Some(scheduler) = get_global_scheduler() { + let _ = scheduler + .submit_dialog_turn(AgentDialogTurnRequest { + session_id: subagent_parent_info_for_emit.session_id.clone(), + message: background_subagent_follow_up_notice( + &subagent_session_id_for_emit, + &agent_type, + ), + original_message: None, + turn_id: None, + execution: Default::default(), + agent_type: String::new(), + workspace_path: None, + remote_connection_id: None, + remote_ssh_host: None, + policy: DialogSubmissionPolicy::for_source( + DialogTriggerSource::AgentSession, + ), + reply_route: None, + prepended_reminders: Vec::new(), + attachments: Vec::new(), + metadata: serde_json::Map::new(), + }) + .await; + } + background_subagent_tasks.remove(&task_pk); }); @@ -10541,8 +11932,12 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .await } - /// Emit event - pub(crate) async fn emit_event(&self, event: AgenticEvent) { + /// Emit event through the shared agentic event queue. + /// + /// Public so product hosts (for example the desktop ACP client port) can + /// broadcast `agentic://*` events for external sessions that are not owned + /// by the internal session store. + pub async fn emit_event(&self, event: AgenticEvent) { let _ = self .event_queue .enqueue(event, Some(EventPriority::Normal)) @@ -10628,6 +12023,23 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet } } +/// P-19:后台 subagent 完成主会话通知只含极简元信息(session_id + 身份标识 + +/// 已回复状态 + use SessionHistory 指引),对齐 scheduler.rs +/// background_result_follow_up_user_input 语义。 +/// +/// 全量 output_text 不回主会话,只由 SubagentTurnCompleted 事件与子会话自身 +/// turn 持久化承载;按需经 SessionHistory(session_id) 检索。 +fn background_subagent_follow_up_notice(session_id: &str, agent_type: &str) -> String { + let identity = if agent_type.trim().is_empty() { + "agent".to_string() + } else { + agent_type.to_string() + }; + format!( + "Background agent session {session_id} ({identity}) has replied; use SessionHistory to view the full reply." + ) +} + fn resolve_agent_submission_turn_id( request: &bitfun_runtime_ports::AgentSubmissionRequest, ) -> String { @@ -10679,24 +12091,56 @@ async fn create_agent_session_from_runtime_request( ) })?; let created_by = resolve_agent_session_create_created_by(&request.metadata); + // Parent lineage facts are carried by create callers (e.g. the SessionControl + // tool chain) through the free-form metadata map. Absent callers yield None + // and the SessionCreated event simply omits the optional fields. + let parent_session_id = request + .metadata + .get("parentSessionId") + .or_else(|| request.metadata.get("parent_session_id")) + .and_then(|value| value.as_str()) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); + let subagent_type = request + .metadata + .get("subagentType") + .or_else(|| request.metadata.get("subagent_type")) + .and_then(|value| value.as_str()) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); + // Subagent sessions (marked by the SessionControl create chain) get a forced + // 1M context window and must not be downgraded by the post-create model-window + // refresh, which targets normal sessions only. + let subagent_forced_1m = request + .metadata + .get("subagent") + .and_then(|value| value.as_bool()) + .unwrap_or(false); + let mut session_config = SessionConfig { + workspace_path: Some(workspace_path.clone()), + project_workspace_path: request.project_workspace_path, + execution_target: request.execution_target, + workspace_id: request.workspace_id, + remote_connection_id: request.remote_connection_id, + remote_ssh_host: request.remote_ssh_host, + model_id: request.model_id, + ..Default::default() + }; + if subagent_forced_1m { + session_config.max_context_tokens = 1_000_000; + } let session = coordinator .create_session_with_workspace_and_creator_internal( session_id, request.session_name, request.agent_type, - SessionConfig { - workspace_path: Some(workspace_path.clone()), - project_workspace_path: request.project_workspace_path, - execution_target: request.execution_target, - workspace_id: request.workspace_id, - remote_connection_id: request.remote_connection_id, - remote_ssh_host: request.remote_ssh_host, - model_id: request.model_id, - ..Default::default() - }, + session_config, workspace_path, created_by, transient, + subagent_forced_1m, + parent_session_id, + subagent_type, ) .await .map_err(map_core_error)?; @@ -10838,7 +12282,7 @@ impl bitfun_runtime_ports::AgentSubmissionPort for ConversationCoordinator { None }, }; - self.restore_session_for_workspace(restore_request, session_id) + self.restore_internal_session_for_workspace(restore_request, session_id) .await .map(|session| Some(session.agent_type)) .map_err(|error| { @@ -11029,6 +12473,14 @@ pub(crate) fn runtime_transcript_messages_from_turns( } fn runtime_session_summary(session: SessionSummary) -> bitfun_runtime_ports::AgentSessionSummary { + let status = Some( + match &session.state { + SessionState::Idle => "idle", + SessionState::Processing { .. } => "active", + SessionState::Error { .. } => "error", + } + .to_string(), + ); bitfun_runtime_ports::AgentSessionSummary { session_id: session.session_id, session_name: session.session_name, @@ -11040,6 +12492,9 @@ fn runtime_session_summary(session: SessionSummary) -> bitfun_runtime_ports::Age turn_count: session.turn_count, created_at_ms: runtime_session_time_ms(session.created_at), last_active_at_ms: runtime_session_time_ms(session.last_activity_at), + parent_session_id: session.parent_session_id, + status, + is_daemon: session.is_daemon, } } @@ -11126,12 +12581,38 @@ impl bitfun_runtime_ports::AgentSessionManagementPort for ConversationCoordinato ) })?; - self.list_sessions(&effective_storage_path) - .await + // R-004: Lazily populate the in-memory session tree from persisted + // metadata on first list so that parent-child relationships are visible. + { + let metadata_list = self + .session_manager + .persistence_manager() + .list_session_metadata_including_internal(&effective_storage_path) + .await + .unwrap_or_default(); + self.session_tree.load_from_sessions(&metadata_list); + } + + let sessions = if request.include_hidden { + // R-2: Full conversation management — include hidden Subagent/ + // Ephemeral sessions in the listing. + self.list_sessions_with_options(&effective_storage_path, true) + .await + } else { + self.list_sessions(&effective_storage_path).await + }; + sessions .map(|sessions| { sessions .into_iter() - .map(runtime_session_summary) + .map(|mut summary| { + // Populate parent_session_id from the session tree if available. + if summary.parent_session_id.is_none() { + summary.parent_session_id = + self.session_tree.get_parent(&summary.session_id); + } + runtime_session_summary(summary) + }) .collect::>() }) .map_err(|error| { @@ -11588,6 +13069,9 @@ impl bitfun_agent_runtime::sdk::AgentSessionRestorePort for ConversationCoordina turn_count: session.dialog_turn_ids.len(), created_at_ms: runtime_session_time_ms(session.created_at), last_active_at_ms: runtime_session_time_ms(session.last_activity_at), + parent_session_id: None, + status: None, + is_daemon: session.config.is_daemon, }, state: session.state, }) @@ -12157,6 +13641,7 @@ impl bitfun_runtime_ports::AgentThreadGoalManagementPort for ConversationCoordin std::path::Path::new(&request.workspace_path), request.objective, request.token_budget, + request.reference_files, ) .await .map_err(runtime_port_error_from_bitfun) @@ -12208,9 +13693,10 @@ impl bitfun_runtime_ports::AgentTurnCancellationPort for ConversationCoordinator &self, request: bitfun_runtime_ports::AgentTurnCancellationRequest, ) -> bitfun_runtime_ports::PortResult { + let user_initiated = Self::cancel_is_user_triggered(request.source); let session_id = request.session_id; if let Some(turn_id) = request.turn_id { - self.cancel_dialog_turn(&session_id, &turn_id) + self.cancel_dialog_turn_for_source(&session_id, &turn_id, user_initiated) .await .map_err(|error| { bitfun_runtime_ports::PortError::new( @@ -12228,10 +13714,11 @@ impl bitfun_runtime_ports::AgentTurnCancellationPort for ConversationCoordinator let wait_timeout = Duration::from_millis(request.wait_timeout_ms.unwrap_or(1500)); let cancelled_turn_id = self - .cancel_active_turn_for_session_with_descendant_policy( + .cancel_active_turn_for_session_with_source( &session_id, wait_timeout, request.cancel_descendants, + user_initiated, ) .await .map_err(|error| { @@ -12632,20 +14119,21 @@ fn merge_prepended_messages_for_turn( #[cfg(test)] mod tests { use super::{ - apply_primary_agent_model_default, btw_session_memory_mode, + apply_primary_agent_model_default, background_subagent_follow_up_notice, + btw_session_memory_mode, build_subagent_session_relationship, lineage_active_turn_after_transcript, lineage_post_admission_cancellation_error, lineage_session_is_settling_without_active_state, logical_subagent_type_or_runtime, - merge_prepended_messages_for_turn, normalize_subagent_max_concurrency, - resolve_agent_session_create_created_by, resolve_agent_submission_turn_id, - resolve_subagent_model_selection, runtime_port_error_preserving_message, - runtime_session_summary, runtime_tool_restrictions_for_session_lifetime, - runtime_transcript_messages_from_turns, session_storage_workspace_locator, - turn_review_manifest_for_agent, validate_required_lineage_turns_settled, - ActiveSubagentExecution, BackgroundSubagentWaitMode, ContextCompactionOutcome, - ConversationCoordinator, ManualCompactionCommitGate, SessionMemoryMode, - SessionReferenceLocator, SessionRelationshipKind, SubagentExecutionRequest, - TEST_AGENT_MODEL_DEFAULTS, + merge_prepended_messages_for_turn, normalize_subagent_max_concurrency, + register_session_tree_edge_idempotent, resolve_agent_session_create_created_by, + resolve_agent_submission_turn_id, resolve_subagent_model_selection, + runtime_port_error_preserving_message, runtime_session_summary, + runtime_tool_restrictions_for_session_lifetime, runtime_transcript_messages_from_turns, + session_storage_workspace_locator, turn_review_manifest_for_agent, + validate_required_lineage_turns_settled, ActiveSubagentExecution, + BackgroundSubagentWaitMode, ContextCompactionOutcome, ConversationCoordinator, + ManualCompactionCommitGate, SessionMemoryMode, SessionReferenceLocator, + SessionRelationshipKind, SubagentExecutionRequest, TEST_AGENT_MODEL_DEFAULTS, }; use crate::agentic::agents::ExternalSubagentModelBinding; use crate::agentic::coordination::coordination_store::{ @@ -12683,129 +14171,537 @@ mod tests { use bitfun_services_core::permission_store::ProjectPermissionSqliteStore; #[test] - fn external_command_delegation_uses_the_resolved_primary_binding() { - let source = include_str!("coordinator.rs").replace("\r\n", "\n"); - let delegation = source - .split_once("pub(crate) fn start_external_subagent_delegation_turn(") - .expect("external command delegation entry") - .1 - .split_once("pub async fn start_dialog_turn_with_prepended_messages(") - .expect("external command delegation boundary") - .0; - - assert!(delegation.contains("Self::resolve_session_primary_agent(")); - assert!(delegation.contains("Some(&primary_runtime_agent_key)")); - assert!(delegation.contains(".update_session_agent_binding(")); - assert!(!delegation.contains(".update_session_agent_type(")); - assert!(delegation - .contains("let _primary_agent_generation_lease = primary_agent_generation_lease;")); - } - - #[test] - fn external_primary_fixed_model_is_only_a_creation_default() { - let fixed = ExternalSubagentModelBinding::Fixed { - model_id: "provider/profile-model".to_string(), - configuration_fingerprint: "fingerprint".to_string(), - }; - - let mut omitted = SessionConfig::default(); - apply_primary_agent_model_default(&mut omitted, Some(&fixed)); - assert_eq!(omitted.model_id.as_deref(), Some("provider/profile-model")); - - let mut automatic = SessionConfig { - model_id: Some("auto".to_string()), - ..SessionConfig::default() - }; - apply_primary_agent_model_default(&mut automatic, Some(&fixed)); + fn resolve_session_role_assigns_executor_to_subagents_and_inherits_creator() { + use crate::agentic::tools::AgentRole; + // Subagent/EphemeralSubagent sessions are always executors, + // regardless of the creator role. assert_eq!( - automatic.model_id.as_deref(), - Some("provider/profile-model") + super::ConversationCoordinator::resolve_session_role( + SessionKind::Subagent, + Some(AgentRole::Commander) + ), + AgentRole::Executor ); - - let mut explicit = SessionConfig { - model_id: Some("provider/user-model".to_string()), - ..SessionConfig::default() - }; - apply_primary_agent_model_default(&mut explicit, Some(&fixed)); - assert_eq!(explicit.model_id.as_deref(), Some("provider/user-model")); - - let mut inherited = SessionConfig::default(); - apply_primary_agent_model_default( - &mut inherited, - Some(&ExternalSubagentModelBinding::InheritParent), + assert_eq!( + super::ConversationCoordinator::resolve_session_role( + SessionKind::EphemeralSubagent, + None + ), + AgentRole::Executor ); - assert_eq!(inherited.model_id, None); - } - - #[test] - fn terminal_persisted_turn_is_not_replayed_as_active() { + // Main sessions inherit the creator role; an unknown creator degrades + // to the commander (permissive) baseline. assert_eq!( - lineage_active_turn_after_transcript( - Some("turn-1".to_string()), - Some("turn-1".to_string()), - Some(&TurnStatus::Completed), + super::ConversationCoordinator::resolve_session_role( + SessionKind::Standard, + Some(AgentRole::Reviewer) ), - None + AgentRole::Reviewer ); assert_eq!( - lineage_active_turn_after_transcript( - Some("turn-1".to_string()), - Some("turn-1".to_string()), - Some(&TurnStatus::InProgress), - ) - .as_deref(), - Some("turn-1") + super::ConversationCoordinator::resolve_session_role(SessionKind::Standard, None), + AgentRole::Commander ); } - #[test] - fn idle_session_with_in_flight_execution_is_not_published_as_settled() { - assert!(lineage_session_is_settling_without_active_state(None, 1)); - assert!(!lineage_session_is_settling_without_active_state( - Some("turn-1"), - 1 + #[tokio::test] + async fn session_creation_registers_rbac_role_in_registry() { + use crate::agentic::tools::{get_session_role, AgentRole}; + let (coordinator, _session_manager) = test_coordinator(); + let workspace = std::env::temp_dir().join(format!( + "bitfun-rbac-role-test-{}", + uuid::Uuid::new_v4() )); - assert!(!lineage_session_is_settling_without_active_state(None, 0)); - } + std::fs::create_dir_all(&workspace).expect("create workspace dir"); + let workspace_path = workspace.to_string_lossy().into_owned(); + + // Main session (no creator) => commander. + let main_session = coordinator + .create_session_with_workspace_and_creator( + Some("rbac-main-01".to_string()), + "main".to_string(), + "agentic".to_string(), + SessionConfig::default(), + workspace_path.clone(), + None, + ) + .await + .expect("create main session"); + assert_eq!( + get_session_role(&main_session.session_id), + Some(AgentRole::Commander) + ); - #[test] - fn lineage_read_barrier_requires_each_turn_to_be_durably_terminal() { - let turn = |turn_id: &str, status| { - let mut turn = DialogTurnData::new( - turn_id.to_string(), - 0, - "session-1".to_string(), - UserMessageData { - id: format!("{turn_id}-user"), - content: "question".to_string(), - timestamp: 1, - metadata: None, + // Subagent session => executor (R-14 B2 role inheritance). + let subagent_session = coordinator + .create_hidden_agent_session( + Some("rbac-sub-01".to_string()), + "sub".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace_path), + ..Default::default() }, - ); - turn.status = status; - turn - }; - let turns = vec![ - turn("turn-settled", TurnStatus::Cancelled), - turn("turn-active", TurnStatus::InProgress), - ]; - - validate_required_lineage_turns_settled(&turns, &["turn-settled".to_string()]) - .expect("terminal turn should satisfy the barrier"); - for required in ["turn-active", "turn-missing"] { - let error = validate_required_lineage_turns_settled(&turns, &[required.to_string()]) - .expect_err("non-terminal or absent turns must keep the read uncertain"); - assert_eq!( - error.kind, - bitfun_runtime_ports::PortErrorKind::OutcomeUnknown - ); - } + Some("rbac-main-01".to_string()), + SessionKind::Subagent, + ) + .await + .expect("create subagent session"); + assert_eq!( + get_session_role(&subagent_session.session_id), + Some(AgentRole::Executor) + ); } - #[test] - fn post_admission_cancellation_errors_are_outcome_unknown() { - for source_error in [ - crate::util::errors::BitFunError::Timeout("drain deadline".to_string()), + #[tokio::test] + async fn subagent_marked_creation_yields_executor_role() { + use crate::agentic::tools::{get_session_role, AgentRole}; + let (coordinator, _session_manager) = test_persistent_coordinator(); + let workspace = tempfile::tempdir().expect("workspace"); + let workspace_path = workspace.path().to_string_lossy().into_owned(); + let session_id = format!("sub-kind-{}", uuid::Uuid::new_v4()); + + // SessionControl-style creation: metadata.subagent=true + subagentType + // map to the Subagent kind, so the role resolves to executor. + let session = coordinator + .create_session_with_workspace_and_creator_internal( + Some(session_id), + "subagent".to_string(), + "agentic".to_string(), + SessionConfig::default(), + workspace_path.clone(), + Some("parent-session".to_string()), + false, // transient + true, // skip_context_window_refresh (subagent forced 1M) + Some("parent-session".to_string()), + Some("TestSubagent".to_string()), + ) + .await + .expect("create subagent session"); + assert_eq!(session.kind, SessionKind::Subagent); + assert_eq!( + get_session_role(&session.session_id), + Some(AgentRole::Executor), + "subagent-marked session must resolve as executor, not commander" + ); + let metadata = coordinator + .session_manager + .load_session_metadata(workspace.path(), &session.session_id) + .await + .expect("load metadata") + .expect("metadata exists"); + assert_eq!( + metadata + .custom_metadata + .as_ref() + .and_then(|value| value.get("role")) + .and_then(|value| value.as_str()), + Some("executor"), + "executor role must be persisted with the session metadata" + ); + } + + #[tokio::test] + async fn restore_derives_role_from_legacy_subagent_metadata() { + use crate::agentic::tools::{clear_session_role, get_session_role, AgentRole}; + let (coordinator, _session_manager) = test_persistent_coordinator(); + let workspace = tempfile::tempdir().expect("workspace"); + let workspace_path = workspace.path().to_string_lossy().into_owned(); + let sub_session_id = format!("restore-sub-{}", uuid::Uuid::new_v4()); + + // Legacy subagent session: created with the subagent marker so the + // lineage metadata carries relationship.kind=Subagent, then the role + // key is stripped to simulate pre-role-persistence state. + coordinator + .create_session_with_workspace_and_creator_internal( + Some(sub_session_id.clone()), + "subagent".to_string(), + "agentic".to_string(), + SessionConfig::default(), + workspace_path.clone(), + Some("parent-session".to_string()), + false, + true, + Some("parent-session".to_string()), + Some("TestSubagent".to_string()), + ) + .await + .expect("create subagent session"); + coordinator + .session_manager + .update_session_metadata(workspace.path(), &sub_session_id, |metadata| { + if let Some(custom) = metadata.custom_metadata.as_mut() { + if let Some(object) = custom.as_object_mut() { + object.remove("role"); + } + } + }) + .await + .expect("strip role key"); + clear_session_role(&sub_session_id); + + coordinator + .restore_session_role_best_effort(workspace.path(), &sub_session_id) + .await; + assert_eq!( + get_session_role(&sub_session_id), + Some(AgentRole::Executor), + "legacy subagent session must restore as executor" + ); + // The derived role is persisted back so the next restore reads it directly. + let metadata = coordinator + .session_manager + .load_session_metadata(workspace.path(), &sub_session_id) + .await + .expect("load metadata") + .expect("metadata exists"); + assert_eq!( + metadata + .custom_metadata + .as_ref() + .and_then(|value| value.get("role")) + .and_then(|value| value.as_str()), + Some("executor") + ); + + // Plain session without any subagent marker restores as commander. + let plain_session_id = format!("restore-plain-{}", uuid::Uuid::new_v4()); + coordinator + .create_session_with_workspace_and_creator_internal( + Some(plain_session_id.clone()), + "plain".to_string(), + "agentic".to_string(), + SessionConfig::default(), + workspace_path.clone(), + None, + false, + false, + None, + None, + ) + .await + .expect("create plain session"); + coordinator + .session_manager + .update_session_metadata(workspace.path(), &plain_session_id, |metadata| { + if let Some(custom) = metadata.custom_metadata.as_mut() { + if let Some(object) = custom.as_object_mut() { + object.remove("role"); + } + } + }) + .await + .expect("strip role key"); + clear_session_role(&plain_session_id); + + coordinator + .restore_session_role_best_effort(workspace.path(), &plain_session_id) + .await; + assert_eq!( + get_session_role(&plain_session_id), + Some(AgentRole::Commander), + "plain session without markers must default to commander" + ); + } + + #[tokio::test] + async fn restore_overrides_stale_commander_role_for_subagent_sessions() { + use crate::agentic::tools::{clear_session_role, get_session_role, AgentRole}; + let (coordinator, _session_manager) = test_persistent_coordinator(); + let workspace = tempfile::tempdir().expect("workspace"); + let workspace_path = workspace.path().to_string_lossy().into_owned(); + let session_id = format!("restore-stale-{}", uuid::Uuid::new_v4()); + + // Create a subagent-marked session (persists role=executor), then + // rewrite the persisted role to "commander" to simulate the stale + // value written by the pre-fix creation chain. + coordinator + .create_session_with_workspace_and_creator_internal( + Some(session_id.clone()), + "subagent".to_string(), + "agentic".to_string(), + SessionConfig::default(), + workspace_path.clone(), + Some("parent-session".to_string()), + false, + true, + Some("parent-session".to_string()), + Some("TestSubagent".to_string()), + ) + .await + .expect("create subagent session"); + coordinator + .session_manager + .update_session_metadata(workspace.path(), &session_id, |metadata| { + crate::service::session::merge_session_custom_metadata( + metadata, + serde_json::json!({ "role": "commander" }), + ); + }) + .await + .expect("write stale commander role"); + clear_session_role(&session_id); + + coordinator + .restore_session_role_best_effort(workspace.path(), &session_id) + .await; + assert_eq!( + get_session_role(&session_id), + Some(AgentRole::Executor), + "stale commander role on a subagent-marked session must be overridden" + ); + // The override is persisted back so the next restore reads executor directly. + let metadata = coordinator + .session_manager + .load_session_metadata(workspace.path(), &session_id) + .await + .expect("load metadata") + .expect("metadata exists"); + assert_eq!( + metadata + .custom_metadata + .as_ref() + .and_then(|value| value.get("role")) + .and_then(|value| value.as_str()), + Some("executor") + ); + } + + #[tokio::test] + async fn session_lifecycle_injects_start_context_and_cleans_up() { + use crate::agentic::tools::{ + clear_session_restrictions, get_session_restrictions, get_session_role, + set_session_role, update_restrictions, AgentRole, ToolRuntimeRestrictionsPatch, + }; + let (coordinator, _session_manager) = test_coordinator(); + let workspace = std::env::temp_dir().join(format!( + "bitfun-lifecycle-test-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&workspace).expect("create workspace dir"); + let workspace_path = workspace.to_string_lossy().into_owned(); + + let session = coordinator + .create_session_with_workspace_and_creator( + Some("lc-main-01".to_string()), + "lifecycle main".to_string(), + "agentic".to_string(), + SessionConfig::default(), + workspace_path, + None, + ) + .await + .expect("create main session"); + let session_id = session.session_id.clone(); + assert_eq!(get_session_role(&session_id), Some(AgentRole::Commander)); + + // SessionStart injection: the lifecycle context must be visible to the model. + let transcript = bitfun_runtime_ports::SessionTranscriptReader::read_session_transcript( + &coordinator, + bitfun_runtime_ports::SessionTranscriptRequest { + session_id: session_id.clone(), + turn_id: None, + }, + ) + .await + .expect("session transcript"); + let injected = transcript.messages.iter().any(|message| { + matches!( + &message.content, + bitfun_runtime_ports::TranscriptContent::Text(text) + if text.contains("[Legion Context]") && text.contains("commander") + ) + }); + assert!(injected, "SessionStart must inject the legion role context"); + + // Populate the registries so cleanup has something to remove. + set_session_role(&session_id, AgentRole::Reviewer).expect("set role"); + update_restrictions(&session_id, None, ToolRuntimeRestrictionsPatch::default()) + .expect("set restrictions"); + assert!(get_session_restrictions(&session_id).is_some()); + + // SessionEnd cleanup: role + restrictions unregistered, idempotent. + coordinator.session_end_cleanup(&session_id).await; + assert_eq!(get_session_role(&session_id), None, "role must be unregistered"); + assert_eq!( + get_session_restrictions(&session_id), + None, + "restrictions must be unregistered" + ); + clear_session_restrictions(&session_id); // exercise the idempotent path + coordinator.session_end_cleanup(&session_id).await; // no-op, must not panic + } + + #[tokio::test] + async fn subagent_start_builds_legion_context() { + use crate::agentic::tools::{get_session_role, set_session_role, AgentRole}; + let (coordinator, _session_manager) = test_coordinator(); + + // Parent session with a registered role (no active thread goal on disk). + set_session_role("lc-parent-01", AgentRole::Commander).expect("set parent role"); + let parent_info = SubagentParentInfo { + tool_call_id: "tool-call-1".to_string(), + session_id: "lc-parent-01".to_string(), + dialog_turn_id: "turn-1".to_string(), + depth: Some(2), + role: Some("reviewer".to_string()), + }; + + // Subagent role resolved from the session registry (takes precedence + // over the parent info role). + set_session_role("lc-sub-01", AgentRole::Reviewer).expect("set subagent role"); + let context = coordinator + .build_subagent_legion_context(Some(&parent_info), "lc-sub-01") + .await + .expect("context must be built"); + assert!(context.contains("[Legion Context]"), "got: {context}"); + assert!( + context.contains("Subagent role: reviewer"), + "subagent role line missing, got: {context}" + ); + assert!( + context.contains("Parent session role: commander"), + "parent role line missing, got: {context}" + ); + assert!( + context.contains("Legion depth: 2"), + "depth line missing, got: {context}" + ); + + // No role and no parent info => nothing to inject. + let empty = coordinator + .build_subagent_legion_context(None, "lc-unknown") + .await; + assert!(empty.is_none(), "unknown session must yield no context"); + + // Registry wins over the parent-info role claim. + assert_eq!(get_session_role("lc-sub-01"), Some(AgentRole::Reviewer)); + } + + #[test] + fn external_command_delegation_uses_the_resolved_primary_binding() { + let source = include_str!("coordinator.rs").replace("\r\n", "\n"); + let delegation = source + .split_once("pub(crate) fn start_external_subagent_delegation_turn(") + .expect("external command delegation entry") + .1 + .split_once("pub async fn start_dialog_turn_with_prepended_messages(") + .expect("external command delegation boundary") + .0; + + assert!(delegation.contains("Self::resolve_session_primary_agent(")); + assert!(delegation.contains("Some(&primary_runtime_agent_key)")); + assert!(delegation.contains(".update_session_agent_binding(")); + assert!(!delegation.contains(".update_session_agent_type(")); + assert!(delegation + .contains("let _primary_agent_generation_lease = primary_agent_generation_lease;")); + } + + #[test] + fn external_primary_fixed_model_is_only_a_creation_default() { + let fixed = ExternalSubagentModelBinding::Fixed { + model_id: "provider/profile-model".to_string(), + configuration_fingerprint: "fingerprint".to_string(), + }; + + let mut omitted = SessionConfig::default(); + apply_primary_agent_model_default(&mut omitted, Some(&fixed)); + assert_eq!(omitted.model_id.as_deref(), Some("provider/profile-model")); + + let mut automatic = SessionConfig { + model_id: Some("auto".to_string()), + ..SessionConfig::default() + }; + apply_primary_agent_model_default(&mut automatic, Some(&fixed)); + assert_eq!( + automatic.model_id.as_deref(), + Some("provider/profile-model") + ); + + let mut explicit = SessionConfig { + model_id: Some("provider/user-model".to_string()), + ..SessionConfig::default() + }; + apply_primary_agent_model_default(&mut explicit, Some(&fixed)); + assert_eq!(explicit.model_id.as_deref(), Some("provider/user-model")); + + let mut inherited = SessionConfig::default(); + apply_primary_agent_model_default( + &mut inherited, + Some(&ExternalSubagentModelBinding::InheritParent), + ); + assert_eq!(inherited.model_id, None); + } + + #[test] + fn terminal_persisted_turn_is_not_replayed_as_active() { + assert_eq!( + lineage_active_turn_after_transcript( + Some("turn-1".to_string()), + Some("turn-1".to_string()), + Some(&TurnStatus::Completed), + ), + None + ); + assert_eq!( + lineage_active_turn_after_transcript( + Some("turn-1".to_string()), + Some("turn-1".to_string()), + Some(&TurnStatus::InProgress), + ) + .as_deref(), + Some("turn-1") + ); + } + + #[test] + fn idle_session_with_in_flight_execution_is_not_published_as_settled() { + assert!(lineage_session_is_settling_without_active_state(None, 1)); + assert!(!lineage_session_is_settling_without_active_state( + Some("turn-1"), + 1 + )); + assert!(!lineage_session_is_settling_without_active_state(None, 0)); + } + + #[test] + fn lineage_read_barrier_requires_each_turn_to_be_durably_terminal() { + let turn = |turn_id: &str, status| { + let mut turn = DialogTurnData::new( + turn_id.to_string(), + 0, + "session-1".to_string(), + UserMessageData { + id: format!("{turn_id}-user"), + content: "question".to_string(), + timestamp: 1, + metadata: None, + }, + ); + turn.status = status; + turn + }; + let turns = vec![ + turn("turn-settled", TurnStatus::Cancelled), + turn("turn-active", TurnStatus::InProgress), + ]; + + validate_required_lineage_turns_settled(&turns, &["turn-settled".to_string()]) + .expect("terminal turn should satisfy the barrier"); + for required in ["turn-active", "turn-missing"] { + let error = validate_required_lineage_turns_settled(&turns, &[required.to_string()]) + .expect_err("non-terminal or absent turns must keep the read uncertain"); + assert_eq!( + error.kind, + bitfun_runtime_ports::PortErrorKind::OutcomeUnknown + ); + } + } + + #[test] + fn post_admission_cancellation_errors_are_outcome_unknown() { + for source_error in [ + crate::util::errors::BitFunError::Timeout("drain deadline".to_string()), crate::util::errors::BitFunError::Session("state persistence failed".to_string()), ] { let error = @@ -12950,6 +14846,8 @@ mod tests { created_at: std::time::UNIX_EPOCH, last_activity_at: std::time::UNIX_EPOCH, state: bitfun_agent_runtime::session_state::SessionState::Idle, + parent_session_id: None, + is_daemon: false, }); assert_eq!(summary.model_id.as_deref(), Some("fast")); @@ -13360,7 +15258,6 @@ mod tests { Some("/projects/other") ); } - #[test] fn btw_session_memory_mode_requires_both_generation_switches() { assert_eq!( @@ -13401,10 +15298,13 @@ mod tests { session_id: "parent-session".to_string(), dialog_turn_id: "parent-turn".to_string(), tool_call_id: "task-tool".to_string(), + depth: None, + role: None, }, context: HashMap::new(), permission_runtime_ceiling: PermissionRuntimeCeiling::default(), delegation_policy: DelegationPolicy::top_level().spawn_child(), + persistent: true, external_generation_lease: None, }; @@ -14143,6 +16043,7 @@ mod tests { child.relationship = Some(SessionRelationship { kind: Some(SessionRelationshipKind::Subagent), parent_session_id: Some(local_session_id.clone()), + depth: Some(1), parent_request_id: None, parent_dialog_turn_id: Some("turn-1".to_string()), parent_turn_index: Some(1), @@ -14166,6 +16067,7 @@ mod tests { grandchild.relationship = Some(SessionRelationship { kind: Some(SessionRelationshipKind::Subagent), parent_session_id: Some(child_session_id.clone()), + depth: Some(2), parent_request_id: None, parent_dialog_turn_id: Some("child-turn".to_string()), parent_turn_index: Some(0), @@ -14205,199 +16107,864 @@ mod tests { ); assert!(session_manager .persistence_manager() - .load_session_revert_state(&local_storage, &local_session_id) + .load_session_revert_state(&local_storage, &local_session_id) + .await + .expect("load local marker") + .is_none()); + for discarded_session_id in [&child_session_id, &grandchild_session_id] { + assert!(session_manager + .persistence_manager() + .load_session_metadata(&local_storage, discarded_session_id) + .await + .expect("discarded child metadata lookup") + .is_none()); + } + + let maintenance_session_id = format!("compact-revert-{}", uuid::Uuid::new_v4()); + let maintenance_storage = create_staged_two_turn_session( + session_manager.as_ref(), + workspace.path(), + &maintenance_session_id, + ) + .await; + let task = coordinator + .start_manual_compaction_task( + maintenance_session_id.clone(), + Some("maintenance-turn".to_string()), + ) + .await + .expect("start maintenance after staged undo"); + let maintenance_turns = session_manager + .persistence_manager() + .load_session_turns(&maintenance_storage, &maintenance_session_id) + .await + .expect("load maintenance turns"); + assert_eq!( + maintenance_turns + .iter() + .map(|turn| turn.turn_id.as_str()) + .collect::>(), + vec!["turn-0", "maintenance-turn"] + ); + assert!(session_manager + .persistence_manager() + .load_session_revert_state(&maintenance_storage, &maintenance_session_id) + .await + .expect("load maintenance marker") + .is_none()); + coordinator + .cancel_dialog_turn(&maintenance_session_id, &task.turn_id) + .await + .expect("cancel maintenance task"); + let _ = tokio::time::timeout(Duration::from_secs(5), task.completion).await; + } + + #[tokio::test] + async fn mutating_restore_reconciles_a_marker_written_before_workspace_apply() { + let (coordinator, session_manager) = test_persistent_coordinator(); + let workspace = tempfile::tempdir().expect("workspace"); + let file_path = workspace.path().join("src/lib.rs"); + std::fs::create_dir_all(file_path.parent().expect("file parent")) + .expect("create file parent"); + tokio::fs::write(&file_path, "before\n") + .await + .expect("write original file"); + let session_id = format!("restore-revert-{}", uuid::Uuid::new_v4()); + let storage_path = + create_two_turn_session(session_manager.as_ref(), workspace.path(), &session_id).await; + let snapshot_manager = crate::service::snapshot::get_or_create_snapshot_manager( + workspace.path().to_path_buf(), + None, + ) + .await + .expect("snapshot manager"); + let operation_id = snapshot_manager + .record_file_change( + &session_id, + 1, + file_path.clone(), + crate::service::snapshot::types::OperationType::Modify, + "Edit".to_string(), + ) + .await + .expect("record file change"); + tokio::fs::write(&file_path, "after\n") + .await + .expect("write changed file"); + snapshot_manager + .get_snapshot_service() + .read() + .await + .complete_file_modification(&session_id, &operation_id, 1) + .await + .expect("complete file change"); + + let mut state = crate::agentic::session::revert::SessionRevertState { + schema_version: crate::agentic::session::revert::SESSION_REVERT_SCHEMA_VERSION, + boundary_turn: 1, + original_turn_end: 2, + phase: crate::agentic::session::revert::SessionRevertPhase::Applying, + workspace_checkpoint: Vec::new(), + }; + snapshot_manager + .prepare_workspace_revert(&session_id, &mut state) + .await + .expect("prepare staged checkpoint"); + session_manager + .persistence_manager() + .save_session_revert_state(&storage_path, &session_id, &state) + .await + .expect("persist marker before workspace apply"); + + coordinator + .restore_session_from_storage_path(&storage_path, &session_id) + .await + .expect("restore should reconcile staged workspace"); + + assert_eq!( + tokio::fs::read_to_string(&file_path) + .await + .expect("read reconciled file"), + "before\n" + ); + assert_eq!( + session_manager + .get_session(&session_id) + .expect("restored session") + .dialog_turn_ids, + vec!["turn-0"] + ); + let staged = session_manager + .persistence_manager() + .load_session_revert_state(&storage_path, &session_id) + .await + .expect("load staged marker") + .expect("staged marker should remain"); + assert_eq!( + staged.phase, + crate::agentic::session::revert::SessionRevertPhase::Staged + ); + + tokio::fs::write(&file_path, "external edit\n") + .await + .expect("write external edit after successful undo"); + coordinator + .commit_session_revert_before_submission(&session_id) + .await + .expect("commit stable staged boundary"); + assert_eq!( + tokio::fs::read_to_string(&file_path) + .await + .expect("read external edit after commit"), + "external edit\n" + ); + assert!(session_manager + .persistence_manager() + .load_session_revert_state(&storage_path, &session_id) + .await + .expect("load committed marker") + .is_none()); + } + + #[tokio::test] + async fn coordinator_delete_reconciles_an_unfinished_revert_before_cleanup() { + let (coordinator, session_manager) = test_persistent_coordinator(); + let workspace = tempfile::tempdir().expect("workspace"); + let session_id = format!("delete-revert-{}", uuid::Uuid::new_v4()); + let storage_path = + create_two_turn_session(session_manager.as_ref(), workspace.path(), &session_id).await; + let state = crate::agentic::session::revert::SessionRevertState { + schema_version: crate::agentic::session::revert::SESSION_REVERT_SCHEMA_VERSION, + boundary_turn: 1, + original_turn_end: 2, + phase: crate::agentic::session::revert::SessionRevertPhase::Applying, + workspace_checkpoint: Vec::new(), + }; + session_manager + .persistence_manager() + .save_session_revert_state(&storage_path, &session_id, &state) + .await + .expect("pending marker"); + + coordinator + .delete_session(workspace.path(), &session_id) + .await + .expect("coordinator should reconcile before deleting"); + + assert!(session_manager.get_session(&session_id).is_none()); + assert!(session_manager + .persistence_manager() + .load_session_revert_state(&storage_path, &session_id) + .await + .expect("deleted marker load") + .is_none()); + } + + fn hidden_tree_child_metadata( + session_id: &str, + parent_session_id: &str, + workspace: &std::path::Path, + depth: u32, + ) -> SessionMetadata { + let mut metadata = SessionMetadata::new( + session_id.to_string(), + "Tree child".to_string(), + "Explore".to_string(), + "model".to_string(), + ); + metadata.session_kind = SessionKind::Subagent; + metadata.relationship = Some(SessionRelationship { + kind: Some(SessionRelationshipKind::Subagent), + parent_session_id: Some(parent_session_id.to_string()), + depth: Some(depth), + parent_request_id: None, + parent_dialog_turn_id: None, + parent_turn_index: None, + parent_tool_call_id: None, + subagent_type: Some("Explore".to_string()), + continuation_policy: None, + }); + metadata.workspace_path = Some(workspace.to_string_lossy().into_owned()); + metadata + } + + #[tokio::test] + async fn coordinator_delete_session_tree_removes_full_persistent_subtree() { + let (coordinator, session_manager) = test_persistent_coordinator(); + let workspace = tempfile::tempdir().expect("workspace"); + let root_id = format!("tree-root-{}", uuid::Uuid::new_v4()); + let child_id = format!("{root_id}-child"); + let grandchild_id = format!("{root_id}-grandchild"); + let storage_path = + create_two_turn_session(session_manager.as_ref(), workspace.path(), &root_id).await; + + let child = hidden_tree_child_metadata(&child_id, &root_id, workspace.path(), 1); + session_manager + .persistence_manager() + .save_session_metadata(&storage_path, &child) + .await + .expect("child metadata"); + let grandchild = hidden_tree_child_metadata(&grandchild_id, &child_id, workspace.path(), 2); + session_manager + .persistence_manager() + .save_session_metadata(&storage_path, &grandchild) + .await + .expect("grandchild metadata"); + + let deleted = coordinator + .delete_session_tree(workspace.path(), None, None, &root_id) + .await + .expect("cascade delete should succeed"); + + assert_eq!( + deleted, + vec![grandchild_id.clone(), child_id.clone(), root_id.clone()], + "children must be deleted before the root" + ); + for member_id in [&root_id, &child_id, &grandchild_id] { + assert!(session_manager + .persistence_manager() + .load_session_metadata(&storage_path, member_id) + .await + .expect("metadata lookup") + .is_none(), "session {member_id} must be fully removed"); + assert!(session_manager.get_session(member_id).is_none()); + } + } + + #[tokio::test] + async fn coordinator_delete_session_tree_aborts_when_a_member_is_undeletable() { + let (coordinator, session_manager) = test_persistent_coordinator(); + let workspace = tempfile::tempdir().expect("workspace"); + let root_id = format!("tree-root-{}", uuid::Uuid::new_v4()); + let child_id = format!("{root_id}-child"); + let storage_path = + create_two_turn_session(session_manager.as_ref(), workspace.path(), &root_id).await; + + let mut child = hidden_tree_child_metadata(&child_id, &root_id, workspace.path(), 1); + child.is_daemon = true; + session_manager + .persistence_manager() + .save_session_metadata(&storage_path, &child) + .await + .expect("daemon child metadata"); + + let error = coordinator + .delete_session_tree(workspace.path(), None, None, &root_id) + .await + .expect_err("a daemon member must reject the whole cascade"); + assert!( + error.to_string().contains("daemon"), + "unexpected error: {error}" + ); + + // Parent must be left untouched when any member rejects deletion. + assert!(session_manager + .persistence_manager() + .load_session_metadata(&storage_path, &root_id) .await - .expect("load local marker") - .is_none()); - for discarded_session_id in [&child_session_id, &grandchild_session_id] { - assert!(session_manager - .persistence_manager() - .load_session_metadata(&local_storage, discarded_session_id) - .await - .expect("discarded child metadata lookup") - .is_none()); - } + .expect("root metadata lookup") + .is_some()); + assert!(session_manager + .persistence_manager() + .load_session_metadata(&storage_path, &child_id) + .await + .expect("child metadata lookup") + .is_some()); + } - let maintenance_session_id = format!("compact-revert-{}", uuid::Uuid::new_v4()); - let maintenance_storage = create_staged_two_turn_session( - session_manager.as_ref(), - workspace.path(), - &maintenance_session_id, - ) - .await; - let task = coordinator - .start_manual_compaction_task( - maintenance_session_id.clone(), - Some("maintenance-turn".to_string()), + #[tokio::test] + async fn coordinator_delete_session_tree_rejects_a_processing_member() { + let (coordinator, session_manager) = test_persistent_coordinator(); + let workspace = tempfile::tempdir().expect("workspace"); + let root_id = format!("tree-root-{}", uuid::Uuid::new_v4()); + let storage_path = + create_two_turn_session(session_manager.as_ref(), workspace.path(), &root_id).await; + session_manager + .start_dialog_turn( + &root_id, + "agentic".to_string(), + "pending".to_string(), + Some("turn-pending".to_string()), + None, + None, ) .await - .expect("start maintenance after staged undo"); - let maintenance_turns = session_manager + .expect("start pending turn"); + + let error = coordinator + .delete_session_tree(workspace.path(), None, None, &root_id) + .await + .expect_err("a running turn must reject deletion"); + assert!( + error.to_string().contains("running turn"), + "unexpected error: {error}" + ); + assert!(session_manager .persistence_manager() - .load_session_turns(&maintenance_storage, &maintenance_session_id) + .load_session_metadata(&storage_path, &root_id) .await - .expect("load maintenance turns"); - assert_eq!( - maintenance_turns - .iter() - .map(|turn| turn.turn_id.as_str()) - .collect::>(), - vec!["turn-0", "maintenance-turn"] + .expect("root metadata lookup") + .is_some()); + } + + // R-FIX-3 root-cause verification: the single-session delete path must + // cancel a running turn first and wait for the state to converge back to + // Idle, so a cancelled turn cannot block deletion. After the cancel the + // session is deleted normally (the processing guard only rejects when the + // state fails to converge, which the bounded poll then reports as a + // deletion error rather than a hang). + #[tokio::test] + async fn coordinator_delete_session_cancels_then_deletes_a_processing_session() { + let (coordinator, session_manager) = test_persistent_coordinator(); + let workspace = tempfile::tempdir().expect("workspace"); + let session_id = format!("delete-processing-{}", uuid::Uuid::new_v4()); + let storage_path = + create_two_turn_session(session_manager.as_ref(), workspace.path(), &session_id).await; + session_manager + .start_dialog_turn( + &session_id, + "agentic".to_string(), + "pending".to_string(), + Some("turn-pending".to_string()), + None, + None, + ) + .await + .expect("start pending turn"); + assert!( + matches!( + session_manager.get_session(&session_id).expect("session").state, + SessionState::Processing { .. } + ), + "precondition: session must be Processing" ); + + coordinator + .delete_session(workspace.path(), &session_id) + .await + .expect("a running turn must be cancelled first, then the session deleted"); + + // The cancelled session must be fully gone. + assert!(session_manager.get_session(&session_id).is_none()); assert!(session_manager .persistence_manager() - .load_session_revert_state(&maintenance_storage, &maintenance_session_id) + .load_session_metadata(&storage_path, &session_id) .await - .expect("load maintenance marker") + .expect("metadata lookup") .is_none()); + } + + // R-FIX-1 root-cause verification: a re-created session id must not + // inherit the deleted marker from its previous incarnation, otherwise its + // turn finalization would be skipped and its data never persisted. + #[tokio::test] + async fn deleted_session_marker_is_cleared_when_session_id_is_recreated() { + let (coordinator, session_manager) = test_persistent_coordinator(); + let workspace = tempfile::tempdir().expect("workspace"); + let session_id = format!("recreate-marker-{}", uuid::Uuid::new_v4()); + let storage_path = + create_two_turn_session(session_manager.as_ref(), workspace.path(), &session_id).await; + coordinator - .cancel_dialog_turn(&maintenance_session_id, &task.turn_id) + .delete_session(workspace.path(), &session_id) .await - .expect("cancel maintenance task"); - let _ = tokio::time::timeout(Duration::from_secs(5), task.completion).await; + .expect("delete session"); + assert!( + session_manager.is_session_deleted(&session_id), + "precondition: deleted marker must be set after deletion" + ); + + // Re-create the same session id. + session_manager + .create_session_with_id_and_details( + Some(session_id.clone()), + "Recreated".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().into_owned()), + ..Default::default() + }, + None, + SessionKind::Standard, + ) + .await + .expect("re-create session with same id"); + assert!( + !session_manager.is_session_deleted(&session_id), + "deleted marker must be cleared on re-creation" + ); + + // A tail write for the re-created session must be persisted normally. + let workspace_path_str = workspace.path().to_string_lossy().into_owned(); + ConversationCoordinator::finalize_persisted_turn_in_workspace_if_needed( + session_manager.as_ref(), + &session_id, + "turn-2", + 2, + "agentic", + "recreated input", + Some(&workspace_path_str), + Some(&storage_path), + Some(crate::service::session::TurnStatus::Completed), + None, + ) + .await; + assert!( + session_manager + .persistence_manager() + .load_dialog_turn(&storage_path, &session_id, 2) + .await + .expect("dialog turn lookup") + .is_some(), + "re-created session tail write must be persisted (finalization must not be skipped)" + ); } + // R-31-2 root-cause verification: an in-flight turn finalization tail + // write that arrives after the session was deleted must NOT recreate + // on-disk session metadata (ghost "Recovered Session") nor persist any + // turn. The control scenario proves a live session still finalizes + // normally through the same entry point. #[tokio::test] - async fn mutating_restore_reconciles_a_marker_written_before_workspace_apply() { + async fn finalize_skips_recreating_metadata_for_deleted_session() { let (coordinator, session_manager) = test_persistent_coordinator(); let workspace = tempfile::tempdir().expect("workspace"); - let file_path = workspace.path().join("src/lib.rs"); - std::fs::create_dir_all(file_path.parent().expect("file parent")) - .expect("create file parent"); - tokio::fs::write(&file_path, "before\n") + + // Deleted-session scenario: delete first, then let the late tail + // write arrive exactly as a spawned finalization task would. + let deleted_id = format!("finalize-deleted-{}", uuid::Uuid::new_v4()); + let deleted_storage = + create_two_turn_session(session_manager.as_ref(), workspace.path(), &deleted_id).await; + coordinator + .delete_session(workspace.path(), &deleted_id) .await - .expect("write original file"); - let session_id = format!("restore-revert-{}", uuid::Uuid::new_v4()); + .expect("delete session before tail write"); + assert!( + session_manager.is_session_deleted(&deleted_id), + "precondition: session must be marked deleted" + ); + let workspace_path_str = workspace.path().to_string_lossy().into_owned(); + ConversationCoordinator::finalize_persisted_turn_in_workspace_if_needed( + session_manager.as_ref(), + &deleted_id, + "turn-2", + 2, + "agentic", + "late input", + Some(&workspace_path_str), + Some(&deleted_storage), + Some(crate::service::session::TurnStatus::Completed), + None, + ) + .await; + assert!( + session_manager + .persistence_manager() + .load_session_metadata(&deleted_storage, &deleted_id) + .await + .expect("metadata lookup") + .is_none(), + "deleted session must not be recreated as a ghost 'Recovered Session'" + ); + assert!( + session_manager + .persistence_manager() + .load_dialog_turn(&deleted_storage, &deleted_id, 2) + .await + .expect("dialog turn lookup") + .is_none(), + "no turn may be persisted for a deleted session" + ); + + // Control scenario: the same entry point persists the tail write for + // a live (never-deleted) session. + let live_id = format!("finalize-live-{}", uuid::Uuid::new_v4()); + let live_storage = + create_two_turn_session(session_manager.as_ref(), workspace.path(), &live_id).await; + ConversationCoordinator::finalize_persisted_turn_in_workspace_if_needed( + session_manager.as_ref(), + &live_id, + "turn-2", + 2, + "agentic", + "live input", + Some(&workspace_path_str), + Some(&live_storage), + Some(crate::service::session::TurnStatus::Completed), + None, + ) + .await; + assert!( + session_manager + .persistence_manager() + .load_session_metadata(&live_storage, &live_id) + .await + .expect("metadata lookup") + .is_some(), + "live session metadata must remain" + ); + assert!( + session_manager + .persistence_manager() + .load_dialog_turn(&live_storage, &live_id, 2) + .await + .expect("dialog turn lookup") + .is_some(), + "live session tail write must be persisted" + ); + } + + // R-FIX-2 root-cause verification (deletion-window): the deleted marker is + // set BEFORE the fallible deletion stage, so a finalization tail write that + // arrives while the deletion is in progress (on-disk storage already gone, + // in-memory session still present) is skipped instead of recreating the + // ghost metadata. + #[tokio::test] + async fn finalize_skips_tail_write_during_in_progress_deletion() { + let (_coordinator, session_manager) = test_persistent_coordinator(); + let workspace = tempfile::tempdir().expect("workspace"); + let session_id = format!("finalize-inprogress-{}", uuid::Uuid::new_v4()); let storage_path = create_two_turn_session(session_manager.as_ref(), workspace.path(), &session_id).await; - let snapshot_manager = crate::service::snapshot::get_or_create_snapshot_manager( - workspace.path().to_path_buf(), + + // Simulate the mid-deletion window: persisted storage removed, session + // still loaded in memory, deleted marker already set (R-FIX-2 sets it + // before the persistence delete stage). + session_manager + .persistence_manager() + .delete_session(&storage_path, &session_id) + .await + .expect("remove persisted session storage"); + assert!(session_manager.get_session(&session_id).is_some()); + session_manager.mark_session_deleted(&session_id); + + let workspace_path_str = workspace.path().to_string_lossy().into_owned(); + ConversationCoordinator::finalize_persisted_turn_in_workspace_if_needed( + session_manager.as_ref(), + &session_id, + "turn-2", + 2, + "agentic", + "mid-delete input", + Some(&workspace_path_str), + Some(&storage_path), + Some(crate::service::session::TurnStatus::Completed), None, ) - .await - .expect("snapshot manager"); - let operation_id = snapshot_manager - .record_file_change( + .await; + assert!( + session_manager + .persistence_manager() + .load_session_metadata(&storage_path, &session_id) + .await + .expect("metadata lookup") + .is_none(), + "in-progress deletion must not be resurrected by a mid-window tail write" + ); + assert!( + session_manager + .persistence_manager() + .load_dialog_turn(&storage_path, &session_id, 2) + .await + .expect("dialog turn lookup") + .is_none(), + "no turn may be persisted during the deletion window" + ); + } + + // R-FIX-2 root-cause verification (rollback): when the deletion fails after + // the early marker was set, the marker must be rolled back so the session + // stays fully usable and later finalization persists normally. + #[tokio::test] + async fn failed_deletion_rolls_back_deleted_marker() { + let (_coordinator, session_manager) = test_persistent_coordinator(); + let workspace = tempfile::tempdir().expect("workspace"); + let session_id = format!("delete-fail-rollback-{}", uuid::Uuid::new_v4()); + let storage_path = + create_two_turn_session(session_manager.as_ref(), workspace.path(), &session_id).await; + // An unfinished (non-staged) revert transition makes the persistence + // delete stage fail after the marker has been set. + session_manager + .persistence_manager() + .save_session_revert_state( + &storage_path, &session_id, - 1, - file_path.clone(), - crate::service::snapshot::types::OperationType::Modify, - "Edit".to_string(), + &crate::agentic::session::revert::SessionRevertState { + schema_version: crate::agentic::session::revert::SESSION_REVERT_SCHEMA_VERSION, + boundary_turn: 1, + original_turn_end: 2, + phase: crate::agentic::session::revert::SessionRevertPhase::Applying, + workspace_checkpoint: Vec::new(), + }, ) .await - .expect("record file change"); - tokio::fs::write(&file_path, "after\n") - .await - .expect("write changed file"); - snapshot_manager - .get_snapshot_service() - .read() - .await - .complete_file_modification(&session_id, &operation_id, 1) + .expect("persist unfinished revert transition"); + + let error = session_manager + .delete_session_locked(workspace.path(), &session_id) .await - .expect("complete file change"); + .expect_err("deletion must fail on an unfinished revert transition"); + assert!( + !error.to_string().is_empty(), + "expected a deletion error" + ); + assert!( + !session_manager.is_session_deleted(&session_id), + "failed deletion must roll back the deleted marker" + ); - let mut state = crate::agentic::session::revert::SessionRevertState { - schema_version: crate::agentic::session::revert::SESSION_REVERT_SCHEMA_VERSION, - boundary_turn: 1, - original_turn_end: 2, - phase: crate::agentic::session::revert::SessionRevertPhase::Applying, - workspace_checkpoint: Vec::new(), - }; - snapshot_manager - .prepare_workspace_revert(&session_id, &mut state) + // The session stays fully usable: clear the revert marker (which would + // block any turn write by its own gate) and verify a later tail write + // persists normally through the same finalization entry point. + session_manager + .persistence_manager() + .delete_session_revert_state(&storage_path, &session_id) + .await + .expect("clear revert transition after failed delete"); + let workspace_path_str = workspace.path().to_string_lossy().into_owned(); + ConversationCoordinator::finalize_persisted_turn_in_workspace_if_needed( + session_manager.as_ref(), + &session_id, + "turn-2", + 2, + "agentic", + "after failed delete", + Some(&workspace_path_str), + Some(&storage_path), + Some(crate::service::session::TurnStatus::Completed), + None, + ) + .await; + assert!( + session_manager + .persistence_manager() + .load_dialog_turn(&storage_path, &session_id, 2) + .await + .expect("dialog turn lookup") + .is_some(), + "finalization must persist normally after a rolled-back deletion" + ); + } + + // P2-A root-cause verification: a loaded session whose on-disk storage was + // removed externally (no explicit delete marker) must also be skipped by + // turn finalization, otherwise the tail write resurrects the storage that + // the external removal deleted. + #[tokio::test] + async fn finalize_skips_tail_write_for_externally_disk_removed_session() { + let (_coordinator, session_manager) = test_persistent_coordinator(); + let workspace = tempfile::tempdir().expect("workspace"); + let session_id = format!("finalize-disk-removed-{}", uuid::Uuid::new_v4()); + let storage_path = + create_two_turn_session(session_manager.as_ref(), workspace.path(), &session_id).await; + // A processing session is kept loaded by the reconcile while its + // storage is registered as externally removed. + session_manager + .start_dialog_turn( + &session_id, + "agentic".to_string(), + "pending".to_string(), + Some("turn-pending".to_string()), + None, + None, + ) .await - .expect("prepare staged checkpoint"); + .expect("start pending turn"); + // External removal: delete the on-disk storage directly (no lifecycle + // marker), then reconcile to register the disk-removed id. session_manager .persistence_manager() - .save_session_revert_state(&storage_path, &session_id, &state) + .delete_session(&storage_path, &session_id) .await - .expect("persist marker before workspace apply"); - - coordinator - .restore_session_from_storage_path(&storage_path, &session_id) + .expect("externally remove session storage"); + session_manager + .reconcile_loaded_sessions_with_disk(&storage_path) .await - .expect("restore should reconcile staged workspace"); + .expect("reconcile loaded sessions with disk"); + assert!( + session_manager.is_session_disk_removed(&session_id), + "precondition: externally removed marker must be set" + ); + assert!( + session_manager.get_session(&session_id).is_some(), + "precondition: processing session stays loaded" + ); - assert_eq!( - tokio::fs::read_to_string(&file_path) + // The tail write must not recreate the removed storage. + let workspace_path_str = workspace.path().to_string_lossy().into_owned(); + ConversationCoordinator::finalize_persisted_turn_in_workspace_if_needed( + session_manager.as_ref(), + &session_id, + "turn-2", + 2, + "agentic", + "late input", + Some(&workspace_path_str), + Some(&storage_path), + Some(crate::service::session::TurnStatus::Completed), + None, + ) + .await; + assert!( + session_manager + .persistence_manager() + .load_session_metadata(&storage_path, &session_id) .await - .expect("read reconciled file"), - "before\n" + .expect("metadata lookup") + .is_none(), + "externally removed session must not be resurrected by a tail write" ); - assert_eq!( + assert!( session_manager - .get_session(&session_id) - .expect("restored session") - .dialog_turn_ids, - vec!["turn-0"] - ); - let staged = session_manager - .persistence_manager() - .load_session_revert_state(&storage_path, &session_id) - .await - .expect("load staged marker") - .expect("staged marker should remain"); - assert_eq!( - staged.phase, - crate::agentic::session::revert::SessionRevertPhase::Staged - ); - - tokio::fs::write(&file_path, "external edit\n") - .await - .expect("write external edit after successful undo"); - coordinator - .commit_session_revert_before_submission(&session_id) - .await - .expect("commit stable staged boundary"); - assert_eq!( - tokio::fs::read_to_string(&file_path) + .persistence_manager() + .load_dialog_turn(&storage_path, &session_id, 2) .await - .expect("read external edit after commit"), - "external edit\n" + .expect("dialog turn lookup") + .is_none(), + "no turn may be persisted for an externally removed session" ); - assert!(session_manager - .persistence_manager() - .load_session_revert_state(&storage_path, &session_id) - .await - .expect("load committed marker") - .is_none()); } + // R-31-3 root-cause verification: cascade deletion must discover a loaded + // durable child even when its persisted relationship edge is broken/missing + // (in-memory creator marker "session-" is the only link). The + // persisted-relationship cascade is covered by + // `coordinator_delete_session_tree_removes_full_persistent_subtree`. #[tokio::test] - async fn coordinator_delete_reconciles_an_unfinished_revert_before_cleanup() { + async fn coordinator_delete_session_tree_removes_broken_relationship_child() { let (coordinator, session_manager) = test_persistent_coordinator(); let workspace = tempfile::tempdir().expect("workspace"); - let session_id = format!("delete-revert-{}", uuid::Uuid::new_v4()); + let root_id = format!("tree-broken-root-{}", uuid::Uuid::new_v4()); + let child_id = format!("{root_id}-child"); let storage_path = - create_two_turn_session(session_manager.as_ref(), workspace.path(), &session_id).await; - let state = crate::agentic::session::revert::SessionRevertState { - schema_version: crate::agentic::session::revert::SESSION_REVERT_SCHEMA_VERSION, - boundary_turn: 1, - original_turn_end: 2, - phase: crate::agentic::session::revert::SessionRevertPhase::Applying, - workspace_checkpoint: Vec::new(), - }; + create_two_turn_session(session_manager.as_ref(), workspace.path(), &root_id).await; + + // Loaded durable child whose persisted relationship is broken but whose + // in-memory creator marker still links it to the root. Creation + // persists the relationship derived from the creator marker, so the + // broken-edge precondition is produced by rewriting the on-disk + // metadata without the relationship (simulating a corrupted/missing + // relationship record) while the loaded session keeps its marker. session_manager + .create_session_with_id_and_details( + Some(child_id.clone()), + "Broken child".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().into_owned()), + ..Default::default() + }, + Some(format!("session-{root_id}")), + SessionKind::Subagent, + ) + .await + .expect("create child session"); + let mut broken_child_metadata = session_manager .persistence_manager() - .save_session_revert_state(&storage_path, &session_id, &state) + .load_session_metadata(&storage_path, &child_id) .await - .expect("pending marker"); - - coordinator - .delete_session(workspace.path(), &session_id) + .expect("child metadata lookup") + .expect("child metadata exists"); + assert!( + broken_child_metadata.relationship.is_some(), + "precondition: fresh child metadata must carry the derived relationship" + ); + broken_child_metadata.relationship = None; + session_manager + .persistence_manager() + .save_session_metadata(&storage_path, &broken_child_metadata) .await - .expect("coordinator should reconcile before deleting"); + .expect("rewrite child metadata without relationship"); + assert!( + session_manager + .persistence_manager() + .load_session_metadata(&storage_path, &child_id) + .await + .expect("child metadata lookup") + .expect("child metadata exists") + .relationship + .is_none(), + "precondition: persisted relationship edge must be missing" + ); + assert!( + session_manager.get_session(&child_id).is_some(), + "precondition: child must be loaded in memory" + ); - assert!(session_manager.get_session(&session_id).is_none()); + let deleted = coordinator + .delete_session_tree(workspace.path(), None, None, &root_id) + .await + .expect("cascade delete should discover the in-memory child"); + assert!( + deleted.contains(&child_id), + "in-memory child with broken persisted relationship must be cascade-deleted, got: {deleted:?}" + ); + assert!(deleted.contains(&root_id), "root must be deleted last"); + assert!(session_manager.get_session(&child_id).is_none()); + assert!(session_manager.get_session(&root_id).is_none()); assert!(session_manager .persistence_manager() - .load_session_revert_state(&storage_path, &session_id) + .load_session_metadata(&storage_path, &child_id) .await - .expect("deleted marker load") + .expect("child metadata lookup") .is_none()); } + #[tokio::test] + async fn coordinator_delete_session_tree_returns_not_found_for_unknown_session() { + let (coordinator, _session_manager) = test_persistent_coordinator(); + let workspace = tempfile::tempdir().expect("workspace"); + let error = coordinator + .delete_session_tree(workspace.path(), None, None, "missing-session") + .await + .expect_err("unknown session must be rejected"); + assert!( + error.to_string().contains("not found"), + "unexpected error: {error}" + ); + } + #[tokio::test] async fn transcript_read_waits_for_session_history_mutation_before_loading_turns() { let (coordinator, session_manager) = test_persistent_coordinator(); @@ -15225,7 +17792,7 @@ mod tests { assert_eq!(other_parent_agent, "a1"); assert_eq!( coordinator - .resolve_agent_id("parent-1", "a2") + .resolve_agent_id("parent-1", "a2", false) .await .expect("resolve agent id"), "subagent-session-2" @@ -15264,7 +17831,7 @@ mod tests { assert_eq!(custom.bg_task_id, "reviewer_bg1"); assert_eq!( coordinator - .resolve_agent_id("parent-1", "reviewer") + .resolve_agent_id("parent-1", "reviewer", false) .await .expect("resolve caller-named agent"), "reviewer-session" @@ -15496,6 +18063,7 @@ mod tests { None, &logical_type, SessionContinuationPolicy::FreshOnly, + None, ); assert_eq!(relationship.subagent_type.as_deref(), Some("Reviewer")); assert_eq!( @@ -15553,6 +18121,7 @@ mod tests { parent_tool_call_id: None, subagent_type: None, continuation_policy: None, + depth: None, }; assert!(super::session_lineage_matches_parent( @@ -15582,6 +18151,7 @@ mod tests { parent_tool_call_id: Some("task-tool-call".to_string()), subagent_type: Some("Explore".to_string()), continuation_policy: None, + depth: None, }; assert_eq!( @@ -15611,6 +18181,7 @@ mod tests { parent_tool_call_id: Some("task-tool-call".to_string()), subagent_type: Some("Explore".to_string()), continuation_policy: None, + depth: None, }; assert!(super::subagent_parent_info_from_relationship(Some(&relationship)).is_none()); @@ -16024,6 +18595,7 @@ mod tests { created_at: index as i64, updated_at: index as i64, auto_continuation_count: 0, + reference_files: Vec::new(), }; let mut metadata = SessionMetadata::new( session_id.clone(), @@ -16099,6 +18671,7 @@ mod tests { created_at: 0, updated_at: 0, auto_continuation_count: 0, + reference_files: Vec::new(), }; let mut loaded_metadata = SessionMetadata::new( loaded_session_id.clone(), @@ -16204,6 +18777,7 @@ mod tests { workspace_path: logical_workspace_path.clone(), objective: "Keep remote ownership structured".to_string(), token_budget: None, + reference_files: None, }, ) .await @@ -16677,10 +19251,13 @@ mod tests { session_id: parent_session.session_id, dialog_turn_id: "parent-turn".to_string(), tool_call_id: "task-tool".to_string(), + depth: None, + role: None, }, context: HashMap::new(), permission_runtime_ceiling: PermissionRuntimeCeiling::default(), delegation_policy: DelegationPolicy::top_level().spawn_child(), + persistent: true, external_generation_lease: None, }) .await @@ -16705,7 +19282,7 @@ mod tests { } #[tokio::test] - async fn fresh_subagent_inherits_transient_parent_persistence_boundary() { + async fn fresh_subagent_rejects_transient_parent_fork() { let (coordinator, session_manager) = test_coordinator(); let workspace_path = std::env::temp_dir().join(format!( "bitfun-fresh-subagent-transient-test-{}", @@ -16736,6 +19313,74 @@ mod tests { .await .expect("transient parent should be created"); + let err = coordinator + .resolve_hidden_subagent_execution_request(SubagentExecutionRequest { + task_description: "Inspect the workspace".to_string(), + context_mode: SubagentContextMode::Fresh, + target_session_id: None, + subagent_type: Some("Explore".to_string()), + logical_subagent_type: None, + continuation_policy: SessionContinuationPolicy::Reusable, + model_binding_policy: SessionModelBindingPolicy::Mutable, + workspace_path: Some(workspace.clone()), + model_id: Some("primary".to_string()), + inherit_parent_model: false, + subagent_parent_info: SubagentParentInfo { + session_id: parent_session.session_id.clone(), + dialog_turn_id: "parent-turn".to_string(), + tool_call_id: "task-tool".to_string(), + depth: None, + role: None, + }, + context: HashMap::new(), + permission_runtime_ceiling: PermissionRuntimeCeiling::default(), + delegation_policy: DelegationPolicy::top_level().spawn_child(), + persistent: true, + external_generation_lease: None, + }) + .await + .expect_err("a transient parent must not spawn subagent sessions"); + + assert!( + err.to_string() + .contains("transient sessions cannot spawn subagent sessions"), + "unexpected error: {err}" + ); + } + + #[tokio::test] + async fn test_prepare_subagent_execution_hidden_target_session_ok() { + let (coordinator, session_manager) = test_coordinator(); + let workspace_path = std::env::temp_dir().join(format!( + "bitfun-hidden-target-test-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&workspace_path).expect("workspace dir should exist"); + struct TempWorkspaceGuard(std::path::PathBuf); + impl Drop for TempWorkspaceGuard { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + let _workspace_guard = TempWorkspaceGuard(workspace_path.clone()); + let workspace = workspace_path.to_string_lossy().into_owned(); + + let parent_session = session_manager + .create_session_with_id_and_details( + None, + "Persistent parent".to_string(), + "agentic".to_string(), + SessionConfig { + model_id: Some("primary".to_string()), + workspace_path: Some(workspace.clone()), + ..Default::default() + }, + None, + SessionKind::Standard, + ) + .await + .expect("persistent parent should be created"); + let resolved = coordinator .resolve_hidden_subagent_execution_request(SubagentExecutionRequest { task_description: "Inspect the workspace".to_string(), @@ -16752,27 +19397,22 @@ mod tests { session_id: parent_session.session_id.clone(), dialog_turn_id: "parent-turn".to_string(), tool_call_id: "task-tool".to_string(), + depth: None, + role: None, }, context: HashMap::new(), permission_runtime_ceiling: PermissionRuntimeCeiling::default(), delegation_policy: DelegationPolicy::top_level().spawn_child(), + persistent: true, external_generation_lease: None, }) .await .expect("fresh subagent request should resolve"); - assert!(resolved.transient); - assert!(!resolved - .runtime_tool_restrictions - .is_tool_allowed("SessionControl")); - assert!(!resolved - .runtime_tool_restrictions - .is_tool_allowed("SessionMessage")); - let prepared = coordinator .prepare_hidden_subagent_execution_request(resolved) .await - .expect("transient child should prepare"); + .expect("subagent child should prepare"); let child_session_id = prepared .target_session_id() .expect("prepared child Session id") @@ -16793,10 +19433,10 @@ mod tests { coordinator .cleanup_subagent_resources(&child_session_id) .await - .expect("transient child cleanup should succeed"); + .expect("subagent child cleanup should succeed"); assert!( session_manager.get_session(&child_session_id).is_some(), - "a reusable transient Subagent must remain available for send_input until its parent is discarded" + "a reusable subagent session must remain available for send_input until its parent is deleted" ); let fresh_only = coordinator @@ -16815,18 +19455,21 @@ mod tests { session_id: parent_session.session_id, dialog_turn_id: "parent-turn-2".to_string(), tool_call_id: "task-tool-2".to_string(), + depth: None, + role: None, }, context: HashMap::new(), permission_runtime_ceiling: PermissionRuntimeCeiling::default(), delegation_policy: DelegationPolicy::top_level().spawn_child(), + persistent: true, external_generation_lease: None, }) .await - .expect("fresh-only transient child should resolve"); + .expect("fresh-only subagent child should resolve"); let fresh_only = coordinator .prepare_hidden_subagent_execution_request(fresh_only) .await - .expect("fresh-only transient child should prepare"); + .expect("fresh-only subagent child should prepare"); let fresh_only_session_id = fresh_only .target_session_id() .expect("fresh-only prepared child Session id") @@ -16835,12 +19478,117 @@ mod tests { coordinator .cleanup_subagent_resources(&fresh_only_session_id) .await - .expect("fresh-only transient child cleanup should succeed"); + .expect("fresh-only subagent child cleanup should succeed"); assert!( session_manager .get_session(&fresh_only_session_id) - .is_none(), - "a fresh-only transient Subagent should be released after terminal cleanup" + .is_some(), + "a persistent fresh-only subagent session survives cleanup (release applies to transient sessions only)" + ); + } + + #[tokio::test] + async fn scope_drop_discards_transient_subagent_family() { + use super::SubagentExecutionScope; + use tokio_util::sync::CancellationToken; + let (coordinator, session_manager) = test_coordinator(); + let workspace_path = std::env::temp_dir().join(format!( + "bitfun-scope-drop-transient-test-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&workspace_path).expect("workspace dir should exist"); + struct TempWorkspaceGuard(std::path::PathBuf); + impl Drop for TempWorkspaceGuard { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + let _workspace_guard = TempWorkspaceGuard(workspace_path.clone()); + let workspace = workspace_path.to_string_lossy().into_owned(); + + let parent_session = session_manager + .create_session_with_id_and_details( + None, + "Persistent parent".to_string(), + "agentic".to_string(), + SessionConfig { + model_id: Some("primary".to_string()), + workspace_path: Some(workspace.clone()), + ..Default::default() + }, + None, + SessionKind::Standard, + ) + .await + .expect("persistent parent should be created"); + let parent_session_id = parent_session.session_id.clone(); + let child_session = session_manager + .create_transient_session_with_id_and_details( + None, + "Scope child".to_string(), + "agentic".to_string(), + SessionConfig { + model_id: Some("primary".to_string()), + workspace_path: Some(workspace.clone()), + ..Default::default() + }, + Some(format!("session-{parent_session_id}")), + SessionKind::Subagent, + ) + .await + .expect("transient child should be created"); + let child_session_id = child_session.session_id.clone(); + let grandchild_session = session_manager + .create_transient_session_with_id_and_details( + None, + "Scope grandchild".to_string(), + "agentic".to_string(), + SessionConfig { + model_id: Some("primary".to_string()), + workspace_path: Some(workspace), + ..Default::default() + }, + Some(format!("session-{child_session_id}")), + SessionKind::EphemeralSubagent, + ) + .await + .expect("transient grandchild should be created"); + let grandchild_session_id = grandchild_session.session_id.clone(); + + let cancel_token = CancellationToken::new(); + let abort_handle = tokio::spawn(async {}).abort_handle(); + + let scope = SubagentExecutionScope { + execution_engine: coordinator.execution_engine.clone(), + tool_pipeline: coordinator.tool_pipeline.clone(), + session_manager: session_manager.clone(), + active_subagent_executions: coordinator.active_subagent_executions.clone(), + subagent_session_id: child_session_id.clone(), + subagent_dialog_turn_id: "scope-drop-turn".to_string(), + subagent_cancel_token: cancel_token, + abort_handle, + disarmed: false, + }; + drop(scope); + + let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(2); + while tokio::time::Instant::now() < deadline { + if session_manager.get_session(&child_session_id).is_none() { + break; + } + tokio::time::sleep(tokio::time::Duration::from_millis(20)).await; + } + assert!( + session_manager.get_session(&child_session_id).is_none(), + "transient child must be discarded when its execution scope drops" + ); + assert!( + session_manager.get_session(&grandchild_session_id).is_none(), + "transient grandchild must be discarded when its execution scope drops" + ); + assert!( + session_manager.get_session(&parent_session_id).is_some(), + "the persistent parent must survive scope drop" ); } @@ -16904,6 +19652,8 @@ mod tests { session_id: parent_session.session_id.clone(), dialog_turn_id: "parent-turn".to_string(), tool_call_id: "task-tool".to_string(), + depth: None, + role: None, }, context: HashMap::from([( AUTO_APPROVE_ASK_CONTEXT_KEY.to_string(), @@ -16915,6 +19665,7 @@ mod tests { ]) .expect("test ceiling should be valid"), delegation_policy: DelegationPolicy::top_level().spawn_child(), + persistent: true, external_generation_lease: None, }; @@ -16968,10 +19719,13 @@ mod tests { session_id: parent_session.session_id.clone(), dialog_turn_id: "parent-turn".to_string(), tool_call_id: "task-tool".to_string(), + depth: None, + role: None, }, context: HashMap::new(), permission_runtime_ceiling: PermissionRuntimeCeiling::default(), delegation_policy: DelegationPolicy::top_level().spawn_child(), + persistent: true, external_generation_lease: None, }; @@ -17040,10 +19794,13 @@ mod tests { session_id: parent_session.session_id.clone(), dialog_turn_id: "parent-turn".to_string(), tool_call_id: "task-tool".to_string(), + depth: None, + role: None, }, context: HashMap::new(), permission_runtime_ceiling: PermissionRuntimeCeiling::default(), delegation_policy: DelegationPolicy::top_level().spawn_child(), + persistent: true, external_generation_lease: None, }; @@ -17083,10 +19840,13 @@ mod tests { session_id: parent_session.session_id.clone(), dialog_turn_id: "parent-turn".to_string(), tool_call_id: "task-tool".to_string(), + depth: None, + role: None, }, context: HashMap::new(), permission_runtime_ceiling: PermissionRuntimeCeiling::default(), delegation_policy: DelegationPolicy::top_level().spawn_child(), + persistent: true, external_generation_lease: None, }; @@ -17521,4 +20281,41 @@ mod tests { .is_err() ); } + + #[test] + fn session_tree_edge_registration_is_idempotent() { + use bitfun_services_core::session::tree::SessionTreeManager; + + let tree = SessionTreeManager::new(bitfun_core_types::session_tree::MAX_TREE_DEPTH); + // A persistent subagent re-executes the same registration repeatedly; + // only the first call must create the edge (COORD-14). + assert!(register_session_tree_edge_idempotent(&tree, "parent", "child", 1)); + assert!(!register_session_tree_edge_idempotent(&tree, "parent", "child", 1)); + assert_eq!(tree.get_children("parent"), vec!["child".to_string()]); + assert_eq!(tree.get_parent("child"), Some("parent".to_string())); + + // A different parent still produces a new edge. + assert!(register_session_tree_edge_idempotent(&tree, "other-parent", "child", 1)); + assert_eq!(tree.get_children("other-parent"), vec!["child".to_string()]); + } + + #[test] + fn background_subagent_follow_up_returns_minimal_metadata_only() { + // P-19 防回退(B/C 代表路径):后台 subagent 完成主会话仅收极简元信息 + // (session_id + 身份 + 已回复 + use SessionHistory 指引),不含全量 + // output_text / 全文;全量由 SubagentTurnCompleted 事件与子会话 turn + // 落盘承载。 + let full_output = format!("SUBAGENT_FULL_OUTPUT_MARKER_{}", "x".repeat(4096)); + let notice = background_subagent_follow_up_notice("flow-session-9", "acp:claude"); + assert!(notice.contains("flow-session-9")); + assert!(notice.contains("acp:claude")); + assert!(notice.contains("has replied")); + assert!(notice.contains("use SessionHistory")); + assert!(!notice.contains(&full_output)); + assert!(!notice.contains("SUBAGENT_FULL_OUTPUT_MARKER_")); + // 身份为空时回退 "agent",与 scheduler background_result_follow_up 一致。 + let fallback = background_subagent_follow_up_notice("flow-session-8", ""); + assert!(fallback.contains("flow-session-8")); + assert!(fallback.contains("(agent)")); + } } diff --git a/src/crates/assembly/core/src/agentic/coordination/mod.rs b/src/crates/assembly/core/src/agentic/coordination/mod.rs index aaba17c2b1..bcc515139a 100644 --- a/src/crates/assembly/core/src/agentic/coordination/mod.rs +++ b/src/crates/assembly/core/src/agentic/coordination/mod.rs @@ -4,7 +4,11 @@ mod background_outcomes; mod coordination_store; +pub(crate) mod plan_todo_binding; pub mod coordinator; +mod review_propagation; + +pub use review_propagation::ReviewPropagationManager; pub mod scheduler; pub mod state_manager; pub mod turn_outcome; diff --git a/src/crates/assembly/core/src/agentic/coordination/plan_todo_binding.rs b/src/crates/assembly/core/src/agentic/coordination/plan_todo_binding.rs new file mode 100644 index 0000000000..2376c8f6a9 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/coordination/plan_todo_binding.rs @@ -0,0 +1,203 @@ +//! Plan-todo binding between agent sessions and plan todos. +//! +//! `SessionMessage` can bind a dispatched session to a plan todo by carrying +//! `planFile` / `todoId` in the forwarded turn metadata (see +//! `session_message_tool.rs`). The scheduler reads that binding and issues +//! best-effort PlanUpdate status changes: +//! - when a bound execution turn starts -> todo `in_progress` +//! - when a bound execution turn finishes OK -> todo `completed` +//! +//! Every failure is logged and swallowed: the binding layer must never break, +//! delay, or block a dialog turn (best-effort semantics). Callers gate on +//! `reply_route.is_some()` so reply turns (which inherit the metadata) never +//! re-trigger the hooks. + +use crate::agentic::tools::implementations::plan_update_tool::{ + apply_todo_status_update, resolve_plan_path_for_backend, +}; +use crate::util::errors::BitFunError; +use bitfun_agent_runtime::scheduler::{TurnOutcome, TurnOutcomeStatus}; +use log::{debug, info, warn}; +use serde_json::Value; +use std::path::Path; + +/// Metadata key injected by SessionMessage when a dispatch is bound to a plan file. +pub(crate) const PLAN_FILE_METADATA_KEY: &str = "planFile"; +/// Metadata key injected by SessionMessage when a dispatch is bound to a plan todo. +pub(crate) const TODO_ID_METADATA_KEY: &str = "todoId"; + +/// Read the optional plan-todo binding from turn metadata. Returns +/// `(plan_file, todo_id)` when both keys are present and non-empty. +pub(crate) fn read_todo_binding(metadata: Option<&Value>) -> Option<(String, String)> { + let metadata = metadata?; + let plan_file = metadata.get(PLAN_FILE_METADATA_KEY)?.as_str()?; + let todo_id = metadata.get(TODO_ID_METADATA_KEY)?.as_str()?; + let plan_file = plan_file.trim(); + let todo_id = todo_id.trim(); + if plan_file.is_empty() || todo_id.is_empty() { + return None; + } + Some((plan_file.to_string(), todo_id.to_string())) +} + +/// Pure decision: should the auto-complete hook fire for this outcome? Only +/// Completed outcomes advance the todo; Failed/Cancelled outcomes are kept +/// pending for the commander to adjudicate. +pub(crate) fn should_auto_complete_todo(outcome: &TurnOutcome) -> bool { + outcome.status() == TurnOutcomeStatus::Completed +} + +/// Best-effort: mark the bound todo `in_progress` when the turn metadata +/// carries a plan-todo binding. Caller gates on `reply_route.is_some()` so +/// only execution turns (never reply turns) reach this hook. +pub(crate) async fn auto_mark_todo_in_progress_if_bound( + metadata: Option<&Value>, + workspace_path: Option<&str>, + remote_connection_id: Option<&str>, + remote_ssh_host: Option<&str>, +) { + mark_todo_status_if_bound( + metadata, + workspace_path, + remote_connection_id, + remote_ssh_host, + "in_progress", + "auto_mark_todo_in_progress", + ) + .await; +} + +/// Best-effort: mark the bound todo `completed` when the finished turn carried +/// a plan-todo binding AND completed normally. Failed/Cancelled outcomes are +/// left untouched. Caller gates on `reply_route.is_some()` so reply turns +/// (which inherit the binding metadata) never re-mark. +pub(crate) async fn auto_mark_todo_completed_if_bound( + metadata: Option<&Value>, + workspace_path: Option<&str>, + remote_connection_id: Option<&str>, + remote_ssh_host: Option<&str>, + outcome: &TurnOutcome, +) { + if !should_auto_complete_todo(outcome) { + return; + } + mark_todo_status_if_bound( + metadata, + workspace_path, + remote_connection_id, + remote_ssh_host, + "completed", + "auto_mark_todo_completed", + ) + .await; +} + +async fn mark_todo_status_if_bound( + metadata: Option<&Value>, + workspace_path: Option<&str>, + remote_connection_id: Option<&str>, + remote_ssh_host: Option<&str>, + status: &str, + hook: &str, +) { + let Some((plan_file, todo_id)) = read_todo_binding(metadata) else { + return; + }; + // Remote workspaces keep their plan files on the remote host; the local + // scheduler cannot read or write them. Skip instead of failing noisily. + if remote_connection_id.is_some() || remote_ssh_host.is_some() { + debug!( + "{}: skipping plan-todo binding on remote workspace (plan files live on the remote host): plan_file={}, todo_id={}", + hook, plan_file, todo_id + ); + return; + } + let Some(workspace_path) = workspace_path else { + warn!( + "{}: cannot resolve plan-todo binding without a workspace path: plan_file={}, todo_id={}", + hook, plan_file, todo_id + ); + return; + }; + let result = async { + let plan_path = resolve_plan_path_for_backend(&plan_file, Some(Path::new(workspace_path))) + .await?; + apply_todo_status_update(&plan_path, &todo_id, status).await?; + Ok::<_, BitFunError>(()) + } + .await; + match result { + Ok(()) => info!( + "{}: plan todo marked {}: plan_file={}, todo_id={}", + hook, status, plan_file, todo_id + ), + Err(error) => warn!( + "{}: failed to update bound plan todo (best-effort, turn continues): plan_file={}, todo_id={}, error={}", + hook, plan_file, todo_id, error + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn completed_outcome(turn_id: &str) -> TurnOutcome { + TurnOutcome::Completed { + turn_id: turn_id.to_string(), + final_response: "done".to_string(), + } + } + + #[test] + fn read_todo_binding_returns_none_without_metadata() { + assert_eq!(read_todo_binding(None), None); + } + + #[test] + fn read_todo_binding_returns_none_without_binding_keys() { + let metadata = json!({ "senderSessionId": "source-1" }); + assert_eq!(read_todo_binding(Some(&metadata)), None); + } + + #[test] + fn read_todo_binding_requires_both_keys() { + let metadata = json!({ "planFile": "my_plan_1234.plan.md" }); + assert_eq!(read_todo_binding(Some(&metadata)), None); + let metadata = json!({ "todoId": "setup-auth" }); + assert_eq!(read_todo_binding(Some(&metadata)), None); + } + + #[test] + fn read_todo_binding_returns_binding_when_both_present() { + let metadata = json!({ + "planFile": "my_plan_1234.plan.md", + "todoId": "setup-auth", + }); + assert_eq!( + read_todo_binding(Some(&metadata)), + Some(("my_plan_1234.plan.md".to_string(), "setup-auth".to_string())) + ); + } + + #[test] + fn read_todo_binding_rejects_empty_values() { + let metadata = json!({ "planFile": " ", "todoId": "setup-auth" }); + assert_eq!(read_todo_binding(Some(&metadata)), None); + let metadata = json!({ "planFile": "my_plan.plan.md", "todoId": "" }); + assert_eq!(read_todo_binding(Some(&metadata)), None); + } + + #[test] + fn should_auto_complete_todo_only_for_completed_outcomes() { + assert!(should_auto_complete_todo(&completed_outcome("turn-1"))); + assert!(!should_auto_complete_todo(&TurnOutcome::Cancelled { + turn_id: "turn-2".to_string() + })); + assert!(!should_auto_complete_todo(&TurnOutcome::Failed { + turn_id: "turn-3".to_string(), + error: "boom".to_string() + })); + } +} diff --git a/src/crates/assembly/core/src/agentic/coordination/review_propagation.rs b/src/crates/assembly/core/src/agentic/coordination/review_propagation.rs new file mode 100644 index 0000000000..7e3be6dd51 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/coordination/review_propagation.rs @@ -0,0 +1,221 @@ +//! Review propagation along the conversation tree - basic version +//! +//! When a leaf agent completes, review results propagate upward along the parent_session_id chain. + +use bitfun_services_core::session::types::{SessionMetadata, SessionRelationshipKind}; +use log::{debug, info}; + +pub struct ReviewPropagationManager; + +/// Review propagation action +pub enum ReviewPropagationAction { + /// No action needed + None, + /// Suggest triggering a review of the parent session + ReviewNeeded { + parent_session_id: String, + child_session_id: String, + }, +} + +impl ReviewPropagationManager { + /// Triggered when a leaf agent completes - checks the parent session and decides whether to propagate a review + pub fn on_leaf_completed( + session_id: &str, + agent_type: &str, + response_text: &str, + parent_session_id: Option<&str>, + ) -> ReviewPropagationAction { + info!( + "ReviewPropagation: leaf agent completed session={} agent_type={} text_len={} parent={:?}", + session_id, + agent_type, + response_text.len(), + parent_session_id, + ); + + match parent_session_id { + Some(parent_id) if !parent_id.is_empty() => { + debug!( + "ReviewPropagation: review may be needed for parent session={} (child={} completed)", + parent_id, session_id + ); + ReviewPropagationAction::ReviewNeeded { + parent_session_id: parent_id.to_string(), + child_session_id: session_id.to_string(), + } + } + _ => ReviewPropagationAction::None, + } + } + + /// Build a commit message prefix from the conversation tree path + /// e.g. "[agentic → Explore → claude-code] fix: ..." + pub fn build_commit_message( + sessions: &[SessionMetadata], + leaf_id: &str, + summary: &str, + ) -> String { + let path = Self::build_tree_path(sessions, leaf_id); + format!("{} {}", path, summary) + } + + /// Build a tree path string from leaf to root + /// e.g. "[agentic → Explore → claude-code]" + pub fn build_tree_path(sessions: &[SessionMetadata], leaf_id: &str) -> String { + let mut path = Vec::new(); + let mut current_id = leaf_id.to_string(); + + while let Some(session) = sessions.iter().find(|s| s.session_id == current_id) { + path.push(session.agent_type.clone()); + + let Some(ref relationship) = session.relationship else { + break; + }; + let Some(ref parent_id) = relationship.parent_session_id else { + break; + }; + current_id = parent_id.clone(); + } + + path.reverse(); + format!("[{}]", path.join(" → ")) + } + + /// Aggregate output summaries of all descendant SubAgents + pub fn build_pr_summary(sessions: &[SessionMetadata], root_id: &str) -> String { + let children: Vec<_> = sessions + .iter() + .filter(|s| { + s.relationship + .as_ref() + .and_then(|r| r.parent_session_id.as_deref()) + == Some(root_id) + }) + .collect(); + + if children.is_empty() { + return String::new(); + } + + let mut summary = String::new(); + for child in &children { + summary.push_str(&format!( + "- **{}** (`{}`): {} turns\n", + child.session_name, child.agent_type, child.turn_count + )); + let child_summary = Self::build_pr_summary(sessions, &child.session_id); + if !child_summary.is_empty() { + for line in child_summary.lines() { + summary.push_str(&format!(" {}\n", line)); + } + } + } + summary + } + + /// Collect all SubAgent session_ids in the subtree + pub fn collect_descendant_subagent_ids( + sessions: &[SessionMetadata], + root_id: &str, + ) -> Vec { + let mut result = Vec::new(); + for session in sessions { + if let Some(ref relationship) = session.relationship { + if relationship.kind == Some(SessionRelationshipKind::Subagent) { + if let Some(ref parent_id) = relationship.parent_session_id { + if parent_id == root_id { + result.push(session.session_id.clone()); + let grandchildren = Self::collect_descendant_subagent_ids( + sessions, + &session.session_id, + ); + result.extend(grandchildren); + } + } + } + } + } + result + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_meta(id: &str, agent_type: &str, parent_id: Option<&str>) -> SessionMetadata { + SessionMetadata { + session_id: id.to_string(), + session_name: format!("Session {}", id), + agent_type: agent_type.to_string(), + last_user_dialog_agent_type: None, + last_submitted_agent_type: None, + created_by: None, + session_kind: bitfun_core_types::SessionKind::Subagent, + memory_mode: bitfun_services_core::session::types::SessionMemoryMode::Enabled, + model_name: "model".to_string(), + created_at: 1, + last_active_at: 1, + last_finished_at: None, + turn_count: 3, + message_count: 5, + tool_call_count: 10, + status: bitfun_services_core::session::types::SessionStatus::Completed, + terminal_session_id: None, + snapshot_session_id: None, + tags: vec![], + custom_metadata: None, + relationship: parent_id.map(|pid| { + bitfun_services_core::session::types::SessionRelationship { + kind: Some(SessionRelationshipKind::Subagent), + parent_session_id: Some(pid.to_string()), + depth: Some(1), + ..Default::default() + } + }), + todos: None, + review_action_state: None, + deep_review_run_manifest: None, + review_target_evidence: None, + deep_review_cache: None, + workspace_path: None, + workspace_hostname: None, + unread_completion: None, + needs_user_attention: None, + runtime_state: None, + is_daemon: false, + execution_target: None, + project_workspace_path: None, + } + } + + #[test] + fn build_tree_path_three_levels() { + let sessions = vec![ + make_meta("root", "agentic", None), + make_meta("child", "Explore", Some("root")), + make_meta("grandchild", "claude-code", Some("child")), + ]; + let path = ReviewPropagationManager::build_tree_path(&sessions, "grandchild"); + assert!(path.contains("agentic")); + assert!(path.contains("Explore")); + assert!(path.contains("claude-code")); + } + + #[test] + fn collect_descendant_subagent_ids_two_levels() { + let sessions = vec![ + make_meta("root", "agentic", None), + make_meta("child-a", "Explore", Some("root")), + make_meta("child-b", "FileFinder", Some("root")), + make_meta("grandchild", "GeneralPurpose", Some("child-a")), + ]; + let descendants = + ReviewPropagationManager::collect_descendant_subagent_ids(&sessions, "root"); + assert_eq!(descendants.len(), 3); + assert!(descendants.contains(&"child-a".to_string())); + assert!(descendants.contains(&"child-b".to_string())); + assert!(descendants.contains(&"grandchild".to_string())); + } +} diff --git a/src/crates/assembly/core/src/agentic/coordination/scheduler.rs b/src/crates/assembly/core/src/agentic/coordination/scheduler.rs index bcab312c81..6c34e92e9f 100644 --- a/src/crates/assembly/core/src/agentic/coordination/scheduler.rs +++ b/src/crates/assembly/core/src/agentic/coordination/scheduler.rs @@ -12,15 +12,20 @@ use super::coordinator::{ session_storage_workspace_locator, ConversationCoordinator, DialogTriggerSource, - HiddenSubagentExecutionRequest, SubagentResult, + HiddenSubagentExecutionRequest, SubagentResult, SubagentResultStatus, +}; +use super::plan_todo_binding::{ + auto_mark_todo_completed_if_bound, auto_mark_todo_in_progress_if_bound, }; use super::turn_outcome::TurnOutcome; use super::turn_settlement::TurnSettlementRegistration; -use crate::agentic::core::{InternalReminderKind, Message, SessionState}; +use crate::agentic::core::{ + InternalReminderKind, Message, Session, SessionKind, SessionState, SessionSummary, +}; use crate::agentic::events::AgenticEvent; use crate::agentic::goal_mode::{ - goal_continuation_submit_retry_delay_ms, goal_internal_context_message, - goal_objective_updated_message, + goal_internal_context_message, goal_objective_updated_message, thread_goal_from_custom_metadata, + GOAL_IDLE_WAKEUP_DELAY_MS, }; use crate::agentic::image_analysis::ImageContextData; use crate::agentic::init_agents_md::build_init_agents_md_user_input; @@ -28,8 +33,12 @@ use crate::agentic::keyed_lock::{KeyedAsyncLock, KeyedAsyncLockGuard}; use crate::agentic::round_preempt::{DialogRoundInjectionSource, SessionRoundInjectionBuffer}; use crate::agentic::session::session_store_port::CoreSessionStorePort; use crate::agentic::session::SessionManager; +use crate::agentic::tools::restrictions::get_session_role; +use crate::agentic::warden::runtime::{warden_enforcement_for_goal, WardenRuntime}; +use crate::infrastructure::PathManager; +use crate::service::workspace::get_global_workspace_service; use crate::util::errors::{BitFunError, BitFunResult}; -use bitfun_runtime_ports::{ThreadGoal, MAX_THREAD_GOAL_AUTO_CONTINUATIONS}; +use bitfun_runtime_ports::ThreadGoal; use log::{debug, info, warn}; use std::collections::HashSet; use std::path::{Path, PathBuf}; @@ -47,7 +56,7 @@ use bitfun_agent_runtime::scheduler::{ resolve_agent_session_reply_action, resolve_background_delivery_action, resolve_background_delivery_injection, resolve_background_delivery_injection_for_turn, resolve_dialog_start_route, resolve_dialog_steering_action, - resolve_turn_outcome_lifecycle_plan, ActiveDialogTurn, ActiveDialogTurnStore, + resolve_turn_outcome_lifecycle_plan, utc_iso8601_now, ActiveDialogTurn, ActiveDialogTurnStore, ActiveDialogTurnTakeResult, AgentSessionReplyAction, AgentSessionReplyPlan, BackgroundDeliveryAction, BackgroundDeliveryFacts, BackgroundInjectionKind, DialogReplySuppressionSet, DialogStartRoute, DialogStartRouteFacts, DialogSteeringAction, @@ -100,6 +109,7 @@ impl QueuedTurn { } #[derive(Debug, Clone, Default)] +#[allow(clippy::large_enum_variant)] pub(crate) enum QueuedTurnExecution { #[default] Standard, @@ -121,6 +131,85 @@ fn remove_queued_turn_by_id( queues.remove_first_matching(session_id, |turn| turn.turn_id.as_deref() == Some(turn_id)) } +/// Pure decision helper for the goal idle-wakeup safety net: the whole +/// session tree (parent plus all subagent descendants at any depth) must be +/// silent. Any node that is busy or has activity newer than `idle_delay` +/// keeps the tree awake; a node that no longer exists contributes nothing. +fn session_tree_is_silent( + tree_ids: &[String], + now: SystemTime, + idle_delay: Duration, + is_busy_or_queued: impl Fn(&str) -> bool, + last_activity_at: impl Fn(&str) -> Option, +) -> bool { + tree_ids.iter().all(|id| { + if is_busy_or_queued(id) { + return false; + } + match last_activity_at(id) { + None => true, + Some(activity) => now + .duration_since(activity) + .map(|elapsed| elapsed >= idle_delay) + .unwrap_or(true), + } + }) +} + +/// Walk up the parent-session chain to find the tree root (the primary +/// conversation). Thread goals are only attachable to main sessions, so in +/// practice this returns `session_id` itself; the walk keeps the primary +/// condition robust if subagent goal support is ever added. +fn session_tree_root_id(summaries: &[SessionSummary], session_id: &str) -> String { + let mut current = session_id.to_string(); + let mut hops = 0u32; + loop { + let parent = summaries + .iter() + .find(|summary| summary.session_id == current) + .and_then(|summary| summary.parent_session_id.clone()); + match parent { + Some(parent) if parent != current && hops < 64 => { + current = parent; + hops += 1; + } + _ => break, + } + } + current +} + +/// Pure decision helper: every conversation in the workspace is quiescent — +/// no session is busy (running or queued). This is the immediate branch of the +/// dual goal trigger: it does NOT require the `GOAL_IDLE_WAKEUP_DELAY_MS` +/// window, so the goal wakes up as soon as nothing in the workspace is running +/// or queued. +fn all_sessions_quiescent( + all_ids: &[String], + is_busy_or_queued: impl Fn(&str) -> bool, +) -> bool { + all_ids.iter().all(|id| !is_busy_or_queued(id)) +} + +/// Pure decision helper for the dual-trigger goal idle-wakeup: the safety net +/// fires when the primary (tree-root) conversation has been silent for a full +/// idle window, OR every conversation in the workspace is quiescent (no +/// running or queued turn anywhere). Returns `(primary_silent, +/// all_sessions_silent)` so callers can log which condition (if any) held. +fn goal_idle_wakeup_conditions_met( + primary_ids: &[String], + all_ids: &[String], + now: SystemTime, + idle_delay: Duration, + is_busy_or_queued: impl Fn(&str) -> bool, + last_activity_at: impl Fn(&str) -> Option, +) -> (bool, bool) { + let primary_silent = + session_tree_is_silent(primary_ids, now, idle_delay, &is_busy_or_queued, &last_activity_at); + let all_silent = all_sessions_quiescent(all_ids, &is_busy_or_queued); + (primary_silent, all_silent) +} + #[derive(Debug)] enum SchedulerSubmitError { Core(BitFunError), @@ -272,7 +361,6 @@ struct BackgroundResultDelivery { workspace_path: Option, remote_connection_id: Option, remote_ssh_host: Option, - content: String, display_content: Option, user_message_metadata: Option, } @@ -346,6 +434,26 @@ pub struct DialogScheduler { /// not yet observed as drained. Retain them across retryable timeouts even /// after their one-shot cancellation controls have been claimed. maintenance_background_sessions: Arc>>, + /// Per-session generation counter for goal idle-wakeup tasks. Each user + /// submission bumps the generation; older wakeup tasks observe a stale + /// generation when they fire and exit without doing anything (re-entrancy + /// guard for the idle safety net). + goal_idle_wakeup_generations: Arc>, + /// Short-TTL cache of `session_has_active_goal` results (COORD-02). The + /// uncached check touches the goal store on disk, which is too expensive + /// to repeat on every outcome; a few seconds of staleness is harmless for + /// Warden enforcement gating. Cleaned in `cleanup_session_state`. + goal_active_cache: Arc>, + /// Weak self-reference set after construction so spawned idle-wakeup tasks + /// can upgrade to a strong reference and submit continuation turns. + goal_idle_wakeup_self: OnceLock>, + /// Warden runtime driving turn-level penalties and challenge pokes. + /// Serialized behind a mutex because turns finalize concurrently. + warden_runtime: Arc>, + /// Best-effort archive root for forwarded agent-session replies. Defaults + /// to `~/.bitfun/taiji/agent-replies` on first use; tests inject a tempdir + /// so outcome-handler tests never touch the real user home. + agent_reply_archive_root: std::sync::Mutex>, } /// Holds the scheduler's exclusive session-operation boundary while a caller @@ -391,6 +499,37 @@ fn queued_submission_outcome( } } +/// Whether a submission originates from a user-facing entry point. Agent-driven +/// (continuation, subagent) and scheduled-job submissions must not reset the +/// goal idle-wakeup timer. +fn is_user_submission_source(source: DialogTriggerSource) -> bool { + matches!( + source, + DialogTriggerSource::DesktopUi + | DialogTriggerSource::DesktopApi + | DialogTriggerSource::Cli + | DialogTriggerSource::Bot + | DialogTriggerSource::RemoteRelay + | DialogTriggerSource::SdkHost + ) +} + +/// P-19:主会话通知只含极简元信息(session_id + 身份标识 + 已回复状态)。 +/// +/// 全量异步消息不回主会话,只由 P-03 persist_background_acp_turn 落盘成 +/// turn,经 SessionHistory(session_id) 检索。命中/非命中通知标记一律返回 +/// 极简元信息,不再保留全文旁路。 +fn background_result_follow_up_user_input(session_id: &str, agent_type: &str) -> String { + let identity = if agent_type.trim().is_empty() { + "agent".to_string() + } else { + agent_type.to_string() + }; + format!( + "Background agent session {session_id} ({identity}) has replied; use SessionHistory to view the full reply." + ) +} + impl DialogScheduler { /// Create a new DialogScheduler and start its background outcome handler. /// @@ -407,6 +546,14 @@ impl DialogScheduler { buffer: round_injection_buffer.clone(), }); + let warden_session_manager = Arc::clone(&session_manager); + let warden_runtime = Arc::new(tokio::sync::Mutex::new(WardenRuntime::new( + warden_session_manager, + ))); + // Inject the Warden runtime into the tool pipeline for tool-level + // audit (custom point outside the hook dispatch channel). Must happen + // before `coordinator` is moved into the struct below. + coordinator.tool_pipeline().set_warden_runtime(warden_runtime.clone()); let scheduler = Arc::new(Self { coordinator, session_manager, @@ -421,13 +568,28 @@ impl DialogScheduler { round_injection_buffer, round_injection_source, maintenance_background_sessions: Arc::new(dashmap::DashMap::new()), + goal_idle_wakeup_generations: Arc::new(dashmap::DashMap::new()), + goal_active_cache: Arc::new(dashmap::DashMap::new()), + goal_idle_wakeup_self: OnceLock::new(), + warden_runtime, + agent_reply_archive_root: std::sync::Mutex::new(None), }); + let _ = scheduler + .goal_idle_wakeup_self + .set(std::sync::Arc::downgrade(&scheduler)); let scheduler_for_handler = Arc::clone(&scheduler); tokio::spawn(async move { scheduler_for_handler.run_outcome_handler(outcome_rx).await; }); + // Best-effort recovery for goal idle-wakeup timers lost on process + // restart (see `rearm_goal_idle_wakeups_after_startup`). + let scheduler_for_rearm = Arc::clone(&scheduler); + tokio::spawn(async move { + scheduler_for_rearm.rearm_goal_idle_wakeups_after_startup().await; + }); + scheduler } @@ -436,15 +598,78 @@ impl DialogScheduler { self.outcome_tx.clone() } + /// Drop all per-session Warden state for `session_id` (session-end cleanup). + /// + /// Called by the coordinator when a session is deleted or discarded so a + /// recycled session id cannot inherit stale enforcement state (failure + /// counters, queued reminders, poke defer counts). + pub async fn cleanup_session_state(&self, session_id: &str) { + let mut warden = self.warden_runtime.lock().await; + warden.cleanup_session(session_id); + drop(warden); + // COORD-11: the per-session in-memory tables only ever grow without + // this cleanup. Removing them here keeps a recycled session id from + // inheriting a stale generation counter (which would silently invalidate + // new idle-wakeup schedules), a stale continuation-abort flag, or a + // stale cached goal-active fact. + self.goal_continuation_abort.clear(session_id); + self.goal_idle_wakeup_generations.remove(session_id); + self.goal_active_cache.remove(session_id); + // COORD-11: suppression marks and retired-outcome tombstones are also + // keyed by session id. A recycled session id must not inherit them: + // a stale suppression mark would silently drop a cancelled-reply + // bounce-back, and a stale tombstone would swallow a new turn outcome. + self.suppressed_cancelled_replies.clear_session(session_id); + self.retired_maintenance_outcomes.clear_session(session_id); + } + + /// Inject the model-backed Warden judgement provider for Audit-Poke + /// decisions (batch-2 warden rework). + /// + /// Forwarded to the tool pipeline, mirroring the `set_warden_runtime` + /// injection in [`DialogScheduler::new`]; the host assembly (desktop) + /// owns the concrete provider and calls this once after construction. + pub fn set_warden_model_judgement(&self, port: Arc) { + self.coordinator + .tool_pipeline() + .set_warden_model_judgement(port); + } + async fn lock_session_operation(&self, session_id: &str) -> KeyedAsyncLockGuard { self.session_operation_locks.lock(session_id).await } + /// Upgrade the weak self-reference installed at construction, when the + /// scheduler is still alive. Used to detach scheduler work into spawned + /// tasks that need an owned `Arc`. + fn self_arc(&self) -> Option> { + let weak = self.goal_idle_wakeup_self.get()?.clone(); + weak.upgrade() + } + /// Pass to [`ConversationCoordinator::set_round_injection_source`](super::coordinator::ConversationCoordinator::set_round_injection_source). pub fn round_injection_monitor(&self) -> Arc { self.round_injection_source.clone() } + /// Current running turn id when the session is `Processing`, otherwise `None`. + /// + /// This is the exact turn [`AgentDialogTurnPort::steer_dialog_turn`] can target. + /// Callers that want to steer (e.g. an urgent agent-to-agent correction) query it + /// first and fall back to a normal `submit` when no turn is running. + pub fn current_processing_turn_id(&self, session_id: &str) -> Option { + match self + .session_manager + .get_session(session_id) + .map(|s| s.state.clone()) + { + Some(SessionState::Processing { + current_turn_id, .. + }) => Some(current_turn_id), + _ => None, + } + } + /// Submit a user "steering" message into the currently running dialog turn. /// /// Unlike [`Self::submit`], this never starts or queues a new turn — it only buffers @@ -460,6 +685,7 @@ impl DialogScheduler { turn_id: String, content: String, display_content: Option, + prepended_reminders: Vec, ) -> Result { if content.trim().is_empty() { return Err("Steering content cannot be empty".to_string()); @@ -490,6 +716,7 @@ impl DialogScheduler { display_content, steering_id, SystemTime::now(), + prepended_reminders, ) { DialogSteeringAction::Reject { error } => { warn!( @@ -629,6 +856,7 @@ impl DialogScheduler { /// running turn at the next model-round boundary. Otherwise, start a new /// turn immediately so the result is handled without waiting for an /// unrelated future message. + #[allow(clippy::too_many_arguments)] pub async fn deliver_background_result( &self, session_id: String, @@ -640,7 +868,10 @@ impl DialogScheduler { display_content: Option, user_message_metadata: Option, ) -> Result<(), String> { - let _operation_guard = self.lock_session_operation(&session_id).await; + // COORD-16: resolve the session agent type before taking the session + // operation lock. `resolve_session_agent_type` performs disk I/O when + // the session is not loaded (storage-path resolution + restore), which + // must not block concurrent submit/cancel on this session's lock. let session_agent_type = self .resolve_session_agent_type( &session_id, @@ -649,6 +880,7 @@ impl DialogScheduler { remote_ssh_host.as_deref(), ) .await?; + let _operation_guard = self.lock_session_operation(&session_id).await; if session_agent_type != agent_type { debug!( "Background result delivery replaced execution agent key with Session logical route: session_id={}, execution_agent_type={}, session_agent_type={}", @@ -662,7 +894,6 @@ impl DialogScheduler { workspace_path, remote_connection_id, remote_ssh_host, - content, display_content: Some(display), user_message_metadata, }; @@ -690,10 +921,13 @@ impl DialogScheduler { )); }; let injection_id = Uuid::new_v4().to_string(); + // B(注入约束):运行中 turn 只注入 display 摘要(极简),全量 + // 结果内容不注入——全文由 P-03 落盘/子会话 turn 承载, + // 避免与通知 turn 构成「通知 + 全文」双路。 let injection = resolve_background_delivery_injection_for_turn( BackgroundInjectionKind::BackgroundResult, injection_id.clone(), - delivery.content.clone(), + delivery.display_content.clone().unwrap_or_default(), delivery.display_content.clone(), SystemTime::now(), current_turn_id, @@ -702,8 +936,20 @@ impl DialogScheduler { Ok(()) } BackgroundDeliveryAction::SubmitAgentSessionFollowUp { queue_priority } => { - self.submit_background_result_follow_up_locked(delivery, queue_priority) - .await + // Type-erase the follow-up future so this delivery path no + // longer embeds the full concrete future chain. The + // review-reminder delivery route (COORD-04) leads back into + // `start_turn` -> the hidden-subagent spawn, which would + // otherwise form a recursive opaque future type that the + // compiler cannot check for `Send`. The awaited future is + // unchanged; only its static type is erased. + let follow_up: std::pin::Pin< + Box> + Send>, + > = Box::pin(self.submit_background_result_follow_up_locked( + delivery, + queue_priority, + )); + follow_up.await } } } @@ -714,9 +960,14 @@ impl DialogScheduler { queue_priority: DialogQueuePriority, ) -> Result<(), String> { let resolved_turn_id = Uuid::new_v4().to_string(); + // P-19:主会话通知只含极简元信息(session_id + 身份标识 + 已回复状态)。 + // 全量结果内容由 P-03 persist_background_acp_turn 落盘, + // 经 SessionHistory(session_id) 检索;不进入主会话 message 历史。 + let user_input = + background_result_follow_up_user_input(&delivery.session_id, &delivery.agent_type); let queued_turn = QueuedTurn { - user_input: delivery.content, - original_user_input: delivery.display_content, + user_input, + original_user_input: None, prepended_messages: Vec::new(), turn_id: Some(resolved_turn_id.clone()), agent_type: delivery.agent_type, @@ -1027,9 +1278,24 @@ impl DialogScheduler { queued_turn: QueuedTurn, reject_if_busy: bool, ) -> Result { + let trigger_source = queued_turn.policy.trigger_source; + let wakeup_session_id = session_id.clone(); let _operation_guard = self.lock_session_operation(&session_id).await; - self.submit_queued_turn_locked(session_id, resolved_turn_id, queued_turn, reject_if_busy) - .await + let outcome = self + .submit_queued_turn_locked(session_id, resolved_turn_id, queued_turn, reject_if_busy) + .await; + // A successful user-initiated submission resets the goal idle-wakeup + // safety net: a goal continuation is only considered again after the + // session has been idle for a full GOAL_IDLE_WAKEUP_DELAY_MS window. + if outcome.is_ok() && is_user_submission_source(trigger_source) { + self.schedule_goal_idle_wakeup(&wakeup_session_id); + } + // Note: the immediate workspace-quiescent condition is evaluated at the + // outcome handler instead of here — a just-submitted session is busy, + // so the workspace cannot be quiescent at this point, and spawning a + // quiescence check from here would create a cyclic Send obligation + // (the wakeup submit path routes back through this method). + outcome } async fn submit_queued_turn_locked( @@ -1182,6 +1448,438 @@ impl DialogScheduler { .is_some_and(|session| matches!(session.state, SessionState::Processing { .. })) } + /// Schedule a goal idle-wakeup check `GOAL_IDLE_WAKEUP_DELAY_MS` from now. + /// + /// Safety-net behavior only: when the session stays idle and an active + /// thread goal exists, the wakeup reuses the continuation state machine to + /// submit a "wake the commander" turn. A newer user submission bumps the + /// session generation and invalidates older wakeup tasks; the + /// auto-continuation budget additionally caps how often a wakeup can fire. + /// Safe to call repeatedly: each call re-arms the timer so only the newest + /// wakeup task fires. + pub fn schedule_goal_idle_wakeup(&self, session_id: &str) { + let Some(weak) = self.goal_idle_wakeup_self.get().cloned() else { + return; + }; + let Some(scheduler) = weak.upgrade() else { + return; + }; + let generation = { + let mut entry = self + .goal_idle_wakeup_generations + .entry(session_id.to_string()) + .or_insert(0u64); + *entry += 1; + *entry + }; + let wakeup_session_id = session_id.to_string(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(GOAL_IDLE_WAKEUP_DELAY_MS)).await; + scheduler + .goal_idle_wakeup_check(&wakeup_session_id, generation) + .await; + }); + } + + /// Idle-wakeup check, called by a spawned task after the delay window. + async fn goal_idle_wakeup_check(&self, session_id: &str, generation: u64) { + if self + .goal_idle_wakeup_generations + .get(session_id) + .map(|value| *value) + != Some(generation) + { + // A newer user submission superseded this wakeup task. + debug!( + "Goal idle wakeup skipped (superseded by a newer schedule): session_id={}, generation={}", + session_id, generation + ); + return; + } + let Some(session) = self.session_manager.get_session(session_id) else { + debug!( + "Goal idle wakeup skipped (session no longer loaded): session_id={}", + session_id + ); + return; + }; + let Some(workspace_path) = session.config.workspace_path.as_deref().map(Path::new) else { + debug!( + "Goal idle wakeup skipped (session has no workspace path): session_id={}", + session_id + ); + return; + }; + // Cheap guard before the workspace-wide silence scan: a session without + // an active thread goal cannot produce a wakeup plan, so stop the chain + // here instead of listing the whole workspace. + let has_active_goal = match self + .coordinator + .get_thread_goal(session_id, workspace_path) + .await + { + Ok(Some(goal)) => goal.is_active(), + Ok(None) => false, + Err(error) => { + warn!( + "Goal idle wakeup goal lookup failed: session_id={}, error={}", + session_id, error + ); + return; + } + }; + if !has_active_goal { + debug!( + "Goal idle wakeup skipped (no active thread goal): session_id={}, generation={}", + session_id, generation + ); + return; + } + // Dual trigger condition: the safety net fires when EITHER the primary + // (tree-root / main) conversation has been silent for a full idle + // window, OR every conversation in the workspace is quiescent (no + // running or queued turn anywhere). A still-active node keeps the + // wakeup pending and re-arms the timer. + let summaries = match self + .session_manager + .list_sessions_with_options(workspace_path, true) + .await + { + Ok(summaries) => summaries, + Err(error) => { + warn!( + "Goal idle wakeup workspace session listing failed: session_id={}, error={}", + session_id, error + ); + return; + } + }; + let now = SystemTime::now(); + let idle_delay = Duration::from_millis(GOAL_IDLE_WAKEUP_DELAY_MS); + let is_busy = |id: &str| self.is_session_busy_or_queued(id); + let last_activity = |id: &str| { + self.session_manager + .get_session(id) + .map(|session| session.last_activity_at) + .or_else(|| { + summaries + .iter() + .find(|summary| summary.session_id == id) + .map(|summary| summary.last_activity_at) + }) + }; + let primary_id = session_tree_root_id(&summaries, session_id); + let all_ids: Vec = summaries + .iter() + .map(|summary| summary.session_id.clone()) + .collect(); + let (primary_silent, all_sessions_silent) = goal_idle_wakeup_conditions_met( + &[primary_id], + &all_ids, + now, + idle_delay, + is_busy, + last_activity, + ); + if !(primary_silent || all_sessions_silent) { + debug!( + "Goal idle wakeup deferred; neither trigger condition met: session_id={}, generation={}, primary_silent={}, all_sessions_silent={}", + session_id, generation, primary_silent, all_sessions_silent + ); + self.schedule_goal_idle_wakeup(session_id); + return; + } + let _ = self + .trigger_goal_idle_wakeup(session_id, &session, "idle_timer") + .await; + } + + /// Batch-2 goal switch: whether the Warden turn hooks apply for a session. + /// + /// Reuses the `get_thread_goal` + `is_active()` pattern of the goal + /// idle-wakeup check: only sessions with an active thread goal are under + /// Warden enforcement, so failures of goal-less or non-active-goal + /// sessions never accumulate consecutive-failure counts. + /// + /// WARDEN-05: subagent / ephemeral sessions are **exempt** outright — + /// thread goals are only attachable to main sessions, so a subagent can + /// never hold an active goal and must not be pushed into fail-open + /// enforcement just because its session lacks a workspace or a persisted + /// goal. This is a hard exemption, not a fail-open: only main + /// (`SessionKind::Standard`) sessions fall through to the goal lookup. A + /// goal *lookup failure* on a main session still keeps enforcement + /// enabled (fail-open) so a transient store error cannot silently disable + /// discipline. + /// + /// COORD-02: this entry point is a short-TTL cache over the disk-backed + /// check below. Outcome handling can query it once per finished turn; a + /// few seconds of staleness is acceptable for enforcement gating and + /// keeps the outcome path off the storage layer. + async fn session_has_active_goal(&self, session_id: &str) -> bool { + if let Some(cached) = self.goal_active_cache.get(session_id) { + if cached.value().0.elapsed() < GOAL_ACTIVE_CACHE_TTL { + return cached.value().1; + } + } + let active = self.session_has_active_goal_uncached(session_id).await; + self.goal_active_cache + .insert(session_id.to_string(), (Instant::now(), active)); + active + } + + async fn session_has_active_goal_uncached(&self, session_id: &str) -> bool { + let Some(session) = self.session_manager.get_session(session_id) else { + return false; + }; + if !matches!(session.kind, SessionKind::Standard) { + return false; + } + let Some(workspace_path) = session.config.workspace_path.as_deref().map(Path::new) else { + return true; + }; + match self + .coordinator + .get_thread_goal(session_id, workspace_path) + .await + { + Ok(goal) => warden_enforcement_for_goal(goal.as_ref()), + Err(error) => { + warn!( + "Warden goal gate lookup failed; keeping Warden turn hooks enabled: session_id={}, error={}", + session_id, error + ); + true + } + } + } + + /// Build and submit a goal wakeup turn for `session_id` (the continuation + /// state machine in `prepare_goal_idle_wakeup`, then a normal submit). + /// Returns true when a wakeup turn was submitted. Shared by the idle-wakeup + /// timer check and the immediate workspace-quiescent trigger. The + /// auto-continuation budget (`prepare_goal_idle_wakeup`) caps how often a + /// wakeup can fire, so this cannot loop indefinitely. + async fn trigger_goal_idle_wakeup( + &self, + session_id: &str, + session: &Session, + trigger: &str, + ) -> bool { + let plan = match self.coordinator.prepare_goal_idle_wakeup(session_id).await { + Ok(plan) => plan, + Err(error) => { + warn!( + "Goal idle wakeup plan failed: session_id={}, error={}", + session_id, error + ); + return false; + } + }; + let Some(plan) = plan else { + // No continuation plan: goal missing, completed, paused, or the + // auto-continuation budget is exhausted. Stop the wakeup chain. + debug!( + "Goal idle wakeup produced no continuation plan; stopping wakeup chain: session_id={}", + session_id + ); + return false; + }; + let prepended: Vec = plan + .prepended_reminders + .iter() + .map(|text| Message::internal_reminder(InternalReminderKind::GoalContinuation, text)) + .collect(); + let agent_type = session.agent_type.trim(); + let agent_type = if agent_type.is_empty() { + "agentic".to_string() + } else { + agent_type.to_string() + }; + match self + .submit_with_prepended_messages( + session_id.to_string(), + format!( + "The active thread goal has been idle for {} minutes. Wake up the commander and continue the remaining goal work.", + GOAL_IDLE_WAKEUP_DELAY_MS / 60_000 + ), + Some(plan.display_message.clone()), + None, + agent_type, + session.config.workspace_path.clone(), + session.config.remote_connection_id.clone(), + session.config.remote_ssh_host.clone(), + DialogSubmissionPolicy::for_source(DialogTriggerSource::AgentSession), + None, + Some(plan.user_message_metadata.clone()), + prepended, + None, + ) + .await + { + Ok(_) => { + info!( + "Goal idle wakeup turn submitted: session_id={}, trigger={}", + session_id, trigger + ); + // The wakeup turn itself keeps the goal alive; schedule the + // next safety-net check so the goal is still picked up if the + // commander does not respond. + self.schedule_goal_idle_wakeup(session_id); + true + } + Err(error) => { + warn!( + "Goal idle wakeup submit failed: session_id={}, error={}", + session_id, error + ); + false + } + } + } + + /// Immediate goal-wakeup trigger for the workspace-quiescent condition: + /// after a scheduling event (a top-level turn finished or a submission was + /// accepted) a session holding an active thread goal is woken up as soon + /// as every conversation in its workspace has no running or queued turn. + /// Unlike the primary (10-minute) condition this fires immediately; the + /// auto-continuation budget caps how often it can fire. + async fn maybe_trigger_goal_wakeup_when_workspace_quiescent(&self, session_id: &str) { + // If the goal session itself is still busy or queued, the workspace + // cannot be quiescent; skip the (relatively expensive) workspace scan. + if self.is_session_busy_or_queued(session_id) { + return; + } + let Some(session) = self.session_manager.get_session(session_id) else { + return; + }; + let Some(workspace_path) = session.config.workspace_path.as_deref().map(Path::new) else { + return; + }; + let has_active_goal = match self + .coordinator + .get_thread_goal(session_id, workspace_path) + .await + { + Ok(Some(goal)) => goal.is_active(), + _ => return, + }; + if !has_active_goal { + return; + } + let Ok(summaries) = self + .session_manager + .list_sessions_with_options(workspace_path, true) + .await + else { + return; + }; + let all_ids: Vec = summaries + .iter() + .map(|summary| summary.session_id.clone()) + .collect(); + if !all_sessions_quiescent(&all_ids, |id| self.is_session_busy_or_queued(id)) { + return; + } + debug!( + "Goal wakeup immediate trigger: every conversation in workspace is silent: session_id={}", + session_id + ); + let _ = self + .trigger_goal_idle_wakeup(session_id, &session, "workspace_quiescent") + .await; + } + + /// Best-effort recovery for goal idle-wakeup timers lost on process + /// restart. + /// + /// The wakeup chain is purely in-memory (spawned timers), so a restart + /// silently orphans every pending goal. This scans the persisted workspace + /// sessions for active thread goals and re-arms the safety net. Hosts + /// register the global workspace service shortly after the scheduler is + /// constructed (see desktop/server bootstraps), so this polls briefly for + /// it before giving up quietly. + async fn rearm_goal_idle_wakeups_after_startup(&self) { + const REARM_MAX_ATTEMPTS: u32 = 30; + const REARM_POLL_INTERVAL: Duration = Duration::from_millis(1_000); + let workspace_service = { + let mut attempts = 0u32; + loop { + if let Some(service) = get_global_workspace_service() { + break service; + } + attempts += 1; + if attempts >= REARM_MAX_ATTEMPTS { + debug!( + "Goal idle-wakeup rearm skipped: global workspace service unavailable" + ); + return; + } + tokio::time::sleep(REARM_POLL_INTERVAL).await; + } + }; + for workspace in workspace_service.list_workspace_infos().await { + let workspace_path = workspace.root_path; + let goal_session_ids = match self + .active_goal_session_ids(&workspace_path) + .await + { + Ok(ids) => ids, + Err(error) => { + debug!( + "Goal idle-wakeup rearm workspace scan failed: workspace={}, error={}", + workspace_path.display(), + error + ); + continue; + } + }; + for session_id in goal_session_ids { + debug!( + "Rearming goal idle-wakeup after restart: session_id={}", + session_id + ); + self.schedule_goal_idle_wakeup(&session_id); + } + } + } + + /// Enumerate sessions in `workspace` that currently hold an active thread + /// goal. Best-effort: requires persistence to observe goals on sessions + /// that are not loaded in memory; individual metadata read failures are + /// skipped rather than aborting the scan. + async fn active_goal_session_ids(&self, workspace_path: &Path) -> BitFunResult> { + let summaries = self + .session_manager + .list_sessions_with_options(workspace_path, true) + .await?; + let mut goal_session_ids = Vec::new(); + for summary in summaries { + // Only main sessions can carry thread goals; skip subagent and + // ephemeral children to keep the startup scan cheap. + if matches!( + summary.kind, + SessionKind::Subagent | SessionKind::EphemeralChild | SessionKind::EphemeralSubagent + ) { + continue; + } + let Ok(Some(metadata)) = self + .session_manager + .load_session_metadata(workspace_path, &summary.session_id) + .await + else { + continue; + }; + let Some(goal) = thread_goal_from_custom_metadata(metadata.custom_metadata.as_ref()) + else { + continue; + }; + if goal.is_active() { + goal_session_ids.push(summary.session_id); + } + } + Ok(goal_session_ids) + } + async fn finish_removed_queued_turn(&self, session_id: &str, removed_turn: QueuedTurn) { match removed_turn.execution { QueuedTurnExecution::Standard | QueuedTurnExecution::FreshExternalSubagent(_) => { @@ -1454,9 +2152,7 @@ impl DialogScheduler { } fn retire_active_turn_for_maintenance(&self, session_id: &str) -> Option { - let Some(active_turn) = self.active_turns.remove(session_id) else { - return None; - }; + let active_turn = self.active_turns.remove(session_id)?; let turn_id = active_turn.turn_id().to_string(); self.retired_maintenance_outcomes.mark(session_id, &turn_id); self.active_internal_turns.remove(session_id); @@ -1594,10 +2290,41 @@ impl DialogScheduler { ) -> Result { match &queued_turn.execution { QueuedTurnExecution::HiddenSubagent(execution) => { - return self - .start_hidden_subagent_turn(session_id, queued_turn, execution) - .await - .map_err(SchedulerSubmitError::Message); + // The scheduler-side await chain + // `start_hidden_subagent_turn` -> spawned hidden execution -> + // coordinator -> `deliver_background_result` -> follow-up + // submission -> `submit_queued_turn_locked` -> + // `try_start_next_queued_locked` -> `start_turn` forms a + // cyclic opaque-future graph; a direct `.await` here would + // make every future in the cycle non-`Send` and break + // `tokio::spawn` at the hidden execution boundary. Run the + // turn start through a detached task and join it: the + // `JoinHandle` is a concrete `Send` type, so the cycle is + // broken while the returned turn id and the caller-held + // session operation permit semantics stay unchanged. + let Some(scheduler) = self.self_arc() else { + return Err(SchedulerSubmitError::Message( + "scheduler self-arc unavailable for hidden subagent start".to_string(), + )); + }; + let session_id_owned = session_id.to_string(); + let queued_turn_owned = queued_turn.clone(); + let execution_owned = execution.clone(); + let start_handle = tokio::spawn(async move { + scheduler + .start_hidden_subagent_turn( + &session_id_owned, + &queued_turn_owned, + &execution_owned, + ) + .await + }); + let start_result = start_handle.await.map_err(|join_error| { + SchedulerSubmitError::Message(format!( + "hidden subagent start task failed: {join_error}" + )) + })?; + return start_result.map_err(SchedulerSubmitError::Message); } QueuedTurnExecution::FreshExternalSubagent(execution) => { self.coordinator @@ -1647,9 +2374,26 @@ impl DialogScheduler { .image_contexts .as_ref() .filter(|imgs| !imgs.is_empty()); + // Merge Warden pending reminders (penalty pokes / challenge pokes) + // with the turn's own prepended messages so pokes ride into the next + // dialog turn. Hidden-subagent turns return above and skip injection. + let prepended_messages: Option> = { + let mut warden_reminders = self + .warden_runtime + .lock() + .await + .take_pending_reminders(session_id); + if warden_reminders.is_empty() { + (!queued_turn.prepended_messages.is_empty()) + .then(|| queued_turn.prepended_messages.clone()) + } else { + warden_reminders.extend(queued_turn.prepended_messages.iter().cloned()); + Some(warden_reminders) + } + }; let route = resolve_dialog_start_route(DialogStartRouteFacts { has_image_contexts: images.is_some(), - has_prepended_messages: !queued_turn.prepended_messages.is_empty(), + has_prepended_messages: prepended_messages.is_some(), }); let res = match route { @@ -1682,7 +2426,9 @@ impl DialogScheduler { queued_turn.remote_ssh_host.clone(), queued_turn.policy, queued_turn.user_message_metadata.clone(), - queued_turn.prepended_messages.clone(), + prepended_messages + .clone() + .expect("prepended-messages route requires merged messages"), ) .await } @@ -1721,7 +2467,9 @@ impl DialogScheduler { queued_turn.remote_ssh_host.clone(), queued_turn.policy, queued_turn.user_message_metadata.clone(), - queued_turn.prepended_messages.clone(), + prepended_messages + .clone() + .expect("prepended-messages route requires merged messages"), ) .await } @@ -1729,6 +2477,21 @@ impl DialogScheduler { res.map_err(SchedulerSubmitError::Core)?; + // Plan-todo binding auto-mark (best-effort): when an agent-session + // execution turn carries a planFile/todoId binding, mark the todo + // in_progress. Only execution turns (reply_route.is_some()) can carry + // a binding; reply turns have reply_route = None and never trigger + // this hook. Failures only warn; they never fail the turn. + if queued_turn.reply_route.is_some() { + auto_mark_todo_in_progress_if_bound( + queued_turn.user_message_metadata.as_ref(), + queued_turn.workspace_path.as_deref(), + queued_turn.remote_connection_id.as_deref(), + queued_turn.remote_ssh_host.as_deref(), + ) + .await; + } + // Standard scheduler submissions resolve and persist their turn ID // before entering the coordinator. Reading SessionState here races a // very fast terminal transition and can incorrectly turn an accepted, @@ -1758,17 +2521,49 @@ impl DialogScheduler { Ok(resolved) } - async fn start_hidden_subagent_turn( - &self, - session_id: &str, - queued_turn: &QueuedTurn, - execution: &HiddenSubagentQueuedExecution, - ) -> Result { - let turn_id = queued_turn - .turn_id - .clone() - .ok_or_else(|| "hidden subagent queued turn is missing turn_id".to_string())?; - let request = execution.request.clone(); + /// Box the hidden-subagent execution future behind a `dyn Future` trait + /// object **outside** the scheduler state machine that spawns it. + /// + /// The review-reminder delivery path (COORD-04) routes from + /// `execute_hidden_subagent_internal` back into the scheduler + /// (`deliver_background_result` -> queued submit -> `start_turn` -> the + /// hidden-subagent spawn site). A `tokio::spawn` block that awaited the + /// concrete future directly would embed that whole chain in its own state + /// machine, forming a self-referential opaque future type the compiler + /// cannot check for `Send` (`fetching the hidden types of an opaque inside + /// of the defining scope is not supported`). Returning a `Pin>` from a plain function keeps the spawned task's state + /// machine small and the type chain finite. Semantics are unchanged. + fn box_hidden_subagent_execution( + coordinator: Arc, + request: HiddenSubagentExecutionRequest, + execution_cancel_token: CancellationToken, + timeout_seconds: Option, + ) -> std::pin::Pin< + Box> + Send>, + > { + Box::pin(async move { + coordinator + .execute_prepared_hidden_subagent( + request, + Some(&execution_cancel_token), + timeout_seconds, + ) + .await + }) + } + + async fn start_hidden_subagent_turn( + &self, + session_id: &str, + queued_turn: &QueuedTurn, + execution: &HiddenSubagentQueuedExecution, + ) -> Result { + let turn_id = queued_turn + .turn_id + .clone() + .ok_or_else(|| "hidden subagent queued turn is missing turn_id".to_string())?; + let request = execution.request.clone(); let parent_cancel_token = request.parent_dialog_turn_id().and_then(|turn_id| { self.coordinator .execution_cancel_token_for_dialog_turn(turn_id) @@ -1845,25 +2640,44 @@ impl DialogScheduler { self.active_internal_turns .insert(session_id.to_string(), ActiveInternalTurn::HiddenSubagent); + let hidden_subagent_task = Self::box_hidden_subagent_execution( + coordinator, + request, + execution_cancel_token, + timeout_seconds, + ); tokio::spawn(async move { - let outcome = coordinator - .execute_prepared_hidden_subagent( - request, - Some(&execution_cancel_token), - timeout_seconds, - ) - .await; + let outcome = hidden_subagent_task.await; match outcome { Ok(result) => { - let _ = outcome_tx - .send(( - session_id_owned.clone(), - TurnOutcome::Completed { - turn_id: turn_id_for_task.clone(), - final_response: result.text.clone(), - }, - )) - .await; + // COORD-08: a partial-timeout result is not a completed + // turn; report it as Failed so callers never treat a + // half-finished subagent as a successful completion. + if result.status == SubagentResultStatus::PartialTimeout { + let reason = result + .reason + .as_deref() + .unwrap_or("timed out before completing the subagent task"); + let _ = outcome_tx + .send(( + session_id_owned.clone(), + TurnOutcome::Failed { + turn_id: turn_id_for_task.clone(), + error: format!("hidden subagent partial timeout: {reason}"), + }, + )) + .await; + } else { + let _ = outcome_tx + .send(( + session_id_owned.clone(), + TurnOutcome::Completed { + turn_id: turn_id_for_task.clone(), + final_response: result.text.clone(), + }, + )) + .await; + } result_tx.send(Ok(result)); } Err(BitFunError::Cancelled(error_text)) => { @@ -1897,11 +2711,128 @@ impl DialogScheduler { Ok(turn_id) } + /// Replace characters unsafe for file names in archive ids (session ids, + /// turn ids). Falls back to `unknown` when nothing safe remains. + fn sanitize_archive_id(value: &str) -> String { + let sanitized: String = value + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || matches!(character, '-' | '_') { + character + } else { + '_' + } + }) + .collect(); + let trimmed = sanitized.trim_matches('_'); + if trimmed.is_empty() { + "unknown".to_string() + } else { + trimmed.chars().take(128).collect() + } + } + + /// Extract the `Status: ...` line written into the reply reminder text by + /// `resolve_agent_session_reply_action`. Best-effort: falls back to + /// `unknown` when the line is missing. + fn extract_status_from_reminder(reminder_text: &str) -> String { + reminder_text + .lines() + .find_map(|line| { + line.strip_prefix("Status: ") + .map(str::trim) + .filter(|status| !status.is_empty()) + }) + .unwrap_or("unknown") + .to_string() + } + + /// Default archive root: `~/.bitfun/taiji/agent-replies`, resolved through + /// the shared `PathManager` so `BITFUN_HOME`/`BITFUN_E2E_HOME` overrides + /// apply. Falls back to a temp location rather than panicking when the + /// path manager cannot be constructed. + fn resolve_default_agent_reply_archive_root() -> PathBuf { + PathManager::new() + .map(|path_manager| { + path_manager + .bitfun_home_dir() + .join("taiji") + .join("agent-replies") + }) + .unwrap_or_else(|_| { + std::env::temp_dir() + .join("bitfun") + .join("taiji") + .join("agent-replies") + }) + } + + /// Best-effort archive of a forwarded agent-session reply. + /// + /// Writes `//-.md` (UTF-8, no BOM) + /// containing the reply facts already present on the plan: responder + /// session, target session, status, server time, and reply text. This is + /// an audit trail only — the caller must ignore failures so a full or + /// read-only disk can never block reply delivery. + async fn archive_agent_session_reply( + root: &Path, + responder_session_id: &str, + turn_id: &str, + plan: &AgentSessionReplyPlan, + ) -> std::io::Result { + let month_dir = utc_iso8601_now(); + let month_dir = month_dir.get(..7).unwrap_or("unknown"); + let dir = root.join(month_dir); + tokio::fs::create_dir_all(&dir).await?; + let file_name = format!( + "{}-{}.md", + Self::sanitize_archive_id(responder_session_id), + Self::sanitize_archive_id(turn_id) + ); + let path = dir.join(file_name); + let server_time = plan + .user_message_metadata + .as_ref() + .and_then(|metadata| metadata.get("serverTime")) + .and_then(|value| value.as_str()) + .unwrap_or("unknown"); + let content = format!( + "# Agent Session Reply Archive\n\n\ + - source_session: {responder_session_id}\n\ + - target_session: {}\n\ + - status: {}\n\ + - server_time: {server_time}\n\ + - archived_at: {}\n\ + - turn_id: {turn_id}\n\n\ + ## Reply Text\n\n{}\n", + plan.target_session_id, + Self::extract_status_from_reminder(&plan.reminder_text), + utc_iso8601_now(), + plan.user_input, + ); + tokio::fs::write(&path, content).await?; + Ok(path) + } + async fn forward_agent_session_reply( &self, responder_session_id: &str, + turn_id: &str, plan: AgentSessionReplyPlan, ) { + if let Err(error) = Self::archive_agent_session_reply( + &self.agent_reply_archive_root(), + responder_session_id, + turn_id, + &plan, + ) + .await + { + warn!( + "Failed to archive agent-session reply (best-effort): responder_session_id={}, target_session_id={}, turn_id={}, error={}", + responder_session_id, plan.target_session_id, turn_id, error + ); + } let reply_user_input = plan.user_input; let target_session_id = plan.target_session_id; let target_workspace_path = plan.target_workspace_path; @@ -1938,6 +2869,40 @@ impl DialogScheduler { } } + /// Resolve the agent-reply archive root, defaulting to + /// `~/.bitfun/taiji/agent-replies` on first use. Poison recovery keeps the + /// best-effort archive path panic-free. + fn agent_reply_archive_root(&self) -> PathBuf { + let configured = { + let guard = self + .agent_reply_archive_root + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + guard.clone() + }; + if let Some(root) = configured { + return root; + } + let default = Self::resolve_default_agent_reply_archive_root(); + let mut guard = self + .agent_reply_archive_root + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if guard.is_none() { + *guard = Some(default.clone()); + } + default + } + + #[cfg(test)] + pub(crate) fn set_agent_reply_archive_root(&self, root: PathBuf) { + let mut guard = self + .agent_reply_archive_root + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + *guard = Some(root); + } + fn take_suppressed_cancelled_reply(&self, session_id: &str, turn_id: &str) -> bool { self.suppressed_cancelled_replies.take(session_id, turn_id) } @@ -1950,239 +2915,238 @@ impl DialogScheduler { Ok(()) } - /// Background loop that receives turn outcome notifications from the coordinator. + /// Background loop that receives turn outcome notifications from the + /// coordinator. + /// + /// COORD-02: each outcome is dispatched into its own spawned task instead + /// of being processed in one serial loop, so a slow outcome for one + /// session no longer delays every other session (the bounded 128-slot + /// channel was a global throughput bottleneck). Same-session ordering and + /// mutual exclusion against submit/cancel stay intact via the session + /// operation lock inside `process_turn_outcome`. The semaphore only caps + /// the number of concurrently processing outcome tasks. async fn run_outcome_handler(&self, mut outcome_rx: mpsc::Receiver<(String, TurnOutcome)>) { + let outcome_concurrency = + Arc::new(tokio::sync::Semaphore::new(OUTCOME_PROCESSING_MAX_CONCURRENCY)); while let Some((session_id, outcome)) = outcome_rx.recv().await { - let (active_turn, active_internal_turn, lifecycle_plan) = { - let _operation_guard = self.lock_session_operation(&session_id).await; - let Some(active_turn_result) = take_active_turn_for_outcome( - &self.active_turns, - &self.retired_maintenance_outcomes, - &session_id, - outcome.turn_id(), - ) else { + let Some(scheduler) = self.self_arc() else { + break; + }; + let permit = outcome_concurrency.clone(); + tokio::spawn(async move { + let _permit = permit.acquire_owned().await; + scheduler.process_turn_outcome(&session_id, outcome).await; + }); + } + } + + /// Process a single turn outcome for one session. Runs inside a spawned + /// task (see `run_outcome_handler`), so different sessions are handled + /// concurrently; the session operation lock keeps same-session outcome + /// processing serialized and closed against concurrent submit/cancel. + async fn process_turn_outcome(&self, session_id: &str, outcome: TurnOutcome) { + let (active_turn, active_internal_turn, lifecycle_plan) = { + let _operation_guard = self.lock_session_operation(session_id).await; + let Some(active_turn_result) = take_active_turn_for_outcome( + &self.active_turns, + &self.retired_maintenance_outcomes, + session_id, + outcome.turn_id(), + ) else { + self.round_injection_buffer + .drain_for_turn(session_id, outcome.turn_id()); + self.take_suppressed_cancelled_reply(session_id, outcome.turn_id()); + debug!( + "Ignoring outcome retired by session deletion: session_id={}, turn_id={}", + session_id, + outcome.turn_id() + ); + return; + }; + let active_turn = match active_turn_result { + ActiveDialogTurnTakeResult::Matched(turn) => Some(turn), + ActiveDialogTurnTakeResult::Absent => None, + ActiveDialogTurnTakeResult::DifferentTurn => { self.round_injection_buffer - .drain_for_turn(&session_id, outcome.turn_id()); - self.take_suppressed_cancelled_reply(&session_id, outcome.turn_id()); + .drain_for_turn(session_id, outcome.turn_id()); + self.take_suppressed_cancelled_reply(session_id, outcome.turn_id()); debug!( - "Ignoring outcome retired by session deletion: session_id={}, turn_id={}", + "Ignoring stale turn outcome: session_id={}, turn_id={}", session_id, outcome.turn_id() ); - continue; - }; - let active_turn = match active_turn_result { - ActiveDialogTurnTakeResult::Matched(turn) => Some(turn), - ActiveDialogTurnTakeResult::Absent => None, - ActiveDialogTurnTakeResult::DifferentTurn => { - self.round_injection_buffer - .drain_for_turn(&session_id, outcome.turn_id()); - self.take_suppressed_cancelled_reply(&session_id, outcome.turn_id()); - debug!( - "Ignoring stale turn outcome: session_id={}, turn_id={}", - session_id, - outcome.turn_id() - ); - continue; - } - }; - let active_internal_turn = active_turn.as_ref().and_then(|_| { - self.active_internal_turns - .remove(&session_id) - .map(|(_, turn)| turn) - }); - let lifecycle_plan = - resolve_turn_outcome_lifecycle_plan(&outcome, active_turn.is_some()); - if lifecycle_plan.queue_action == TurnOutcomeQueueAction::ClearQueue { - debug!( - "Turn {}, clearing queue: session_id={}", - lifecycle_plan.status, session_id - ); - let _ = self.clear_queue(&session_id).await; + return; } - (active_turn, active_internal_turn, lifecycle_plan) }; - let status = lifecycle_plan.status; - let queue_action = lifecycle_plan.queue_action; - // Only drop steering messages targeted at the *finished* turn. We - // must NOT clear the entire session buffer here: a user might have - // legitimately submitted steering against a brand-new follow-up - // turn that the dispatcher will pick up immediately after this - // outcome is processed (race window between turn finalize and the - // next turn starting). Targeting by turn_id keeps those alive. - if lifecycle_plan.drain_finished_turn_injections { - self.round_injection_buffer - .drain_for_turn(&session_id, outcome.turn_id()); - } - let suppressed_cancelled_reply = - self.take_suppressed_cancelled_reply(&session_id, outcome.turn_id()); - let is_internal_turn = active_internal_turn.is_some(); - if !is_internal_turn { - if let Some(active_turn) = active_turn.as_ref() { - match resolve_agent_session_reply_action( - &session_id, - active_turn, - &outcome, - suppressed_cancelled_reply, - ) { - AgentSessionReplyAction::NoReply => {} - AgentSessionReplyAction::SkipSuppressedCancelledReply => { - debug!( + let active_internal_turn = active_turn.as_ref().and_then(|_| { + self.active_internal_turns + .remove(session_id) + .map(|(_, turn)| turn) + }); + let lifecycle_plan = + resolve_turn_outcome_lifecycle_plan(&outcome, active_turn.is_some()); + if lifecycle_plan.queue_action == TurnOutcomeQueueAction::ClearQueue { + debug!( + "Turn {}, clearing queue: session_id={}", + lifecycle_plan.status, session_id + ); + let _ = self.clear_queue(session_id).await; + } + (active_turn, active_internal_turn, lifecycle_plan) + }; + let status = lifecycle_plan.status; + let queue_action = lifecycle_plan.queue_action; + // Turn-driven Warden runtime: feed the finished turn outcome so + // consecutive-failure penalties and challenge pokes are queued for + // the next turn of this session. Batch-2 goal switch: Warden hooks + // only run while the session has an active thread goal, so + // failures of goal-less or non-active-goal sessions never + // accumulate (see `session_has_active_goal`). + if self.session_has_active_goal(session_id).await { + let mut warden = self.warden_runtime.lock().await; + warden + .on_turn_outcome(session_id, status, outcome.turn_id()) + .await; + } else { + // WARDEN-01: the session's goal left the active state (or the + // session is non-main) — drop the stale consecutive-failure + // counts here so a later, *new* goal generation starts from a + // clean ladder instead of firing the previous goal's L2/L3 on + // its first failure. Idempotent; harmless when already clear. + self.warden_runtime + .lock() + .await + .clear_failure_counts(session_id); + } + // Only drop steering messages targeted at the *finished* turn. We + // must NOT clear the entire session buffer here: a user might have + // legitimately submitted steering against a brand-new follow-up + // turn that the dispatcher will pick up immediately after this + // outcome is processed (race window between turn finalize and the + // next turn starting). Targeting by turn_id keeps those alive. + if lifecycle_plan.drain_finished_turn_injections { + self.round_injection_buffer + .drain_for_turn(session_id, outcome.turn_id()); + } + let suppressed_cancelled_reply = + self.take_suppressed_cancelled_reply(session_id, outcome.turn_id()); + let is_internal_turn = active_internal_turn.is_some(); + if !is_internal_turn { + if let Some(active_turn) = active_turn.as_ref() { + // COORD-10: re-acquire the session operation lock around the + // reply decision and delivery. The take above released it, and + // this section reads session facts (role, tree depth) and + // forwards replies into other sessions; serializing it against + // a concurrent submit/cancel for this session removes the + // stale-window race. + let _reply_guard = self.lock_session_operation(session_id).await; + match resolve_agent_session_reply_action( + session_id, + get_session_role(session_id).map(|role| role.as_str()), + self.coordinator.session_tree().get_depth(session_id), + active_turn, + &outcome, + suppressed_cancelled_reply, + ) { + AgentSessionReplyAction::NoReply => {} + AgentSessionReplyAction::SkipSuppressedCancelledReply => { + debug!( "Skipping cancelled auto-reply because the source session explicitly cancelled its own SessionMessage request: session_id={}, turn_id={}", session_id, outcome.turn_id() ); - } - AgentSessionReplyAction::Forward(plan) => { - self.forward_agent_session_reply(&session_id, plan).await; - } + } + AgentSessionReplyAction::Forward(plan) => { + self.forward_agent_session_reply( + session_id, + outcome.turn_id(), + plan, + ) + .await; } } + + // Plan-todo binding auto-complete (best-effort): when the + // finished turn is an agent-session execution turn bound + // to a plan todo (reply_route.is_some()) and it completed + // normally, mark the todo completed. Failed/Cancelled + // outcomes are intentionally left untouched (kept pending + // for the commander to adjudicate). Reply turns have + // reply_route = None and never trigger this hook. Failures + // only warn; they never affect the outcome pipeline. + if active_turn.reply_route().is_some() { + auto_mark_todo_completed_if_bound( + active_turn.user_message_metadata(), + active_turn.workspace_path(), + active_turn.remote_connection_id(), + active_turn.remote_ssh_host(), + &outcome, + ) + .await; + } } + } - if !is_internal_turn { - if let Some(active_turn) = active_turn.as_ref() { - match lifecycle_plan.goal_continuation { - GoalContinuationAfterTurnAction::SkipNoActiveTurn => {} - GoalContinuationAfterTurnAction::AbortForCancelled => { - self.goal_continuation_abort.mark(&session_id); - debug!( - "Skipping thread goal continuation after user-cancelled turn: session_id={}, turn_id={}", - session_id, - outcome.turn_id() - ); - } - GoalContinuationAfterTurnAction::Evaluate { turn_completed } => { - self.goal_continuation_abort.clear(&session_id); - match self - .coordinator - .prepare_goal_continuation_after_turn( - &session_id, - outcome.turn_id(), - active_turn.user_input(), - active_turn.user_message_metadata(), - turn_completed, - ) - .await - { - Ok(Some(plan)) => { - let prepended: Vec = plan - .prepended_reminders - .into_iter() - .map(|text| { - Message::internal_reminder( - InternalReminderKind::GoalContinuation, - text, - ) - }) - .collect(); - let mut last_error = None; - for attempt in 1..=MAX_THREAD_GOAL_AUTO_CONTINUATIONS { - if self.goal_continuation_abort.contains(&session_id) { - debug!( - "Aborting goal continuation submit retries after user cancellation: session_id={}", - session_id - ); - break; - } - match self - .submit_with_prepended_messages( - session_id.clone(), - "Continue working toward the active thread goal." - .to_string(), - Some(plan.display_message.clone()), - None, - active_turn.agent_type_owned(), - active_turn.workspace_path_owned(), - active_turn.remote_connection_id_owned(), - active_turn.remote_ssh_host_owned(), - DialogSubmissionPolicy::for_source( - DialogTriggerSource::AgentSession, - ), - None, - Some(plan.user_message_metadata.clone()), - prepended.clone(), - None, - ) - .await - { - Ok(_) => { - last_error = None; - break; - } - Err(error) => { - last_error = Some(error); - if self - .goal_continuation_abort - .contains(&session_id) - { - debug!( - "Aborting goal continuation submit retries after user cancellation: session_id={}", - session_id - ); - break; - } - if attempt < MAX_THREAD_GOAL_AUTO_CONTINUATIONS { - let delay_ms = - goal_continuation_submit_retry_delay_ms( - attempt, - ); - warn!( - "Goal continuation submit failed; retrying: session_id={}, attempt={}/{}, delay_ms={}, error={}", - session_id, - attempt, - MAX_THREAD_GOAL_AUTO_CONTINUATIONS, - delay_ms, - last_error.as_ref().unwrap() - ); - tokio::time::sleep( - std::time::Duration::from_millis(delay_ms), - ) - .await; - } - } - } - } - if let Some(error) = last_error { - if !self.goal_continuation_abort.contains(&session_id) { - warn!( - "Failed to submit goal continuation turn after retries: session_id={}, error={}", - session_id, error - ); - } - } - } - Ok(None) => {} - Err(error) => { - warn!( - "Goal verification failed after turn stopped: session_id={}, status={}, error={}", - session_id, status, error - ); - } - } - } - } + if !is_internal_turn { + // The plan already encodes "no active turn" as SkipNoActiveTurn, + // so no extra active_turn guard is needed here. + match lifecycle_plan.goal_continuation { + GoalContinuationAfterTurnAction::SkipNoActiveTurn => {} + GoalContinuationAfterTurnAction::AbortForCancelled => { + self.goal_continuation_abort.mark(session_id); + debug!( + "Skipping thread goal continuation after user-cancelled turn: session_id={}, turn_id={}", + session_id, + outcome.turn_id() + ); + } + GoalContinuationAfterTurnAction::Evaluate { .. } => { + // COORD-02: `prepare_goal_continuation_after_turn` + // always returns `Ok(None)` (the immediate after-turn + // continuation channel is closed; only the idle-wakeup + // safety net continues goals). The submit-retry loop + // below it was therefore unreachable dead code and is + // removed. The abort-flag clear is kept so a normal + // completion un-sticks the flag for future goal paths. + self.goal_continuation_abort.clear(session_id); } } + } - match queue_action { - TurnOutcomeQueueAction::DispatchNext => { - if status == TurnOutcomeStatus::Cancelled { - debug!( - "Turn cancelled, dispatching next queued message if present: session_id={}", - session_id - ); - } + match queue_action { + TurnOutcomeQueueAction::DispatchNext => { + if status == TurnOutcomeStatus::Cancelled { + debug!( + "Turn cancelled, dispatching next queued message if present: session_id={}", + session_id + ); + } - if let Err(e) = self.dispatch_next_if_idle(&session_id).await { - warn!( - "Failed to dispatch next queued message after {}: session_id={}, error={}", - status, session_id, e - ); - } + if let Err(e) = self.dispatch_next_if_idle(session_id).await { + warn!( + "Failed to dispatch next queued message after {}: session_id={}, error={}", + status, session_id, e + ); } - TurnOutcomeQueueAction::ClearQueue => {} } + TurnOutcomeQueueAction::ClearQueue => {} + } + + // Top-level turn finished: restart the goal idle-wakeup safety net + // so it counts from turn end, not from submission. Subagent and + // other internal turns skip this; they carry no goal of their own. + // schedule_goal_idle_wakeup bumps the session generation, which + // invalidates any older wakeup task, so a user submission that + // raced in ahead of this outcome is still honored. + if !is_internal_turn { + self.schedule_goal_idle_wakeup(session_id); + // Immediate workspace-quiescent condition: this top-level turn + // just finished and (when nothing else is running or queued) + // every conversation in the workspace is now silent, so wake + // the goal right away instead of waiting for the 10-minute + // timer. + self.maybe_trigger_goal_wakeup_when_workspace_quiescent(session_id) + .await; } } } @@ -2285,6 +3249,7 @@ fn agent_dialog_turn_prepended_messages( .map(|reminder| { let kind = match reminder.kind.as_str() { "session_message_request" => InternalReminderKind::SessionMessageRequest, + "task_subagent_result" => InternalReminderKind::BackgroundResult, "scheduled_job" => InternalReminderKind::ScheduledJob, other => { return Err(PortError::new( @@ -2420,6 +3385,7 @@ impl AgentDialogTurnPort for DialogScheduler { request.turn_id, request.content, request.display_content, + request.prepended_reminders, ) .await .map_err(|error| { @@ -2504,10 +3470,16 @@ impl AgentTurnCancellationPort for DialogScheduler { let wait_timeout = Duration::from_millis(request.wait_timeout_ms.unwrap_or(1500)); let cancelled_turn_id = if let Some(turn_id) = request.turn_id { - self.cancel_queued_or_active_turn(&session_id, &turn_id) + // COORD-12: map the removal result instead of discarding it. The + // previous code unconditionally reported `Some(turn_id)`, so + // `requested` was always true even when the turn was neither + // queued nor active. `cancel_queued_or_active_turn` returns true + // only when the turn was actually removed before it started. + let removed = self + .cancel_queued_or_active_turn(&session_id, &turn_id) .await .map_err(|error| PortError::new(PortErrorKind::Backend, error.to_string()))?; - Some(turn_id) + if removed { Some(turn_id) } else { None } } else if let Some(requester_session_id) = request.requester_session_id { self.cancel_active_turn_for_session_from_requester( &session_id, @@ -2590,6 +3562,18 @@ fn background_result_delivery_state_fact( // ── Global instance ────────────────────────────────────────────────────────── +/// TTL for the `session_has_active_goal` short-term cache (COORD-02). Kept +/// small so goal state changes (pause/resume/complete) reach Warden +/// enforcement within a few seconds, while outcome handling stays off the +/// disk-backed goal store. +const GOAL_ACTIVE_CACHE_TTL: Duration = Duration::from_secs(5); + +/// Ceiling for concurrently processing outcome tasks (COORD-02). The outcome +/// channel itself stays bounded at 128; this semaphore only prevents an +/// unbounded task pile-up when a burst of outcomes arrives while sessions +/// are busy. +const OUTCOME_PROCESSING_MAX_CONCURRENCY: usize = 64; + static GLOBAL_SCHEDULER: OnceLock> = OnceLock::new(); pub fn get_global_scheduler() -> Option> { @@ -2701,14 +3685,12 @@ mod tests { ), ), )); - ( - DialogScheduler::new(coordinator, session_manager.clone()), - session_manager, - event_queue, - root, - ) + let scheduler = DialogScheduler::new(coordinator, session_manager.clone()); + // Isolate the best-effort agent-reply archive so outcome-handler + // tests never write into the real `~/.bitfun` home. + scheduler.set_agent_reply_archive_root(root.path().join("agent-replies")); + (scheduler, session_manager, event_queue, root) } - #[test] fn queued_turn_execution_default_is_standard() { assert!(matches!( @@ -2717,6 +3699,282 @@ mod tests { )); } + #[test] + fn session_tree_silence_requires_every_descendant_idle() { + let now = SystemTime::now(); + let idle_delay = Duration::from_millis(GOAL_IDLE_WAKEUP_DELAY_MS); + let idle = now - idle_delay - Duration::from_secs(60); + let active = now - Duration::from_secs(1); + let tree = vec![ + "parent".to_string(), + "child".to_string(), + "grandchild".to_string(), + ]; + + // Every node idle -> the whole tree is silent. + assert!(session_tree_is_silent(&tree, now, idle_delay, |_| false, |_| { + Some(idle) + })); + + // A descendant active within the idle window blocks the wakeup even + // when the parent itself is idle. + assert!(!session_tree_is_silent(&tree, now, idle_delay, |_| false, |id| { + if id == "child" { Some(active) } else { Some(idle) } + })); + + // A busy descendant blocks the wakeup even when every node looks idle. + assert!(!session_tree_is_silent(&tree, now, idle_delay, |id| { + id == "grandchild" + }, |_| { + Some(idle) + })); + + // A descendant that no longer exists contributes no activity. + assert!(session_tree_is_silent(&tree, now, idle_delay, |_| false, |id| { + if id == "grandchild" { None } else { Some(idle) } + })); + + // Root-only tree follows the root activity. + let root_only = vec!["parent".to_string()]; + assert!(session_tree_is_silent(&root_only, now, idle_delay, |_| false, |_| { + Some(idle) + })); + assert!(!session_tree_is_silent(&root_only, now, idle_delay, |_| false, |_| { + Some(active) + })); + } + + fn session_summary(session_id: &str, parent_session_id: Option<&str>) -> SessionSummary { + SessionSummary { + session_id: session_id.to_string(), + session_name: session_id.to_string(), + agent_type: "agentic".to_string(), + model_id: None, + reasoning_preset: None, + last_user_dialog_agent_type: None, + last_submitted_agent_type: None, + created_by: None, + kind: SessionKind::Standard, + turn_count: 0, + created_at: SystemTime::now(), + last_activity_at: SystemTime::now(), + state: SessionState::Idle, + parent_session_id: parent_session_id.map(ToOwned::to_owned), + is_daemon: false, + } + } + + #[test] + fn session_tree_root_walks_up_parent_chain() { + let summaries = vec![ + session_summary("root", None), + session_summary("child", Some("root")), + session_summary("grandchild", Some("child")), + ]; + // The deepest descendant resolves to the tree root (primary + // conversation). + assert_eq!(session_tree_root_id(&summaries, "grandchild"), "root"); + assert_eq!(session_tree_root_id(&summaries, "child"), "root"); + assert_eq!(session_tree_root_id(&summaries, "root"), "root"); + // Unknown sessions fall back to themselves. + assert_eq!(session_tree_root_id(&summaries, "unknown"), "unknown"); + // A parent chain that never terminates is capped at 64 hops. + let self_cycle = vec![session_summary("a", Some("b")), session_summary("b", Some("a"))]; + let _ = session_tree_root_id(&self_cycle, "a"); + } + + #[test] + fn goal_idle_wakeup_fires_when_primary_or_all_conversations_silent() { + let now = SystemTime::now(); + let idle_delay = Duration::from_millis(GOAL_IDLE_WAKEUP_DELAY_MS); + let idle = now - idle_delay - Duration::from_secs(60); + let active = now - Duration::from_secs(1); + let primary = vec!["primary".to_string()]; + let all = vec!["primary".to_string(), "subagent".to_string()]; + + // Primary silent while a subagent is still busy -> condition 1 fires, + // condition 2 (workspace quiescent) does not. + let (primary_silent, all_silent) = goal_idle_wakeup_conditions_met( + &primary, + &all, + now, + idle_delay, + |id| id == "subagent", + |_| Some(idle), + ); + assert!(primary_silent); + assert!(!all_silent); + + // Everything old-idle -> both conditions fire. + let (primary_silent, all_silent) = goal_idle_wakeup_conditions_met( + &primary, + &all, + now, + idle_delay, + |_| false, + |_| Some(idle), + ); + assert!(primary_silent && all_silent); + + // Primary busy -> neither condition fires. + let (primary_silent, all_silent) = goal_idle_wakeup_conditions_met( + &primary, + &all, + now, + idle_delay, + |id| id == "primary", + |_| Some(idle), + ); + assert!(!primary_silent && !all_silent); + + // Primary had activity within the window (so condition 1 does not + // fire) but nothing is busy/queued -> condition 2 fires immediately. + let (primary_silent, all_silent) = goal_idle_wakeup_conditions_met( + &primary, + &all, + now, + idle_delay, + |_| false, + |id| { + if id == "primary" { Some(active) } else { Some(idle) } + }, + ); + assert!(!primary_silent && all_silent); + } + + #[test] + fn goal_idle_wakeup_all_sessions_condition_ignores_idle_window() { + let now = SystemTime::now(); + let idle_delay = Duration::from_millis(GOAL_IDLE_WAKEUP_DELAY_MS); + // Activity within the idle window: under the old semantics this blocked + // the whole-workspace condition; the immediate condition-2 fires on + // quiescence alone. + let recent = now - Duration::from_secs(1); + let all = vec!["session-a".to_string(), "session-b".to_string()]; + + // No session busy/queued, even with recent activity -> immediate. + assert!(all_sessions_quiescent(&all, |_| false)); + + // Any busy or queued session keeps the workspace from being quiescent. + assert!(!all_sessions_quiescent(&all, |id| id == "session-b")); + + // Condition-2 helper does not consult last activity. + let (_, all_silent) = goal_idle_wakeup_conditions_met( + &["session-a".to_string()], + &all, + now, + idle_delay, + |_| false, + |_| Some(recent), + ); + assert!(all_silent); + } + + #[tokio::test] + async fn top_level_turn_outcome_restarts_goal_idle_wakeup() { + let (scheduler, session_manager, _, root) = test_scheduler(); + let session_id = "goal-wakeup-session"; + let turn_id = "goal-wakeup-turn"; + let workspace = root.path().join("workspace"); + std::fs::create_dir_all(&workspace).expect("workspace"); + session_manager + .create_session_with_id( + Some(session_id.to_string()), + "GoalWakeup".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.to_string_lossy().into_owned()), + ..Default::default() + }, + ) + .await + .expect("create session"); + scheduler + .active_turns + .insert(session_id, desktop_active_turn(turn_id)); + + scheduler + .outcome_tx + .send(( + session_id.to_string(), + TurnOutcome::Completed { + turn_id: turn_id.to_string(), + final_response: "done".to_string(), + }, + )) + .await + .expect("send outcome"); + + // The outcome handler runs on a background task. Wait until the turn + // is consumed, then require the idle-wakeup generation to have been + // bumped (the schedule_goal_idle_wakeup side effect of this hook). + for _ in 0..100 { + let turn_consumed = !scheduler.active_turns.matches_turn(session_id, turn_id); + let generation_bumped = scheduler + .goal_idle_wakeup_generations + .get(session_id) + .is_some_and(|generation| *generation >= 1); + if turn_consumed && generation_bumped { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("top-level turn outcome did not restart the goal idle-wakeup timer"); + } + + #[tokio::test] + async fn internal_turn_outcome_skips_goal_idle_wakeup() { + let (scheduler, session_manager, _, root) = test_scheduler(); + let session_id = "internal-wakeup-session"; + let turn_id = "internal-wakeup-turn"; + let workspace = root.path().join("workspace"); + std::fs::create_dir_all(&workspace).expect("workspace"); + session_manager + .create_session_with_id( + Some(session_id.to_string()), + "InternalWakeup".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.to_string_lossy().into_owned()), + ..Default::default() + }, + ) + .await + .expect("create session"); + scheduler + .active_turns + .insert(session_id, desktop_active_turn(turn_id)); + scheduler + .active_internal_turns + .insert(session_id.to_string(), ActiveInternalTurn::HiddenSubagent); + + scheduler + .outcome_tx + .send(( + session_id.to_string(), + TurnOutcome::Completed { + turn_id: turn_id.to_string(), + final_response: "done".to_string(), + }, + )) + .await + .expect("send outcome"); + + for _ in 0..100 { + if !scheduler.active_turns.matches_turn(session_id, turn_id) { + // Turn consumed; the internal-turn guard must have skipped the + // idle-wakeup restart entirely. + assert!(scheduler + .goal_idle_wakeup_generations + .get(session_id) + .is_none()); + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("internal turn outcome was not consumed by the outcome handler"); + } + #[tokio::test] async fn submission_preflight_commits_a_persisted_revert_marker() { let (scheduler, session_manager, _, root) = test_scheduler(); @@ -3597,6 +4855,7 @@ mod tests { turn_id.to_string(), "check tests".to_string(), None, + Vec::new(), ) .await .expect_err("stale processing state must not accept steering"); @@ -3619,6 +4878,7 @@ mod tests { turn_id: "turn-1".to_string(), content: " ".to_string(), display_content: None, + prepended_reminders: Vec::new(), }, ) .await @@ -3646,6 +4906,7 @@ mod tests { turn_id.to_string(), "check tests".to_string(), None, + Vec::new(), ) .await }); @@ -3826,15 +5087,36 @@ mod tests { }; assert_eq!( - resolve_agent_session_reply_action("session_b", &active_turn, &cancelled, true), + resolve_agent_session_reply_action( + "session_b", + None, + None, + &active_turn, + &cancelled, + true + ), AgentSessionReplyAction::SkipSuppressedCancelledReply ); assert!(matches!( - resolve_agent_session_reply_action("session_b", &active_turn, &cancelled, false), + resolve_agent_session_reply_action( + "session_b", + None, + None, + &active_turn, + &cancelled, + false + ), AgentSessionReplyAction::Forward(_) )); assert!(matches!( - resolve_agent_session_reply_action("session_b", &active_turn, &completed, true), + resolve_agent_session_reply_action( + "session_b", + None, + None, + &active_turn, + &completed, + true + ), AgentSessionReplyAction::Forward(_) )); } @@ -3985,4 +5267,511 @@ mod tests { .message .contains("unsupported agent dialog prepended reminder kind")); } + + // --------------------------------------------------------------------- + // Plan-todo binding hooks (integration-level): verify the scheduler + // wiring (reply_route.is_some() gates) all the way to the on-disk plan + // file. The pure binding logic itself lives in plan_todo_binding.rs; these + // tests cover the scheduler-side hook trigger points: + // - start_turn with a binding + reply_route marks the todo in_progress + // - a Completed outcome marks the bound todo completed + // - Failed/Cancelled outcomes keep the todo pending + // - reply turns (reply_route = None) never trigger either hook + // --------------------------------------------------------------------- + + fn write_bound_plan_file(root: &tempfile::TempDir, file_name: &str) -> (PathBuf, String) { + let workspace = root.path().join("workspace"); + std::fs::create_dir_all(&workspace).expect("workspace"); + let plan_path = workspace.join(file_name); + let plan_file = plan_path.to_string_lossy().into_owned(); + std::fs::write( + &plan_path, + "---\nname: My Plan\noverview: An overview\ntodos:\n- id: setup-auth\n content: Set up auth\n status: pending\n---\n\n# My Plan\n\nBody text here.\n", + ) + .expect("write plan file"); + (plan_path, plan_file) + } + + fn plan_todo_status(plan_path: &Path) -> String { + let content = std::fs::read_to_string(plan_path).expect("read plan file"); + let status_line = content + .lines() + .find(|line| line.trim_start().starts_with("status:")) + .expect("plan todo status line"); + status_line + .split_once("status:") + .expect("status separator") + .1 + .trim() + .to_string() + } + + fn binding_metadata(plan_file: &str) -> Option { + Some(serde_json::json!({ + "planFile": plan_file, + "todoId": "setup-auth", + })) + } + + fn bound_active_turn( + turn_id: &str, + workspace_path: &str, + plan_file: &str, + reply_route: Option, + ) -> ActiveDialogTurn { + ActiveDialogTurn::new( + turn_id.to_string(), + Some(workspace_path.to_string()), + None, + None, + "agentic".to_string(), + "bound execution turn".to_string(), + binding_metadata(plan_file), + DialogSubmissionPolicy::for_source(DialogTriggerSource::AgentSession), + reply_route, + ) + } + + fn sample_reply_route() -> AgentSessionReplyRoute { + AgentSessionReplyRoute { + source_session_id: "source-session".to_string(), + source_workspace_path: "/workspace".to_string(), + source_remote_connection_id: None, + source_remote_ssh_host: None, + } + } + + async fn create_bound_session( + session_manager: &SessionManager, + root: &tempfile::TempDir, + session_id: &str, + ) -> String { + let workspace = root.path().join("workspace"); + std::fs::create_dir_all(&workspace).expect("workspace"); + session_manager + .create_session_with_id( + Some(session_id.to_string()), + "Bound".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.to_string_lossy().into_owned()), + ..Default::default() + }, + ) + .await + .expect("create bound session"); + workspace.to_string_lossy().into_owned() + } + + async fn wait_for_active_turn_consumed( + scheduler: &DialogScheduler, + session_id: &str, + turn_id: &str, + ) { + for _ in 0..100 { + if !scheduler.active_turns.matches_turn(session_id, turn_id) { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("active turn was not consumed by the outcome handler: session_id={session_id}, turn_id={turn_id}"); + } + + /// The in_progress hook function itself, exercised against a real plan + /// file: binding metadata + workspace resolve the plan path and rewrite + /// the todo status on disk. (The scheduler-side gate that calls this hook + /// from start_turn is covered by + /// `start_turn_binding_hook_wiring_is_gated_on_reply_route`; the full + /// start_turn pipeline is not reachable in the test harness because it + /// resolves session storage through the global PathManager.) + #[tokio::test] + async fn in_progress_hook_direct_call_marks_real_plan_file() { + let root = tempfile::tempdir().expect("test root"); + let workspace_path = root + .path() + .join("workspace") + .to_string_lossy() + .into_owned(); + let (plan_path, plan_file) = write_bound_plan_file(&root, "hook_in_progress_plan.plan.md"); + + let _override_guard = + PathManager::set_plans_dir_override_guard(root.path().join("workspace")); + + auto_mark_todo_in_progress_if_bound( + binding_metadata(&plan_file).as_ref(), + Some(&workspace_path), + None, + None, + ) + .await; + + assert_eq!(plan_todo_status(&plan_path), "in_progress"); + } + + /// Source-level wiring assertion (same pattern as + /// `submission_preflight_commits_a_persisted_revert_marker` above): the + /// start_turn in_progress hook must exist and must be gated on + /// `reply_route.is_some()` so reply turns (reply_route = None) never + /// trigger it. The full start_turn pipeline is not runnable in the test + /// harness (global PathManager storage resolution), so the wiring itself + /// is pinned against the source. + #[test] + fn start_turn_binding_hook_wiring_is_gated_on_reply_route() { + let source = include_str!("scheduler.rs"); + let start_turn = source + .split_once("async fn start_turn(") + .expect("start_turn method") + .1 + .split_once("async fn start_hidden_subagent_turn(") + .expect("start_turn boundary") + .0; + let gate_pos = start_turn + .find("if queued_turn.reply_route.is_some() {") + .expect("reply_route gate"); + let hook_pos = start_turn + .find("auto_mark_todo_in_progress_if_bound(") + .expect("in_progress hook call"); + assert!( + gate_pos < hook_pos, + "in_progress hook must be gated on reply_route.is_some()" + ); + assert!( + start_turn.contains("// in_progress. Only execution turns (reply_route.is_some()) can carry"), + "missing gate comment explaining the reply_route condition" + ); + } + + #[tokio::test] + async fn bound_execution_turn_completed_marks_todo_completed() { + let (scheduler, session_manager, _, root) = test_scheduler(); + let session_id = "bound-complete-session"; + let workspace_path = create_bound_session(&session_manager, &root, session_id).await; + let (plan_path, plan_file) = write_bound_plan_file(&root, "bound_complete_plan.plan.md"); + let turn_id = "bound-complete-turn"; + scheduler.active_turns.insert( + session_id, + bound_active_turn( + turn_id, + &workspace_path, + &plan_file, + Some(sample_reply_route()), + ), + ); + + let _override_guard = + PathManager::set_plans_dir_override_guard(PathBuf::from(&workspace_path)); + + scheduler + .outcome_tx + .send(( + session_id.to_string(), + TurnOutcome::Completed { + turn_id: turn_id.to_string(), + final_response: "done".to_string(), + }, + )) + .await + .expect("send completed outcome"); + + for _ in 0..100 { + if plan_todo_status(&plan_path) == "completed" { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("bound todo was not marked completed"); + } + + #[tokio::test] + async fn bound_execution_turn_failed_keeps_todo_pending() { + let (scheduler, session_manager, _, root) = test_scheduler(); + let session_id = "bound-failed-session"; + let workspace_path = create_bound_session(&session_manager, &root, session_id).await; + let (plan_path, plan_file) = write_bound_plan_file(&root, "bound_failed_plan.plan.md"); + let turn_id = "bound-failed-turn"; + scheduler.active_turns.insert( + session_id, + bound_active_turn( + turn_id, + &workspace_path, + &plan_file, + Some(sample_reply_route()), + ), + ); + + scheduler + .outcome_tx + .send(( + session_id.to_string(), + TurnOutcome::Failed { + turn_id: turn_id.to_string(), + error: "boom".to_string(), + }, + )) + .await + .expect("send failed outcome"); + + wait_for_active_turn_consumed(&scheduler, session_id, turn_id).await; + assert_eq!(plan_todo_status(&plan_path), "pending"); + } + + #[tokio::test] + async fn bound_execution_turn_cancelled_keeps_todo_pending() { + let (scheduler, session_manager, _, root) = test_scheduler(); + let session_id = "bound-cancelled-session"; + let workspace_path = create_bound_session(&session_manager, &root, session_id).await; + let (plan_path, plan_file) = write_bound_plan_file(&root, "bound_cancelled_plan.plan.md"); + let turn_id = "bound-cancelled-turn"; + scheduler.active_turns.insert( + session_id, + bound_active_turn( + turn_id, + &workspace_path, + &plan_file, + Some(sample_reply_route()), + ), + ); + + scheduler + .outcome_tx + .send(( + session_id.to_string(), + TurnOutcome::Cancelled { + turn_id: turn_id.to_string(), + }, + )) + .await + .expect("send cancelled outcome"); + + wait_for_active_turn_consumed(&scheduler, session_id, turn_id).await; + assert_eq!(plan_todo_status(&plan_path), "pending"); + } + + /// A Completed reply turn (reply_route = None) must not trigger the + /// completed hook even though the binding metadata is present. The + /// start_turn side of the same gate (reply_route = None → in_progress + /// hook not triggered) is covered by the source-level wiring assertion in + /// `start_turn_binding_hook_wiring_is_gated_on_reply_route` because the + /// full start_turn pipeline is not runnable in the test harness (global + /// PathManager storage resolution). + #[tokio::test] + async fn reply_turn_without_route_never_triggers_binding_hooks() { + let (scheduler, session_manager, _, root) = test_scheduler(); + let reply_session_id = "bound-reply-outcome-session"; + let reply_workspace_path = + create_bound_session(&session_manager, &root, reply_session_id).await; + let (reply_plan_path, reply_plan_file) = + write_bound_plan_file(&root, "bound_reply_outcome_plan.plan.md"); + let reply_turn_id = "bound-reply-outcome-turn"; + scheduler.active_turns.insert( + reply_session_id, + bound_active_turn( + reply_turn_id, + &reply_workspace_path, + &reply_plan_file, + None, + ), + ); + scheduler + .outcome_tx + .send(( + reply_session_id.to_string(), + TurnOutcome::Completed { + turn_id: reply_turn_id.to_string(), + final_response: "done".to_string(), + }, + )) + .await + .expect("send completed reply outcome"); + + wait_for_active_turn_consumed(&scheduler, reply_session_id, reply_turn_id).await; + assert_eq!(plan_todo_status(&reply_plan_path), "pending"); + } + + // --------------------------------------------------------------------- + // Agent-session reply best-effort archiving (F9): forwarded replies are + // written to `//-.md` with the + // reply facts, and archive failures never block reply delivery. + // --------------------------------------------------------------------- + + fn reply_archive_files(root: &Path) -> Vec { + let mut files = Vec::new(); + for month in std::fs::read_dir(root).into_iter().flatten().flatten() { + if !month.file_type().map(|kind| kind.is_dir()).unwrap_or(false) { + continue; + } + for entry in std::fs::read_dir(month.path()).into_iter().flatten().flatten() { + if entry.path().extension().and_then(|ext| ext.to_str()) == Some("md") { + files.push(entry.path()); + } + } + } + files + } + + #[tokio::test] + async fn forwarded_agent_session_reply_is_archived_with_reply_facts() { + let (scheduler, session_manager, _, root) = test_scheduler(); + let session_id = "archive-reply-session"; + let workspace_path = create_bound_session(&session_manager, &root, session_id).await; + let (_, plan_file) = write_bound_plan_file(&root, "archive_reply_plan.plan.md"); + let turn_id = "archive-reply-turn"; + scheduler.active_turns.insert( + session_id, + bound_active_turn( + turn_id, + &workspace_path, + &plan_file, + Some(sample_reply_route()), + ), + ); + + scheduler + .outcome_tx + .send(( + session_id.to_string(), + TurnOutcome::Completed { + turn_id: turn_id.to_string(), + final_response: "archive this reply".to_string(), + }, + )) + .await + .expect("send completed outcome"); + + let archive_root = root.path().join("agent-replies"); + for _ in 0..100 { + if !reply_archive_files(&archive_root).is_empty() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + let files = reply_archive_files(&archive_root); + assert_eq!(files.len(), 1, "exactly one reply archive must be written"); + let content = std::fs::read_to_string(&files[0]).expect("read reply archive"); + assert!(content.contains("source_session: archive-reply-session")); + assert!(content.contains("target_session: source-session")); + assert!(content.contains("status: completed")); + assert!( + content.contains("server_time: ") && !content.contains("server_time: unknown"), + "the serverTime written into the reply metadata must be archived" + ); + assert!(content.contains("## Reply Text")); + assert!(content.contains("archive this reply")); + } + + #[tokio::test] + async fn failed_reply_archive_write_does_not_block_delivery() { + let (scheduler, session_manager, _, root) = test_scheduler(); + // Point the archive root at an existing *file* so create_dir_all must + // fail; delivery must still proceed past the best-effort archive. + let blocking_file = root.path().join("blocking-file"); + std::fs::write(&blocking_file, b"not a directory").expect("write blocking file"); + scheduler.set_agent_reply_archive_root(blocking_file); + let session_id = "archive-blocked-session"; + let workspace_path = create_bound_session(&session_manager, &root, session_id).await; + let (_, plan_file) = write_bound_plan_file(&root, "archive_blocked_plan.plan.md"); + let turn_id = "archive-blocked-turn"; + scheduler.active_turns.insert( + session_id, + bound_active_turn( + turn_id, + &workspace_path, + &plan_file, + Some(sample_reply_route()), + ), + ); + + scheduler + .outcome_tx + .send(( + session_id.to_string(), + TurnOutcome::Completed { + turn_id: turn_id.to_string(), + final_response: "deliver anyway".to_string(), + }, + )) + .await + .expect("send completed outcome"); + + wait_for_active_turn_consumed(&scheduler, session_id, turn_id).await; + } + + #[test] + fn archive_id_sanitization_replaces_unsafe_characters() { + assert_eq!(DialogScheduler::sanitize_archive_id("session-1"), "session-1"); + assert_eq!(DialogScheduler::sanitize_archive_id("../evil"), "evil"); + assert_eq!(DialogScheduler::sanitize_archive_id("a b/c"), "a_b_c"); + assert_eq!(DialogScheduler::sanitize_archive_id(""), "unknown"); + assert_eq!(DialogScheduler::sanitize_archive_id(":::"), "unknown"); + let long = "x".repeat(200); + assert_eq!(DialogScheduler::sanitize_archive_id(&long).len(), 128); + } + + #[tokio::test] + async fn warden_goal_gate_follows_thread_goal_activity() { + let (scheduler, _session_manager, _, root) = test_scheduler(); + let session_id = "warden-gate-session"; + // Create the session through the coordinator (like the coordinator + // goal tests do) so the workspace binding resolves inside the test + // root instead of the real user home. + let workspace_dir = root.path().join("warden-gate-workspace"); + std::fs::create_dir_all(&workspace_dir).expect("workspace dir"); + scheduler + .coordinator + .create_session_with_id( + Some(session_id.to_string()), + "Warden gate".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace_dir.to_string_lossy().into_owned()), + ..Default::default() + }, + ) + .await + .expect("session should load"); + + // No goal yet: the Warden gate is closed, so failures of this + // session would never accumulate consecutive-failure counts. + assert!(!scheduler.session_has_active_goal(session_id).await); + + // A session that is not loaded has no goal and closes the gate. + assert!(!scheduler.session_has_active_goal("missing-session").await); + + // The active-goal branch of the gate (`goal.is_active()` → + // `warden_enforcement_for_goal`) is covered by the pure-function + // tests in `warden::runtime::tests` and `rbac_poke_integration`; + // persisting a real goal in this harness would write into the real + // user home because the workspace binding resolver falls back to the + // global PathManager (existing test-infrastructure limitation). + } + + #[test] + fn background_result_follow_up_returns_minimal_metadata_only() { + // P-19:主会话通知只含极简元信息(session_id + 身份标识 + 已回复状态), + // 不含内容全文;全文由 P-03 persist_background_acp_turn 落盘后经 + // SessionHistory(session_id) 检索。 + let notice = + background_result_follow_up_user_input("flow-session-1", "external::opencode"); + assert!(notice.contains("flow-session-1")); + assert!(notice.contains("external::opencode")); + assert!(notice.contains("has replied")); + assert!(notice.contains("use SessionHistory")); + assert!(!notice.contains("full reply body")); + assert!(!notice.contains("EXTERNAL_REPLY_MARKER_")); + } + + #[test] + fn background_result_follow_up_is_minimal_for_marker_and_full_reply() { + // P-19:命中/非命中通知标记一律返回极简元信息,不再保留全文旁路。 + let bash_notice = + "Background Bash command completed; use SessionHistory to view the full reply. Full output was saved to /tmp/out.txt"; + let marker_notice = background_result_follow_up_user_input("flow-session-2", "agentic"); + assert!(marker_notice.contains("flow-session-2")); + assert!(marker_notice.contains("agentic")); + assert!(marker_notice.contains("has replied")); + // 通知式摘要标记内容不再保留为旁路:极简元信息与原文不同且不含原文。 + assert_ne!(marker_notice, bash_notice); + assert!(!marker_notice.contains("Full output was saved")); + assert!(!marker_notice.contains("/tmp/out.txt")); + } } diff --git a/src/crates/assembly/core/src/agentic/core/message.rs b/src/crates/assembly/core/src/agentic/core/message.rs index b940b36a08..34af18d412 100644 --- a/src/crates/assembly/core/src/agentic/core/message.rs +++ b/src/crates/assembly/core/src/agentic/core/message.rs @@ -90,7 +90,14 @@ pub enum MessageSemanticKind { ComputerUsePostActionSnapshot, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +// Serialization is hand-written so the three private variants below +// (PokePenalty / ChallengePoke / LifecycleContext) are persisted as the +// stable "generic" name: upstream builds do not know these variants and +// would otherwise fail to deserialize snapshot JSON. Deserialization keeps +// the derived snake_case mapping so legacy snapshots written by this build +// still read back, and `#[serde(other)] Unknown` absorbs future/upstream +// variant names instead of erroring. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] #[serde(rename_all = "snake_case")] pub enum InternalReminderKind { Generic, @@ -123,6 +130,63 @@ pub enum InternalReminderKind { HookContext, /// Instructions activated after a successful read of a matching file. ConditionalInstructions, + /// Warden penalty outcome injected after a violating turn (kept through + /// compaction so the agent sees the consequence). + PokePenalty, + /// Warden challenge poke injected when the poke-first protocol fires + /// (kept through compaction so the agent sees the challenge). + ChallengePoke, + /// Legion role / hierarchy context injected at SessionStart and + /// SubagentStart custom points (outside hook gating, so the lifecycle + /// context is not controlled by `app.hooks.enabled`). + LifecycleContext, + /// Fallback for variant names unknown to this build (e.g. written by a + /// newer or upstream build). Keeps deserialization from failing on an + /// unrecognized kind. + #[serde(other)] + Unknown, +} + +impl Serialize for InternalReminderKind { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let name = match self { + Self::Generic => "generic", + Self::SkillListingDiff => "skill_listing_diff", + Self::AgentListingDiff => "agent_listing_diff", + Self::AgentMode => "agent_mode", + Self::SideQuestion => "side_question", + Self::InitAgentsMd => "init_agents_md", + Self::ScheduledJob => "scheduled_job", + Self::ForkSubagent => "fork_subagent", + Self::GoalMode => "goal_mode", + Self::GoalContinuation => "goal_continuation", + Self::GoalObjectiveUpdated => "goal_objective_updated", + Self::RemoteFileDelivery => "remote_file_delivery", + Self::SessionMessageRequest => "session_message_request", + Self::SessionMessageReply => "session_message_reply", + Self::LoopRecovery => "loop_recovery", + Self::PeriodicLoopRecovery => "periodic_loop_recovery", + Self::UserSteering => "user_steering", + Self::BackgroundResult => "background_result", + Self::InterruptedContinue => "interrupted_continue", + Self::ThinkingOnlyRescue => "thinking_only_rescue", + Self::FinalizeCacheAnchor => "finalize_cache_anchor", + Self::CompressionContinuation => "compression_continuation", + Self::StopHookBlock => "stop_hook_block", + Self::HookContext => "hook_context", + Self::ConditionalInstructions => "conditional_instructions", + // Private variants and the unknown fallback serialize as the stable + // "generic" name so upstream builds (which lack these variants) + // can still deserialize snapshot JSON. + Self::PokePenalty | Self::ChallengePoke | Self::LifecycleContext | Self::Unknown => { + "generic" + } + }; + serializer.serialize_str(name) + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -774,6 +838,58 @@ mod tests { ); assert!(!tool_call.recovered_from_truncation); } + + #[test] + fn private_reminder_kinds_serialize_as_generic_for_upstream_compat() { + use super::InternalReminderKind; + + let cases = [ + (InternalReminderKind::PokePenalty, "generic"), + (InternalReminderKind::ChallengePoke, "generic"), + (InternalReminderKind::LifecycleContext, "generic"), + (InternalReminderKind::Unknown, "generic"), + (InternalReminderKind::Generic, "generic"), + (InternalReminderKind::SkillListingDiff, "skill_listing_diff"), + (InternalReminderKind::HookContext, "hook_context"), + (InternalReminderKind::CompressionContinuation, "compression_continuation"), + ]; + for (kind, expected) in cases { + assert_eq!( + serde_json::to_string(&kind).unwrap(), + format!("\"{}\"", expected), + "kind {:?} should serialize as {}", + kind, + expected + ); + } + } + + #[test] + fn private_reminder_kinds_deserialize_from_legacy_snapshots() { + use super::InternalReminderKind; + + assert_eq!( + serde_json::from_str::("\"poke_penalty\"").unwrap(), + InternalReminderKind::PokePenalty + ); + assert_eq!( + serde_json::from_str::("\"challenge_poke\"").unwrap(), + InternalReminderKind::ChallengePoke + ); + assert_eq!( + serde_json::from_str::("\"lifecycle_context\"").unwrap(), + InternalReminderKind::LifecycleContext + ); + assert_eq!( + serde_json::from_str::("\"generic\"").unwrap(), + InternalReminderKind::Generic + ); + // Unknown future/upstream variant names fall back instead of erroring. + assert_eq!( + serde_json::from_str::("\"some_future_kind\"").unwrap(), + InternalReminderKind::Unknown + ); + } } // ============ Tool Calls and Results ============ diff --git a/src/crates/assembly/core/src/agentic/events/types.rs b/src/crates/assembly/core/src/agentic/events/types.rs index 4c7fa66822..40c0b7ed5b 100644 --- a/src/crates/assembly/core/src/agentic/events/types.rs +++ b/src/crates/assembly/core/src/agentic/events/types.rs @@ -16,10 +16,16 @@ pub use bitfun_events::{ // ============ Core layer AgenticEvent extension ============ -/// Core layer AgenticEvent +/// Core layer AgenticEvent type alias. /// -/// Used internally in core, contains full type information (SessionState) -/// When sent to transport layer, it is converted to BaseAgenticEvent (using serde_json::Value) +/// Currently an alias for `BaseAgenticEvent` (from `bitfun_events`). In earlier phases +/// this was intended to wrap `BaseAgenticEvent` with core-specific extensions (e.g., +/// `SessionState`), but that enrichment now happens through re-exports rather than a +/// newtype. If core-specific fields are needed in the future, replace this alias with +/// a struct wrapping `BaseAgenticEvent`. +/// +/// When sent to the transport layer, this is serialized as `BaseAgenticEvent` +/// (using `serde_json::Value`). pub type AgenticEvent = BaseAgenticEvent; // ============ Helper conversion functions ============ diff --git a/src/crates/assembly/core/src/agentic/execution/edit_constraint_guard.rs b/src/crates/assembly/core/src/agentic/execution/edit_constraint_guard.rs index f19702bc14..d9b526b091 100644 --- a/src/crates/assembly/core/src/agentic/execution/edit_constraint_guard.rs +++ b/src/crates/assembly/core/src/agentic/execution/edit_constraint_guard.rs @@ -36,7 +36,7 @@ use shell_targets::ShellMutationOperation; use shell_targets::{explicit_bash_mutation_targets, has_unresolved_bash_mutation}; pub const EDIT_CONSTRAINT_METADATA_KEY: &str = "editConstraintGuard"; -const EDIT_CONSTRAINT_SCHEMA_VERSION: u32 = 6; +const EDIT_CONSTRAINT_SCHEMA_VERSION: u32 = 7; const MAX_PROMPT_CHARS: usize = 8_000; const MAX_RESPONSE_TELEMETRY_CHARS: usize = 4_000; const MAX_MODEL_ATTEMPTS: usize = 2; @@ -50,6 +50,16 @@ You receive the currently active prohibitions and the latest user message. - Add a prohibition only when the latest message explicitly forbids modifying certain files, file types, or categories of files. +- An allow-list is NOT a prohibition. Phrases like "only modify X", "只修改 X", + "仅允许修改 X", or "limit changes to X" say which files MAY be edited; they + never prohibit editing anything. Never add a prohibition derived from an + allow-list. +- A prohibition (deny-list) exists only when the message explicitly forbids + modifying something, e.g. "do not modify X", "X is off limits", "禁止修改 X", + "不得修改 X", "不要修改 X". +- When the latest message defines a new task scope (for example a fresh + allow-list), the new scope supersedes older prohibitions: keep only + prohibitions that are explicit in this latest message. - Revoke an active prohibition only when the latest message explicitly cancels, relaxes, or contradicts it (e.g. "you may modify tests now"). A revocation MUST copy the exact constraint_id from the active list. Never invent an id. @@ -151,9 +161,6 @@ fn has_prohibition_signal(message: &str) -> bool { "must remain untouched", "without modifying", "without changing", - "only modify", - "only change", - "non-test files only", "不得", "不能修改", "不能删除", @@ -163,7 +170,50 @@ fn has_prohibition_signal(message: &str) -> bool { "不要更改", "不要删除", "测试文件保持不变", + ] + .iter() + .any(|signal| lower.contains(signal)) +} + +/// Recognizes allow-list phrasing ("only modify X", "只修改 X", ...). These +/// phrases define which files MAY be edited; they are not prohibitions and +/// must not trigger the deny-list extraction path (F3/F5 regression: the guard +/// rejected files the task explicitly allowed). +fn has_allow_set_signal(message: &str) -> bool { + let lower = message.to_lowercase(); + [ + "only modify", + "only change", + "only edit", + "only touch", + "only update", + "only write", + "modify only", + "change only", + "edit only", + "restrict changes to", + "restrict edits to", + "limit changes to", + "limit edits to", + "changes must be limited to", + "changes should be limited to", + "changes should be restricted to", + "non-test files only", + "只修改", + "只更改", + "只改动", + "只编辑", + "仅修改", + "仅更改", + "仅改动", + "仅编辑", + "仅允许修改", + "只能修改", + "只能更改", + "只能改", "仅修改非测试", + "仅限于修改", + "修改范围", ] .iter() .any(|signal| lower.contains(signal)) @@ -454,17 +504,21 @@ pub async fn extract_constraints_with_active_and_revocation_authorization( .into_iter() .collect::>(); let deterministic_constraint_count = constraints.len(); + let allow_set = has_allow_set_signal(user_message); let (truncated, input_truncated) = truncate_for_extraction(user_message); let prompt_chars = truncated.chars().count(); // Irrelevant follow-ups stay on the local fast path even when constraints // are active. Only messages that may add or relax a file-edit boundary use - // the model-backed classifier. + // the model-backed classifier. Allow-list phrasing ("only modify X") never + // reaches the model: it defines a new scope instead of a prohibition. if !has_prohibition_signal(user_message) && !has_relaxation_signal(user_message) { return ConstraintExtractionRecord { message_sha256, dialog_turn_id: None, - status: if constraints.is_empty() { + status: if allow_set { + ExtractionStatus::ScopeReplaced + } else if constraints.is_empty() { ExtractionStatus::NoConstraints } else { ExtractionStatus::Extracted @@ -623,6 +677,11 @@ pub async fn extract_constraints_with_active_and_revocation_authorization( ExtractionStatus::Extracted } else if failure.is_some() { ExtractionStatus::Failed + } else if allow_set { + // Mixed message (e.g. "don't modify Y, only modify X") that the model + // found no explicit prohibition in: the new scope still supersedes + // older constraints. + ExtractionStatus::ScopeReplaced } else { ExtractionStatus::NoConstraints }; @@ -812,6 +871,7 @@ fn resolved_path(context: &ToolUseContext, file_path: &str) -> Option { .map(|resolved| resolved.resolved_path) } +#[allow(clippy::too_many_arguments)] fn decision_result( context: Option<&ToolUseContext>, tool_name: &str, diff --git a/src/crates/assembly/core/src/agentic/execution/edit_constraint_guard/model.rs b/src/crates/assembly/core/src/agentic/execution/edit_constraint_guard/model.rs index 384a12517a..11cd957f6c 100644 --- a/src/crates/assembly/core/src/agentic/execution/edit_constraint_guard/model.rs +++ b/src/crates/assembly/core/src/agentic/execution/edit_constraint_guard/model.rs @@ -120,6 +120,10 @@ pub enum ExtractionStatus { Extracted, NoConstraints, Failed, + /// The message defines a fresh task scope (e.g. "only modify X"). Merging + /// such a record replaces previously accumulated constraints instead of + /// accumulating on top of them. + ScopeReplaced, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] @@ -241,12 +245,18 @@ impl EditConstraintState { pub fn merge_extraction(&mut self, extraction: ConstraintExtractionRecord) { self.schema_version = EDIT_CONSTRAINT_SCHEMA_VERSION; - self.constraints.retain(|constraint| { - !extraction - .revoked_constraint_ids - .iter() - .any(|constraint_id| constraint_id == &constraint.id) - }); + if extraction.status == ExtractionStatus::ScopeReplaced { + // A fresh task scope supersedes every previously accumulated + // constraint. Only the new message's own constraints survive. + self.constraints.clear(); + } else { + self.constraints.retain(|constraint| { + !extraction + .revoked_constraint_ids + .iter() + .any(|constraint_id| constraint_id == &constraint.id) + }); + } for constraint in &extraction.constraints { if !self.constraints.iter().any(|existing| { existing.matcher == constraint.matcher diff --git a/src/crates/assembly/core/src/agentic/execution/edit_constraint_guard/shell_targets.rs b/src/crates/assembly/core/src/agentic/execution/edit_constraint_guard/shell_targets.rs index 4ed8eba605..fbbe044c91 100644 --- a/src/crates/assembly/core/src/agentic/execution/edit_constraint_guard/shell_targets.rs +++ b/src/crates/assembly/core/src/agentic/execution/edit_constraint_guard/shell_targets.rs @@ -147,26 +147,24 @@ pub(super) fn explicit_bash_mutation_targets(command: &str) -> Vec { - if arguments.iter().any(|argument| in_place_flag(argument)) { - let mut script_seen = false; - for argument in arguments - .iter() - .filter(|argument| !argument.starts_with('-')) + "sed" | "perl" if arguments.iter().any(|argument| in_place_flag(argument)) => { + let mut script_seen = false; + for argument in arguments + .iter() + .filter(|argument| !argument.starts_with('-')) + { + if !script_seen { + script_seen = true; + continue; + } + if argument.starts_with('/') + || argument.starts_with("./") + || argument.starts_with("../") + || argument.contains('.') + || argument.starts_with("test/") + || argument.starts_with("tests/") { - if !script_seen { - script_seen = true; - continue; - } - if argument.starts_with('/') - || argument.starts_with("./") - || argument.starts_with("../") - || argument.contains('.') - || argument.starts_with("test/") - || argument.starts_with("tests/") - { - push_bash_target(&mut targets, argument, ShellMutationOperation::Write); - } + push_bash_target(&mut targets, argument, ShellMutationOperation::Write); } } } diff --git a/src/crates/assembly/core/src/agentic/execution/edit_constraint_guard/tests.rs b/src/crates/assembly/core/src/agentic/execution/edit_constraint_guard/tests.rs index 711918ce8f..46faeb6c3c 100644 --- a/src/crates/assembly/core/src/agentic/execution/edit_constraint_guard/tests.rs +++ b/src/crates/assembly/core/src/agentic/execution/edit_constraint_guard/tests.rs @@ -386,7 +386,8 @@ fn internal_turns_cannot_revoke_a_user_edit_constraint() { description: "tests may be modified now".to_string(), }; - let (revoked, unmatched) = validated_revocation_ids(&[revocation], &[protected.clone()], false); + let (revoked, unmatched) = + validated_revocation_ids(&[revocation], std::slice::from_ref(&protected), false); assert!(revoked.is_empty()); assert!(unmatched.is_empty()); @@ -857,3 +858,177 @@ fn local_recursive_delete_fallback_finds_protected_descendant() { let _ = fs::remove_dir_all(root); } + +#[test] +fn allow_set_phrases_do_not_trigger_prohibition_signal() { + for message in [ + "Only modify src/lib.rs.", + "Only change the files under src/.", + "Please edit only the api/ directory.", + "只修改 src/ 下的文件。", + "仅允许修改 config/ 目录。", + "只能修改 tools/ 里的内容。", + ] { + assert!( + !has_prohibition_signal(message), + "allow-set phrasing must not be a prohibition signal: {message}" + ); + } +} + +#[test] +fn allow_set_signal_recognizes_scope_defining_phrases() { + for message in [ + "Only modify src/lib.rs.", + "只修改 src/ 下的文件。", + "仅允许修改 config/ 目录。", + "Modify only the files in src/.", + "Limit changes to the api/ directory.", + "Only modify non-test files.", + ] { + assert!( + has_allow_set_signal(message), + "expected allow-set signal for: {message}" + ); + } + for message in [ + "Do not modify tests.", + "Cargo.lock is off limits.", + "Continue with the implementation.", + "可以修改测试文件了。", + ] { + assert!( + !has_allow_set_signal(message), + "unexpected allow-set signal for: {message}" + ); + } +} + +#[tokio::test] +async fn allow_set_message_marks_scope_replacement_without_constraints() { + let active = constraint("don't touch tests", ConstraintMatcher::TestFiles); + let extraction = extract_constraints_with_active("只修改 src/ 下的文件。", &[active]).await; + + assert_eq!(extraction.status, ExtractionStatus::ScopeReplaced); + assert!(extraction.constraints.is_empty()); + assert_eq!(extraction.model_attempts, 0); + assert!(extraction.failure.is_none()); + assert!(extraction_requires_session_state(&extraction)); +} + +#[tokio::test] +async fn non_test_allow_set_keeps_deterministic_test_prohibition() { + // "Only modify non-test files." is an allow-list that explicitly excludes + // test files: the deterministic extractor keeps that prohibition, while + // the message still marks a scope replacement for older constraints. + let extraction = extract_constraints("Only modify non-test files.").await; + assert_eq!(extraction.status, ExtractionStatus::ScopeReplaced); + assert_eq!(extraction.constraints.len(), 1); + assert_eq!( + extraction.constraints[0].matcher, + ConstraintMatcher::TestFiles + ); +} + +#[test] +fn new_scope_replaces_previous_constraints_in_state() { + let mut state = EditConstraintState::default(); + let old = constraint("don't touch tests", ConstraintMatcher::TestFiles); + state.merge_extraction(ConstraintExtractionRecord { + message_sha256: "turn-1-hash".to_string(), + dialog_turn_id: Some("turn-1".to_string()), + status: ExtractionStatus::Extracted, + constraints: vec![old], + deterministic_constraint_count: 1, + model_attempts: 0, + active_constraint_ids: Vec::new(), + revocation_authorized: true, + model_status: ModelExtractionStatus::NotRun, + model_constraints: Vec::new(), + model_revocations: Vec::new(), + revoked_constraint_ids: Vec::new(), + unmatched_revocation_ids: Vec::new(), + input_chars: 10, + prompt_chars: 10, + input_truncated: false, + latency_ms: 1, + extracted_at_ms: 1, + failure: None, + response_excerpt: None, + }); + assert!(state.has_enforceable_constraints()); + + state.merge_extraction(ConstraintExtractionRecord { + message_sha256: "turn-2-hash".to_string(), + dialog_turn_id: Some("turn-2".to_string()), + status: ExtractionStatus::ScopeReplaced, + constraints: Vec::new(), + deterministic_constraint_count: 0, + model_attempts: 0, + active_constraint_ids: Vec::new(), + revocation_authorized: true, + model_status: ModelExtractionStatus::NotRun, + model_constraints: Vec::new(), + model_revocations: Vec::new(), + revoked_constraint_ids: Vec::new(), + unmatched_revocation_ids: Vec::new(), + input_chars: 10, + prompt_chars: 10, + input_truncated: false, + latency_ms: 1, + extracted_at_ms: 2, + failure: None, + response_excerpt: None, + }); + + assert!(state.constraints.is_empty()); + assert!(!state.has_enforceable_constraints()); +} + +#[test] +fn scope_replacement_keeps_only_explicit_new_prohibition() { + let mut state = EditConstraintState::default(); + state.constraints.push(constraint( + "don't touch lockfiles", + ConstraintMatcher::Extension { + exts: vec![".lock".to_string()], + }, + )); + let new_test = constraint("don't modify tests", ConstraintMatcher::TestFiles); + + state.merge_extraction(ConstraintExtractionRecord { + message_sha256: "turn-2-hash".to_string(), + dialog_turn_id: Some("turn-2".to_string()), + status: ExtractionStatus::ScopeReplaced, + constraints: vec![new_test.clone()], + deterministic_constraint_count: 0, + model_attempts: 1, + active_constraint_ids: Vec::new(), + revocation_authorized: true, + model_status: ModelExtractionStatus::Parsed, + model_constraints: vec![new_test.clone()], + model_revocations: Vec::new(), + revoked_constraint_ids: Vec::new(), + unmatched_revocation_ids: Vec::new(), + input_chars: 10, + prompt_chars: 10, + input_truncated: false, + latency_ms: 1, + extracted_at_ms: 2, + failure: None, + response_excerpt: None, + }); + + assert_eq!(state.constraints, vec![new_test]); + assert!(find_violation(&state.constraints, "Cargo.lock").is_none()); + assert!(find_violation(&state.constraints, "report/util_test.go").is_some()); +} + +#[test] +fn explicit_prohibition_still_generates_constraint() { + assert!(has_prohibition_signal("Do not modify Cargo.lock.")); + assert!(has_prohibition_signal("禁止修改 src/config.rs。")); + let extracted = + deterministic_test_constraint("Do not modify test files.").expect("test constraint"); + assert_eq!(extracted.matcher, ConstraintMatcher::TestFiles); +} diff --git a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs index 0ce48d0e5c..4fc40c1625 100644 --- a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs +++ b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs @@ -57,6 +57,7 @@ use crate::util::types::Message as AIMessage; use crate::util::types::ToolDefinition; use crate::util::{elapsed_ms_u64, truncate_at_char_boundary}; use bitfun_agent_runtime::output_surface::TOOL_CONTEXT_INLINE_MARKDOWN_IMAGE_DISPLAY_KEY; +use bitfun_agent_runtime::prompt::RuntimeFactsUsage; use bitfun_agent_runtime::remote_file_delivery::TOOL_CONTEXT_REMOTE_FILE_DELIVERY_KEY; use bitfun_ai_adapters::ModelExchangeTraceConfig; use bitfun_core_types::SessionModelBindingPolicy; @@ -103,6 +104,21 @@ const MANUAL_COMPACTION_PLANNING: u8 = 0; const MANUAL_COMPACTION_CANCELLED: u8 = 1; const MANUAL_COMPACTION_COMMITTING: u8 = 2; +/// Session metadata key for the pre-compaction progress snapshot. Written by +/// the custom compaction checkpoint, which is intentionally not gated by +/// `app.hooks.enabled` so long-running tasks keep a recoverable record of +/// goal/role/todos state across context compaction. +const COMPACTION_PROGRESS_SNAPSHOT_KEY: &str = "compactionProgressSnapshot"; + +/// Current wall-clock time in milliseconds since the Unix epoch, used for +/// compaction snapshot timestamps. +fn compaction_snapshot_timestamp_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or(0) +} + /// Arbitrates the only race that matters for manual compaction: cancellation /// may win while the model is planning, but context commit must be atomic once /// it begins. @@ -421,6 +437,7 @@ struct TurnPromptScaffoldInput<'a> { supports_image_understanding: bool, model_name: &'a str, current_agent: &'a dyn crate::agentic::agents::Agent, + runtime_facts_usage: RuntimeFactsUsage, context: &'a ExecutionContext, } @@ -430,7 +447,8 @@ struct FinalizeRoundInput<'a> { tool_definitions: Option>, reminder_text: &'a str, messages: &'a [Message], - prepended_reminders: &'a [&'a str], + static_prepended_reminders: &'a [&'a str], + dynamic_prepended_reminders: &'a [&'a str], primary_model_facts: &'a PrimaryModelFacts, execution_context_vars: &'a HashMap, round_group_id: Option, @@ -523,6 +541,19 @@ impl ExecutionEngine { ) } + /// Map a token pressure snapshot to the prompt-level runtime facts used by + /// the Runtime Facts reminder: live usage ratio plus the dynamic + /// compression preview trigger point (input_limit / context_window). + fn runtime_facts_usage_from_pressure(pressure: &TokenPressureSnapshot) -> RuntimeFactsUsage { + let compression_preview_ratio = (pressure.context_window > 0).then(|| { + pressure.input_limit as f32 / pressure.context_window as f32 + }); + RuntimeFactsUsage { + context_usage_ratio: Some(pressure.usage_ratio), + compression_preview_ratio, + } + } + fn estimate_auto_compression_pressure_with_anchor( messages: &[Message], tools: Option<&[ToolDefinition]>, @@ -637,9 +668,16 @@ impl ExecutionEngine { let output_reserve_tokens = configured_max_tokens .map(|value| value as usize) .unwrap_or_else(|| automatic_max_output_tokens(context_window as u32) as usize); + // ENGINE-03:把输出预留钳制到窗口的 40% 以内(与 + // `is_valid_configured_max_output_tokens` 强制执行的同一比例)。否则配置了 + // 超过窗口的 max_tokens 会把 input_limit 压到 0,导致每一轮都无条件触发自动压缩。 + let max_output_reserve = (context_window as f64 * 0.40) as usize; + let output_reserve_tokens = output_reserve_tokens.min(max_output_reserve); let safety_reserve_tokens = Self::AUTO_COMPRESSION_SAFETY_RESERVE_TOKENS; - let input_limit = - context_window.saturating_sub(output_reserve_tokens + safety_reserve_tokens); + // ENGINE-05: saturating_add guards a 32-bit usize overflow when both + // reserves are summed. + let input_limit = context_window + .saturating_sub(output_reserve_tokens.saturating_add(safety_reserve_tokens)); CompressionTriggerBudget { input_limit, @@ -1193,6 +1231,7 @@ impl ExecutionEngine { execution_context: &ExecutionContext, current_agent: &dyn crate::agentic::agents::Agent, prompt_context: Option<&PromptBuilderContext>, + runtime_facts_usage: RuntimeFactsUsage, ) -> PrependedPromptReminders { let Some(prompt_context) = prompt_context.cloned() else { return PrependedPromptReminders::default(); @@ -1292,6 +1331,7 @@ impl ExecutionEngine { built_user_context }; let runtime_context = prompt_builder.build_runtime_context_reminder().await; + let runtime_facts = Some(prompt_builder.build_runtime_facts_reminder(runtime_facts_usage)); PrependedPromptReminders { deferred_tool_listing: prompt_builder.build_deferred_tool_listing_reminder(), @@ -1302,6 +1342,7 @@ impl ExecutionEngine { .as_ref() .and_then(|sections| sections.render_agent_listing_reminder()), runtime_context, + runtime_facts, user_context, } } @@ -1367,6 +1408,7 @@ impl ExecutionEngine { input.context, input.current_agent, prompt_context.as_ref(), + input.runtime_facts_usage, ) .await; let system_prompt = self @@ -1399,7 +1441,7 @@ impl ExecutionEngine { prepended_prompt_reminders: &PrependedPromptReminders, ) { debug!( - "Turn prompt scaffold resolved: session_id={}, turn_id={}, stage={}, system_prompt_len={} bytes, skill_listing_len={}, agent_listing_len={}, deferred_tool_listing_len={}, user_context_len={}, runtime_context_len={}", + "Turn prompt scaffold resolved: session_id={}, turn_id={}, stage={}, system_prompt_len={} bytes, skill_listing_len={}, agent_listing_len={}, deferred_tool_listing_len={}, user_context_len={}, runtime_context_len={}, runtime_facts_len={}", session_id, turn_id, stage, @@ -1428,6 +1470,11 @@ impl ExecutionEngine { .runtime_context .as_ref() .map(|text| text.len()) + .unwrap_or(0), + prepended_prompt_reminders + .runtime_facts + .as_ref() + .map(|text| text.len()) .unwrap_or(0) ); } @@ -1444,6 +1491,81 @@ impl ExecutionEngine { } } + /// Refresh only the per-round runtime facts reminder on a turn scaffold so + /// every model request carries live time and the current token pressure + /// snapshot instead of the turn-start values. Long-lived turns (background + /// Task agents, subagents, deep-review passes) can span many rounds and + /// minutes; keeping the turn-start snapshot would freeze the model's view + /// of time and context usage for the whole turn. + /// + /// ENGINE-01/07: sessions without a workspace never produce a prompt + /// context (`build_prompt_context` returns `None`), so the round-level + /// reminder previously stayed frozen at the turn-start value forever. + /// `build_runtime_facts_reminder` only needs the live clock and the usage + /// snapshot, so a minimal context refreshes it for every session shape. + /// The reminder always builds (returns `String`, never `None`), so the + /// round-level refresh can no longer silently skip. + /// P-17:按回合标记刷新或置空 Runtime Facts。 + /// - inject_runtime_facts == true(用户消息回合首轮或上下文恢复后首轮)→ 刷新注入。 + /// - false(同回合工具轮)→ 置空,动态后置不再携带 Runtime Facts。 + fn refresh_runtime_facts_for_round( + scaffold: &mut TurnPromptScaffold, + prompt_context: Option, + usage: RuntimeFactsUsage, + inject_runtime_facts: bool, + ) { + if !inject_runtime_facts { + scaffold.prepended_prompt_reminders.runtime_facts = None; + return; + } + let builder = match prompt_context { + Some(prompt_context) => PromptBuilder::new(prompt_context), + None => { + let mut context = PromptBuilderContext::new("", None, None); + // Preserve remote_execution from original context if available + if let Some(original_context) = &prompt_context { + context.remote_execution = original_context.remote_execution.clone(); + } + PromptBuilder::new(context) + }, + }; + let refreshed = builder.build_runtime_facts_reminder(usage); + scaffold.prepended_prompt_reminders.runtime_facts = Some(refreshed); + } + + /// P-18:按会话级 User Context 注入规则构建本轮动态后置提醒。 + /// - Runtime Facts:沿用 scaffold(refresh_runtime_facts_for_round 已按回合标记 + /// 置空或刷新:用户首轮/恢复后首轮 = Some,工具轮 = None)。 + /// - User Context:仅当 User Context 缓存世代变化(新对话/压缩后)时注入一次, + /// 记录注入世代,同世代后续轮不再重复注入(会话级一次)。 + async fn round_dynamic_reminders<'a>( + &self, + session_id: &str, + reminders: &'a PrependedPromptReminders, + ) -> Vec<&'a str> { + let mut dynamic = Vec::new(); + if let Some(runtime_facts) = reminders.runtime_facts.as_deref() { + dynamic.push(runtime_facts); + } + let generation = self + .session_manager + .user_context_cache_generation(session_id) + .await; + let injected_generation = self + .session_manager + .user_context_injected_generation(session_id) + .await; + if injected_generation != Some(generation) { + if let Some(user_context) = reminders.user_context.as_deref() { + dynamic.push(user_context); + } + self.session_manager + .remember_user_context_injected_generation(session_id, generation) + .await; + } + dynamic + } + pub(crate) async fn resolve_model_id_for_turn( &self, session: &Session, @@ -1618,7 +1740,8 @@ impl ExecutionEngine { .map(|workspace| workspace.root_path()), &input.context.dialog_turn_id, input.primary_model_facts.supports_image_inputs, - input.prepended_reminders, + input.static_prepended_reminders, + input.dynamic_prepended_reminders, ) .await?; final_ai_messages.push(AIMessage::user(render_system_reminder(input.reminder_text))); @@ -1675,19 +1798,27 @@ impl ExecutionEngine { workspace_path: Option<&Path>, current_turn_id: &str, attach_images: bool, - prepended_reminders: &[&str], + static_prepended_reminders: &[&str], + dynamic_prepended_reminders: &[&str], ) -> BitFunResult> { /// Only the last this many **messages** that contain images keep their images for the API. const MAX_IMAGE_BEARING_MESSAGE_ROUNDS: usize = 2; let limits = ImageLimits::for_provider(provider); - let trimmed_reminders = prepended_reminders + let trimmed_static_reminders = static_prepended_reminders + .iter() + .map(|text| text.trim()) + .filter(|text| !text.is_empty()) + .collect::>(); + let trimmed_dynamic_reminders = dynamic_prepended_reminders .iter() .map(|text| text.trim()) .filter(|text| !text.is_empty()) .collect::>(); - let mut result = Vec::with_capacity(messages.len() + trimmed_reminders.len()); + let mut result = Vec::with_capacity( + messages.len() + trimmed_static_reminders.len() + trimmed_dynamic_reminders.len(), + ); let mut attached_image_count = 0usize; let first_non_system_index = messages .iter() @@ -1703,7 +1834,10 @@ impl ExecutionEngine { for (msg_idx, msg) in messages.iter().enumerate() { if !prepended_reminders_injected && msg_idx == first_non_system_index { - for reminder in &trimmed_reminders { + // Static reminders (deferred tool listing / skill / agent / + // runtime context) stay right after the system message so the + // provider-side prompt/prefix cache prefix stays stable. + for reminder in &trimmed_static_reminders { result.push(AIMessage::user(render_system_reminder(reminder))); } prepended_reminders_injected = true; @@ -1830,11 +1964,20 @@ impl ExecutionEngine { } if !prepended_reminders_injected { - for reminder in trimmed_reminders { + for reminder in trimmed_static_reminders { result.push(AIMessage::user(render_system_reminder(reminder))); } } + // Dynamic reminders (runtime facts refreshed every round + user + // context) are always appended at the very end of the message + // sequence, after the newest user message, so their per-round + // changes never break the stable cache prefix built from the system + // message, the static reminders and the full conversation history. + for reminder in trimmed_dynamic_reminders { + result.push(AIMessage::user(render_system_reminder(reminder))); + } + Ok(result) } @@ -1886,14 +2029,16 @@ impl ExecutionEngine { attach_images: bool, prepended_prompt_reminders: &PrependedPromptReminders, ) -> BitFunResult> { - let prepended_reminders = prepended_prompt_reminders.ordered_reminders(); + let static_reminders = prepended_prompt_reminders.static_ordered_reminders(); + let dynamic_reminders = prepended_prompt_reminders.dynamic_ordered_reminders(); let mut compression_messages = Self::build_ai_messages_for_send( runtime_messages, provider, workspace.map(|workspace| workspace.root_path()), dialog_turn_id, attach_images, - &prepended_reminders, + &static_reminders, + &dynamic_reminders, ) .await?; compression_messages.push(AIMessage::user( @@ -2311,6 +2456,9 @@ impl ExecutionEngine { supports_image_understanding: primary_supports_image_understanding, tool_listing_sections, runtime_context_needs, + // Compression model requests do not need per-turn runtime + // facts; the default keeps their prompt prefix stable. + runtime_facts_usage: RuntimeFactsUsage::default(), stage: "compression_scaffold", }) .await?; @@ -2353,6 +2501,187 @@ impl ExecutionEngine { } } + /// Custom compaction checkpoint, intentionally outside the `app.hooks.enabled` + /// gate: persist a lightweight pre-compaction progress snapshot into session + /// metadata so long-running tasks can verify goal/role/todos state survived + /// context compaction. + async fn preserve_compaction_progress_snapshot( + &self, + session_id: &str, + trigger: &str, + session: &Session, + ) { + let Some(storage_path) = self + .session_manager + .effective_session_storage_path(session_id) + .await + else { + // Session persistence is disabled; there is nowhere to store the + // snapshot and post-compaction verification is skipped accordingly. + debug!( + "Compaction snapshot skipped (session storage unavailable): session_id={}", + session_id + ); + return; + }; + + let mut has_thread_goal = false; + let mut todos_present = false; + let mut custom_metadata_present = false; + match self + .session_manager + .load_session_metadata(&storage_path, session_id) + .await + { + Ok(Some(metadata)) => { + has_thread_goal = metadata + .custom_metadata + .as_ref() + .and_then(|value| value.get(bitfun_runtime_ports::THREAD_GOAL_METADATA_KEY)) + .is_some(); + todos_present = metadata.todos.is_some(); + custom_metadata_present = metadata.custom_metadata.is_some(); + } + Ok(None) => {} + Err(error) => { + debug!( + "Compaction snapshot baseline unavailable: session_id={}, error={}", + session_id, error + ); + } + } + + let snapshot = serde_json::json!({ + "trigger": trigger, + "compressionCountBefore": session.compression_state.compression_count, + "agentType": session.agent_type, + "hasThreadGoal": has_thread_goal, + "todosPresent": todos_present, + "customMetadataPresent": custom_metadata_present, + "recordedAtMs": compaction_snapshot_timestamp_ms(), + }); + if let Err(error) = self + .session_manager + .merge_session_custom_metadata( + session_id, + serde_json::json!({ COMPACTION_PROGRESS_SNAPSHOT_KEY: snapshot }), + ) + .await + { + warn!( + "Failed to persist compaction progress snapshot: session_id={}, trigger={}, error={}", + session_id, trigger, error + ); + } else { + // Registered: active subagent tracking is runtime-only (coordinator + // in-memory state) and is not persisted in session metadata; + // compaction does not clear it. + debug!( + "Compaction snapshot recorded: session_id={}, trigger={}, active_subagents=runtime_only_not_persisted", + session_id, trigger + ); + } + } + + /// Custom compaction checkpoint, intentionally outside the `app.hooks.enabled` + /// gate: read-only verification that goal/role/todos survived context + /// compaction. Only warns on missing state; never blocks or rewrites anything. + async fn verify_compaction_progress_state( + &self, + session_id: &str, + trigger: &str, + session: &Session, + ) { + let Some(storage_path) = self + .session_manager + .effective_session_storage_path(session_id) + .await + else { + return; + }; + let metadata = match self + .session_manager + .load_session_metadata(&storage_path, session_id) + .await + { + Ok(Some(metadata)) => metadata, + Ok(None) => { + warn!( + "Compaction verification: session metadata missing after compaction: session_id={}, trigger={}", + session_id, trigger + ); + return; + } + Err(error) => { + warn!( + "Compaction verification: failed to load session metadata after compaction: session_id={}, trigger={}, error={}", + session_id, trigger, error + ); + return; + } + }; + + let Some(snapshot) = metadata + .custom_metadata + .as_ref() + .and_then(|value| value.get(COMPACTION_PROGRESS_SNAPSHOT_KEY)) + else { + // No baseline was recorded (e.g. persistence disabled at snapshot + // time); verification is skipped without noise. + return; + }; + + let mut missing = Vec::new(); + if session.agent_type + != snapshot + .get("agentType") + .and_then(serde_json::Value::as_str) + .unwrap_or("") + { + missing.push("role(agent_type)"); + } + if snapshot + .get("hasThreadGoal") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + && metadata + .custom_metadata + .as_ref() + .and_then(|value| value.get(bitfun_runtime_ports::THREAD_GOAL_METADATA_KEY)) + .is_none() + { + missing.push("thread_goal"); + } + if snapshot + .get("todosPresent") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + && metadata.todos.is_none() + { + missing.push("todos"); + } + if snapshot + .get("customMetadataPresent") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + && metadata.custom_metadata.is_none() + { + missing.push("custom_metadata"); + } + + if missing.is_empty() { + debug!( + "Compaction verification passed: session_id={}, trigger={}", + session_id, trigger + ); + } else { + warn!( + "Compaction verification: state lost across compaction: session_id={}, trigger={}, missing={}", + session_id, trigger, missing.join(",") + ); + } + } + /// Compress context, will emit compression events (Started, Completed, and Failed) #[allow(clippy::too_many_arguments)] async fn compress_messages( @@ -2391,6 +2720,11 @@ impl ExecutionEngine { // Captured before `ai_client` is consumed by summary generation. let ai_client_model = ai_client.config.model.clone(); + // Capture pre-compaction progress state before native hook dispatch so + // long-running task state can be verified after compaction. + self.preserve_compaction_progress_snapshot(session_id, trigger, &session) + .await; + native_hooks::dispatch_pre_compact( Self::native_hook_facts(session_id, dialog_turn_id, workspace, &ai_client_model), trigger, @@ -2593,6 +2927,11 @@ impl ExecutionEngine { ) .await; + // Verify goal/role/todos survived compaction after native hook + // dispatch; only warns on missing state. + self.verify_compaction_progress_state(session_id, trigger, &session) + .await; + Ok(Some((compressed_tokens, new_messages))) } Ok(None) => Ok(None), @@ -2636,6 +2975,10 @@ impl ExecutionEngine { let scaffold = self .resolve_compression_runtime_scaffold(&session, &context) .await?; + // Capture pre-compaction progress state before native hook dispatch so + // long-running task state can be verified after compaction. + self.preserve_compaction_progress_snapshot(&session_id, trigger, &session) + .await; native_hooks::dispatch_pre_compact( Self::native_hook_facts( &session_id, @@ -2858,6 +3201,11 @@ impl ExecutionEngine { ) .await; + // Verify goal/role/todos survived compaction after native hook + // dispatch; only warns on missing state. + self.verify_compaction_progress_state(&session_id, trigger, &session) + .await; + Ok(ContextCompactionOutcome { compression_id, compression_count, @@ -3278,6 +3626,9 @@ impl ExecutionEngine { // 4. Resolve the prompt scaffold used by model requests in this turn. // It is refreshed after successful context compression so the first // post-compaction request builds the new provider-side prefix cache. + // Runtime facts carry a turn-start usage estimate: system prompt and + // prepended reminder tokens are not yet measurable at this point, so + // it is a lower bound that gets refreshed after context compression. let mut turn_prompt_scaffold = self .resolve_turn_prompt_scaffold(TurnPromptScaffoldInput { context: &context, @@ -3286,6 +3637,18 @@ impl ExecutionEngine { supports_image_understanding: primary_supports_image_understanding, tool_listing_sections: tool_listing_sections.clone(), runtime_context_needs, + runtime_facts_usage: Self::runtime_facts_usage_from_pressure( + &Self::estimate_auto_compression_pressure( + &initial_messages, + tool_definitions.as_deref(), + context_window, + Self::compression_trigger_budget( + context_window, + ai_client.config.max_tokens, + ), + 0, + ), + ), stage: "turn_start", }) .await?; @@ -3295,6 +3658,8 @@ impl ExecutionEngine { messages.extend(initial_messages); let mut round_index = 0; + // P-17:本轮是否发生上下文恢复(压缩/溢出恢复),恢复后首轮需注入 Runtime Facts。 + let mut context_recovered_this_round = false; let mut completed_rounds = 0usize; let mut total_tools = 0; let mut last_partial_recovery_reason: Option = None; @@ -3401,7 +3766,7 @@ impl ExecutionEngine { .session_manager .select_latest_matching_token_anchor(&context.session_id, &messages) .await; - let (token_pressure, anchor_details) = + let (mut token_pressure, anchor_details) = Self::estimate_auto_compression_pressure_with_anchor( &messages, tool_definitions.as_deref(), @@ -3483,7 +3848,10 @@ impl ExecutionEngine { token_pressure.safety_reserve_tokens ); + // ENGINE-03:input_limit == 0 表示窗口过小,仅预留(output reserve + + // safety reserve)就已超出窗口;此时禁用自动压缩,而不是每轮都无条件压缩。 let should_compress = enable_context_compression + && token_pressure.input_limit > 0 && token_pressure.total_tokens >= token_pressure.input_limit; let mut send_pressure_reusable = true; @@ -3520,77 +3888,154 @@ impl ExecutionEngine { token_pressure.usage_ratio * 100.0 ); - match self - .compress_messages( - &context.session_id, - &context.dialog_turn_id, - "auto", - messages.clone(), - token_pressure, - context_window, - ai_client.clone(), - &tool_definitions, - turn_prompt_scaffold.system_prompt_message.clone(), - &turn_prompt_scaffold.prepended_prompt_reminders, - primary_supports_image_understanding, - context_profile_policy.compression_contract_limit, - context.workspace.as_ref(), - ) - .await + // ENGINE-04: a single full-compression pass can still leave the + // context over input_limit (the compression contract preserves a + // recent-context tail). Re-check input_limit after each pass and + // compress again in the same round (bounded) instead of trusting + // the pre-compression snapshot. + const MAX_SAME_ROUND_COMPRESSION_PASSES: u32 = 2; + let mut compression_passes = 0u32; + let mut compressed_this_round = false; + while !circuit_breaker_open + && compression_passes < MAX_SAME_ROUND_COMPRESSION_PASSES + && token_pressure.total_tokens >= token_pressure.input_limit { - Ok(Some((compressed_tokens, compressed_messages))) => { - info!( - "Round {} compression completed: messages {} -> {}, tokens {} -> {}", - round_index, - messages.len(), - compressed_messages.len(), - token_pressure.total_tokens, - compressed_tokens, - ); + compression_passes += 1; + match self + .compress_messages( + &context.session_id, + &context.dialog_turn_id, + "auto", + messages.clone(), + token_pressure, + context_window, + ai_client.clone(), + &tool_definitions, + turn_prompt_scaffold.system_prompt_message.clone(), + &turn_prompt_scaffold.prepended_prompt_reminders, + primary_supports_image_understanding, + context_profile_policy.compression_contract_limit, + context.workspace.as_ref(), + ) + .await + { + Ok(Some((compressed_tokens, compressed_messages))) => { + info!( + "Round {} compression pass {} completed: messages {} -> {}, tokens {} -> {}", + round_index, + compression_passes, + messages.len(), + compressed_messages.len(), + token_pressure.total_tokens, + compressed_tokens, + ); - messages = compressed_messages; - turn_prompt_scaffold = self - .resolve_turn_prompt_scaffold(TurnPromptScaffoldInput { - context: &context, - current_agent: current_agent.as_ref(), - model_name: &ai_client.config.model, - supports_image_understanding: primary_supports_image_understanding, - tool_listing_sections: tool_listing_sections.clone(), - runtime_context_needs, - stage: "after_context_compression", - }) - .await?; - Self::apply_turn_prompt_scaffold_to_messages( - &mut messages, - &turn_prompt_scaffold, - ); - full_compression_count += 1; - consecutive_compression_failures = 0; - send_pressure_reusable = false; - } - Ok(None) => { - debug!("No eligible multi-turn context available for compression"); - consecutive_compression_failures = 0; - } - Err(e) => { - consecutive_compression_failures += 1; - compression_failure_count += 1; - error!( - "Round {} compression failed ({}/{}): {}, continuing with uncompressed context", - round_index, - consecutive_compression_failures, - MAX_CONSECUTIVE_COMPRESSION_FAILURES, - e - ); + messages = compressed_messages; + // ENGINE-02: recompute the pressure against the + // compressed messages so the next-pass decision, the + // scaffold refresh, and the runtime-facts reminder all + // see the post-compression state instead of the stale + // pre-compression snapshot. The prepended reminders are + // still the pre-refresh values here — they are small and + // the final send-pressure estimate below reuses the + // freshly resolved scaffold. + token_pressure = Self::estimate_auto_compression_pressure( + &messages, + tool_definitions.as_deref(), + context_window, + compression_trigger_budget, + Self::prepended_reminder_tokens_for_pressure( + &turn_prompt_scaffold + .prepended_prompt_reminders + .ordered_reminders(), + ), + ); + compressed_this_round = true; + context_recovered_this_round = true; + full_compression_count += 1; + consecutive_compression_failures = 0; + send_pressure_reusable = false; + } + Ok(None) => { + debug!("No eligible multi-turn context available for compression"); + consecutive_compression_failures = 0; + break; + } + Err(e) => { + consecutive_compression_failures += 1; + compression_failure_count += 1; + error!( + "Round {} compression failed ({}/{}): {}, continuing with uncompressed context", + round_index, + consecutive_compression_failures, + MAX_CONSECUTIVE_COMPRESSION_FAILURES, + e + ); + break; + } } } + + // Re-resolve the scaffold once after compression so the first + // post-compaction request builds the new provider-side prefix + // cache with the post-compression token pressure (ENGINE-02). + if compressed_this_round { + turn_prompt_scaffold = self + .resolve_turn_prompt_scaffold(TurnPromptScaffoldInput { + context: &context, + current_agent: current_agent.as_ref(), + model_name: &ai_client.config.model, + supports_image_understanding: primary_supports_image_understanding, + tool_listing_sections: tool_listing_sections.clone(), + runtime_context_needs, + runtime_facts_usage: Self::runtime_facts_usage_from_pressure( + &token_pressure, + ), + stage: "after_context_compression", + }) + .await?; + Self::apply_turn_prompt_scaffold_to_messages( + &mut messages, + &turn_prompt_scaffold, + ); + } } // L2: Emergency truncation — if tokens still exceed context_window // after all compression layers, drop oldest API rounds until we fit. + // Refresh runtime facts per round so every model request carries + // live time and the current token pressure snapshot; long-lived + // turns must not freeze the model's view at turn start. + let prompt_context = Self::build_prompt_context( + &context, + &ai_client.config.model, + primary_supports_image_understanding, + tool_listing_sections.clone(), + runtime_context_needs, + ) + .await; + // P-17/P-18 回合标记:用户消息回合首轮(round_index == 0)或上下文恢复后首轮 + // 注入 Runtime Facts;同回合工具轮(round_index > 0 且未恢复)不注入。 + let inject_runtime_facts = round_index == 0 || context_recovered_this_round; + context_recovered_this_round = false; + Self::refresh_runtime_facts_for_round( + &mut turn_prompt_scaffold, + prompt_context, + Self::runtime_facts_usage_from_pressure(&token_pressure), + inject_runtime_facts, + ); let send_prepended_reminders = turn_prompt_scaffold .prepended_prompt_reminders .ordered_reminders(); + let send_static_prepended_reminders = turn_prompt_scaffold + .prepended_prompt_reminders + .static_ordered_reminders(); + let send_dynamic_prepended_reminders = self + .round_dynamic_reminders( + &context.session_id, + &turn_prompt_scaffold.prepended_prompt_reminders, + ) + .await; let send_prepended_reminder_tokens = Self::prepended_reminder_tokens_for_pressure(&send_prepended_reminders); let mut send_pressure = if send_pressure_reusable @@ -3709,7 +4154,8 @@ impl ExecutionEngine { .map(|workspace| workspace.root_path()), &context.dialog_turn_id, primary_supports_image_understanding, - &send_prepended_reminders, + &send_static_prepended_reminders, + &send_dynamic_prepended_reminders, ) .await?; @@ -3784,6 +4230,9 @@ impl ExecutionEngine { primary_supports_image_understanding, tool_listing_sections: tool_listing_sections.clone(), runtime_context_needs, + runtime_facts_usage: Self::runtime_facts_usage_from_pressure( + &send_pressure, + ), stage: "after_context_overflow_recovery", }) .await?; @@ -3801,6 +4250,7 @@ impl ExecutionEngine { .await; full_compression_count += 1; consecutive_compression_failures = 0; + context_recovered_this_round = true; continue; } Ok(None) => { @@ -4079,14 +4529,29 @@ impl ExecutionEngine { let injection_id = injection.id.clone(); let injection_kind = injection.kind; let wrapped = match injection.kind { - RoundInjectionKind::UserSteering => format!( - "\nThe user sent a new message while this turn was running. You have just finished the previous atomic action; handle this new user message now as the current direction, while preserving the existing conversation and task context. Do not ignore it or wait for a separate future turn.\n\nNew user message:\n{}\n", - injection.content - ), - RoundInjectionKind::BackgroundResult => format!( - "\nA background task has finished and returned new information while this turn was running. Incorporate it into your current work immediately when relevant. Do not wait for a separate future turn.\n\nBackground result:\n{}\n", - injection.content - ), + RoundInjectionKind::UserSteering => { + let prepended_text = injection + .prepended_reminders + .iter() + .map(|reminder| reminder.text.as_str()) + .collect::>() + .join("\n"); + if prepended_text.is_empty() { + format!( + "\nThe user sent a new message while this turn was running. You have just finished the previous atomic action; handle this new user message now as the current direction, while preserving the existing conversation and task context. Do not ignore it or wait for a separate future turn.\n\nNew user message:\n{}\n", + injection.content + ) + } else { + format!( + "\n{}\n\nAn agent sent a new message while this turn was running. You have just finished the previous atomic action; handle this new message now as the current direction, while preserving the existing conversation and task context. Do not ignore it or wait for a separate future turn.\n\nNew message:\n{}\n", + prepended_text, injection.content + ) + } + } + RoundInjectionKind::BackgroundResult => { + "\nA background task has finished. The background subagent has replied. Use SessionHistory / SessionMessage to view the message content.\n" + .to_string() + } RoundInjectionKind::ThreadGoalObjectiveUpdated => { injection.content.clone() } @@ -4355,9 +4820,15 @@ impl ExecutionEngine { context.session_id, context.dialog_turn_id, reason ); - let finalize_prepended_reminders = turn_prompt_scaffold + let finalize_static_prepended_reminders = turn_prompt_scaffold .prepended_prompt_reminders - .ordered_reminders(); + .static_ordered_reminders(); + let finalize_dynamic_prepended_reminders = self + .round_dynamic_reminders( + &context.session_id, + &turn_prompt_scaffold.prepended_prompt_reminders, + ) + .await; let final_round_result = self .run_finalize_round(FinalizeRoundInput { permission_constraints: tool_policy.permission_constraints.clone(), @@ -4368,7 +4839,8 @@ impl ExecutionEngine { round_group_id: finalize_round_group_id.clone(), execution_context_vars: &execution_context_vars, primary_model_facts: &primary_model_facts, - prepended_reminders: &finalize_prepended_reminders, + static_prepended_reminders: &finalize_static_prepended_reminders, + dynamic_prepended_reminders: &finalize_dynamic_prepended_reminders, messages: &messages, reminder_text: finalize_reminder, tool_definitions: tool_definitions.clone(), @@ -4399,7 +4871,8 @@ impl ExecutionEngine { round_group_id: finalize_round_group_id.clone(), execution_context_vars: &execution_context_vars, primary_model_facts: &primary_model_facts, - prepended_reminders: &finalize_prepended_reminders, + static_prepended_reminders: &finalize_static_prepended_reminders, + dynamic_prepended_reminders: &finalize_dynamic_prepended_reminders, messages: &messages, reminder_text: finalize_reminder, tool_definitions: tool_definitions.clone(), @@ -4528,7 +5001,7 @@ impl ExecutionEngine { } // Print dialog turn token statistics (from model's last returned usage) - if let Some(usage) = last_usage { + if let Some(ref usage) = last_usage { info!( "Dialog turn completed - Token stats: turn_id={}, rounds={}, tools={}, duration={}ms, prompt_tokens={}, completion_tokens={}, total_tokens={}", context.dialog_turn_id, @@ -4564,6 +5037,12 @@ impl ExecutionEngine { .cloned() .unwrap_or_else(|| Message::assistant(String::new())), total_rounds: completed_rounds, + total_tools, + total_tokens: last_usage + .as_ref() + .map(|usage| usage.total_token_count as usize) + .unwrap_or(0), + duration_ms, success, new_messages, finish_reason, @@ -4627,7 +5106,9 @@ mod tests { use crate::agentic::agents::{ PrependedPromptReminders, PromptBuilderContext, UserContextPolicy, }; - use crate::agentic::core::{InternalReminderKind, Message, MessageRole, ToolCall, ToolResult}; + use crate::agentic::core::{ + InternalReminderKind, Message, MessageRole, MessageSemanticKind, ToolCall, ToolResult, + }; use crate::agentic::persistence::PersistenceManager; use crate::agentic::session::{ ContextCompressor, PromptCachePolicy, SessionContextStore, SessionManager, @@ -4641,7 +5122,9 @@ mod tests { use crate::service::config::types::AIConfig; use crate::service::config::types::AIModelConfig; use crate::service::remote_ssh::workspace_state::workspace_session_identity; + use crate::util::TokenCounter; use crate::util::types::ToolDefinition; + use bitfun_agent_runtime::prompt::RuntimeFactsUsage; use bitfun_runtime_ports::{WorkspaceDirEntry, WorkspaceFileSystem, WorkspacePathKind}; use serde_json::json; use sha2::{Digest, Sha256}; @@ -4650,6 +5133,13 @@ mod tests { use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::Arc; use std::time::Duration; + use crate::agentic::events::{EventQueue, EventQueueConfig}; + use crate::agentic::execution::{ExecutionEngineConfig, RoundExecutor, StreamProcessor}; + use crate::agentic::session::compression::CompressionConfig; + use crate::agentic::session::PromptCacheScope; + use crate::agentic::tools::registry::ToolRegistry; + use crate::agentic::tools::{ToolPipeline, ToolStateManager}; + use tokio::sync::RwLock as TokioRwLock; #[test] fn manual_compaction_preserves_cancellation_as_a_terminal_cancellation() { @@ -4856,6 +5346,7 @@ mod tests { #[cfg(feature = "external-sources")] #[tokio::test] + #[allow(clippy::await_holding_lock)] // environment lock is intentionally held for the whole test body async fn local_workspace_services_still_include_local_user_instruction_sources() { let _environment = lock_environment(); let temp = tempfile::tempdir().expect("tempdir"); @@ -4901,6 +5392,7 @@ mod tests { #[cfg(feature = "external-sources")] #[tokio::test] + #[allow(clippy::await_holding_lock)] // environment lock is intentionally held for the whole test body async fn local_workspace_services_remain_the_project_instruction_io_owner() { let _environment = lock_environment(); let temp = tempfile::tempdir().expect("tempdir"); @@ -5260,6 +5752,30 @@ mod tests { assert_eq!(budget.input_limit, 86_000); } + #[test] + fn compression_trigger_budget_clamps_output_reserve_to_window_ratio() { + // ENGINE-03:配置的 max_tokens 超过窗口时,不允许把 input_limit 饿死到 0; + // 输出预留被钳制到窗口的 40%(与 is_valid_configured_max_output_tokens 允许的同一比例)。 + let budget = ExecutionEngine::compression_trigger_budget(32_000, Some(100_000)); + + assert_eq!(budget.output_reserve_tokens, 12_800); + assert_eq!(budget.safety_reserve_tokens, 10_000); + assert!( + budget.input_limit > 0, + "input_limit must stay positive after the clamp, got {}", + budget.input_limit + ); + assert_eq!(budget.input_limit, 32_000 - 12_800 - 10_000); + } + + #[test] + fn compression_trigger_budget_disables_auto_compression_on_zero_input_limit() { + // ENGINE-03:当窗口小到仅预留就超出窗口时,input_limit 饱和为 0; + // 调用方在此时禁用自动压缩,而不是每轮都无条件压缩。 + let budget = ExecutionEngine::compression_trigger_budget(1_000, None); + assert_eq!(budget.input_limit, 0); + } + #[test] fn compression_trigger_budget_uses_the_automatic_output_tier_when_max_tokens_is_unset() { let budget = ExecutionEngine::compression_trigger_budget(128_000, None); @@ -5451,6 +5967,195 @@ mod tests { assert_eq!(messages[1].role, MessageRole::User); } + #[test] + fn per_round_runtime_facts_refresh_replaces_turn_start_value() { + let mut scaffold = TurnPromptScaffold { + system_prompt_message: Message::system("system prompt".to_string()), + prepended_prompt_reminders: PrependedPromptReminders::default(), + }; + let context = PromptBuilderContext::new( + "E:/workspace".to_string(), + Some("session-1".to_string()), + Some("model-1".to_string()), + ); + + ExecutionEngine::refresh_runtime_facts_for_round( + &mut scaffold, + Some(context), + RuntimeFactsUsage { + context_usage_ratio: Some(0.35), + compression_preview_ratio: Some(0.9), + }, + true, + ); + let first = scaffold + .prepended_prompt_reminders + .runtime_facts + .clone() + .expect("runtime facts should be refreshed for the round"); + assert!(first.contains("[Runtime Facts]")); + assert!(first.contains("当前上下文占比: 35%")); + + // A later round with a different pressure snapshot replaces the text: + // the runtime facts must not stay frozen at the first round's values. + ExecutionEngine::refresh_runtime_facts_for_round( + &mut scaffold, + Some(PromptBuilderContext::new( + "E:/workspace".to_string(), + Some("session-1".to_string()), + Some("model-1".to_string()), + )), + RuntimeFactsUsage { + context_usage_ratio: Some(0.72), + compression_preview_ratio: Some(0.9), + }, + true, + ); + let second = scaffold + .prepended_prompt_reminders + .runtime_facts + .clone() + .expect("runtime facts should stay refreshed"); + assert_ne!(first, second); + assert!(second.contains("当前上下文占比: 72%")); + + // ENGINE-01/07: a missing prompt context (workspace-less session) must + // still refresh the reminder from a minimal context instead of leaving + // the previous round's value frozen; the usage ratio is replaced. + ExecutionEngine::refresh_runtime_facts_for_round( + &mut scaffold, + None, + RuntimeFactsUsage { + context_usage_ratio: Some(0.41), + compression_preview_ratio: Some(0.9), + }, + true, + ); + let third = scaffold + .prepended_prompt_reminders + .runtime_facts + .clone() + .expect("runtime facts should refresh even without a prompt context"); + assert_ne!(second, third); + assert!(third.contains("[Runtime Facts]")); + assert!(third.contains("当前上下文占比: 41%")); + } + + #[test] + fn tool_round_clears_runtime_facts_after_user_round_injection() { + // P-17: user round first turn injects runtime facts; the same round's + // tool turn clears them so the dynamic postfix no longer carries them. + let mut scaffold = TurnPromptScaffold { + system_prompt_message: Message::system("system prompt".to_string()), + prepended_prompt_reminders: PrependedPromptReminders::default(), + }; + let context = PromptBuilderContext::new( + "E:/workspace".to_string(), + Some("session-1".to_string()), + Some("model-1".to_string()), + ); + let usage = RuntimeFactsUsage { + context_usage_ratio: Some(0.35), + compression_preview_ratio: Some(0.9), + }; + + // User round first turn: inject. + ExecutionEngine::refresh_runtime_facts_for_round( + &mut scaffold, + Some(context.clone()), + usage, + true, + ); + assert!( + scaffold.prepended_prompt_reminders.runtime_facts.is_some(), + "user round first turn should inject runtime facts" + ); + + // Same-round tool turn: clear. + ExecutionEngine::refresh_runtime_facts_for_round( + &mut scaffold, + Some(context), + usage, + false, + ); + assert!( + scaffold.prepended_prompt_reminders.runtime_facts.is_none(), + "same-round tool turn must not carry runtime facts" + ); + } + + #[tokio::test] + async fn round_dynamic_reminders_injects_user_context_once_per_cache_generation() { + // P-18: User Context is session-scoped - injected on the first round of a + // new conversation (or after context compaction bumps the cache + // generation), and skipped on subsequent rounds of the same generation. + let temp = tempfile::tempdir().expect("tempdir"); + let event_queue = Arc::new(EventQueue::new(EventQueueConfig::default())); + let session_manager = Arc::new(SessionManager::new( + Arc::new(SessionContextStore::new()), + Arc::new( + PersistenceManager::new(Arc::new(PathManager::with_user_root_for_tests( + temp.path().join("user-root"), + ))) + .expect("persistence manager"), + ), + SessionManagerConfig { + max_active_sessions: 4, + session_idle_timeout: Duration::from_secs(3600), + auto_save_interval: Duration::from_secs(300), + enable_persistence: false, + prompt_cache_policy: PromptCachePolicy::default(), + }, + )); + let tool_pipeline = Arc::new(ToolPipeline::new( + Arc::new(TokioRwLock::new(ToolRegistry::new())), + Arc::new(ToolStateManager::new(event_queue.clone())), + None, + )); + let engine = ExecutionEngine::new( + Arc::new(RoundExecutor::new( + Arc::new(StreamProcessor::new(event_queue.clone())), + event_queue.clone(), + tool_pipeline.clone(), + )), + event_queue.clone(), + session_manager.clone(), + Arc::new(ContextCompressor::new(CompressionConfig::default())), + ExecutionEngineConfig::default(), + ); + + let session_id = "p18-round-session"; + let reminders = PrependedPromptReminders { + runtime_facts: Some("[Runtime Facts] 当前上下文占比: 35%".to_string()), + user_context: Some("[User Context] workspace instructions".to_string()), + ..Default::default() + }; + + // New conversation first round: runtime facts + user context both inject. + let first = engine + .round_dynamic_reminders(session_id, &reminders) + .await; + assert!(first.iter().any(|r| r.contains("[Runtime Facts]"))); + assert!(first.iter().any(|r| r.contains("[User Context]"))); + + // Subsequent round of the same generation: user context skipped. + let second = engine + .round_dynamic_reminders(session_id, &reminders) + .await; + assert!(second.iter().any(|r| r.contains("[Runtime Facts]"))); + assert!(!second.iter().any(|r| r.contains("[User Context]"))); + + // Context compaction bumps the generation: first round re-injects. + session_manager + .invalidate_prompt_cache(session_id, PromptCacheScope::UserContext, "test") + .await; + let third = engine + .round_dynamic_reminders(session_id, &reminders) + .await; + assert!(third.iter().any(|r| r.contains("[Runtime Facts]"))); + assert!(third.iter().any(|r| r.contains("[User Context]"))); + } + #[test] fn tool_signature_args_summary_truncates_on_utf8_boundary() { let args = format!("{}{}", "a".repeat(62), "案".repeat(30)); @@ -5813,4 +6518,218 @@ mod tests { image_attachments: None, }) } + + #[tokio::test] + async fn resident_subagent_session_compaction_keeps_context_reusable() { + // A resident subagent work post (Task spawn then repeated send_input + // reuse) accumulates context across dialog turns. Automatic compaction + // must replace the in-memory context — the exact source the next + // send_input loads — without changing the session identity, and the + // compacted context must stay compressible so the resident session + // never dies from an ever-growing context window. + let temp = tempfile::tempdir().expect("tempdir"); + let session_manager = SessionManager::new( + Arc::new(SessionContextStore::new()), + Arc::new( + PersistenceManager::new(Arc::new(PathManager::with_user_root_for_tests( + temp.path().join("user-root"), + ))) + .expect("persistence manager"), + ), + SessionManagerConfig { + max_active_sessions: 4, + session_idle_timeout: Duration::from_secs(3600), + auto_save_interval: Duration::from_secs(300), + enable_persistence: false, + prompt_cache_policy: PromptCachePolicy::default(), + }, + ); + let compressor = ContextCompressor::new(Default::default()); + let session_id = "resident-subagent-session"; + // A small window keeps the test fast while exercising the real trigger + // math (input_limit = window - output reserve - safety reserve). It + // must stay above the 10k safety reserve so input_limit is meaningful. + let context_window = 32_000usize; + let trigger_budget = ExecutionEngine::compression_trigger_budget(context_window, None); + assert!(trigger_budget.input_limit > 0); + + // Repeated send_input turns: each turn appends a user message plus + // assistant/tool round messages (the engine loop's add_message path). + let mut turn = 0usize; + let compressed_turn = loop { + turn += 1; + assert!(turn < 50, "compression never triggered"); + let user_message = Message::user(format!("send_input turn {}: continue the standing task", turn)) + .with_turn_id(format!("turn-{turn}")); + let assistant_message = + Message::assistant(format!("round evidence {}", "x".repeat(2_000))) + .with_turn_id(format!("turn-{turn}")); + let tool_message = + command_result("Bash", true, Some(0)).with_turn_id(format!("turn-{turn}")); + for message in [&user_message, &assistant_message, &tool_message] { + session_manager + .add_message(session_id, message.clone()) + .await + .expect("append turn messages"); + } + + let context = session_manager + .get_context_messages(session_id) + .await + .expect("reusable context"); + let pressure = ExecutionEngine::estimate_auto_compression_pressure( + &context, + None, + context_window, + trigger_budget, + 0, + ); + if pressure.total_tokens >= pressure.input_limit { + let Some(plan) = compressor + .plan_compression( + session_id, + &context, + context_window, + ContextCompressor::DEFAULT_RECENT_CONTEXT_TOKENS, + ) + .expect("compression planning succeeds") + else { + // Not enough compressible history yet; keep accumulating. + continue; + }; + let result = compressor + .compress_plan_with_contract( + session_id, + context_window, + plan, + None, + Some(format!("turn {} handoff summary", turn)), + ) + .expect("compression succeeds"); + let before_message_count = context.len(); + session_manager + .replace_context_messages(session_id, result.messages.clone()) + .await; + let after = session_manager + .get_context_messages(session_id) + .await + .expect("compacted context"); + // ENGINE-06:压缩回归断言必须与真实 send_input 使用同一度量—— + // 完整会话消息走 estimate_auto_compression_pressure,而不是按单条 + // 消息求和(后者测的是另一个 token 口径)。 + let after_pressure = ExecutionEngine::estimate_auto_compression_pressure( + &after, + None, + context_window, + trigger_budget, + 0, + ); + assert!( + after_pressure.total_tokens < after_pressure.input_limit, + "compaction must bring the resident context back under the input limit: after={}, input_limit={}", + after_pressure.total_tokens, + after_pressure.input_limit + ); + // ENGINE-06:压缩后的会话消息并不是完整请求。下一次 send_input 会 + // 在其上重新拼回系统提示、前置提醒与工具定义;这些固定脚手架的开销 + // 必须由压缩后的裕量(input_limit - total_tokens)覆盖,否则常驻 + // 会话在下一轮又会立刻触发压缩,依然会在窗口处耗尽。 + let scaffold_system_tokens = ExecutionEngine::system_tokens_for_pressure( + std::slice::from_ref(&Message::system( + "You are BitFun, the LVPA taiji quant trading agent. Execute the user's task within the workshop workflow." + .to_string(), + )), + ); + let scaffold_reminder_tokens = + ExecutionEngine::prepended_reminder_tokens_for_pressure(&[ + "Continue executing the standing task. The prior context was summarized by compression.", + "Current time is 2026-08-05T12:00:00Z. Context usage is low after compaction.", + ]); + let scaffold_tools = vec![ + ToolDefinition { + name: "Bash".to_string(), + description: "Run a shell command and capture its output.".to_string(), + parameters: json!({"type": "object", "properties": {"command": {"type": "string"}}}), + }, + ToolDefinition { + name: "Read".to_string(), + description: "Read a file from the workspace and return its content.".to_string(), + parameters: json!({"type": "object", "properties": {"path": {"type": "string"}}}), + }, + ]; + let scaffold_tool_tokens = + TokenCounter::estimate_tool_definitions_tokens(&scaffold_tools); + let scaffold_overhead = scaffold_system_tokens + .saturating_add(scaffold_reminder_tokens) + .saturating_add(scaffold_tool_tokens); + let after_headroom = after_pressure + .input_limit + .saturating_sub(after_pressure.total_tokens); + assert!( + after_headroom >= scaffold_overhead, + "compaction must leave margin for the system/reminder/tool scaffold the next send_input adds back: headroom={}, scaffold={} (system={}, reminders={}, tools={}), after={}, input_limit={}", + after_headroom, + scaffold_overhead, + scaffold_system_tokens, + scaffold_reminder_tokens, + scaffold_tool_tokens, + after_pressure.total_tokens, + after_pressure.input_limit + ); + assert!( + after.len() < before_message_count, + "compaction must fold the accumulated turn messages: before={}, after={}", + before_message_count, + after.len() + ); + assert!( + after.iter().any(|message| message.metadata.semantic_kind + == Some(MessageSemanticKind::CompressionSummary)), + "compacted context must carry the compression summary" + ); + assert!( + after.iter().any(|message| message.internal_reminder_kind() + == Some(InternalReminderKind::CompressionContinuation)), + "compacted context must carry the continuation reminder" + ); + break turn; + } + }; + + // The next send_input loads the compacted context (same session_id), + // appends a new user message, and must remain compressible so the + // resident session can keep running instead of dying at the window. + let continued = session_manager + .get_context_messages(session_id) + .await + .expect("reusable context after compaction"); + assert!( + !continued.is_empty(), + "compacted context is loadable by the next send_input" + ); + session_manager + .add_message( + session_id, + Message::user("send_input after compaction: keep going".to_string()) + .with_turn_id(format!("turn-{}", compressed_turn + 1)), + ) + .await + .expect("append after compaction"); + let continued = session_manager + .get_context_messages(session_id) + .await + .expect("reloaded context"); + let plan = compressor + .plan_compression( + session_id, + &continued, + context_window, + ContextCompressor::DEFAULT_RECENT_CONTEXT_TOKENS, + ) + .expect("recompression planning succeeds"); + assert!( + plan.is_some(), + "compacted resident context remains compressible" + ); + } } diff --git a/src/crates/assembly/core/src/agentic/execution/round_executor.rs b/src/crates/assembly/core/src/agentic/execution/round_executor.rs index c06191057c..c3fd2eb43a 100644 --- a/src/crates/assembly/core/src/agentic/execution/round_executor.rs +++ b/src/crates/assembly/core/src/agentic/execution/round_executor.rs @@ -131,6 +131,7 @@ impl RoundExecutor { } } + #[allow(clippy::too_many_arguments)] async fn record_retry_diagnostic( &self, context: &RoundContext, diff --git a/src/crates/assembly/core/src/agentic/execution/types.rs b/src/crates/assembly/core/src/agentic/execution/types.rs index dd12ab189b..a98286e019 100644 --- a/src/crates/assembly/core/src/agentic/execution/types.rs +++ b/src/crates/assembly/core/src/agentic/execution/types.rs @@ -119,6 +119,12 @@ pub struct ExecutionResult { /// Last assistant message pub final_message: Message, pub total_rounds: usize, + /// Total number of tool calls executed during this execution + pub total_tools: usize, + /// Total token usage reported by the model for this execution (0 when unavailable) + pub total_tokens: usize, + /// Total wall-clock duration of this execution in milliseconds + pub duration_ms: u64, pub success: bool, /// All new messages generated by this execution (including AI responses and tool results) pub new_messages: Vec, diff --git a/src/crates/assembly/core/src/agentic/goal_mode/mod.rs b/src/crates/assembly/core/src/agentic/goal_mode/mod.rs index 5223da8bbb..00e0bfe434 100644 --- a/src/crates/assembly/core/src/agentic/goal_mode/mod.rs +++ b/src/crates/assembly/core/src/agentic/goal_mode/mod.rs @@ -29,6 +29,13 @@ pub use bitfun_runtime_ports::{ MAX_GOAL_CONTINUATIONS, MAX_THREAD_GOAL_AUTO_CONTINUATIONS, MAX_THREAD_GOAL_OBJECTIVE_CHARS, THREAD_GOAL_METADATA_KEY, }; + +/// Idle window before the goal safety net wakes the commander. +/// +/// Immediate after-turn auto-continuation is disabled; a goal is only picked up +/// again when a session with an active thread goal has been idle for this long +/// with no new user submission. +pub const GOAL_IDLE_WAKEUP_DELAY_MS: u64 = 600_000; use log::{info, warn}; use std::path::Path; use std::time::{SystemTime, UNIX_EPOCH}; @@ -124,6 +131,7 @@ impl<'a> ThreadGoalStore<'a> { .await } + #[allow(clippy::too_many_arguments)] pub async fn set_thread_goal( &self, session_id: &str, @@ -131,6 +139,7 @@ impl<'a> ThreadGoalStore<'a> { objective: Option, status: Option, token_budget: Option>, + reference_files: Option>, replace_existing: bool, ) -> BitFunResult { let existing = self.get_thread_goal(session_id, workspace_path).await?; @@ -145,6 +154,7 @@ impl<'a> ThreadGoalStore<'a> { objective, status, token_budget, + reference_files, replace_existing, now_epoch_seconds: now_epoch_seconds(), new_goal_id: Uuid::new_v4().to_string(), @@ -170,6 +180,7 @@ impl<'a> ThreadGoalStore<'a> { workspace_path: &Path, objective: String, token_budget: Option, + reference_files: Vec, ) -> BitFunResult { if self .get_thread_goal(session_id, workspace_path) @@ -187,6 +198,7 @@ impl<'a> ThreadGoalStore<'a> { Some(objective), Some(ThreadGoalStatus::Active), Some(token_budget), + Some(reference_files), false, ) .await?; @@ -287,15 +299,16 @@ mod tests { created_at: 1, updated_at: 2, auto_continuation_count: 2, + reference_files: Vec::new(), }); assert!(plan.display_message.contains("completion check")); - assert!(plan.display_message.contains("2/100")); + assert!(plan.display_message.contains("2/10")); assert_eq!( plan.user_message_metadata["threadGoalContinuationCheck"], true ); assert_eq!(plan.user_message_metadata["autoContinuationAttempt"], 2); - assert_eq!(plan.user_message_metadata["autoContinuationMax"], 100); + assert_eq!(plan.user_message_metadata["autoContinuationMax"], 10); } #[test] @@ -311,6 +324,7 @@ mod tests { created_at: 1, updated_at: 2, auto_continuation_count: 0, + reference_files: Vec::new(), }); assert!(prompt.contains("finish stack")); assert!(prompt.contains("update_goal")); @@ -348,8 +362,8 @@ mod tests { #[test] fn max_goal_continuations_matches_legacy_limit() { - assert_eq!(MAX_GOAL_CONTINUATIONS, 100); - assert_eq!(MAX_THREAD_GOAL_AUTO_CONTINUATIONS, 100); + assert_eq!(MAX_GOAL_CONTINUATIONS, 10); + assert_eq!(MAX_THREAD_GOAL_AUTO_CONTINUATIONS, 10); } #[test] @@ -391,6 +405,7 @@ mod tests { created_at: 1, updated_at: 2, auto_continuation_count: 0, + reference_files: Vec::new(), }) .user_message_metadata; assert!(should_skip_goal_for_turn("Adjust work", Some(&metadata))); diff --git a/src/crates/assembly/core/src/agentic/memories/runner.rs b/src/crates/assembly/core/src/agentic/memories/runner.rs index d67957ee96..09e301390c 100644 --- a/src/crates/assembly/core/src/agentic/memories/runner.rs +++ b/src/crates/assembly/core/src/agentic/memories/runner.rs @@ -761,6 +761,8 @@ fn memory_phase2_tool_restrictions(memory_root: &std::path::Path) -> ToolRuntime edit_roots: vec![root.clone()], delete_roots: vec![root], }, + allowed_operation_classes: BTreeSet::new(), + denied_operation_classes: BTreeSet::new(), } } diff --git a/src/crates/assembly/core/src/agentic/memories/startup.rs b/src/crates/assembly/core/src/agentic/memories/startup.rs index 46252d10b5..c728115506 100644 --- a/src/crates/assembly/core/src/agentic/memories/startup.rs +++ b/src/crates/assembly/core/src/agentic/memories/startup.rs @@ -111,7 +111,7 @@ pub fn memory_startup_is_eligible(request: &MemoryStartupRequest) -> bool { } if matches!( request.session_kind, - SessionKind::Subagent | SessionKind::EphemeralChild + SessionKind::Subagent | SessionKind::EphemeralChild | SessionKind::EphemeralSubagent ) { return false; } @@ -161,6 +161,10 @@ mod tests { session_kind: SessionKind::EphemeralChild, ..request() })); + assert!(!memory_startup_is_eligible(&MemoryStartupRequest { + session_kind: SessionKind::EphemeralSubagent, + ..request() + })); assert!(!memory_startup_is_eligible(&MemoryStartupRequest { workspace_path: None, ..request() diff --git a/src/crates/assembly/core/src/agentic/mod.rs b/src/crates/assembly/core/src/agentic/mod.rs index 84edde742e..a677a982c3 100644 --- a/src/crates/assembly/core/src/agentic/mod.rs +++ b/src/crates/assembly/core/src/agentic/mod.rs @@ -26,6 +26,9 @@ pub mod deep_review_policy; pub mod harness; pub(crate) mod subagent_runtime; +// Warden protocol module (RBAC+Poke) +pub mod warden; + // Shared-context fork-agent execution module pub mod fork_agent; @@ -79,4 +82,5 @@ pub use system::{ init_agentic_system, init_agentic_system_for_profile, init_agentic_system_for_profile_with_runtime_ownership, AgenticSystem, }; +pub use warden::*; pub use workspace::{WorkspaceBackend, WorkspaceBinding}; diff --git a/src/crates/assembly/core/src/agentic/persistence/manager.rs b/src/crates/assembly/core/src/agentic/persistence/manager.rs index 6ef94eb095..62202e3e80 100644 --- a/src/crates/assembly/core/src/agentic/persistence/manager.rs +++ b/src/crates/assembly/core/src/agentic/persistence/manager.rs @@ -1035,6 +1035,7 @@ impl PersistenceManager { workspace_hostname: workspace_hostname.as_deref(), new_session_memory_mode: new_session_memory_mode_from_global_config().await, existing, + is_daemon: session.config.is_daemon, }) } @@ -1170,6 +1171,17 @@ impl PersistenceManager { pub async fn list_session_metadata( &self, workspace_path: &Path, + ) -> BitFunResult> { + self.list_session_metadata_with_options(workspace_path, false) + .await + } + + /// Lists session metadata. With `include_internal`, hidden Subagent/ + /// Ephemeral sessions are included for full conversation management. + pub async fn list_session_metadata_with_options( + &self, + workspace_path: &Path, + include_internal: bool, ) -> BitFunResult> { if !workspace_path.exists() { return Ok(Vec::new()); @@ -1180,7 +1192,7 @@ impl PersistenceManager { } self.session_metadata_store(workspace_path) - .list_metadata() + .list_metadata_with_options(include_internal) .await .map_err(Self::session_metadata_store_error) } @@ -1190,6 +1202,18 @@ impl PersistenceManager { workspace_path: &Path, cursor: Option<&str>, limit: usize, + ) -> BitFunResult { + self.list_session_metadata_page_with_options(workspace_path, cursor, limit, false) + .await + } + + /// Paginated variant of [`list_session_metadata_with_options`]. + pub async fn list_session_metadata_page_with_options( + &self, + workspace_path: &Path, + cursor: Option<&str>, + limit: usize, + include_internal: bool, ) -> BitFunResult { if !workspace_path.exists() { return Ok(empty_session_metadata_page()); @@ -1200,7 +1224,7 @@ impl PersistenceManager { } self.session_metadata_store(workspace_path) - .list_metadata_page(cursor, limit) + .list_metadata_page_with_options(cursor, limit, include_internal) .await .map_err(Self::session_metadata_store_error) } @@ -1515,7 +1539,7 @@ impl PersistenceManager { .map_err(Self::session_metadata_store_error) } - async fn load_stored_session_state( + pub(crate) async fn load_stored_session_state( &self, workspace_path: &Path, session_id: &str, @@ -2109,9 +2133,14 @@ impl PersistenceManager { let existing_metadata = self .load_session_metadata(workspace_path, &session.session_id) .await?; - let metadata = self + let mut metadata = self .build_session_metadata(workspace_path, session, existing_metadata.as_ref()) .await; + metadata.runtime_state = + Some(serde_json::to_value(sanitize_persisted_session_state( + &session.state, + )) + .unwrap_or(serde_json::Value::Null)); self.save_session_metadata_locked(workspace_path, &metadata) .await?; @@ -2525,6 +2554,11 @@ impl PersistenceManager { created_at: Self::unix_ms_to_system_time(metadata.created_at), last_activity_at: Self::unix_ms_to_system_time(metadata.last_active_at), state, + parent_session_id: metadata + .relationship + .as_ref() + .and_then(|r| r.parent_session_id.clone()), + is_daemon: metadata.is_daemon, }); } diff --git a/src/crates/assembly/core/src/agentic/session/mod.rs b/src/crates/assembly/core/src/agentic/session/mod.rs index 2a9db73749..2635077ce9 100644 --- a/src/crates/assembly/core/src/agentic/session/mod.rs +++ b/src/crates/assembly/core/src/agentic/session/mod.rs @@ -9,6 +9,7 @@ pub mod evidence_ledger; pub mod file_read_state; pub mod prompt_cache; pub(crate) mod revert; +pub mod session_gc; pub mod session_manager; pub mod session_store_port; pub mod token_anchor; @@ -21,6 +22,7 @@ pub use context_usage::*; pub use evidence_ledger::*; pub use file_read_state::*; pub use prompt_cache::*; +pub use session_gc::*; pub use session_manager::*; pub use session_store_port::*; pub use token_anchor::*; diff --git a/src/crates/assembly/core/src/agentic/session/session_gc.rs b/src/crates/assembly/core/src/agentic/session/session_gc.rs new file mode 100644 index 0000000000..a4efb68678 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/session/session_gc.rs @@ -0,0 +1,222 @@ +//! Session GC: orphan detection and transient sweep candidate reporting. +//! +//! Conservative by design: this module only *reports* cleanup candidates. +//! Automatic deletion is deliberately not performed, so a scan can never +//! destroy a session that a concurrent owner still holds a reference to. +//! Callers decide whether to act on a report. + +use std::collections::HashSet; + +use bitfun_services_core::session::SessionMetadata; + +/// Why a session is considered an orphan. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OrphanKind { + /// The session declares a parent that no longer exists in the scanned set. + DanglingChild, + /// The session carries a `session-{parent}` creator marker but declares no + /// relationship, and that parent no longer exists. + DetachedChild, +} + +/// One reported orphan candidate. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OrphanedSessionRecord { + pub session_id: String, + pub kind: OrphanKind, + pub reason: String, +} + +/// Result of a report-only GC scan. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct SessionGcReport { + pub scanned_metadata_count: usize, + pub orphaned: Vec, +} + +/// A transient session that finished executing and whose parent (if any) is +/// no longer loaded, so no reuse reference can remain (report-only). +/// +/// Parent identity follows the same `session-{parent}` creator marker used by +/// `SessionManager::transient_descendants_postorder`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TransientSweepCandidate { + pub session_id: String, + pub parent_session_id: Option, +} + +/// Creator marker prefix used when a coordinator spawns a subagent session. +/// Mirrors the `session-{parent_session_id}` marker in +/// `SessionManager::transient_descendants_postorder`. +const SUBAGENT_CREATOR_PREFIX: &str = "session-"; + +/// Classify session metadata and report orphan candidates. +/// +/// Conservative rules: +/// - `relationship.parent_session_id = Some(parent)` with `parent` absent +/// from the scanned set is a dangling child (its parent was deleted without +/// a cascading delete). +/// - A `created_by` of the form `session-{parent}` with no relationship and an +/// absent `parent` is a detached child. All other `created_by` values +/// (user-supplied names, `memory-phase2`, `None`, ...) are treated as +/// legitimate top-level creators and are never flagged. +/// - Children whose parent is present, and top-level sessions, are never +/// flagged. +pub fn classify_orphaned_metadata(metadata: &[SessionMetadata]) -> SessionGcReport { + let known_ids: HashSet<&str> = metadata + .iter() + .map(|entry| entry.session_id.as_str()) + .collect(); + let mut orphaned = Vec::new(); + + for entry in metadata { + let session_id = entry.session_id.as_str(); + + if let Some(parent_session_id) = entry + .relationship + .as_ref() + .and_then(|relationship| relationship.parent_session_id.as_deref()) + { + if !known_ids.contains(parent_session_id) { + orphaned.push(OrphanedSessionRecord { + session_id: session_id.to_string(), + kind: OrphanKind::DanglingChild, + reason: format!("parent session {} is missing from metadata", parent_session_id), + }); + } + continue; + } + + if let Some(created_by) = entry.created_by.as_deref() { + if let Some(parent_session_id) = created_by.strip_prefix(SUBAGENT_CREATOR_PREFIX) { + if !known_ids.contains(parent_session_id) { + orphaned.push(OrphanedSessionRecord { + session_id: session_id.to_string(), + kind: OrphanKind::DetachedChild, + reason: format!("creator marker references missing parent {}", parent_session_id), + }); + } + } + } + } + + SessionGcReport { + scanned_metadata_count: metadata.len(), + orphaned, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bitfun_core_types::{SessionContinuationPolicy, SessionKind}; + use bitfun_services_core::session::{SessionMemoryMode, SessionRelationship, SessionStatus}; + + fn metadata(session_id: &str) -> SessionMetadata { + SessionMetadata { + session_id: session_id.to_string(), + session_name: format!("test-{}", session_id), + agent_type: "agentic".to_string(), + last_user_dialog_agent_type: None, + last_submitted_agent_type: None, + created_by: None, + session_kind: SessionKind::Standard, + memory_mode: SessionMemoryMode::Enabled, + model_name: "primary".to_string(), + created_at: 1, + last_active_at: 1, + last_finished_at: None, + turn_count: 0, + message_count: 0, + tool_call_count: 0, + status: SessionStatus::Active, + terminal_session_id: None, + snapshot_session_id: None, + tags: Vec::new(), + custom_metadata: None, + relationship: None, + todos: None, + review_action_state: None, + deep_review_run_manifest: None, + review_target_evidence: None, + deep_review_cache: None, + workspace_path: None, + project_workspace_path: None, + execution_target: None, + workspace_hostname: None, + unread_completion: None, + needs_user_attention: None, + runtime_state: None, + is_daemon: false, + } + } + + fn metadata_with_parent(session_id: &str, parent_session_id: &str) -> SessionMetadata { + let mut entry = metadata(session_id); + entry.created_by = Some(format!("session-{}", parent_session_id)); + entry.relationship = Some(SessionRelationship { + parent_session_id: Some(parent_session_id.to_string()), + continuation_policy: Some(SessionContinuationPolicy::FreshOnly), + ..Default::default() + }); + entry + } + + #[test] + fn top_level_sessions_are_never_flagged() { + let entries = vec![metadata("root-a"), metadata("root-b")]; + let report = classify_orphaned_metadata(&entries); + assert_eq!(report.scanned_metadata_count, 2); + assert!(report.orphaned.is_empty()); + } + + #[test] + fn child_with_live_parent_is_not_flagged() { + let entries = vec![ + metadata("parent-1"), + metadata_with_parent("child-1", "parent-1"), + ]; + let report = classify_orphaned_metadata(&entries); + assert!(report.orphaned.is_empty()); + } + + #[test] + fn dangling_child_is_flagged_when_parent_metadata_is_missing() { + let entries = vec![metadata_with_parent("child-1", "ghost-parent")]; + let report = classify_orphaned_metadata(&entries); + assert_eq!(report.orphaned.len(), 1); + let record = &report.orphaned[0]; + assert_eq!(record.session_id, "child-1"); + assert_eq!(record.kind, OrphanKind::DanglingChild); + assert!(record.reason.contains("ghost-parent")); + } + + #[test] + fn detached_child_with_missing_creator_parent_is_flagged() { + let mut entry = metadata("detached-1"); + entry.created_by = Some("session-ghost-creator".to_string()); + entry.relationship = None; + let report = classify_orphaned_metadata(&[entry]); + assert_eq!(report.orphaned.len(), 1); + assert_eq!(report.orphaned[0].kind, OrphanKind::DetachedChild); + } + + #[test] + fn non_subagent_creator_markers_are_not_flagged() { + let mut entry = metadata("memory-1"); + entry.created_by = Some("memory-phase2".to_string()); + let mut user_entry = metadata("user-1"); + user_entry.created_by = Some("alice".to_string()); + let report = classify_orphaned_metadata(&[entry, user_entry]); + assert!(report.orphaned.is_empty()); + } + + #[test] + fn detached_child_with_live_creator_parent_is_not_flagged() { + let mut entry = metadata("child-2"); + entry.created_by = Some("session-parent-2".to_string()); + entry.relationship = None; + let report = classify_orphaned_metadata(&[metadata("parent-2"), entry]); + assert!(report.orphaned.is_empty()); + } +} diff --git a/src/crates/assembly/core/src/agentic/session/session_manager.rs b/src/crates/assembly/core/src/agentic/session/session_manager.rs index aba2a8b11d..16c28c879c 100644 --- a/src/crates/assembly/core/src/agentic/session/session_manager.rs +++ b/src/crates/assembly/core/src/agentic/session/session_manager.rs @@ -14,6 +14,9 @@ use crate::agentic::keyed_lock::{KeyedAsyncLock, KeyedAsyncLockGuard}; use crate::agentic::memories::db::{MemoryDatabase, MEMORY_PHASE2_GLOBAL_JOB_KEY}; use crate::agentic::persistence::{MaterializedSessionReferenceTranscript, PersistenceManager}; use crate::agentic::session::revert::SessionRevertPhase; +use crate::agentic::session::session_gc::{ + classify_orphaned_metadata, SessionGcReport, TransientSweepCandidate, +}; use crate::agentic::session::session_store_port::CoreSessionStorePort; use crate::agentic::session::{ prompt_cache_persist_action, reconcile_prompt_cache_restore, CachedSystemPrompt, @@ -53,10 +56,9 @@ use bitfun_core_types::SessionExecutionTarget; pub use bitfun_runtime_ports::SessionViewRestoreTiming; use bitfun_runtime_ports::{SessionStoragePathRequest, SessionStorePort}; use bitfun_services_core::session::{ - apply_session_lineage, collect_hidden_subagent_cascade as collect_hidden_subagent_cascade_ids, - merge_session_custom_metadata as merge_session_custom_metadata_value, + apply_session_lineage, merge_session_custom_metadata as merge_session_custom_metadata_value, set_deep_review_run_manifest, set_review_target_evidence, set_session_relationship, - SessionStorageLayout, SessionWriteLock, + SessionRelationshipKind, SessionStorageLayout, SessionWriteLock, }; use dashmap::{mapref::entry::Entry, DashMap}; use log::{debug, error, info, warn}; @@ -70,6 +72,20 @@ use std::time::{Duration, SystemTime}; use tokio::sync::{OwnedSemaphorePermit, Semaphore}; use tokio::time; +/// File name of the persistent deletion tombstone registry. Stored in the +/// workspace runtime directory (the parent of the sessions directory, i.e. +/// `/../deleted-session-ids.json`) so a later process restart can +/// still answer "was this session id confirmed deleted" for the workspace. +/// The frontend initialization path pulls this registry to guard against +/// ghost resurrection of deleted subagent sessions. +const DELETED_SESSION_IDS_FILE_NAME: &str = "deleted-session-ids.json"; + +/// Upper bound for tombstone entries per workspace. The registry is a +/// best-effort guard; entries are kept in deletion order and the oldest are +/// dropped beyond the cap so a workspace with heavy churn cannot grow it +/// without bound. +const DELETED_SESSION_IDS_MAX_ENTRIES: usize = 2000; + #[cfg(test)] tokio::task_local! { static TEST_MODEL_RESOLUTION_AI_CONFIG: crate::service::config::types::AIConfig; @@ -279,10 +295,32 @@ pub struct SessionManager { persistence_manager: Arc, memory_database: Arc, + /// Cache of parent_session_id → subagent children (child_session_id, parent_dialog_turn_id). + /// Incrementally maintained to avoid full metadata scans during cascade traversal. + subagent_children: Arc>>, + /// Set to true when sessions are created or deleted so the subagent_children + /// cache is rebuilt on the next cascade traversal. + subagent_children_dirty: Arc, + + /// Loaded session IDs whose on-disk storage was removed externally (for + /// example a directory-level GC or manual deletion) while the runtime + /// still holds them. Auto-save skips these IDs so a deleted session cannot + /// resurrect its storage directory; the next reconcile unloads the session + /// from runtime memory once it is no longer processing. + disk_removed_loaded_ids: Arc>, + + /// Session IDs explicitly deleted through the session lifecycle API while + /// their in-flight tail writes (turn finalization spawned by the turn + /// execution task) may still be in flight. Turn finalization consults this + /// set before recreating on-disk session metadata so a deleted session + /// cannot resurrect as a ghost "Recovered Session". + deleted_session_ids: Arc>, + /// Configuration config: SessionManagerConfig, } +#[allow(clippy::too_many_arguments)] fn clear_session_runtime_stores( session_id: &str, context_store: &SessionContextStore, @@ -609,10 +647,21 @@ impl SessionManager { .map(|tokens| tokens as usize) } + /// Product-guaranteed minimum session context window. Session configs are + /// never downgraded below this value; model windows cap the effective + /// execution window at runtime instead. + const SESSION_CONTEXT_WINDOW_MIN_TOKENS: usize = 1_048_576; + fn session_context_window_from_ai_config( session: &Session, ai_config: &crate::service::config::types::AIConfig, ) -> Option { + // Subagent sessions are created with a forced 1M context window and must + // not be downgraded by model-window refresh or model updates. + if session.kind == SessionKind::Subagent || session.kind == SessionKind::EphemeralSubagent { + return None; + } + let configured_model_id = session .config .model_id @@ -625,7 +674,8 @@ impl SessionManager { return Self::context_window_for_model_selection(ai_config, configured_model_id); } - let fallback_model_id = (session.kind != SessionKind::Subagent) + let fallback_model_id = (session.kind != SessionKind::Subagent + && session.kind != SessionKind::EphemeralSubagent) .then(|| ai_config.agent_model_defaults.mode.trim().to_string()) .filter(|model_id| !Self::is_auto_model_selector(model_id)); @@ -640,8 +690,13 @@ impl SessionManager { ai_config: &crate::service::config::types::AIConfig, ) -> Option { let context_window = Self::session_context_window_from_ai_config(session, ai_config)?; - session.config.max_context_tokens = context_window; - Some(context_window) + // Sessions keep the product-guaranteed 1M context window. Model + // windows only cap the effective execution window at runtime via + // min() in execute_dialog_turn_impl; they must not downgrade the + // session's configured window below 1M. + let kept = context_window.max(Self::SESSION_CONTEXT_WINDOW_MIN_TOKENS); + session.config.max_context_tokens = kept; + Some(kept) } async fn normalize_session_reasoning_preset( @@ -759,7 +814,7 @@ impl SessionManager { fn should_persist_session_kind(kind: SessionKind) -> bool { match kind { SessionKind::Standard | SessionKind::Subagent => true, - SessionKind::EphemeralChild => false, + SessionKind::EphemeralChild | SessionKind::EphemeralSubagent => false, } } @@ -786,13 +841,18 @@ impl SessionManager { fn collect_auto_save_snapshots( sessions: &DashMap, transient_session_ids: &DashMap, + disk_removed_loaded_ids: &DashMap, ) -> Vec { sessions .iter() .filter_map(|entry| { let session = entry.value(); if !Self::should_persist_session_with_transient_ids(session, transient_session_ids) + || disk_removed_loaded_ids.contains_key(&session.session_id) { + // Sessions whose on-disk storage was removed externally are + // never written back: persisting them would resurrect a + // deleted session on the next list. return None; } Some(SessionAutoSaveSnapshot { @@ -886,6 +946,149 @@ impl SessionManager { .unwrap_or(true) } + /// Records a session id as explicitly deleted through the session + /// lifecycle API. Kept process-locally: after a process restart there is + /// no in-flight tail write left to protect. + pub(crate) fn mark_session_deleted(&self, session_id: &str) { + self.deleted_session_ids.insert(session_id.to_string(), ()); + } + + /// Returns true when the session was explicitly deleted through the + /// session lifecycle API. Turn finalization consults this before + /// recreating on-disk session metadata so a deleted session cannot + /// resurrect as a ghost "Recovered Session". + pub(crate) fn is_session_deleted(&self, session_id: &str) -> bool { + self.deleted_session_ids.contains_key(session_id) + } + + /// Removes the deleted marker for a session id, durably. Called when a + /// session is (re)created or restored successfully, and when a deletion + /// fails after the early marker was set (rollback), so the marker only + /// covers the actual deletion window and cannot poison a later re-created + /// id. The on-disk tombstone registry is cleared too: an in-memory-only + /// unmark would leave the id in the disk registry, so a later restart + /// would keep hiding the re-created/restored session from lists and + /// restore paths (ghost-session root cause R3 registry counterpart). + /// Best-effort by contract: a registry write failure only logs and must + /// never fail the calling create/restore/rollback path. + pub(crate) async fn unmark_session_deleted( + &self, + session_storage_path: &Path, + session_id: &str, + ) { + self.deleted_session_ids.remove(session_id); + let Ok(ids) = self.list_deleted_session_ids(session_storage_path).await else { + return; + }; + if !ids.iter().any(|id| id == session_id) { + return; + } + let remaining: Vec = ids.into_iter().filter(|id| id != session_id).collect(); + let Some(workspace_runtime_path) = session_storage_path.parent() else { + return; + }; + let tombstone_path = workspace_runtime_path.join(DELETED_SESSION_IDS_FILE_NAME); + if let Ok(payload) = serde_json::to_string(&remaining) { + if let Err(error) = tokio::fs::write(&tombstone_path, payload).await { + warn!( + "Failed to persist deleted session id unmark: session_id={}, error={}", + session_id, error + ); + } + } + } + + /// Loads the persistent deletion tombstone registry for the workspace. + /// `session_storage_path` is the workspace sessions directory; the + /// registry file lives next to it in the workspace runtime directory. + /// A missing or corrupt registry reads as an empty list: the registry is + /// a best-effort guard and must never fail session listing. + pub(crate) async fn list_deleted_session_ids( + &self, + session_storage_path: &Path, + ) -> BitFunResult> { + let Some(workspace_runtime_path) = session_storage_path.parent() else { + return Ok(Vec::new()); + }; + let tombstone_path = workspace_runtime_path.join(DELETED_SESSION_IDS_FILE_NAME); + let raw = match tokio::fs::read_to_string(&tombstone_path).await { + Ok(raw) => raw, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => { + warn!( + "Failed to read deleted session ids tombstone {}: {}", + tombstone_path.display(), + error + ); + return Ok(Vec::new()); + } + }; + match serde_json::from_str::>(&raw) { + Ok(ids) => Ok(ids), + Err(error) => { + warn!( + "Failed to parse deleted session ids tombstone {}: {}", + tombstone_path.display(), + error + ); + Ok(Vec::new()) + } + } + } + + /// Records a session id in the persistent deletion tombstone registry + /// for the workspace. Best-effort by contract: a registry write failure + /// is logged by the caller and must never roll back an already-successful + /// session deletion. + pub(crate) async fn record_deleted_session_id( + &self, + session_storage_path: &Path, + session_id: &str, + ) -> BitFunResult<()> { + let Some(workspace_runtime_path) = session_storage_path.parent() else { + return Ok(()); + }; + let mut ids = self.list_deleted_session_ids(session_storage_path).await?; + if ids.iter().any(|id| id == session_id) { + return Ok(()); + } + ids.push(session_id.to_string()); + if ids.len() > DELETED_SESSION_IDS_MAX_ENTRIES { + ids.drain(..ids.len() - DELETED_SESSION_IDS_MAX_ENTRIES); + } + let payload = serde_json::to_string(&ids)?; + let tombstone_path = workspace_runtime_path.join(DELETED_SESSION_IDS_FILE_NAME); + // Ensure the workspace runtime directory exists: deletion can succeed + // while persistence is disabled, in which case the sessions directory + // (and its parent runtime directory) may never have been created. The + // tombstone write is the only stage that touches this path in that + // configuration, so it must create the parent itself (ghost-session + // root cause R3). + tokio::fs::create_dir_all(workspace_runtime_path).await?; + tokio::fs::write(&tombstone_path, payload).await?; + Ok(()) + } + + /// Returns true when the loaded session's on-disk storage was removed + /// externally (directory-level GC, manual deletion, or a concurrent + /// process) while the runtime still holds it. Turn finalization skips + /// these ids too, otherwise the tail write would recreate the storage the + /// external removal deleted (same ghost-resurrection shape as R1, via the + /// out-of-band removal path that does not set the explicit deleted marker). + pub(crate) fn is_session_disk_removed(&self, session_id: &str) -> bool { + self.disk_removed_loaded_ids.contains_key(session_id) + } + + /// Snapshot of every loaded session (durable and transient) in memory. + /// Used by cascade traversal to discover descendants whose persisted + /// relationship may be broken. + pub(crate) fn loaded_sessions_snapshot(&self) -> Vec { + self.sessions + .iter() + .map(|entry| entry.value().clone()) + .collect() + } + pub(crate) fn is_transient_session(&self, session_id: &str) -> bool { self.transient_session_ids.contains_key(session_id) } @@ -1890,6 +2093,10 @@ impl SessionManager { evidence_ledger: Arc::new(SessionEvidenceLedger::new()), persistence_manager, memory_database, + subagent_children: Arc::new(DashMap::new()), + subagent_children_dirty: Arc::new(std::sync::atomic::AtomicBool::new(true)), + disk_removed_loaded_ids: Arc::new(DashMap::new()), + deleted_session_ids: Arc::new(DashMap::new()), config, }; @@ -2222,6 +2429,7 @@ impl SessionManager { let evidence_ledger = self.evidence_ledger.clone(); let persistence_manager = self.persistence_manager.clone(); let memory_database = self.memory_database.clone(); + let deleted_session_ids = self.deleted_session_ids.clone(); let manager_config = self.config.clone(); tokio::spawn(async move { @@ -2254,6 +2462,10 @@ impl SessionManager { evidence_ledger, persistence_manager, memory_database, + subagent_children: Arc::new(DashMap::new()), + subagent_children_dirty: Arc::new(std::sync::atomic::AtomicBool::new(true)), + disk_removed_loaded_ids: Arc::new(DashMap::new()), + deleted_session_ids, config: manager_config, }; @@ -2406,6 +2618,7 @@ impl SessionManager { .await } + #[allow(clippy::too_many_arguments)] async fn create_session_with_id_and_details_internal( &self, session_id: Option, @@ -2549,10 +2762,23 @@ impl SessionManager { info!("Session created: session_name={}", session.session_name); + // R-FIX-1: a successfully re-created session id must not inherit the + // deleted marker from a previous incarnation, otherwise its turn + // finalization would be skipped and its data would never be persisted. + // The durable unmark also clears the on-disk tombstone so a restart + // cannot keep hiding the re-created session from lists. + self.unmark_session_deleted(&session_storage_path, &session_id) + .await; + Ok(session) } - /// Get session + /// Get session. + /// Hot-path: cloning the full Session is intentional to avoid holding + /// the DashMap shard lock across await points. The session struct is + /// relatively lightweight for typical workloads; the heaviest field + /// (dialog_turn_ids) is a Vec that rarely exceeds a few + /// hundred entries. pub fn get_session(&self, session_id: &str) -> Option { self.sessions.get(session_id).map(|s| s.clone()) } @@ -2656,6 +2882,24 @@ impl SessionManager { stored } + /// P-18:读取该 session 最近一次实际注入 User Context 时的缓存世代。 + /// None = 从未注入(新对话首轮应注入)。 + pub async fn user_context_injected_generation(&self, session_id: &str) -> Option { + self.ensure_prompt_cache_loaded(session_id).await; + self.prompt_cache_store.user_context_injected_generation(session_id) + } + + /// P-18:记录该 session 已在指定缓存世代实际注入过 User Context(会话级一次)。 + pub async fn remember_user_context_injected_generation( + &self, + session_id: &str, + generation: u64, + ) { + self.ensure_prompt_cache_loaded(session_id).await; + self.prompt_cache_store + .remember_user_context_injected_generation(session_id, generation); + } + pub async fn clone_prompt_cache( &self, source_session_id: &str, @@ -4119,7 +4363,7 @@ impl SessionManager { /// Sync session context window from AI config without requiring an explicit model_id. /// /// Subagent sessions created via `build_session_config_for_workspace` use - /// `SessionConfig::default()` which hardcodes `max_context_tokens: 128128`. + /// `SessionConfig::default()` which hardcodes `max_context_tokens: 1M`. /// This method reloads the AI config and updates `max_context_tokens` to the /// model's actual configured `context_window`, so subagents with large-context /// models are not prematurely capped. @@ -4203,12 +4447,29 @@ impl SessionManager { workspace_path, ) .await; - self.delete_session_from_paths_locked( - &cleanup_workspace_path, - &session_storage_path, - session_id, - ) - .await + // R-FIX-2: mark the session as deleted BEFORE the fallible deletion + // stage. This closes the check-then-delete race for an in-flight turn + // finalization tail write: from this point on finalization sees the + // session as deleted and skips metadata/turn recreation even while the + // in-memory session still exists. A failed deletion rolls the marker + // back so it cannot poison a re-created id. + self.mark_session_deleted(session_id); + let delete_result = self + .delete_session_from_paths_locked( + &cleanup_workspace_path, + &session_storage_path, + session_id, + ) + .await; + if delete_result.is_err() { + // Rollback the early marker; the tombstone was never written in + // this window, so the durable unmark is a no-op registry-wise. + self.unmark_session_deleted(&session_storage_path, session_id) + .await; + } + delete_result?; + self.invalidate_subagent_children_cache(); + Ok(()) } pub(crate) async fn delete_session_by_id(&self, session_id: &str) -> BitFunResult<()> { @@ -4245,12 +4506,222 @@ impl SessionManager { &session_storage_path, ) .await; - self.delete_session_from_paths_locked( - &cleanup_workspace_path, - &session_storage_path, - session_id, - ) - .await + // R-FIX-2: mark before the fallible deletion stage (see + // `delete_session_locked`); roll back on failure. + self.mark_session_deleted(session_id); + let delete_result = self + .delete_session_from_paths_locked( + &cleanup_workspace_path, + &session_storage_path, + session_id, + ) + .await; + if delete_result.is_err() { + // Rollback the early marker; the tombstone was never written in + // this window, so the durable unmark is a no-op registry-wise. + self.unmark_session_deleted(&session_storage_path, session_id) + .await; + } + delete_result + } + + /// Report-only disk scan: find orphaned session metadata in one workspace. + /// Nothing is deleted by this scan; callers decide whether to act. + pub async fn scan_orphaned_sessions_in_workspace( + &self, + workspace_path: &Path, + ) -> BitFunResult { + let metadata = self + .persistence_manager() + .list_session_metadata_including_internal(workspace_path) + .await?; + Ok(classify_orphaned_metadata(&metadata)) + } + + /// Report-only process-local sweep: transient sessions that have finished + /// executing (not Processing) and whose parent (if any) is no longer + /// loaded, so no reuse reference can remain. Nothing is discarded by this + /// scan; callers decide whether to act. + pub fn list_transient_sweep_candidates(&self) -> Vec { + let sessions = self + .sessions + .iter() + .map(|entry| entry.value().clone()) + .collect::>(); + let mut candidates = Vec::new(); + for session in sessions { + if !self.transient_session_ids.contains_key(&session.session_id) { + continue; + } + if matches!(session.state, SessionState::Processing { .. }) { + continue; + } + let parent_session_id = session + .created_by + .as_deref() + .and_then(|marker| marker.strip_prefix("session-")) + .map(str::to_string); + // Sessions without a `session-{parent}` creator marker (top-level + // and Commander-owner sessions) are structurally exempt from orphan + // classification and must never be swept. + let Some(parent_session_id) = parent_session_id else { + continue; + }; + let parent_alive = self.get_session(&parent_session_id).is_some(); + if parent_alive { + // A live parent may still reuse this session. + continue; + } + candidates.push(TransientSweepCandidate { + session_id: session.session_id, + parent_session_id: Some(parent_session_id), + }); + } + candidates + } + + /// Periodic orphan recycling: archive-then-delete with guards. + /// + /// Runs on the 60-second cleanup ticker. Candidates come from two + /// report-only scans: + /// - `scan_orphaned_sessions_in_workspace` (persisted metadata whose + /// parent is missing from the workspace scan); + /// - `list_transient_sweep_candidates` (finished transient sessions whose + /// parent is no longer loaded). + /// + /// Disposal is deliberately conservative: + /// - daemon/warden sessions are never recycled; + /// - Processing sessions are skipped until they finish; + /// - sessions without a `session-{parent}` creator marker (top-level and + /// Commander-owner sessions) are never recycled; + /// - a candidate is archived first (`SessionStatus::Archived`, the same + /// write the frontend archive RPC performs) and only deleted through + /// the full `delete_session` chain once the archive succeeded. + pub(crate) async fn recycle_orphaned_sessions(&self) { + let mut workspaces: Vec = Vec::new(); + let mut seen = std::collections::HashSet::new(); + for session in self.loaded_sessions_snapshot() { + if let Some(workspace_path) = session.config.workspace_path { + if seen.insert(workspace_path.clone()) { + workspaces.push(PathBuf::from(workspace_path)); + } + } + } + for binding in self.session_storage_path_index.iter() { + if seen.insert(binding.value().path.to_string_lossy().to_string()) { + workspaces.push(binding.value().path.clone()); + } + } + for workspace_path in workspaces { + if let Err(error) = self + .recycle_orphaned_sessions_in_workspace(&workspace_path) + .await + { + warn!( + "Failed to recycle orphaned sessions: workspace_path={}, error={}", + workspace_path.display(), + error + ); + } + } + for candidate in self.list_transient_sweep_candidates() { + let Some(session) = self.get_session(&candidate.session_id) else { + continue; + }; + if session.config.is_daemon || session.agent_type.starts_with("warden-") { + continue; + } + let Some(workspace_path) = session.config.workspace_path.clone() else { + continue; + }; + if let Err(error) = self + .discard_transient_session( + Path::new(&workspace_path), + session.config.remote_connection_id.as_deref(), + session.config.remote_ssh_host.as_deref(), + &candidate.session_id, + ) + .await + { + warn!( + "Failed to discard transient orphan session: session_id={}, error={}", + candidate.session_id, error + ); + } + } + } + + /// Archive-then-delete orphan candidates reported for one workspace. + pub(crate) async fn recycle_orphaned_sessions_in_workspace( + &self, + workspace_path: &Path, + ) -> BitFunResult<()> { + let report = self + .scan_orphaned_sessions_in_workspace(workspace_path) + .await?; + for orphan in report.orphaned { + if self + .orphan_recycle_guard_blocks(workspace_path, &orphan.session_id) + .await + { + debug!( + "Skipping orphan recycle by guard: session_id={}", + orphan.session_id + ); + continue; + } + let archive_result = self + .update_session_metadata(workspace_path, &orphan.session_id, |metadata| { + metadata.status = SessionStatus::Archived; + }) + .await; + if let Err(error) = archive_result { + warn!( + "Failed to archive orphaned session before recycle: session_id={}, error={}", + orphan.session_id, error + ); + continue; + } + if let Err(error) = self.delete_session(workspace_path, &orphan.session_id).await { + warn!( + "Failed to delete archived orphaned session: session_id={}, error={}", + orphan.session_id, error + ); + } + } + Ok(()) + } + + /// Guard gate for one orphan candidate. Returns true when the candidate + /// must not be recycled: daemon/warden sessions, Processing sessions, and + /// sessions without a `session-{parent}` creator marker (top-level and + /// Commander-owner sessions are structurally exempt from orphan + /// classification, so this is a defensive second gate). + async fn orphan_recycle_guard_blocks(&self, workspace_path: &Path, session_id: &str) -> bool { + let loaded = self.get_session(session_id); + if let Some(session) = loaded.as_ref() { + if session.config.is_daemon || session.agent_type.starts_with("warden-") { + return true; + } + if matches!(session.state, SessionState::Processing { .. }) { + return true; + } + } + let metadata = self + .load_session_metadata(workspace_path, session_id) + .await + .ok() + .flatten(); + if let Some(metadata) = metadata.as_ref() { + if metadata.is_daemon || metadata.agent_type.starts_with("warden-") { + return true; + } + } + let created_by = loaded + .as_ref() + .and_then(|session| session.created_by.as_deref()) + .or_else(|| metadata.as_ref().and_then(|m| m.created_by.as_deref())); + !created_by.is_some_and(|marker| marker.starts_with("session-")) } /// Discards one loaded non-durable Session without touching persisted @@ -4329,7 +4800,7 @@ impl SessionManager { Ok(family) } - fn transient_descendants_postorder(&self, root_session_id: &str) -> Vec { + pub(crate) fn transient_descendants_postorder(&self, root_session_id: &str) -> Vec { fn visit( parent_session_id: &str, sessions: &[Session], @@ -4664,8 +5135,32 @@ impl SessionManager { elapsed_ms_u64(memory_stage_started_at) ); self.session_storage_path_index.remove(session_id); + self.disk_removed_loaded_ids.remove(session_id); + // The deleted marker was set before this stage by + // `delete_session_locked`/`delete_session_by_id` (R-FIX-2) so the + // in-flight finalization window is closed from the start of deletion. self.release_session_write_lock(session_id); + // Persist a deletion tombstone so a later process restart can answer + // "was this session confirmed deleted" for the workspace (the + // frontend initialization path pulls this registry to guard against + // ghost resurrection of deleted subagent sessions). The registry + // write is intentionally decoupled from `enable_persistence`: even + // when persistence is disabled (and the on-disk deletion stage is + // skipped), the deletion fact must still be recorded so a residual + // session directory cannot be loaded back as a ghost on the next + // restart (ghost-session root cause R3). Best-effort: a registry + // write failure must not roll back an already-completed deletion. + if let Err(error) = self + .record_deleted_session_id(session_storage_path, session_id) + .await + { + warn!( + "Failed to record deleted session id tombstone: session_id={}, error={}", + session_id, error + ); + } + info!( "Session deletion completed: session_id={}, cleanup_workspace_path={}, session_storage_path={}, duration_ms={}", session_id, @@ -4677,51 +5172,196 @@ impl SessionManager { Ok(()) } - /// Restore session from a local or legacy workspace path. + /// Reconcile runtime loaded sessions against on-disk session storage. /// - /// Callers that know remote identity must use [`Self::restore_session_for_workspace`]. - /// Callers that already resolved a `sessions` directory must use - /// [`Self::restore_session_from_storage_path`]. - pub async fn restore_session( + /// Sessions whose storage directory was removed externally (directory-level + /// GC, manual deletion, or a concurrent process) are unloaded from runtime + /// memory once they are not processing, and any on-disk remnants (a running + /// turn may have re-saved the directory before this reconcile) are removed + /// so the deleted session cannot resurrect through a later list. Sessions + /// still processing are kept until they finish; auto-save skips them so a + /// finished deleted session is never persisted again. + /// + /// `sessions_dir` is the resolved sessions storage root (same path + /// semantics as `list_sessions`). + pub async fn reconcile_loaded_sessions_with_disk( &self, - workspace_path: &Path, - session_id: &str, - ) -> BitFunResult { - let session_storage_path = self - .resolve_storage_path_for_restore_workspace_path(workspace_path) + sessions_dir: &Path, + ) -> BitFunResult<()> { + if !self.config.enable_persistence { + return Ok(()); + } + let disk_metadata = self + .persistence_manager + .list_session_metadata_including_internal(sessions_dir) .await?; - self.restore_session_from_storage_path(&session_storage_path, session_id) - .await - } - - pub async fn restore_session_for_workspace( - &self, - request: SessionStoragePathRequest, - session_id: &str, - ) -> BitFunResult { - let session_storage_path = self.resolve_storage_path_for_request(request).await?; - self.restore_session_from_storage_path(&session_storage_path, session_id) - .await - } + let disk_ids: HashSet<&str> = disk_metadata + .iter() + .map(|metadata| metadata.session_id.as_str()) + .collect(); + let normalized_sessions_dir = Self::normalize_session_storage_path(sessions_dir); - pub async fn restore_internal_session( - &self, - workspace_path: &Path, - session_id: &str, - ) -> BitFunResult { - let session_storage_path = self - .resolve_storage_path_for_restore_workspace_path(workspace_path) - .await?; - self.restore_internal_session_from_storage_path(&session_storage_path, session_id) - .await - } + // Snapshot the loaded sessions bound to this storage path so the + // DashMap can be mutated while iterating. + let loaded: Vec = self + .sessions + .iter() + .filter_map(|entry| { + let session = entry.value(); + let bound_path = self + .session_storage_path_index + .get(&session.session_id) + .map(|binding| binding.path.clone()) + .unwrap_or_default(); + (bound_path == normalized_sessions_dir).then(|| session.clone()) + }) + .collect(); - pub async fn restore_internal_session_for_workspace( - &self, - request: SessionStoragePathRequest, - session_id: &str, - ) -> BitFunResult { - let session_storage_path = self.resolve_storage_path_for_request(request).await?; + for session in loaded { + if self.is_transient_session(&session.session_id) { + continue; + } + let on_disk = disk_ids.contains(session.session_id.as_str()); + let is_marked_removed = self + .disk_removed_loaded_ids + .contains_key(&session.session_id); + if on_disk && !is_marked_removed { + // Normal session: storage is present and no external deletion + // was observed. + continue; + } + if on_disk && is_marked_removed { + // The session was externally deleted while processing and a + // running turn re-saved its storage. Keep the deletion marker + // until the session finishes so it is not silently restored; + // once idle it is unloaded and its storage removed below. + if matches!(session.state, SessionState::Processing { .. }) { + continue; + } + info!( + "Externally deleted session finished running; unloading and removing storage: session_id={}, sessions_dir={}", + session.session_id, + normalized_sessions_dir.display() + ); + self.unload_disk_removed_session(&session.session_id); + if let Err(error) = self + .persistence_manager + .delete_session(&normalized_sessions_dir, &session.session_id) + .await + { + warn!( + "Failed to remove disk remnants of externally deleted session: session_id={}, error={}", + session.session_id, error + ); + } + continue; + } + + // Storage is missing while the session stays loaded: the session + // was removed externally. Auto-save skips it (see + // `collect_auto_save_snapshots`) so the storage cannot resurrect. + self.disk_removed_loaded_ids + .insert(session.session_id.clone(), ()); + if matches!(session.state, SessionState::Processing { .. }) { + warn!( + "Loaded session storage was removed externally; keeping running session until it finishes: session_id={}, sessions_dir={}", + session.session_id, + normalized_sessions_dir.display() + ); + continue; + } + info!( + "Loaded session storage was removed externally; unloading from runtime memory: session_id={}, sessions_dir={}", + session.session_id, + normalized_sessions_dir.display() + ); + self.unload_disk_removed_session(&session.session_id); + if let Err(error) = self + .persistence_manager + .delete_session(&normalized_sessions_dir, &session.session_id) + .await + { + warn!( + "Failed to remove disk remnants of externally deleted session: session_id={}, error={}", + session.session_id, error + ); + } + } + Ok(()) + } + + /// Unload a session from runtime memory without persisting it. + /// + /// Used by [`Self::reconcile_loaded_sessions_with_disk`] for sessions whose + /// on-disk storage was removed externally. The normal delete path + /// (`delete_session_from_paths_locked`) removes storage first and then + /// memory; this path must never write the session back to disk, so it skips + /// the pre-unload save that `unload_session_from_memory` performs. + fn unload_disk_removed_session(&self, session_id: &str) { + self.sessions.remove(session_id); + self.transient_session_ids.remove(session_id); + self.release_active_session_reservation(session_id); + clear_session_runtime_stores( + session_id, + self.context_store.as_ref(), + self.prompt_cache_store.as_ref(), + self.token_anchor_store.as_ref(), + self.turn_skill_agent_snapshot_store.as_ref(), + self.skill_agent_baseline_override_snapshot_store.as_ref(), + self.file_read_state_store.as_ref(), + self.evidence_ledger.as_ref(), + ); + self.session_storage_path_index.remove(session_id); + self.release_session_write_lock(session_id); + self.disk_removed_loaded_ids.remove(session_id); + self.invalidate_subagent_children_cache(); + } + + /// Restore session from a local or legacy workspace path. + /// + /// Callers that know remote identity must use [`Self::restore_session_for_workspace`]. + /// Callers that already resolved a `sessions` directory must use + /// [`Self::restore_session_from_storage_path`]. + pub async fn restore_session( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult { + let session_storage_path = self + .resolve_storage_path_for_restore_workspace_path(workspace_path) + .await?; + self.restore_session_from_storage_path(&session_storage_path, session_id) + .await + } + + pub async fn restore_session_for_workspace( + &self, + request: SessionStoragePathRequest, + session_id: &str, + ) -> BitFunResult { + let session_storage_path = self.resolve_storage_path_for_request(request).await?; + self.restore_session_from_storage_path(&session_storage_path, session_id) + .await + } + + pub async fn restore_internal_session( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult { + let session_storage_path = self + .resolve_storage_path_for_restore_workspace_path(workspace_path) + .await?; + self.restore_internal_session_from_storage_path(&session_storage_path, session_id) + .await + } + + pub async fn restore_internal_session_for_workspace( + &self, + request: SessionStoragePathRequest, + session_id: &str, + ) -> BitFunResult { + let session_storage_path = self.resolve_storage_path_for_request(request).await?; self.restore_internal_session_from_storage_path(&session_storage_path, session_id) .await } @@ -4757,6 +5397,12 @@ impl SessionManager { include_internal, ) .await?; + // R-FIX-1: a restored session id is live again; clear any deleted + // marker left by a previous incarnation so finalization persists. + // The durable unmark also clears the on-disk tombstone so a restart + // cannot keep hiding the restored session from lists and restores. + self.unmark_session_deleted(&session_storage_path, session_id) + .await; Ok(session) } @@ -5031,7 +5677,7 @@ impl SessionManager { .is_some_and(|metadata| !include_internal && metadata.should_hide_from_user_lists()) { return Err(BitFunError::NotFound(format!( - "Session not found: {}", + "Session exists but is hidden: {}", session_id ))); } @@ -5291,7 +5937,7 @@ impl SessionManager { .is_some_and(|metadata| !include_internal && metadata.should_hide_from_user_lists()) { return Err(BitFunError::NotFound(format!( - "Session not found: {}", + "Session exists but is hidden: {}", session_id ))); } @@ -5374,7 +6020,11 @@ impl SessionManager { external_sources_supported, Some(session.config.agent_route_owner), ); - if let Some(binding) = persisted_binding { + // 契约升级:resolve_primary_agent_for_turn 现返回 Result + // (OwnerMismatch/CandidateUnavailable)。按原有语义适配—— + // Err 视为无绑定:External owner 继续 fail-closed(保持绑定), + // 非 External 走可执行 fallback。 + if let Some(binding) = persisted_binding.ok() { if session.config.agent_route_owner != binding.route_owner { session.config.agent_route_owner = binding.route_owner; should_persist_restored_session = true; @@ -6047,12 +6697,77 @@ impl SessionManager { /// List all sessions pub async fn list_sessions(&self, workspace_path: &Path) -> BitFunResult> { + self.list_sessions_with_options(workspace_path, false).await + } + + /// Lists sessions, optionally including hidden Subagent/Ephemeral sessions + /// for full conversation management. + pub async fn list_sessions_with_options( + &self, + workspace_path: &Path, + include_internal: bool, + ) -> BitFunResult> { if self.config.enable_persistence { - self.persistence_manager.list_sessions(workspace_path).await + // Reconcile runtime memory against disk first so sessions whose + // storage was removed externally (directory-level GC / manual + // deletion) stop being listed and cannot be auto-saved back. + self.reconcile_loaded_sessions_with_disk(workspace_path) + .await?; + let metadata_list = self + .persistence_manager + .list_session_metadata_with_options(workspace_path, include_internal) + .await?; + let mut summaries = Vec::with_capacity(metadata_list.len()); + for metadata in metadata_list { + let reasoning_preset = self + .persistence_manager + .load_stored_session_state(workspace_path, &metadata.session_id) + .await? + .and_then(|value| value.config.reasoning_preset); + let state = metadata + .runtime_state + .as_ref() + .and_then(|v| serde_json::from_value::(v.clone()).ok()) + .unwrap_or(SessionState::Idle); + summaries.push(SessionSummary { + session_id: metadata.session_id, + session_name: metadata.session_name, + agent_type: metadata.agent_type, + model_id: (!metadata.model_name.trim().is_empty()) + .then_some(metadata.model_name), + reasoning_preset, + last_user_dialog_agent_type: metadata.last_user_dialog_agent_type, + last_submitted_agent_type: metadata.last_submitted_agent_type, + created_by: metadata.created_by, + kind: metadata.session_kind, + turn_count: metadata.turn_count, + created_at: std::time::UNIX_EPOCH + + std::time::Duration::from_millis(metadata.created_at), + last_activity_at: std::time::UNIX_EPOCH + + std::time::Duration::from_millis(metadata.last_active_at), + state, + parent_session_id: metadata + .relationship + .as_ref() + .and_then(|r| r.parent_session_id.clone()), + is_daemon: metadata.is_daemon, + }); + } + summaries.sort_by_key(|summary| std::cmp::Reverse(summary.last_activity_at)); + return Ok(summaries); } else { let summaries: Vec<_> = self .sessions .iter() + .filter(|entry| { + include_internal + || !matches!( + entry.value().kind, + SessionKind::Subagent + | SessionKind::EphemeralChild + | SessionKind::EphemeralSubagent + ) + }) .map(|entry| { let session = entry.value(); SessionSummary { @@ -6069,14 +6784,10 @@ impl SessionManager { created_at: session.created_at, last_activity_at: session.last_activity_at, state: session.state.clone(), + parent_session_id: None, + is_daemon: session.config.is_daemon, } }) - .filter(|summary| { - !matches!( - summary.kind, - SessionKind::Subagent | SessionKind::EphemeralChild - ) - }) .collect(); Ok(summaries) } @@ -6281,10 +6992,15 @@ impl SessionManager { session_id: &str, relationship: SessionRelationship, ) -> BitFunResult<()> { - self.update_persisted_session_metadata(session_id, |metadata| { - set_session_relationship(metadata, relationship) - }) - .await + let result = self + .update_persisted_session_metadata(session_id, |metadata| { + set_session_relationship(metadata, relationship) + }) + .await; + if result.is_ok() { + self.invalidate_subagent_children_cache(); + } + result } pub async fn persist_session_lineage( @@ -6292,10 +7008,15 @@ impl SessionManager { session_id: &str, relationship: SessionRelationship, ) -> BitFunResult<()> { - self.update_persisted_session_metadata(session_id, |metadata| { - apply_session_lineage(metadata, relationship) - }) - .await + let result = self + .update_persisted_session_metadata(session_id, |metadata| { + apply_session_lineage(metadata, relationship) + }) + .await; + if result.is_ok() { + self.invalidate_subagent_children_cache(); + } + result } pub async fn collect_hidden_subagent_cascade_for_parent_turns( @@ -6308,15 +7029,82 @@ impl SessionManager { return Ok(Vec::new()); } + self.ensure_subagent_children_cache(workspace_path).await?; + Ok(collect_hidden_subagent_cascade_from_index( + &self.subagent_children, + parent_session_id, + parent_dialog_turn_ids, + )) + } + + /// Enumerate every descendant session id in the subagent tree rooted at + /// `session_id`, excluding `session_id` itself. + /// + /// The traversal covers the full subtree (nested child sessions at any + /// depth) using the subagent-children index rebuilt from persisted + /// metadata when dirty. Returns an empty list when the workspace is + /// unknown, the session has no descendants, or persistence is disabled. + pub async fn session_tree_descendants( + &self, + workspace_path: Option<&Path>, + session_id: &str, + ) -> BitFunResult> { + let Some(workspace_path) = workspace_path else { + return Ok(Vec::new()); + }; + self.ensure_subagent_children_cache(workspace_path).await?; + let mut visited = HashSet::new(); + let mut ordered_session_ids = Vec::new(); + collect_subagent_post_order_from_index( + &self.subagent_children, + session_id, + &mut visited, + &mut ordered_session_ids, + ); + // Post-order traversal appends the root itself last; descendants + // precede it, so popping the tail excludes the root. + ordered_session_ids.pop(); + Ok(ordered_session_ids) + } + + async fn ensure_subagent_children_cache(&self, workspace_path: &Path) -> BitFunResult<()> { + if !self + .subagent_children_dirty + .swap(false, std::sync::atomic::Ordering::AcqRel) + { + return Ok(()); + } let metadata_list = self .persistence_manager .list_session_metadata_including_internal(workspace_path) .await?; - Ok(collect_hidden_subagent_cascade_ids( - metadata_list, - parent_session_id, - parent_dialog_turn_ids, - )) + self.subagent_children.clear(); + for metadata in &metadata_list { + let Some(ref relationship) = metadata.relationship else { + continue; + }; + if !matches!(relationship.kind, Some(SessionRelationshipKind::Subagent)) { + continue; + } + let Some(ref parent_id) = relationship.parent_session_id else { + continue; + }; + let dialog_turn_id = relationship + .parent_dialog_turn_id + .clone() + .unwrap_or_default(); + self.subagent_children + .entry(parent_id.clone()) + .or_default() + .push((metadata.session_id.clone(), dialog_turn_id)); + } + Ok(()) + } + + /// Mark subagent children cache as dirty, forcing a rebuild on next cascade traversal. + fn invalidate_subagent_children_cache(&self) { + self.subagent_children_dirty + .store(true, std::sync::atomic::Ordering::Release); } pub async fn set_session_deep_review_run_manifest( @@ -6572,6 +7360,7 @@ impl SessionManager { .await } + #[allow(clippy::too_many_arguments)] pub async fn start_dialog_turn_with_prepended_messages( &self, session_id: &str, @@ -6844,15 +7633,13 @@ impl SessionManager { let mut order_index = 0usize; match &msg.content { - MessageContent::Text(text) => { - if !text.trim().is_empty() { - text_items.push(Self::make_text_item( - &format!("{}-text-{}", round_id, order_index), - text, - timestamp, - order_index, - )); - } + MessageContent::Text(text) if !text.trim().is_empty() => { + text_items.push(Self::make_text_item( + &format!("{}-text-{}", round_id, order_index), + text, + timestamp, + order_index, + )); } MessageContent::Mixed { reasoning_content, @@ -7838,6 +8625,7 @@ impl SessionManager { fn spawn_auto_save_task(&self) { let sessions = self.sessions.clone(); let transient_session_ids = self.transient_session_ids.clone(); + let disk_removed_loaded_ids = self.disk_removed_loaded_ids.clone(); let persistence = self.persistence_manager.clone(); let session_mutation_locks = self.session_mutation_locks.clone(); let interval = self.config.auto_save_interval; @@ -7848,8 +8636,11 @@ impl SessionManager { loop { ticker.tick().await; - for snapshot in Self::collect_auto_save_snapshots(&sessions, &transient_session_ids) - { + for snapshot in Self::collect_auto_save_snapshots( + &sessions, + &transient_session_ids, + &disk_removed_loaded_ids, + ) { let _mutation_guard = session_mutation_locks.lock(&snapshot.session_id).await; if !Self::auto_save_snapshot_is_current(&sessions, &snapshot) { continue; @@ -7885,6 +8676,7 @@ impl SessionManager { fn spawn_cleanup_task(&self) { let sessions = self.sessions.clone(); let transient_session_ids = self.transient_session_ids.clone(); + let disk_removed_loaded_ids = self.disk_removed_loaded_ids.clone(); let active_session_permits = self.active_session_permits.clone(); let timeout = self.config.session_idle_timeout; let persistence = self.persistence_manager.clone(); @@ -7900,13 +8692,54 @@ impl SessionManager { let edit_constraints_store = self.edit_constraints_store.clone(); let file_read_state_store = self.file_read_state_store.clone(); let evidence_ledger = self.evidence_ledger.clone(); + // Orphan recycling rebuilds a thin `Self` handle inside the ticker (the + // same pattern used by `spawn_model_reconciliation_listener`) so the + // full `&self` archive/delete chain can be reused. + let active_session_capacity = self.active_session_capacity.clone(); + let session_storage_path_index = self.session_storage_path_index.clone(); + let prompt_cache_operation_locks = self.prompt_cache_operation_locks.clone(); + let memory_database = self.memory_database.clone(); + let subagent_children = self.subagent_children.clone(); + let subagent_children_dirty = self.subagent_children_dirty.clone(); + let deleted_session_ids = self.deleted_session_ids.clone(); + let manager_config = self.config.clone(); tokio::spawn(async move { + // The thin handle clones the shared Arc fields: the loop body below + // still borrows the original locals (e.g. for the expired-session + // cleanup path), and Arc clones share the same underlying maps. + let manager = Self { + sessions: sessions.clone(), + transient_session_ids: transient_session_ids.clone(), + active_session_capacity: active_session_capacity.clone(), + active_session_permits: active_session_permits.clone(), + session_storage_path_index: session_storage_path_index.clone(), + session_mutation_locks: session_mutation_locks.clone(), + session_write_locks: session_write_locks.clone(), + context_store: context_store.clone(), + prompt_cache_store: prompt_cache_store.clone(), + prompt_cache_operation_locks: prompt_cache_operation_locks.clone(), + token_anchor_store: token_anchor_store.clone(), + turn_skill_agent_snapshot_store: turn_skill_agent_snapshot_store.clone(), + skill_agent_baseline_override_snapshot_store: skill_agent_baseline_override_snapshot_store.clone(), + edit_constraints_store: edit_constraints_store.clone(), + file_read_state_store: file_read_state_store.clone(), + evidence_ledger: evidence_ledger.clone(), + persistence_manager: persistence.clone(), + memory_database: memory_database.clone(), + subagent_children: subagent_children.clone(), + subagent_children_dirty: subagent_children_dirty.clone(), + disk_removed_loaded_ids: disk_removed_loaded_ids.clone(), + deleted_session_ids: deleted_session_ids.clone(), + config: manager_config, + }; let mut ticker = time::interval(Duration::from_secs(60)); loop { ticker.tick().await; + manager.recycle_orphaned_sessions().await; + let now = SystemTime::now(); let candidates = Self::collect_expired_session_candidates( &sessions, @@ -7933,7 +8766,13 @@ impl SessionManager { }; let mut can_remove = true; + // Sessions whose storage was removed externally must not be + // written back by the pre-eviction save: persisting them + // would resurrect the deleted session on the next list. + let skip_pre_evict_save = + disk_removed_loaded_ids.contains_key(&candidate.session_id); if enable_persistence + && !skip_pre_evict_save && Self::should_persist_session_with_transient_ids( &session, &transient_session_ids, @@ -8007,6 +8846,57 @@ impl SessionManager { } } +/// Traverse the subagent_children index in post-order to collect hidden subagent +/// session IDs matching the given parent session and dialog turn IDs. +fn collect_hidden_subagent_cascade_from_index( + subagent_children: &DashMap>, + parent_session_id: &str, + parent_dialog_turn_ids: &HashSet, +) -> Vec { + let mut root_session_ids = Vec::new(); + if let Some(children) = subagent_children.get(parent_session_id) { + for (child_id, dialog_turn_id) in children.iter() { + if parent_dialog_turn_ids.contains(dialog_turn_id.as_str()) { + root_session_ids.push(child_id.clone()); + } + } + } + + let mut visited = HashSet::new(); + let mut ordered_session_ids = Vec::new(); + for root_id in root_session_ids { + collect_subagent_post_order_from_index( + subagent_children, + &root_id, + &mut visited, + &mut ordered_session_ids, + ); + } + ordered_session_ids +} + +fn collect_subagent_post_order_from_index( + subagent_children: &DashMap>, + session_id: &str, + visited: &mut HashSet, + ordered_session_ids: &mut Vec, +) { + if !visited.insert(session_id.to_string()) { + return; + } + if let Some(children) = subagent_children.get(session_id) { + for (child_id, _) in children.iter() { + collect_subagent_post_order_from_index( + subagent_children, + child_id, + visited, + ordered_session_ids, + ); + } + } + ordered_session_ids.push(session_id.to_string()); +} + #[cfg(test)] mod tests { use super::{ @@ -8032,9 +8922,9 @@ mod tests { }; use crate::service::session::{ DialogTurnData, DialogTurnKind, ModelRoundData, SessionContextUsage, - SessionContextUsageSource, SessionKind, SessionMetadata, SessionRelationship, - SessionRelationshipKind, ToolCallData, ToolItemData, ToolResultData, TurnStatus, - UserMessageData, + SessionContextUsageSource, SessionKind, SessionMemoryMode, SessionMetadata, + SessionRelationship, SessionRelationshipKind, SessionStatus, ToolCallData, + ToolItemData, ToolResultData, TurnStatus, UserMessageData, }; use crate::util::errors::BitFunError; use bitfun_core_types::{ @@ -10331,7 +11221,7 @@ mod tests { ); let manager = test_manager(persistence_manager.clone()); let ai_config = ServiceAIConfig { - models: vec![test_model("deepseek-v4-flash", 200_000)], + models: vec![test_model("deepseek-v4-flash", 2_000_000)], ..Default::default() }; @@ -10353,12 +11243,12 @@ mod tests { .await .expect("session should create"); - assert_eq!(session.config.max_context_tokens, 200_000); + assert_eq!(session.config.max_context_tokens, 2_000_000); let persisted = persistence_manager .load_session(workspace.path(), &session.session_id) .await .expect("persisted session should load"); - assert_eq!(persisted.config.max_context_tokens, 200_000); + assert_eq!(persisted.config.max_context_tokens, 2_000_000); } #[test] @@ -10382,8 +11272,10 @@ mod tests { let resolved = SessionManager::sync_session_context_window_from_ai_config(&mut session, &ai_config); - assert_eq!(resolved, Some(1_000_000)); - assert_eq!(session.config.max_context_tokens, 1_000_000); + // Model window 1M is below the product-guaranteed default window + // (1_048_576), so the stale 256K session is lifted to the default. + assert_eq!(resolved, Some(1_048_576)); + assert_eq!(session.config.max_context_tokens, 1_048_576); } #[test] @@ -10412,8 +11304,10 @@ mod tests { let resolved = SessionManager::sync_session_context_window_from_ai_config(&mut session, &ai_config); - assert_eq!(resolved, Some(1_000_000)); - assert_eq!(session.config.max_context_tokens, 1_000_000); + // Mode-default model window 1M is below the product-guaranteed + // default window (1_048_576), so the session keeps the default. + assert_eq!(resolved, Some(1_048_576)); + assert_eq!(session.config.max_context_tokens, 1_048_576); ai_config.agent_model_defaults.mode = "auto".to_string(); session.config.max_context_tokens = 256_000; @@ -10421,12 +11315,15 @@ mod tests { let resolved = SessionManager::sync_session_context_window_from_ai_config(&mut session, &ai_config); - assert_eq!(resolved, Some(512_000)); - assert_eq!(session.config.max_context_tokens, 512_000); + // Main sessions keep the product-guaranteed 1M window even when the + // resolved model window is smaller; the execution engine caps the + // effective window with min() at runtime. + assert_eq!(resolved, Some(1_048_576)); + assert_eq!(session.config.max_context_tokens, 1_048_576); } #[test] - fn sync_session_context_window_resolves_subagent_auto_through_primary() { + fn sync_session_context_window_keeps_subagent_at_one_million() { let mut ai_config = ServiceAIConfig { models: vec![ test_model("primary-model", 512_000), @@ -10443,17 +11340,49 @@ mod tests { "Explore".to_string(), SessionConfig { model_id: Some("auto".to_string()), - max_context_tokens: 256_000, + max_context_tokens: 1_000_000, ..Default::default() }, ); session.kind = SessionKind::Subagent; + // Subagent sessions are created with a forced 1M context window and must + // not be downgraded by model-window refresh or model updates. + let resolved = + SessionManager::sync_session_context_window_from_ai_config(&mut session, &ai_config); + + assert_eq!(resolved, None); + assert_eq!(session.config.max_context_tokens, 1_000_000); + } + + #[test] + fn sync_session_context_window_keeps_main_session_at_one_million() { + let mut ai_config = ServiceAIConfig { + models: vec![test_model("primary-model", 512_000)], + ..Default::default() + }; + ai_config.default_models.primary = Some("primary-model".to_string()); + ai_config.agent_model_defaults.mode = "auto".to_string(); + + let mut session = Session::new_with_id( + "main-session".to_string(), + "Main session".to_string(), + "agentic".to_string(), + SessionConfig { + model_id: Some("auto".to_string()), + max_context_tokens: 1_000_000, + ..Default::default() + }, + ); + + // Main sessions keep the product-guaranteed 1M window even when the + // resolved model window is smaller; the execution engine caps the + // effective window with min() at runtime. let resolved = SessionManager::sync_session_context_window_from_ai_config(&mut session, &ai_config); - assert_eq!(resolved, Some(512_000)); - assert_eq!(session.config.max_context_tokens, 512_000); + assert_eq!(resolved, Some(1_048_576)); + assert_eq!(session.config.max_context_tokens, 1_048_576); } #[tokio::test] @@ -10485,6 +11414,7 @@ mod tests { let snapshots = SessionManager::collect_auto_save_snapshots( &manager.sessions, &manager.transient_session_ids, + &manager.disk_removed_loaded_ids, ); assert!(snapshots .iter() @@ -10498,28 +11428,195 @@ mod tests { } #[tokio::test] - async fn reset_session_state_if_processing_ignores_a_newer_turn() { - let manager = in_memory_test_manager(); - let session_id = Uuid::new_v4().to_string(); - let mut session = Session::new_with_id( - session_id.clone(), - "Active session".to_string(), - "agent".to_string(), - SessionConfig::default(), + async fn reconcile_unloads_loaded_session_whose_storage_was_removed_externally() { + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), ); - session.state = SessionState::Processing { - current_turn_id: "turn-2".to_string(), - phase: ProcessingPhase::Thinking, - }; - manager.sessions.insert(session_id.clone(), session); - - manager.reset_session_state_if_processing(&session_id, "turn-1"); - + let manager = test_manager(persistence_manager.clone()); let session = manager - .get_session(&session_id) - .expect("session should remain available"); - assert!(matches!( - session.state, + .create_session( + "Reconcile target".to_string(), + "agent".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await + .expect("session should create"); + let sessions_dir = persistence_manager + .path_manager() + .project_sessions_dir(workspace.path()); + assert!(manager.get_session(&session.session_id).is_some()); + + // Simulate an external directory-level deletion (GC / manual removal). + std::fs::remove_dir_all(sessions_dir.join(&session.session_id)) + .expect("session dir should be removable"); + assert!(sessions_dir.join(&session.session_id).exists() == false); + + let summaries = manager + .list_sessions(&sessions_dir) + .await + .expect("list should succeed"); + assert!( + summaries + .iter() + .all(|summary| summary.session_id != session.session_id), + "deleted session must not be listed" + ); + assert!( + manager.get_session(&session.session_id).is_none(), + "deleted session must be unloaded from runtime memory" + ); + assert!(!sessions_dir.join(&session.session_id).exists()); + + // A second list stays clean: the unloaded session cannot resurrect. + let summaries = manager + .list_sessions(&sessions_dir) + .await + .expect("second list should succeed"); + assert!(summaries + .iter() + .all(|summary| summary.session_id != session.session_id)); + } + + #[tokio::test] + async fn auto_save_snapshots_skip_disk_removed_loaded_sessions() { + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), + ); + let manager = test_manager(persistence_manager.clone()); + let session = manager + .create_session( + "Auto-save skip".to_string(), + "agent".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await + .expect("session should create"); + let sessions_dir = persistence_manager + .path_manager() + .project_sessions_dir(workspace.path()); + std::fs::remove_dir_all(sessions_dir.join(&session.session_id)) + .expect("session dir should be removable"); + manager + .disk_removed_loaded_ids + .insert(session.session_id.clone(), ()); + + let snapshots = SessionManager::collect_auto_save_snapshots( + &manager.sessions, + &manager.transient_session_ids, + &manager.disk_removed_loaded_ids, + ); + assert!( + snapshots + .iter() + .all(|snapshot| snapshot.session_id != session.session_id), + "auto-save must skip externally deleted sessions" + ); + } + + #[tokio::test] + async fn reconcile_keeps_processing_session_until_it_finishes() { + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), + ); + let manager = test_manager(persistence_manager.clone()); + let session = manager + .create_session( + "Processing reconcile".to_string(), + "agent".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await + .expect("session should create"); + let sessions_dir = persistence_manager + .path_manager() + .project_sessions_dir(workspace.path()); + manager + .sessions + .get_mut(&session.session_id) + .expect("session should be loaded") + .state = SessionState::Processing { + current_turn_id: "turn-1".to_string(), + phase: ProcessingPhase::Thinking, + }; + std::fs::remove_dir_all(sessions_dir.join(&session.session_id)) + .expect("session dir should be removable"); + + manager + .reconcile_loaded_sessions_with_disk(&sessions_dir) + .await + .expect("reconcile should succeed"); + + // A processing session must not be unloaded mid-execution, but it is + // marked so auto-save cannot persist it. + assert!(manager.get_session(&session.session_id).is_some()); + assert!(manager + .disk_removed_loaded_ids + .contains_key(&session.session_id)); + + // A running turn may re-save the storage directory while the session + // is still processing; the deletion marker must survive that so the + // session is not silently restored. + std::fs::create_dir_all(sessions_dir.join(&session.session_id)) + .expect("session dir should be re-creatable"); + manager + .reconcile_loaded_sessions_with_disk(&sessions_dir) + .await + .expect("reconcile with re-saved storage should succeed"); + assert!(manager.get_session(&session.session_id).is_some()); + assert!(manager + .disk_removed_loaded_ids + .contains_key(&session.session_id)); + + // Once the session finishes, the next reconcile unloads it and removes + // the re-saved storage so the deleted session cannot resurrect. + manager + .sessions + .get_mut(&session.session_id) + .expect("session should be loaded") + .state = SessionState::Idle; + manager + .reconcile_loaded_sessions_with_disk(&sessions_dir) + .await + .expect("second reconcile should succeed"); + assert!(manager.get_session(&session.session_id).is_none()); + assert!(!sessions_dir.join(&session.session_id).exists()); + } + + #[tokio::test] + async fn reset_session_state_if_processing_ignores_a_newer_turn() { + let manager = in_memory_test_manager(); + let session_id = Uuid::new_v4().to_string(); + let mut session = Session::new_with_id( + session_id.clone(), + "Active session".to_string(), + "agent".to_string(), + SessionConfig::default(), + ); + session.state = SessionState::Processing { + current_turn_id: "turn-2".to_string(), + phase: ProcessingPhase::Thinking, + }; + manager.sessions.insert(session_id.clone(), session); + + manager.reset_session_state_if_processing(&session_id, "turn-1"); + + let session = manager + .get_session(&session_id) + .expect("session should remain available"); + assert!(matches!( + session.state, SessionState::Processing { ref current_turn_id, .. @@ -10890,6 +11987,7 @@ mod tests { parent_tool_call_id: None, subagent_type: None, continuation_policy: None, + ..Default::default() }, ) .await @@ -10912,6 +12010,7 @@ mod tests { parent_tool_call_id: None, subagent_type: None, continuation_policy: None, + ..Default::default() }) ); @@ -10952,6 +12051,7 @@ mod tests { parent_tool_call_id: Some("tool-1".to_string()), subagent_type: Some("Explore".to_string()), continuation_policy: None, + ..Default::default() }); persistence_manager .save_session_metadata(workspace.path(), &matched_root) @@ -10974,6 +12074,7 @@ mod tests { parent_tool_call_id: Some("tool-child".to_string()), subagent_type: Some("Explore".to_string()), continuation_policy: None, + ..Default::default() }); persistence_manager .save_session_metadata(workspace.path(), &matched_grandchild) @@ -10996,6 +12097,7 @@ mod tests { parent_tool_call_id: Some("tool-2".to_string()), subagent_type: Some("Explore".to_string()), continuation_policy: None, + ..Default::default() }); persistence_manager .save_session_metadata(workspace.path(), &unmatched_root) @@ -11017,6 +12119,7 @@ mod tests { parent_tool_call_id: None, subagent_type: None, continuation_policy: None, + ..Default::default() }); persistence_manager .save_session_metadata(workspace.path(), &visible_review_child) @@ -11039,6 +12142,115 @@ mod tests { ); } + #[tokio::test] + async fn session_tree_descendants_covers_full_subtree_and_excludes_root() { + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), + ); + let manager = test_manager(persistence_manager.clone()); + + let mut child_root = SessionMetadata::new( + "child-root".to_string(), + "Subagent: root".to_string(), + "Explore".to_string(), + "model".to_string(), + ); + child_root.session_kind = SessionKind::Subagent; + child_root.relationship = Some(SessionRelationship { + kind: Some(SessionRelationshipKind::Subagent), + parent_session_id: Some("parent-session".to_string()), + parent_dialog_turn_id: Some("turn-2".to_string()), + ..Default::default() + }); + persistence_manager + .save_session_metadata(workspace.path(), &child_root) + .await + .expect("child-root should save"); + + let mut grandchild = SessionMetadata::new( + "grandchild".to_string(), + "Subagent: grandchild".to_string(), + "Explore".to_string(), + "model".to_string(), + ); + grandchild.session_kind = SessionKind::Subagent; + grandchild.relationship = Some(SessionRelationship { + kind: Some(SessionRelationshipKind::Subagent), + parent_session_id: Some("child-root".to_string()), + parent_dialog_turn_id: Some("child-turn".to_string()), + ..Default::default() + }); + persistence_manager + .save_session_metadata(workspace.path(), &grandchild) + .await + .expect("grandchild should save"); + + let mut other_child = SessionMetadata::new( + "child-other-turn".to_string(), + "Subagent: other turn".to_string(), + "Explore".to_string(), + "model".to_string(), + ); + other_child.session_kind = SessionKind::Subagent; + other_child.relationship = Some(SessionRelationship { + kind: Some(SessionRelationshipKind::Subagent), + parent_session_id: Some("parent-session".to_string()), + parent_dialog_turn_id: Some("turn-1".to_string()), + ..Default::default() + }); + persistence_manager + .save_session_metadata(workspace.path(), &other_child) + .await + .expect("other child should save"); + + let mut review_child = SessionMetadata::new( + "review-child".to_string(), + "Review child".to_string(), + "DeepReview".to_string(), + "model".to_string(), + ); + review_child.relationship = Some(SessionRelationship { + kind: Some(SessionRelationshipKind::DeepReview), + parent_session_id: Some("parent-session".to_string()), + parent_dialog_turn_id: Some("turn-2".to_string()), + ..Default::default() + }); + persistence_manager + .save_session_metadata(workspace.path(), &review_child) + .await + .expect("review child should save"); + + let descendants = manager + .session_tree_descendants(Some(workspace.path()), "parent-session") + .await + .expect("descendant lookup should succeed"); + let descendant_set: HashSet<&str> = + descendants.iter().map(|id| id.as_str()).collect(); + assert_eq!( + descendant_set, + HashSet::from(["child-root", "grandchild", "child-other-turn"]) + ); + // Non-subagent relationships are not part of the subagent tree. + assert!(!descendant_set.contains("review-child")); + // The root session itself is excluded. + assert!(!descendant_set.contains("parent-session")); + + // Nested lookup starts from the given root. + let nested = manager + .session_tree_descendants(Some(workspace.path()), "child-root") + .await + .expect("nested descendant lookup should succeed"); + assert_eq!(nested, vec!["grandchild".to_string()]); + + // Unknown workspace yields no descendants. + let no_workspace = manager + .session_tree_descendants(None, "parent-session") + .await + .expect("no-workspace lookup should succeed"); + assert!(no_workspace.is_empty()); + } + #[tokio::test] async fn core_session_store_port_resolves_local_storage_to_sessions_dir() { use bitfun_runtime_ports::{ @@ -11261,6 +12473,56 @@ mod tests { assert_eq!(restored.session_id, session_id); } + #[tokio::test] + async fn hidden_subagent_restore_rejects_user_list_but_internal_restore_succeeds() { + // P-04 防回退:SessionControl 子代理(session_kind=Subagent,隐藏)在 + // idle>1h 内存驱逐后,用户列表语义 restore 必须拒绝(列表仍隐藏), + // 精确寻址(投递路径)restore 必须放行(方案 B + C)。 + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), + ); + let manager = test_manager(persistence_manager.clone()); + let session_id = Uuid::new_v4().to_string(); + let mut session = Session::new_with_id( + session_id.clone(), + "Hidden subagent".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ); + session.kind = SessionKind::Subagent; + persistence_manager + .save_session(workspace.path(), &session) + .await + .expect("hidden subagent should save"); + + // 用户列表语义:非 internal restore 必须拒绝隐藏子代理并带原因。 + let user_list_request = SessionStoragePathRequest { + workspace_path: workspace.path().to_path_buf(), + remote_connection_id: None, + remote_ssh_host: None, + }; + let rejection = manager + .restore_session_for_workspace(user_list_request.clone(), &session_id) + .await + .expect_err("user-list restore must reject a hidden subagent"); + assert!( + rejection.to_string().contains("Session exists but is hidden"), + "rejection should carry the hidden reason: {}", + rejection + ); + + // 精确寻址(投递路径):internal restore 必须放行隐藏子代理。 + let restored = manager + .restore_internal_session_for_workspace(user_list_request, &session_id) + .await + .expect("internal restore must allow the hidden subagent"); + assert_eq!(restored.session_id, session_id); + } + #[tokio::test] async fn restore_session_view_loads_turns_without_restoring_runtime_context() { let workspace = TestWorkspace::new(); @@ -12724,6 +13986,11 @@ mod tests { .map(|entry| entry.path.clone()), Some(expected_storage_path) ); + // A deletion marker left by a previous reconcile must also be cleared + // so the normal delete path fully resets the runtime session table. + manager + .disk_removed_loaded_ids + .insert(session.session_id.clone(), ()); manager .delete_session(workspace.path(), &session.session_id) @@ -12734,6 +14001,10 @@ mod tests { .session_storage_path_index .get(&session.session_id) .is_none()); + assert!(!manager + .disk_removed_loaded_ids + .contains_key(&session.session_id)); + assert!(!session_storage_dir.join(&session.session_id).exists()); } #[tokio::test] @@ -12767,6 +14038,117 @@ mod tests { assert!(!resolved_sessions_dir.join(&session.session_id).exists()); } + #[tokio::test] + async fn delete_session_records_tombstone_even_when_persistence_is_disabled() { + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), + ); + let manager = test_manager_with_config( + persistence_manager, + SessionManagerConfig { + max_active_sessions: 100, + session_idle_timeout: Duration::from_secs(3600), + auto_save_interval: Duration::from_secs(300), + enable_persistence: false, + prompt_cache_policy: PromptCachePolicy::default(), + }, + ); + let session = manager + .create_session( + "Tombstone without persistence".to_string(), + "agent".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await + .expect("session should create"); + + manager + .delete_session(workspace.path(), &session.session_id) + .await + .expect("session should delete"); + + let session_storage_path = manager + .resolve_storage_path_for_workspace_path(workspace.path()) + .await; + let deleted_ids = manager + .list_deleted_session_ids(&session_storage_path) + .await + .expect("tombstone registry should be readable"); + assert!( + deleted_ids.contains(&session.session_id), + "a successful deletion must record a tombstone even when persistence is disabled" + ); + } + + #[tokio::test] + async fn recreated_session_durably_clears_tombstone() { + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), + ); + let manager = test_manager(persistence_manager); + let session_id = format!("recreated-tombstone-{}", Uuid::new_v4()); + let session = manager + .create_session_with_id( + Some(session_id.clone()), + "First incarnation".to_string(), + "agent".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await + .expect("session should create"); + assert_eq!(session.session_id, session_id); + + manager + .delete_session(workspace.path(), &session_id) + .await + .expect("session should delete"); + + let session_storage_path = manager + .resolve_storage_path_for_workspace_path(workspace.path()) + .await; + let deleted_ids = manager + .list_deleted_session_ids(&session_storage_path) + .await + .expect("tombstone registry should be readable"); + assert!( + deleted_ids.contains(&session_id), + "precondition: deletion must record a tombstone" + ); + + // Re-create the same session id: the durable unmark must clear the + // on-disk tombstone, otherwise a restart would keep hiding the + // re-created session from lists and restore paths. + manager + .create_session_with_id( + Some(session_id.clone()), + "Second incarnation".to_string(), + "agent".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await + .expect("same id should be re-creatable after deletion"); + + let deleted_ids_after_recreate = manager + .list_deleted_session_ids(&session_storage_path) + .await + .expect("tombstone registry should be readable"); + assert!( + !deleted_ids_after_recreate.contains(&session_id), + "re-creation must durably clear the on-disk tombstone" + ); + } + #[tokio::test] async fn evicted_session_uses_persisted_workspace_identity_for_snapshot_cleanup() { let workspace = TestWorkspace::new(); @@ -13803,4 +15185,290 @@ mod tests { None ); } + + fn orphan_test_metadata(session_id: &str, created_by: Option<&str>) -> SessionMetadata { + SessionMetadata { + session_id: session_id.to_string(), + session_name: format!("test-{}", session_id), + agent_type: "agentic".to_string(), + last_user_dialog_agent_type: None, + last_submitted_agent_type: None, + created_by: created_by.map(str::to_string), + session_kind: SessionKind::Standard, + memory_mode: SessionMemoryMode::Enabled, + model_name: "primary".to_string(), + created_at: 1, + last_active_at: 1, + last_finished_at: None, + turn_count: 0, + message_count: 0, + tool_call_count: 0, + status: SessionStatus::Active, + terminal_session_id: None, + snapshot_session_id: None, + tags: Vec::new(), + custom_metadata: None, + relationship: None, + todos: None, + review_action_state: None, + deep_review_run_manifest: None, + review_target_evidence: None, + deep_review_cache: None, + workspace_path: None, + project_workspace_path: None, + execution_target: None, + workspace_hostname: None, + unread_completion: None, + needs_user_attention: None, + runtime_state: None, + is_daemon: false, + } + } + + #[tokio::test] + async fn orphan_recycle_archives_and_deletes_orphaned_session() { + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), + ); + let manager = test_manager(persistence_manager.clone()); + let mut metadata = orphan_test_metadata("orphan-1", Some("session-ghost-parent")); + metadata.workspace_path = Some(workspace.path().to_string_lossy().to_string()); + manager + .save_session_metadata(workspace.path(), &metadata) + .await + .expect("orphan metadata should save"); + + let report = manager + .scan_orphaned_sessions_in_workspace(workspace.path()) + .await + .expect("scan should succeed"); + assert_eq!(report.orphaned.len(), 1); + assert_eq!(report.orphaned[0].session_id, "orphan-1"); + + manager + .recycle_orphaned_sessions_in_workspace(workspace.path()) + .await + .expect("recycle should succeed"); + + assert!( + manager + .load_session_metadata(workspace.path(), "orphan-1") + .await + .expect("metadata load should succeed") + .is_none(), + "orphaned session should be deleted after archive-then-delete recycle" + ); + let storage_path = manager + .resolve_storage_path_for_workspace_path(workspace.path()) + .await; + let tombstones = manager + .list_deleted_session_ids(&storage_path) + .await + .expect("tombstone list should load"); + assert!( + tombstones.contains(&"orphan-1".to_string()), + "recycled orphan should be recorded in the deletion tombstone registry" + ); + } + + #[tokio::test] + async fn orphan_recycle_skips_daemon_sessions() { + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), + ); + let manager = test_manager(persistence_manager.clone()); + let mut metadata = orphan_test_metadata("daemon-orphan", Some("session-ghost-parent")); + metadata.is_daemon = true; + manager + .save_session_metadata(workspace.path(), &metadata) + .await + .expect("daemon orphan metadata should save"); + + manager + .recycle_orphaned_sessions_in_workspace(workspace.path()) + .await + .expect("recycle should succeed"); + + let remaining = manager + .load_session_metadata(workspace.path(), "daemon-orphan") + .await + .expect("metadata load should succeed") + .expect("daemon orphan must not be recycled"); + assert_eq!(remaining.status, SessionStatus::Active); + } + + #[tokio::test] + async fn orphan_recycle_skips_processing_loaded_session() { + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), + ); + let manager = test_manager(persistence_manager.clone()); + let session = manager + .create_session( + "Processing orphan".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await + .expect("session should create"); + // Rewrite on-disk metadata as an orphan while the runtime session is processing. + let mut metadata = + orphan_test_metadata(&session.session_id, Some("session-ghost-parent")); + metadata.workspace_path = Some(workspace.path().to_string_lossy().to_string()); + manager + .save_session_metadata(workspace.path(), &metadata) + .await + .expect("orphan metadata should save"); + manager + .sessions + .get_mut(&session.session_id) + .expect("session should remain loaded") + .state = SessionState::Processing { + current_turn_id: "turn-1".to_string(), + phase: ProcessingPhase::Thinking, + }; + + manager + .recycle_orphaned_sessions_in_workspace(workspace.path()) + .await + .expect("recycle should succeed"); + + assert!( + manager.get_session(&session.session_id).is_some(), + "processing orphan must stay loaded" + ); + assert!( + manager + .load_session_metadata(workspace.path(), &session.session_id) + .await + .expect("metadata load should succeed") + .is_some(), + "processing orphan metadata must stay" + ); + } + + #[tokio::test] + async fn orphan_recycle_discards_transient_orphan_candidates() { + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), + ); + let manager = test_manager(persistence_manager.clone()); + let session = manager + .create_session( + "Transient orphan".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await + .expect("session should create"); + // Make it a finished transient child of a vanished parent. The persisted + // metadata keeps its original (non-orphan) shape; only the in-memory + // transient entry is an orphan candidate. + manager + .transient_session_ids + .insert(session.session_id.clone(), ()); + manager + .sessions + .get_mut(&session.session_id) + .expect("session should remain loaded") + .created_by = Some("session-ghost-parent".to_string()); + + let candidates = manager.list_transient_sweep_candidates(); + assert!( + candidates.iter().any(|c| c.session_id == session.session_id), + "transient orphan should be a sweep candidate" + ); + + manager.recycle_orphaned_sessions().await; + + assert!( + manager.get_session(&session.session_id).is_none(), + "transient orphan should be discarded" + ); + } + + #[tokio::test] + async fn in_memory_list_sessions_filters_hidden_session_kinds() { + let manager = in_memory_test_manager(); + let workspace = TestWorkspace::new(); + let workspace_path = workspace.path().to_string_lossy().to_string(); + let mut standard_ids = Vec::new(); + let mut hidden_ids = Vec::new(); + for (name, kind) in [ + ("Standard visible".to_string(), SessionKind::Standard), + ("Hidden subagent".to_string(), SessionKind::Subagent), + ( + "Hidden ephemeral child".to_string(), + SessionKind::EphemeralChild, + ), + ( + "Hidden ephemeral subagent".to_string(), + SessionKind::EphemeralSubagent, + ), + ] { + let session = manager + .create_session_with_id_and_details( + None, + name, + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace_path.clone()), + ..Default::default() + }, + None, + kind, + ) + .await + .expect("session should be created"); + if matches!( + kind, + SessionKind::Subagent | SessionKind::EphemeralChild | SessionKind::EphemeralSubagent + ) { + hidden_ids.push(session.session_id); + } else { + standard_ids.push(session.session_id); + } + } + + let visible = manager + .list_sessions(workspace.path()) + .await + .expect("list sessions"); + let visible_ids: Vec<_> = visible.iter().map(|s| s.session_id.as_str()).collect(); + assert!(!visible_ids.is_empty(), "standard sessions must be listed"); + for hidden_id in &hidden_ids { + assert!( + !visible_ids.contains(&hidden_id.as_str()), + "hidden session must not leak: {hidden_id}" + ); + } + for standard_id in &standard_ids { + assert!( + visible_ids.contains(&standard_id.as_str()), + "standard session must be listed: {standard_id}" + ); + } + + let all = manager + .list_sessions_with_options(workspace.path(), true) + .await + .expect("list sessions with internal"); + let all_ids: Vec<_> = all.iter().map(|s| s.session_id.as_str()).collect(); + for hidden_id in &hidden_ids { + assert!( + all_ids.contains(&hidden_id.as_str()), + "internal listing must include hidden session: {hidden_id}" + ); + } + } } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/acp_tools.rs b/src/crates/assembly/core/src/agentic/tools/implementations/acp_tools.rs new file mode 100644 index 0000000000..81e0a43b5a --- /dev/null +++ b/src/crates/assembly/core/src/agentic/tools/implementations/acp_tools.rs @@ -0,0 +1,1224 @@ +//! Dedicated ACP tool family (real external process channel). +//! +//! These tools mirror SessionControl / SessionMessage / SessionHistory but +//! drive the true ACP bridge: every call forwards to the external ACP client +//! process through the coordinator-injected `AcpClientPort` (implemented by +//! the desktop host over `AcpClientService`). Core never depends on the ACP +//! crate; the port is the architecture boundary. +//! +//! - `acp_control`: create / list / delete / cancel real external ACP sessions. +//! - `acp_message`: forward one message through the real channel and return +//! the external agent's response synchronously. +//! - `acp_history`: read the persisted transcript of an ACP session. + +use crate::agentic::coordination::get_global_coordinator; +use crate::agentic::tools::framework::{ + Tool, ToolExposure, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, +}; +use crate::util::errors::{BitFunError, BitFunResult}; +use async_trait::async_trait; +use bitfun_runtime_ports::{ + AcpClientCancelRequest, AcpClientCreateRequest, AcpClientHistoryRequest, AcpClientMessageRequest, + AcpClientPort, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::sync::Arc; + +/// `acp_control` input. +/// +/// Field names are snake_case on the wire, matching the tool `input_schema` +/// and the SessionControl/SessionMessage input contract. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct AcpControlInput { + pub action: String, + pub client_id: Option, + pub workspace_path: Option, + pub session_name: Option, + pub session_id: Option, +} + +/// `acp_message` input. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct AcpMessageInput { + pub session_id: String, + pub message: String, + pub workspace_path: Option, + pub timeout_seconds: Option, +} + +/// `acp_history` input. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct AcpHistoryInput { + pub session_id: String, + pub workspace_path: Option, +} + +/// Resolve the ACP client port injected by the desktop host. +fn resolve_acp_client_port() -> BitFunResult> { + let coordinator = get_global_coordinator() + .ok_or_else(|| BitFunError::tool("coordinator not initialized".to_string()))?; + coordinator.acp_client_port().ok_or_else(|| { + BitFunError::tool( + "ACP client port is not available; the desktop host did not inject it".to_string(), + ) + }) +} + +/// Map a port-level failure to a tool error with its kind surfaced. +fn port_error(error: bitfun_runtime_ports::PortError) -> BitFunError { + BitFunError::tool(format!( + "ACP client port failed ({:?}): {}", + error.kind, error.message + )) +} + +fn required_session_id(value: Option<&str>, action: &str) -> BitFunResult { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + .ok_or_else(|| { + BitFunError::tool(format!("session_id is required for {}", action)) + }) +} + +fn workspace_or_context( + workspace_param: Option<&str>, + context: &ToolUseContext, +) -> BitFunResult { + if let Some(workspace) = workspace_param + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return Ok(workspace.to_string()); + } + context + .workspace_root() + .map(|path| path.to_string_lossy().to_string()) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| { + BitFunError::tool( + "workspace_path is required when the current workspace is unavailable".to_string(), + ) + }) +} + +/// Execute one `acp_control` action against the real ACP port. +pub(crate) async fn run_acp_control( + port: &dyn AcpClientPort, + input: &Value, + context: &ToolUseContext, +) -> BitFunResult> { + let params: AcpControlInput = serde_json::from_value(input.clone()) + .map_err(|error| BitFunError::tool(format!("Invalid input: {}", error)))?; + + match params.action.as_str() { + "create" => { + let client_id = params + .client_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| BitFunError::tool("client_id is required for create".to_string()))? + .to_string(); + let workspace_path = workspace_or_context(params.workspace_path.as_deref(), context)?; + let created_workspace = workspace_path.clone(); + let created = port + .create_session(AcpClientCreateRequest { + client_id, + workspace_path, + session_name: params.session_name, + remote_connection_id: None, + }) + .await + .map_err(port_error)?; + let result_for_assistant = format!( + "Started external ACP session '{}' (agent '{}') for workspace '{}'.", + created.session_name, created.agent_type, created_workspace + ); + Ok(vec![ToolResult::Result { + data: json!({ + "success": true, + "action": "create", + "session": { + "session_id": created.session_id, + "session_name": created.session_name, + "agent_type": created.agent_type, + } + }), + result_for_assistant: Some(result_for_assistant), + image_attachments: None, + }]) + } + "list" => { + let listed = port.list_clients().await.map_err(port_error)?; + let result_for_assistant = if listed.clients.is_empty() { + "No ACP clients are registered.".to_string() + } else { + format!("Found {} ACP client(s):", listed.clients.len()) + }; + let clients = listed + .clients + .iter() + .map(|client| { + json!({ + "client_id": client.client_id, + "name": client.name, + "status": client.status, + "session_count": client.session_count, + "readonly": client.readonly, + }) + }) + .collect::>(); + Ok(vec![ToolResult::Result { + data: json!({ + "success": true, + "action": "list", + "count": listed.clients.len(), + "clients": clients, + }), + result_for_assistant: Some(result_for_assistant), + image_attachments: None, + }]) + } + "delete" => { + let session_id = required_session_id(params.session_id.as_deref(), "delete")?; + let workspace_path = workspace_or_context(params.workspace_path.as_deref(), context)?; + // 删除持久化流会话记录并释放外部进程:两个效果都需要,否则只剩 + // release 会留下孤儿记录(已回收会话仍出现在列表里)。 + port.delete_session_record(session_id.clone(), Some(workspace_path)) + .await + .map_err(port_error)?; + Ok(vec![ToolResult::Result { + data: json!({ + "success": true, + "action": "delete", + "session_id": session_id, + }), + result_for_assistant: Some(format!( + "Deleted external ACP session '{}'.", + session_id + )), + image_attachments: None, + }]) + } + "cancel" => { + let session_id = required_session_id(params.session_id.as_deref(), "cancel")?; + port.cancel_session(AcpClientCancelRequest { + session_id: session_id.clone(), + }) + .await + .map_err(port_error)?; + Ok(vec![ToolResult::Result { + data: json!({ + "success": true, + "action": "cancel", + "session_id": session_id, + }), + result_for_assistant: Some(format!( + "Cancelled the running turn of external ACP session '{}'.", + session_id + )), + image_attachments: None, + }]) + } + other => Err(BitFunError::tool(format!( + "unknown acp_control action '{}'; expected one of create, list, delete, cancel", + other + ))), + } +} + +/// Execute one `acp_message` forward through the real channel. +pub(crate) async fn run_acp_message( + port: &dyn AcpClientPort, + input: &Value, + context: &ToolUseContext, +) -> BitFunResult> { + let params: AcpMessageInput = serde_json::from_value(input.clone()) + .map_err(|error| BitFunError::tool(format!("Invalid input: {}", error)))?; + let session_id = required_session_id(Some(¶ms.session_id), "message")?; + let message = params + .message + .trim() + .to_string(); + if message.is_empty() { + return Err(BitFunError::tool("message is required".to_string())); + } + let workspace_path = params + .workspace_path + .as_deref() + .map(|value| { + value + .trim() + .to_string() + }) + .filter(|value| !value.is_empty()) + .or_else(|| { + context + .workspace_root() + .map(|path| path.to_string_lossy().to_string()) + }); + let sent = port + .send_message(AcpClientMessageRequest { + session_id: session_id.clone(), + message, + workspace_path, + timeout_seconds: params.timeout_seconds, + }) + .await + .map_err(port_error)?; + // 方向 C(并列返回面):result_for_assistant 只内嵌极简通知句(对齐 + // task/execution.rs acp_send_input_notice 语义),不内嵌 sent.response 全文; + // 全文留在 data JSON 的 response 字段,父会话按需取 data / SessionHistory。 + let result_for_assistant = if sent.response.trim().is_empty() { + format!("External ACP session '{}' returned an empty response.", session_id) + } else { + format!( + "External ACP session '{}' responded; use SessionHistory to view the full reply.", + session_id + ) + }; + Ok(vec![ToolResult::Result { + data: json!({ + "success": true, + "session_id": sent.session_id, + "response": sent.response, + }), + result_for_assistant: Some(result_for_assistant), + image_attachments: None, + }]) +} + +/// Execute one `acp_history` transcript read. +pub(crate) async fn run_acp_history( + port: &dyn AcpClientPort, + input: &Value, + context: &ToolUseContext, +) -> BitFunResult> { + let params: AcpHistoryInput = serde_json::from_value(input.clone()) + .map_err(|error| BitFunError::tool(format!("Invalid input: {}", error)))?; + let session_id = required_session_id(Some(¶ms.session_id), "history")?; + let workspace_path = params + .workspace_path + .as_deref() + .map(|value| { + value + .trim() + .to_string() + }) + .filter(|value| !value.is_empty()) + .or_else(|| { + context + .workspace_root() + .map(|path| path.to_string_lossy().to_string()) + }); + let read = port + .read_history(AcpClientHistoryRequest { + session_id: session_id.clone(), + workspace_path, + }) + .await + .map_err(port_error)?; + let result_for_assistant = format!( + "Session '{}' has {} transcript entr{}.", + session_id, + read.entries.len(), + if read.entries.len() == 1 { "y" } else { "ies" } + ); + Ok(vec![ToolResult::Result { + data: json!({ + "success": true, + "session_id": read.session_id, + "count": read.entries.len(), + "truncated": read.truncated, + "entries": read.entries, + }), + result_for_assistant: Some(result_for_assistant), + image_attachments: None, + }]) +} + +/// `acp_control` tool - create, list, delete, or cancel real external ACP sessions. +pub struct AcpControlTool; + +impl Default for AcpControlTool { + fn default() -> Self { + Self::new() + } +} + +impl AcpControlTool { + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl Tool for AcpControlTool { + fn name(&self) -> &str { + "acp_control" + } + + async fn description(&self) -> BitFunResult { + Ok( + r#"Manage real external ACP agent sessions (true bridge: every action drives the external ACP client process, never a local model). + +Actions: +- "create": Start an external ACP client process for a client_id (for example "codex" or "claude-code") bound to a persisted session in the given workspace. Requires client_id and workspace_path. +- "list": List registered ACP clients with their runtime status and session counts. +- "delete": Delete an external ACP session: release the external process bound to a session_id created by this tool or acp_control create, and remove its persisted record so it stops appearing in listings. +- "cancel": Cancel the currently running dialog turn of the external ACP session. + +Related tools: +- Use acp_message to send a message to an external ACP session (synchronous real-channel response). +- Use acp_history to read the persisted transcript of an ACP session. + +Arguments: +- "action": Required. One of "create", "list", "delete", "cancel". +- "client_id": Required for create. Registered ACP client id. +- "workspace_path": Optional absolute workspace path; defaults to the current workspace when omitted. Used by create and delete. +- "session_name": Optional display name; only used by create. +- "session_id": Required for delete and cancel."# + .to_string(), + ) + } + + fn short_description(&self) -> String { + "Create, list, delete, and cancel real external ACP agent sessions.".to_string() + } + + fn default_exposure(&self) -> ToolExposure { + ToolExposure::Deferred + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["create", "list", "delete", "cancel"], + "description": "The ACP session action to perform." + }, + "client_id": { + "type": "string", + "description": "Required for create. Registered ACP client id." + }, + "workspace_path": { + "type": "string", + "description": "Optional absolute workspace path for create and delete; defaults to the current workspace when omitted." + }, + "session_name": { + "type": "string", + "description": "Optional display name when creating a session." + }, + "session_id": { + "type": "string", + "description": "Required for delete and cancel." + } + }, + "required": ["action"], + "additionalProperties": false + }) + } + + fn is_readonly(&self) -> bool { + false + } + + fn is_concurrency_safe(&self, _input: Option<&Value>) -> bool { + false + } + + async fn validate_input( + &self, + input: &Value, + _context: Option<&ToolUseContext>, + ) -> ValidationResult { + let parsed: AcpControlInput = match serde_json::from_value(input.clone()) { + Ok(value) => value, + Err(error) => { + return ValidationResult { + result: false, + message: Some(format!("Invalid input: {}", error)), + error_code: Some(400), + meta: None, + }; + } + }; + let mut message = None; + let mut result = true; + match parsed.action.as_str() { + "create" => { + if parsed + .client_id + .as_deref() + .map(str::trim) + .unwrap_or_default() + .is_empty() + { + result = false; + message = Some("client_id is required for create".to_string()); + } + } + "delete" | "cancel" => { + if parsed + .session_id + .as_deref() + .map(str::trim) + .unwrap_or_default() + .is_empty() + { + result = false; + message = Some(format!( + "session_id is required for {}", + parsed.action + )); + } + } + "list" => {} + other => { + result = false; + message = Some(format!( + "unknown acp_control action '{}'; expected one of create, list, delete, cancel", + other + )); + } + } + ValidationResult { + result, + message, + error_code: if result { None } else { Some(400) }, + meta: None, + } + } + + fn render_tool_use_message(&self, input: &Value, _options: &ToolRenderOptions) -> String { + let action = input + .get("action") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"); + match action { + "create" => { + let client_id = input + .get("client_id") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"); + format!("Start external ACP session for client '{}'", client_id) + } + "delete" => { + let session_id = input + .get("session_id") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"); + format!("Delete external ACP session '{}'", session_id) + } + "cancel" => { + let session_id = input + .get("session_id") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"); + format!("Cancel external ACP session '{}'", session_id) + } + _ => "List external ACP clients".to_string(), + } + } + + async fn call_impl( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { + let port = resolve_acp_client_port()?; + run_acp_control(port.as_ref(), input, context).await + } +} + +/// `acp_message` tool - forward one message through the real ACP channel. +pub struct AcpMessageTool; + +impl Default for AcpMessageTool { + fn default() -> Self { + Self::new() + } +} + +impl AcpMessageTool { + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl Tool for AcpMessageTool { + fn name(&self) -> &str { + "acp_message" + } + + async fn description(&self) -> BitFunResult { + Ok( + r#"Send a message to an existing external ACP agent session and synchronously return the external agent's response. + +This is the true bridge path: the message is forwarded to the real external ACP client process (for example Codex or Claude Code) and the response text comes back from that process, not from a local model. + +Related tools: +- Use acp_control create to start an external ACP session, then acp_message to talk to it. +- Use acp_history to read the persisted transcript. + +Arguments: +- "session_id": Required. The ACP session id returned by acp_control create. +- "message": Required. The prompt to forward to the external agent. +- "workspace_path": Optional absolute workspace path; defaults to the current workspace when omitted. +- "timeout_seconds": Optional timeout for the external agent turn; omitted means the host default."# + .to_string(), + ) + } + + fn short_description(&self) -> String { + "Send a message to a real external ACP agent session and return its response.".to_string() + } + + fn default_exposure(&self) -> ToolExposure { + ToolExposure::Deferred + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "The ACP session id returned by acp_control create." + }, + "message": { + "type": "string", + "description": "The prompt to forward to the external agent." + }, + "workspace_path": { + "type": "string", + "description": "Optional absolute workspace path; defaults to the current workspace." + }, + "timeout_seconds": { + "type": "integer", + "description": "Optional timeout for the external agent turn." + } + }, + "required": ["session_id", "message"], + "additionalProperties": false + }) + } + + fn is_readonly(&self) -> bool { + false + } + + fn is_concurrency_safe(&self, _input: Option<&Value>) -> bool { + false + } + + async fn validate_input( + &self, + input: &Value, + _context: Option<&ToolUseContext>, + ) -> ValidationResult { + let parsed: AcpMessageInput = match serde_json::from_value(input.clone()) { + Ok(value) => value, + Err(error) => { + return ValidationResult { + result: false, + message: Some(format!("Invalid input: {}", error)), + error_code: Some(400), + meta: None, + }; + } + }; + let mut result = true; + let mut message = None; + if parsed.session_id.trim().is_empty() { + result = false; + message = Some("session_id is required".to_string()); + } else if parsed.message.trim().is_empty() { + result = false; + message = Some("message is required".to_string()); + } + ValidationResult { + result, + message, + error_code: if result { None } else { Some(400) }, + meta: None, + } + } + + fn render_tool_use_message(&self, input: &Value, _options: &ToolRenderOptions) -> String { + let session_id = input + .get("session_id") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"); + format!("Send message to external ACP session '{}'", session_id) + } + + async fn call_impl( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { + let port = resolve_acp_client_port()?; + run_acp_message(port.as_ref(), input, context).await + } +} + +/// `acp_history` tool - read the persisted transcript of an ACP session. +pub struct AcpHistoryTool; + +impl Default for AcpHistoryTool { + fn default() -> Self { + Self::new() + } +} + +impl AcpHistoryTool { + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl Tool for AcpHistoryTool { + fn name(&self) -> &str { + "acp_history" + } + + async fn description(&self) -> BitFunResult { + Ok( + r#"Read the persisted transcript of an external ACP agent session. + +Returns the same turn history the external ACP process replays on restore, so the transcript reflects the real external conversation. + +Related tools: +- Use acp_control create to start an external ACP session. +- Use acp_message to continue the conversation. + +Arguments: +- "session_id": Required. The ACP session id returned by acp_control create. +- "workspace_path": Optional absolute workspace path; defaults to the current workspace when omitted."# + .to_string(), + ) + } + + fn short_description(&self) -> String { + "Read the persisted transcript of an external ACP agent session.".to_string() + } + + fn default_exposure(&self) -> ToolExposure { + ToolExposure::Deferred + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "The ACP session id returned by acp_control create." + }, + "workspace_path": { + "type": "string", + "description": "Optional absolute workspace path; defaults to the current workspace." + } + }, + "required": ["session_id"], + "additionalProperties": false + }) + } + + fn is_readonly(&self) -> bool { + true + } + + fn is_concurrency_safe(&self, _input: Option<&Value>) -> bool { + true + } + + async fn validate_input( + &self, + input: &Value, + _context: Option<&ToolUseContext>, + ) -> ValidationResult { + let parsed: AcpHistoryInput = match serde_json::from_value(input.clone()) { + Ok(value) => value, + Err(error) => { + return ValidationResult { + result: false, + message: Some(format!("Invalid input: {}", error)), + error_code: Some(400), + meta: None, + }; + } + }; + let result = !parsed.session_id.trim().is_empty(); + ValidationResult { + result, + message: if result { + None + } else { + Some("session_id is required".to_string()) + }, + error_code: if result { None } else { Some(400) }, + meta: None, + } + } + + fn render_tool_use_message(&self, input: &Value, _options: &ToolRenderOptions) -> String { + let session_id = input + .get("session_id") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"); + format!("Read transcript of external ACP session '{}'", session_id) + } + + async fn call_impl( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { + let port = resolve_acp_client_port()?; + run_acp_history(port.as_ref(), input, context).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bitfun_runtime_ports::{ + AcpClientBitfunMessageRequest, AcpClientCreateResult, AcpClientHistoryEntry, + AcpClientHistoryResult, AcpClientListResult, AcpClientMessageResult, + AcpClientReleaseRequest, AcpClientStreamChunk, AcpClientStreamChunkSink, AcpClientSummary, + PortResult, RuntimeServiceCapability, RuntimeServicePort, + }; + use std::sync::Mutex; + + #[derive(Debug, Default)] + struct FakeAcpClientPort { + created: Mutex>, + listed: Mutex, + released: Mutex>, + deleted: Mutex>, + cancelled: Mutex>, + messages: Mutex>, + bitfun_messages: Mutex>, + histories: Mutex>, + } + + impl RuntimeServicePort for FakeAcpClientPort { + fn capability(&self) -> RuntimeServiceCapability { + RuntimeServiceCapability::AcpClient + } + } + + #[async_trait] + impl AcpClientPort for FakeAcpClientPort { + async fn create_session( + &self, + request: AcpClientCreateRequest, + ) -> PortResult { + self.created.lock().unwrap().push(request.clone()); + Ok(AcpClientCreateResult { + session_id: format!("acp_{}_{}", request.client_id, "session-1"), + session_name: request + .session_name + .unwrap_or_else(|| format!("{} ACP", request.client_id)), + agent_type: format!("acp:{}", request.client_id), + }) + } + + async fn list_clients(&self) -> PortResult { + *self.listed.lock().unwrap() += 1; + Ok(AcpClientListResult { + clients: vec![AcpClientSummary { + client_id: "codex".to_string(), + name: "Codex".to_string(), + status: "running".to_string(), + session_count: 1, + readonly: false, + }], + }) + } + + async fn release_session(&self, request: AcpClientReleaseRequest) -> PortResult<()> { + self.released.lock().unwrap().push(request.session_id); + Ok(()) + } + + async fn cancel_session(&self, request: AcpClientCancelRequest) -> PortResult<()> { + self.cancelled.lock().unwrap().push(request.session_id); + Ok(()) + } + + async fn send_message( + &self, + request: AcpClientMessageRequest, + ) -> PortResult { + self.messages.lock().unwrap().push(request.clone()); + Ok(AcpClientMessageResult { + session_id: request.session_id, + response: "external response".to_string(), + }) + } + + async fn send_message_stream( + &self, + request: AcpClientMessageRequest, + chunk_sink: AcpClientStreamChunkSink, + ) -> PortResult { + self.messages.lock().unwrap().push(request.clone()); + let _ = chunk_sink.send(AcpClientStreamChunk::Text { + text: "external response".to_string(), + }); + let _ = chunk_sink.send(AcpClientStreamChunk::Completed); + Ok(AcpClientMessageResult { + session_id: request.session_id, + response: "external response".to_string(), + }) + } + + async fn send_message_to_bitfun_session( + &self, + request: AcpClientBitfunMessageRequest, + ) -> PortResult { + self.bitfun_messages.lock().unwrap().push(request.clone()); + Ok(AcpClientMessageResult { + session_id: request.bitfun_session_id, + response: "external response".to_string(), + }) + } + + async fn send_message_to_bitfun_session_stream( + &self, + request: AcpClientBitfunMessageRequest, + chunk_sink: AcpClientStreamChunkSink, + ) -> PortResult { + self.bitfun_messages.lock().unwrap().push(request.clone()); + let _ = chunk_sink.send(AcpClientStreamChunk::Text { + text: "external response".to_string(), + }); + let _ = chunk_sink.send(AcpClientStreamChunk::Completed); + Ok(AcpClientMessageResult { + session_id: request.bitfun_session_id, + response: "external response".to_string(), + }) + } + + async fn delete_session_record( + &self, + session_id: String, + _workspace_path: Option, + ) -> PortResult<()> { + // 与真实桌面实现一致:delete_session_record 内部会 release + 删除记录 + self.deleted.lock().unwrap().push(session_id); + Ok(()) + } + + async fn read_history( + &self, + request: AcpClientHistoryRequest, + ) -> PortResult { + self.histories.lock().unwrap().push(request.clone()); + Ok(AcpClientHistoryResult { + session_id: request.session_id, + entries: vec![AcpClientHistoryEntry { + role: "user".to_string(), + content: "hello".to_string(), + timestamp_ms: Some(1_700_000_000_000), + }], + truncated: false, + }) + } + } + + fn context() -> ToolUseContext { + use std::collections::HashMap; + use std::path::PathBuf; + ToolUseContext { + tool_call_id: None, + agent_type: None, + session_id: None, + dialog_turn_id: None, + workspace: Some(crate::agentic::WorkspaceBinding::new( + None, + PathBuf::from("/repo/project"), + )), + loaded_deferred_tool_specs: Vec::new(), + primary_model_facts: Default::default(), + custom_data: HashMap::new(), + computer_use_host: None, + runtime_tool_restrictions: Default::default(), + runtime_handles: bitfun_runtime_ports::ToolRuntimeHandles::default(), + } + } + + #[tokio::test] + async fn acp_control_create_forwards_client_and_workspace() { + let port = FakeAcpClientPort::default(); + let results = run_acp_control( + &port, + &json!({ + "action": "create", + "client_id": "codex", + "workspace_path": "/repo/project", + "session_name": "my acp", + }), + &context(), + ) + .await + .expect("create should succeed"); + + let created = port.created.lock().unwrap(); + assert_eq!(created.len(), 1); + assert_eq!(created[0].client_id, "codex"); + assert_eq!(created[0].workspace_path, "/repo/project"); + assert_eq!(created[0].session_name.as_deref(), Some("my acp")); + + let data = results[0].content(); + assert_eq!(data["success"], true); + assert_eq!(data["action"], "create"); + assert_eq!(data["session"]["session_id"], "acp_codex_session-1"); + assert_eq!(data["session"]["agent_type"], "acp:codex"); + } + + #[tokio::test] + async fn acp_control_create_falls_back_to_context_workspace() { + let port = FakeAcpClientPort::default(); + run_acp_control( + &port, + &json!({ "action": "create", "client_id": "codex" }), + &context(), + ) + .await + .expect("create should fall back to the context workspace"); + + let created = port.created.lock().unwrap(); + assert_eq!(created[0].workspace_path, "/repo/project"); + } + + #[tokio::test] + async fn acp_control_create_requires_client_id() { + let port = FakeAcpClientPort::default(); + let error = run_acp_control(&port, &json!({ "action": "create" }), &context()) + .await + .unwrap_err(); + assert!(error.to_string().contains("client_id is required")); + assert!(port.created.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn acp_control_list_returns_client_summaries() { + let port = FakeAcpClientPort::default(); + let results = run_acp_control(&port, &json!({ "action": "list" }), &context()) + .await + .expect("list should succeed"); + + assert_eq!(*port.listed.lock().unwrap(), 1); + let data = results[0].content(); + assert_eq!(data["count"], 1); + assert_eq!(data["clients"][0]["client_id"], "codex"); + assert_eq!(data["clients"][0]["status"], "running"); + } + + #[tokio::test] + async fn acp_control_delete_deletes_session_record() { + let port = FakeAcpClientPort::default(); + let results = run_acp_control( + &port, + &json!({ + "action": "delete", + "session_id": "acp_codex_s1", + "workspace_path": "/repo/project", + }), + &context(), + ) + .await + .expect("delete should succeed"); + + // delete 走 delete_session_record(release + 删除持久记录),而非仅 release + assert_eq!( + port.deleted.lock().unwrap().as_slice(), + &["acp_codex_s1".to_string()] + ); + assert!(port.released.lock().unwrap().is_empty()); + assert_eq!(results[0].content()["session_id"], "acp_codex_s1"); + } + + #[tokio::test] + async fn acp_control_delete_requires_session_id() { + let port = FakeAcpClientPort::default(); + let error = run_acp_control(&port, &json!({ "action": "delete" }), &context()) + .await + .unwrap_err(); + assert!(error.to_string().contains("session_id is required")); + assert!(port.released.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn acp_control_cancel_cancels_session() { + let port = FakeAcpClientPort::default(); + run_acp_control( + &port, + &json!({ "action": "cancel", "session_id": "acp_codex_s1" }), + &context(), + ) + .await + .expect("cancel should succeed"); + + assert_eq!( + port.cancelled.lock().unwrap().as_slice(), + &["acp_codex_s1".to_string()] + ); + } + + #[tokio::test] + async fn acp_control_unknown_action_rejected() { + let port = FakeAcpClientPort::default(); + let error = run_acp_control(&port, &json!({ "action": "explode" }), &context()) + .await + .unwrap_err(); + assert!(error.to_string().contains("unknown acp_control action")); + } + + #[tokio::test] + async fn acp_message_forwards_through_real_channel() { + let port = FakeAcpClientPort::default(); + let results = run_acp_message( + &port, + &json!({ + "session_id": "acp_codex_s1", + "message": "hello external agent", + "timeout_seconds": 30, + }), + &context(), + ) + .await + .expect("message should succeed"); + + let messages = port.messages.lock().unwrap(); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].session_id, "acp_codex_s1"); + assert_eq!(messages[0].message, "hello external agent"); + assert_eq!(messages[0].timeout_seconds, Some(30)); + assert_eq!(messages[0].workspace_path.as_deref(), Some("/repo/project")); + + let data = results[0].content(); + assert_eq!(data["response"], "external response"); + let ToolResult::Result { + result_for_assistant, + .. + } = &results[0] + else { + panic!("expected a result payload"); + }; + let assistant_text = result_for_assistant.as_ref().unwrap(); + // 方向 C:result_for_assistant 为极简通知句,不含全量 response 全文 + //(全文留在 data["response"]);断言收到极简通知而非全文。 + assert!(assistant_text.contains("responded")); + assert!(assistant_text.contains("SessionHistory")); + assert!(!assistant_text.contains("external response")); + } + + #[tokio::test] + async fn acp_message_requires_message() { + let port = FakeAcpClientPort::default(); + let error = run_acp_message( + &port, + &json!({ "session_id": "acp_codex_s1", "message": " " }), + &context(), + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("message is required")); + } + + #[tokio::test] + async fn acp_history_returns_persisted_entries() { + let port = FakeAcpClientPort::default(); + let results = run_acp_history( + &port, + &json!({ "session_id": "acp_codex_s1" }), + &context(), + ) + .await + .expect("history should succeed"); + + let histories = port.histories.lock().unwrap(); + assert_eq!(histories.len(), 1); + assert_eq!(histories[0].session_id, "acp_codex_s1"); + + let data = results[0].content(); + assert_eq!(data["count"], 1); + assert_eq!(data["entries"][0]["role"], "user"); + assert_eq!(data["entries"][0]["content"], "hello"); + assert_eq!(data["truncated"], false); + } + + #[tokio::test] + async fn acp_history_requires_session_id() { + let port = FakeAcpClientPort::default(); + let error = run_acp_history(&port, &json!({}), &context()) + .await + .unwrap_err(); + assert!(error.to_string().contains("session_id")); + } + + #[tokio::test] + async fn acp_control_validation_rejects_unknown_action() { + let tool = AcpControlTool::new(); + let result = tool.validate_input(&json!({ "action": "boom" }), None).await; + assert!(!result.result); + assert!(result + .message + .unwrap() + .contains("unknown acp_control action")); + } + + #[tokio::test] + async fn acp_control_validation_requires_client_id_for_create() { + let tool = AcpControlTool::new(); + let result = tool.validate_input(&json!({ "action": "create" }), None).await; + assert!(!result.result); + assert!(result.message.unwrap().contains("client_id is required")); + } + + #[tokio::test] + async fn acp_message_validation_requires_session_and_message() { + let tool = AcpMessageTool::new(); + let result = tool + .validate_input(&json!({ "session_id": "", "message": "" }), None) + .await; + assert!(!result.result); + + let ok = tool + .validate_input( + &json!({ "session_id": "s1", "message": "hi" }), + None, + ) + .await; + assert!(ok.result); + } + + #[tokio::test] + async fn acp_history_validation_requires_session_id() { + let tool = AcpHistoryTool::new(); + let result = tool.validate_input(&json!({}), None).await; + assert!(!result.result); + + let ok = tool.validate_input(&json!({ "session_id": "s1" }), None).await; + assert!(ok.result); + } + + #[test] + fn acp_tool_names_match_registered_contract() { + assert_eq!(AcpControlTool::new().name(), "acp_control"); + assert_eq!(AcpMessageTool::new().name(), "acp_message"); + assert_eq!(AcpHistoryTool::new().name(), "acp_history"); + } +} diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/agent_wait_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/agent_wait_tool.rs index 102c3d6a13..0d0145172c 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/agent_wait_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/agent_wait_tool.rs @@ -11,9 +11,11 @@ use serde_json::{json, Value}; use std::collections::HashSet; use tokio::time::Duration; -const DEFAULT_TIMEOUT_MS: u64 = 10 * 60 * 1_000; +const DEFAULT_TIMEOUT_MS: u64 = 600_000; const MAX_TIMEOUT_MS: u64 = 60 * 60 * 1_000; +/// DEPRECATED. Use SessionMessage for sub-agent communication (async, no waiting needed). +/// Max 10min, only for short waits confirming session creation, not for long-running tasks. pub struct AgentWaitTool; #[derive(Debug, PartialEq, Eq)] @@ -116,6 +118,10 @@ impl AgentWaitTool { }) } + /// 方向 C(并列返回面):result_for_assistant 只内嵌极简状态(wait 状态 + + /// 每个 outcome 的 bg_task_id/agent_id/status),不嵌入 outcome.content 全文。 + /// 全文留在 data JSON(outcome_json 含 content/error),父会话按需取 data; + /// 避免显式等待返回面携带「通知 + 全文」双路。 fn assistant_result(result: &BackgroundSubagentWaitResult) -> String { if result.outcomes.is_empty() { return format!( @@ -133,9 +139,6 @@ impl AgentWaitTool { outcome.model_agent_id(), outcome.status.as_str(), )); - if let Some(content) = &outcome.content { - message.push_str(content); - } if let Some(error) = &outcome.error { message.push_str("\nError: "); message.push_str(error); @@ -190,7 +193,7 @@ The selected task set is fixed when the call starts. wait_mode defaults to `all` }, "timeout_ms": { "type": "integer", - "description": "Maximum time to wait in milliseconds. Defaults to ten minutes." + "description": "Maximum time to wait in milliseconds. Defaults to 10 minutes." } }, "additionalProperties": false diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/code_review_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/code_review_tool.rs index 71f6765d92..7a562aa45e 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/code_review_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/code_review_tool.rs @@ -16,6 +16,18 @@ use bitfun_agent_runtime::deep_review::{ use log::warn; use serde_json::{json, Value}; +/// Human-readable serde_json variant name for diagnostics logging. +fn json_type_name(value: &Value) -> &'static str { + match value { + Value::Null => "null", + Value::Bool(_) => "boolean", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } +} + /// Code review tool definition pub struct CodeReviewTool; @@ -474,6 +486,16 @@ impl CodeReviewTool { run_manifest: Option<&Value>, compression_contract: Option<&CompressionContract>, ) { + // All key-indexed writes below assume an object root. Reset non-object + // inputs (e.g. a JSON array) to an empty object and fail closed instead + // of panicking inside serde_json's IndexMut. + if !input.is_object() { + warn!( + "CodeReview tool received a non-object input (type: {}), resetting to an empty object and failing closed", + json_type_name(input) + ); + *input = json!({}); + } let summary_is_valid = input .get("summary") @@ -781,6 +803,40 @@ mod tests { assert_eq!(input["evidence_status"], "failed"); } + #[test] + fn non_object_input_fails_closed_without_panicking() { + for mut input in [json!([1, 2, 3]), json!("review"), json!(42), json!(null)] { + CodeReviewTool::validate_and_fill_defaults(&mut input, false, None, None); + + assert_eq!(input["evidence_status"], "failed"); + assert_eq!(input["summary"]["risk_level"], "high"); + assert_eq!(input["summary"]["recommended_action"], "request_changes"); + assert!(input["issues"].as_array().is_some()); + assert!(input["positive_points"].as_array().is_some()); + assert_eq!(input["review_mode"], "standard"); + } + } + + #[tokio::test] + async fn call_impl_with_array_input_returns_failed_review_without_panicking() { + let tool = CodeReviewTool::new(); + let context = tool_context(None); + + let result = tool + .call_impl(&json!([1, 2, 3]), &context) + .await + .expect("array input should be handled without panicking"); + + let ToolResult::Result { data, .. } = &result[0] else { + panic!("expected tool result"); + }; + assert_eq!(data["evidence_status"], "failed"); + assert_eq!( + data["summary"]["overall_assessment"], + "Review result is incomplete or invalid" + ); + } + #[test] fn partially_invalid_summary_is_replaced_as_a_unit() { let mut input = json!({ diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_actions.rs b/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_actions.rs index f14275447a..44c0531ee2 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_actions.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_actions.rs @@ -1857,6 +1857,7 @@ mod tests { /// Windows shape: window title in `name`, executable basename in /// `process_name`. + #[allow(dead_code)] fn windows_app(window_title: &str, exe: &str) -> ComputerUseForegroundApplication { ComputerUseForegroundApplication { name: Some(window_title.to_string()), diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_tool.rs index cf9ce31d5f..f29869960e 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_tool.rs @@ -850,7 +850,7 @@ The **primary model cannot consume images** in tool results — **do not** use * if files.len() <= COMPUTER_USE_DEBUG_MAX_FILES { return; } - files.sort_by(|a, b| b.0.cmp(&a.0)); + files.sort_by_key(|file| std::cmp::Reverse(file.0)); for (_, path) in files.into_iter().skip(COMPUTER_USE_DEBUG_MAX_FILES) { if let Err(e) = tokio::fs::remove_file(&path).await { warn!( diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/create_plan_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/create_plan_tool.rs index 6080ae03d4..bf00dbf4ad 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/create_plan_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/create_plan_tool.rs @@ -2,7 +2,11 @@ //! //! Used to create and store plan files during the planning phase -use crate::agentic::tools::framework::{Tool, ToolExposure, ToolResult, ToolUseContext}; +use crate::agentic::tools::file_permissions::file_permission_intents; +use crate::agentic::tools::framework::{ + PermissionIntent, Tool, ToolExposure, ToolResult, ToolUseContext, +}; +use crate::agentic::tools::implementations::plan_update_tool::atomic_write_plan_file; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; use bitfun_agent_runtime::remote_file_delivery::{ @@ -10,7 +14,6 @@ use bitfun_agent_runtime::remote_file_delivery::{ }; use serde::Serialize; use serde_json::{json, Value}; -use tokio::fs; /// YAML frontmatter structure for Plan files #[derive(Serialize)] @@ -90,7 +93,10 @@ Additional guidelines: } fn default_exposure(&self) -> ToolExposure { - ToolExposure::Deferred + // 2026-08-04 user calibration: plan tool family is a commander + // staple; Direct so no GetToolSpec unlock round-trip is needed. + // Also mirrored in `shared_coding_mode_tool_exposure_overrides()`. + ToolExposure::Direct } fn input_schema(&self) -> Value { @@ -141,14 +147,38 @@ Additional guidelines: } fn is_readonly(&self) -> bool { - // Only writes plan file, doesn't modify code - true + // PLAN-02: CreatePlan writes the plan file, so it must NOT be declared + // readonly - otherwise permission_intents would be empty and the write + // would have no permission gate. + false } fn is_concurrency_safe(&self, _input: Option<&Value>) -> bool { + // Each call generates a unique plan file name, so concurrent creates + // never collide on the same target. true } + fn permission_intents( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { + // PLAN-02: emit an edit intent for the plan file that will be created + // so permission rules actually gate the write (mirrors + // file_write_tool.rs). The uuid nonce differs per call; the intent + // still describes the plans-dir target the tool writes to. + let name = input + .get("name") + .and_then(Value::as_str) + .unwrap_or_default(); + let plans_dir = context.current_workspace_runtime_root()?.join("plans"); + let plan_file_name = generate_plan_file_name(name); + let plan_path = plans_dir.join(plan_file_name); + let plan_path_str = plan_path.to_string_lossy().to_string(); + file_permission_intents("edit", [plan_path_str.as_str()], context) + } + async fn call_impl( &self, input: &Value, @@ -172,31 +202,16 @@ Additional guidelines: let todos = input.get("todos").and_then(|v| v.as_array()); - // Generate filename: {name_lowercase_underscored}_{8-digit uuid}.plan.md - let name_normalized = name - .to_lowercase() - .replace(' ', "_") - .chars() - .filter(|c| c.is_alphanumeric() || *c == '_') - .collect::(); - - let uuid_short = uuid::Uuid::new_v4() - .to_string() - .split('-') - .next() - .unwrap_or("00000000") - .to_string(); - - let plan_file_name = format!("{}_{}.plan.md", name_normalized, uuid_short); + let plan_file_name = generate_plan_file_name(name); let file_content = generate_plan_file_content(name, overview, plan, todos); let runtime_context = context.ensure_current_workspace_runtime().await?; let plans_dir = runtime_context.plans_dir.clone(); let plan_file_path = plans_dir.join(&plan_file_name); - fs::write(&plan_file_path, &file_content) - .await - .map_err(|e| BitFunError::tool(format!("Failed to write plan file: {}", e)))?; + // PLAN-11: atomic write (sibling temp file + rename) so a crash never + // leaves a half-written plan file. + atomic_write_plan_file(&plan_file_path, file_content.as_bytes()).await?; let plan_file_path_str = plan_file_path.to_string_lossy().to_string(); // Process todos for return result @@ -258,6 +273,26 @@ Your next reply MUST show the clickable link and then end the conversation turn. } } +/// Build the plan file name: `{name_lowercase_underscored}_{8-char uuid}.plan.md`. +/// Falls back to a "plan" stem when the name normalizes to an empty string +/// (PLAN-11: previously produced an ugly `_.plan.md`). +fn generate_plan_file_name(name: &str) -> String { + let name_normalized = name + .to_lowercase() + .replace(' ', "_") + .chars() + .filter(|c| c.is_alphanumeric() || *c == '_') + .collect::(); + let name_stem = if name_normalized.is_empty() { + "plan".to_string() + } else { + name_normalized + }; + let uuid_short = uuid::Uuid::new_v4().simple().to_string(); + let uuid_short = &uuid_short[..8]; + format!("{}_{}.plan.md", name_stem, uuid_short) +} + /// Generate plan file content fn generate_plan_file_content( name: &str, @@ -307,17 +342,78 @@ fn generate_plan_file_content( #[cfg(test)] mod tests { - use super::CreatePlanTool; - use crate::agentic::tools::framework::{Tool, ToolExposure}; + use super::{generate_plan_file_name, CreatePlanTool}; + use crate::agentic::tools::framework::{Tool, ToolExposure, ToolUseContext}; + use serde_json::json; #[test] - fn create_plan_is_deferred_and_plan_mode_specific() { + fn create_plan_is_direct_available() { let tool = CreatePlanTool::new(); - assert_eq!(tool.default_exposure(), ToolExposure::Deferred); + assert_eq!(tool.default_exposure(), ToolExposure::Direct); assert_eq!( tool.short_description(), "Create and store a concise implementation plan; only for Plan mode." ); } + + #[test] + fn generate_plan_file_name_uses_normalized_stem() { + let name = generate_plan_file_name("Deploy API 2026"); + assert!(name.starts_with("deploy_api_2026_"), "name: {}", name); + assert!(name.ends_with(".plan.md"), "name: {}", name); + } + + #[test] + fn generate_plan_file_name_falls_back_for_empty_normalized_stem() { + // PLAN-11: a name with no alphanumeric characters must not produce an + // ugly leading-underscore file name. + let name = generate_plan_file_name("!!!"); + assert!(name.starts_with("plan_"), "name: {}", name); + assert!(name.ends_with(".plan.md"), "name: {}", name); + } + + #[test] + fn create_plan_permission_intents_emits_edit_for_plans_dir_target() { + // PLAN-02: the write must surface a non-empty edit intent so the + // permission system can gate it. + let dir = std::env::temp_dir().join(format!("create-plan-intent-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(dir.join("plans")).expect("plans dir should be created"); + let mut context = ToolUseContext::for_tool_listing( + Some(crate::agentic::WorkspaceBinding::new(None, dir.clone())), + None, + ); + context.custom_data.insert( + "__bitfun_test_runtime_root".to_string(), + json!(dir.to_string_lossy().to_string()), + ); + + let intents = CreatePlanTool::new() + .permission_intents( + &json!({ + "name": "My Plan", + "overview": "Overview", + "plan": "# My Plan" + }), + &context, + ) + .expect("permission intents"); + let _ = std::fs::remove_dir_all(&dir); + + assert!(!intents.is_empty(), "edit intent must be emitted"); + assert_eq!(intents[0].action, "edit"); + assert!( + intents[0].resources.iter().any(|resource| { + resource.replace('\\', "/").contains("/plans/") + }), + "intent must target the plans directory: {:?}", + intents[0].resources + ); + } + + #[test] + fn create_plan_is_no_longer_readonly() { + // PLAN-02: CreatePlan writes a file, so it must report non-readonly. + assert!(!CreatePlanTool::new().is_readonly()); + } } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/cron_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/cron_tool.rs index 4aa05675fb..8a241d732e 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/cron_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/cron_tool.rs @@ -170,6 +170,7 @@ impl CronTool { .unwrap_or_else(|| workspace_ref.workspace_path.clone()), remote_connection_id: workspace_ref.remote_connection_id.clone(), remote_ssh_host: workspace_ref.remote_ssh_host.clone(), + include_hidden: false, }) .await .map_err(|error| { @@ -441,6 +442,7 @@ impl CronToolJobPatchInput { struct CronToolInput { action: CronAction, session_id: Option, + target_kind: Option, job: Option, patch: Option, job_id: Option, @@ -635,10 +637,11 @@ impl Tool for CronTool { Defaults: - "session_id": defaults to the current session for "list" and "add". +- "target_kind": optional, one of "session" | "workspace". Defaults to "session" for "list"; pass "workspace" to list workspace-scoped jobs. Actions: - "get_time": Return the current local time including timezone information. -- "list": List all jobs for the effective session scope. +- "list": List all jobs for the effective session scope (or workspace scope when "target_kind" is "workspace"). - "add": Create a job. Requires "job". When "job.name" is omitted, uses "Cron job". - "update": Update a job. Requires "job_id" and "patch". - "remove": Delete a job. Requires "job_id". @@ -684,6 +687,11 @@ Patch schema for "update": "type": "string", "description": "Optional target session ID. Defaults to the current session for list/add." }, + "target_kind": { + "type": "string", + "enum": ["session", "workspace"], + "description": "Optional target kind filter for list. Defaults to session; use workspace to list workspace-scoped jobs." + }, "action": { "type": "string", "enum": ["get_time", "list", "add", "update", "remove", "run"], @@ -1047,7 +1055,7 @@ Patch schema for "update": workspace_ref.workspace_id.as_deref(), workspace_ref.remote_connection_id.as_deref(), Some(&session_id), - Some(CronJobTargetKind::Session), + Some(params.target_kind.unwrap_or(CronJobTargetKind::Session)), ) .await; jobs.sort_by(|left, right| { diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/glob_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/glob_tool.rs index 287c769057..b54e46dee9 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/glob_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/glob_tool.rs @@ -549,6 +549,16 @@ mod tests { dir } + fn rg_available() -> bool { + std::process::Command::new("rg") + .arg("--version") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) + } + fn remote_context(root: &str) -> ToolUseContext { let session_identity = crate::service::remote_ssh::workspace_state::workspace_session_identity( @@ -653,6 +663,9 @@ mod tests { #[test] fn absolute_pattern_searches_its_external_parent_with_local_rg() { + if !rg_available() { + return; + } let workspace_root = make_temp_dir("absolute-pattern-workspace"); let transcript_dir = make_temp_dir("absolute-pattern-transcripts"); fs::write(transcript_dir.join("session.log"), "transcript").unwrap(); @@ -734,6 +747,9 @@ mod tests { #[test] fn keeps_shallowest_matches_from_rg_results() { + if !rg_available() { + return; + } let root = make_temp_dir("limit"); fs::create_dir_all(root.join("src/deep")).unwrap(); fs::create_dir_all(root.join("tests")).unwrap(); @@ -763,6 +779,9 @@ mod tests { #[test] fn static_glob_prefix_results_are_relative_to_walk_root() { + if !rg_available() { + return; + } let root = make_temp_dir("relative-walk-root"); fs::create_dir_all(root.join("src/deep")).unwrap(); fs::write(root.join("src/lib.rs"), "").unwrap(); @@ -794,6 +813,9 @@ mod tests { #[test] fn wildcard_search_now_returns_files_only() { + if !rg_available() { + return; + } let root = make_temp_dir("files-only"); fs::create_dir_all(root.join("src/nested")).unwrap(); fs::write(root.join("src/nested/lib.rs"), "").unwrap(); diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/legion_control_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/legion_control_tool.rs new file mode 100644 index 0000000000..14a5597eef --- /dev/null +++ b/src/crates/assembly/core/src/agentic/tools/implementations/legion_control_tool.rs @@ -0,0 +1,1238 @@ +//! LegionControl deploys a legion team topology into persisted agent sessions. +//! +//! A legion is described by a preset (stored via `team_presets`) or by inline +//! `nodes`/`edges` input. The tool validates the topology (no cycles, at most +//! one parent per node), deploys each node as a persisted session through the +//! same runtime path as SessionControl, and attaches sessions to the session +//! tree along the edges. + +use super::util::normalize_path; +use crate::agentic::agents::team_presets::{get_preset, list_presets, LegionEdge, LegionNode}; +use crate::agentic::coordination::{get_global_coordinator, ConversationCoordinator}; +use crate::agentic::tools::framework::{ + Tool, ToolExposure, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, +}; +use crate::agentic::tools::implementations::session_control_tool::get_available_agent_type_ids_for_creation; +use crate::agentic::tools::restrictions::{get_session_role, validate_delegation, AgentRole}; +use crate::service_agent_runtime::CoreServiceAgentRuntime; +use crate::util::errors::{BitFunError, BitFunResult}; +use async_trait::async_trait; +use bitfun_agent_runtime::session_control::session_control_creator_marker; +use bitfun_runtime_ports::AgentSessionCreateRequest; +use bitfun_services_core::session::types::{SessionRelationship, SessionRelationshipKind}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::{BTreeSet, HashMap}; + +/// Hard upper bound on the number of legion nodes in one topology. +/// +/// LEGION-03: an unbounded node count lets a single LegionControl call spawn an +/// unbounded number of persisted sessions. 20 keeps the deployment bounded +/// while leaving room for realistic team shapes (the built-in presets use at +/// most a handful of nodes). +const MAX_LEGION_NODES: usize = 20; + +/// LegionControl tool - deploy a legion team topology into persisted sessions. +pub struct LegionControlTool; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum LegionControlAction { + Load, + List, +} + +impl LegionControlAction { + fn from_str(value: &str) -> Option { + match value { + "load" => Some(Self::Load), + "list" => Some(Self::List), + _ => None, + } + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct LegionNodeOverride { + pub agent: Option, + pub role: Option, + pub prompt: Option, + pub gate: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct LegionControlInput { + pub action: String, + pub preset_id: Option, + #[serde(default)] + pub overrides: HashMap, + pub nodes: Option>, + #[serde(default)] + pub edges: Vec, +} + +/// A node resolved for deployment: topologically sorted with depth and parent. +#[derive(Debug, Clone)] +pub(crate) struct ResolvedLegionNode { + pub node: LegionNode, + pub depth: u32, + pub parent: Option, +} + +impl Default for LegionControlTool { + fn default() -> Self { + Self::new() + } +} + +impl LegionControlTool { + pub fn new() -> Self { + Self + } + + /// Apply per-node overrides (keyed by node id) to a topology. + pub(crate) fn apply_legion_node_overrides( + mut nodes: Vec, + overrides: &HashMap, + ) -> Vec { + for node in nodes.iter_mut() { + if let Some(over) = overrides.get(&node.id) { + if let Some(agent) = &over.agent { + node.agent = agent.clone(); + } + if let Some(role) = &over.role { + node.role = role.clone(); + } + if let Some(prompt) = &over.prompt { + node.prompt = prompt.clone(); + } + if let Some(gate) = over.gate { + node.gate = gate; + } + } + } + nodes + } + + /// Validate a legion topology and resolve a deterministic deployment order. + /// + /// Rejects: empty topologies, empty node ids/agents, daemon/warden agents, + /// duplicate ids, edges referencing unknown nodes, self-loops, nodes with + /// more than one parent, and cycles. + /// + /// Returns nodes in topological order (deterministic: lexicographically + /// smallest ready node first) with depth (root = 0) and parent node id. + pub(crate) fn resolve_legion_topology( + nodes: Vec, + edges: Vec, + ) -> Result, String> { + if nodes.is_empty() { + return Err("Legion topology must contain at least one node".to_string()); + } + if nodes.len() > MAX_LEGION_NODES { + return Err(format!( + "Legion topology exceeds the maximum node count ({} > {})", + nodes.len(), + MAX_LEGION_NODES + )); + } + + // 1. Basic node validation + let mut ids = BTreeSet::new(); + for node in &nodes { + if node.id.trim().is_empty() { + return Err("Legion node id must not be empty".to_string()); + } + if node.agent.trim().is_empty() { + return Err(format!("Legion node '{}' has an empty agent type", node.id)); + } + if node.agent == "daemon" || node.agent.starts_with("warden-") { + return Err(format!( + "Legion node '{}' uses protected agent '{}' (daemon/warden agents cannot be controlled)", + node.id, node.agent + )); + } + if !ids.insert(node.id.clone()) { + return Err(format!("Duplicate legion node id '{}'", node.id)); + } + } + + // 2. Edge validation: endpoints exist, no self-loops, at most one parent + let mut parents: HashMap = HashMap::new(); + for edge in &edges { + if !ids.contains(&edge.from) { + return Err(format!( + "Legion edge references unknown node '{}'", + edge.from + )); + } + if !ids.contains(&edge.to) { + return Err(format!("Legion edge references unknown node '{}'", edge.to)); + } + if edge.from == edge.to { + return Err(format!( + "Legion edge has a self-loop on node '{}'", + edge.from + )); + } + if parents.insert(edge.to.clone(), edge.from.clone()).is_some() { + return Err(format!( + "Legion node '{}' has multiple parents; each node may have at most one parent", + edge.to + )); + } + } + + // 3. Kahn topological sort with deterministic (lexicographic) order + let mut adjacency: HashMap> = HashMap::new(); + let mut in_degree: HashMap = HashMap::new(); + for node in &nodes { + adjacency.insert(node.id.clone(), Vec::new()); + in_degree.insert(node.id.clone(), 0); + } + for edge in &edges { + let nexts = adjacency + .get_mut(&edge.from) + .ok_or_else(|| format!("Internal error: missing adjacency for '{}'", edge.from))?; + nexts.push(edge.to.clone()); + let degree = in_degree + .get_mut(&edge.to) + .ok_or_else(|| format!("Internal error: missing in-degree for '{}'", edge.to))?; + *degree += 1; + } + + let mut ready: BTreeSet = nodes + .iter() + .filter(|node| in_degree.get(&node.id).copied().unwrap_or(usize::MAX) == 0) + .map(|node| node.id.clone()) + .collect(); + + let mut order: Vec = Vec::with_capacity(nodes.len()); + while let Some(id) = ready.iter().next().cloned() { + ready.remove(&id); + order.push(id.clone()); + let nexts = adjacency + .get(&id) + .cloned() + .ok_or_else(|| format!("Internal error: missing adjacency for '{id}'"))?; + for next in nexts { + let degree = in_degree + .get_mut(&next) + .ok_or_else(|| format!("Internal error: missing in-degree for '{next}'"))?; + *degree -= 1; + if *degree == 0 { + ready.insert(next); + } + } + } + if order.len() != nodes.len() { + return Err("Legion topology contains a cycle".to_string()); + } + + // 4. Depth: root = 0, child = parent depth + 1 (parents precede children + // in topological order, so the parent depth is always known) + let nodes_by_id: HashMap = nodes + .into_iter() + .map(|node| (node.id.clone(), node)) + .collect(); + let mut depth_by_id: HashMap = HashMap::new(); + for id in &order { + let depth = match parents.get(id) { + Some(parent_id) => { + let parent_depth = depth_by_id.get(parent_id).copied().ok_or_else(|| { + format!("Internal error: missing depth for parent '{parent_id}'") + })?; + parent_depth + 1 + } + None => 0, + }; + depth_by_id.insert(id.clone(), depth); + } + + let mut resolved = Vec::with_capacity(order.len()); + for id in order { + let node = nodes_by_id + .get(&id) + .cloned() + .ok_or_else(|| format!("Internal error: missing node '{id}'"))?; + let depth = depth_by_id + .get(&id) + .copied() + .ok_or_else(|| format!("Internal error: missing depth for '{id}'"))?; + resolved.push(ResolvedLegionNode { + node, + depth, + parent: parents.get(&id).cloned(), + }); + } + Ok(resolved) + } + + /// Persist the session lineage and register the child in the in-memory + /// session tree. Failures are logged but do not fail the deployment. + async fn attach_session_to_tree( + coordinator: &ConversationCoordinator, + created_session_id: &str, + parent_session_id: Option<&str>, + child_depth: u32, + ) { + let relationship = SessionRelationship { + kind: Some(SessionRelationshipKind::Subagent), + parent_session_id: parent_session_id.map(ToOwned::to_owned), + depth: Some(child_depth), + ..Default::default() + }; + if let Err(e) = coordinator + .session_manager + .persist_session_lineage(created_session_id, relationship) + .await + { + log::error!( + "LegionControl load: failed to persist session lineage for {}: {:?}", + created_session_id, + e + ); + } + if let Some(pid) = parent_session_id { + if let Err(e) = + coordinator + .session_tree() + .register_child(pid, created_session_id, child_depth) + { + log::warn!( + "LegionControl load: failed to register child {} under {} in tree: {:?}", + created_session_id, + pid, + e + ); + } + } + } + + /// Roll back a partially deployed legion (LEGION-01). + /// + /// When a later node fails its pre-create checks or its session creation, + /// every session already persisted earlier in this deployment is deleted so + /// a failed LegionControl load never leaks orphaned sessions. Best-effort: + /// a deletion failure is logged and never masks the original error. + async fn cleanup_deployed_sessions( + coordinator: &ConversationCoordinator, + workspace_path: &std::path::Path, + session_ids: &[String], + ) { + for session_id in session_ids { + if let Err(e) = coordinator + .session_manager + .delete_session(workspace_path, session_id) + .await + { + log::warn!( + "LegionControl load: failed to clean up session {} after deployment failure: {:?}", + session_id, + e + ); + } + } + } +} + +#[async_trait] +impl Tool for LegionControlTool { + fn name(&self) -> &str { + "LegionControl" + } + + async fn description(&self) -> BitFunResult { + Ok( + r#"Deploy a legion team topology into a set of persisted agent sessions. + +Actions: +- "load": Materialize a legion from a saved preset (preset_id) or an inline topology (nodes/edges). Creates one persisted session per node (SessionControl semantics) and attaches sessions to the session tree along the edges. Returns the deployed topology with session ids. +- "list": List saved legion presets (id, name, description, node/edge counts). + +Arguments: +- "preset_id": Id of a saved legion preset. Mutually exclusive with "nodes". +- "overrides": Optional per-node overrides keyed by node id. Each value may set agent, role, prompt, and/or gate. +- "nodes": Inline topology nodes when preset_id is omitted: [{id, agent, role, prompt, gate}]. At most 20 nodes. +- "edges": Optional parent-child edges: [{from, to, condition}]. Each node may have at most one parent; cycles are rejected. + +Notes: +- Agent types are validated against the available agent registry (same as SessionControl). +- daemon/warden agents cannot be deployed through LegionControl. +- Nodes are sorted topologically (deterministic order) and deployed root-first. +- node.prompt, node.gate, and edge.condition are reserved fields: they are persisted into the created session metadata and echoed in the result for observability, but do not yet change runtime behavior. + +Related tools: +- Use SessionControl to manage the created sessions (cancel/delete/list). +- Use SessionMessage to drive the deployed sessions. +- Use Team mode to operate inside a pre-deployed legion."# + .to_string(), + ) + } + + fn short_description(&self) -> String { + "Deploy a legion team topology into persisted agent sessions.".to_string() + } + + fn default_exposure(&self) -> ToolExposure { + ToolExposure::Deferred + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["load", "list"], + "description": "The legion action to perform: \"load\" deploys a preset or inline topology into sessions; \"list\" lists saved presets." + }, + "preset_id": { + "type": "string", + "description": "Id of a saved legion preset. Mutually exclusive with \"nodes\"." + }, + "overrides": { + "type": "object", + "description": "Optional per-node overrides keyed by node id. Each value may set agent, role, prompt, and/or gate.", + "additionalProperties": { + "type": "object", + "properties": { + "agent": { "type": "string" }, + "role": { "type": "string" }, + "prompt": { "type": "string" }, + "gate": { "type": "boolean" } + } + } + }, + "nodes": { + "type": "array", + "description": "Inline topology nodes when preset_id is not given: [{id, agent, role, prompt, gate}].", + "items": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "agent": { "type": "string" }, + "role": { "type": "string" }, + "prompt": { "type": "string" }, + "gate": { "type": "boolean" } + }, + "required": ["id", "agent"] + } + }, + "edges": { + "type": "array", + "description": "Optional parent-child edges between nodes: [{from, to, condition}]. Each node may have at most one parent.", + "items": { + "type": "object", + "properties": { + "from": { "type": "string" }, + "to": { "type": "string" }, + "condition": { "type": "string" } + }, + "required": ["from", "to"] + } + } + }, + "required": ["action"], + "additionalProperties": false + }) + } + + fn is_readonly(&self) -> bool { + false + } + + async fn validate_input( + &self, + input: &Value, + _context: Option<&ToolUseContext>, + ) -> ValidationResult { + let parsed: LegionControlInput = match serde_json::from_value(input.clone()) { + Ok(value) => value, + Err(err) => { + return ValidationResult { + result: false, + message: Some(format!("Invalid input: {}", err)), + error_code: Some(400), + meta: None, + }; + } + }; + + let action = match LegionControlAction::from_str(&parsed.action) { + Some(action) => action, + None => { + return ValidationResult { + result: false, + message: Some(format!( + "Invalid action '{}': expected one of load, list", + parsed.action + )), + error_code: Some(400), + meta: None, + }; + } + }; + + if action == LegionControlAction::Load { + match (&parsed.preset_id, &parsed.nodes) { + (Some(_), Some(_)) => { + return ValidationResult { + result: false, + message: Some("preset_id and nodes are mutually exclusive".to_string()), + error_code: Some(400), + meta: None, + }; + } + (None, None) => { + return ValidationResult { + result: false, + message: Some("load requires either preset_id or nodes".to_string()), + error_code: Some(400), + meta: None, + }; + } + _ => {} + } + + // LEGION-03: reject inline topologies larger than MAX_LEGION_NODES at + // validation time so an oversized request never reaches deployment. + // resolve_legion_topology applies the same bound as a second guard. + if let Some(nodes) = &parsed.nodes { + if nodes.len() > MAX_LEGION_NODES { + return ValidationResult { + result: false, + message: Some(format!( + "Legion topology exceeds the maximum node count ({} > {})", + nodes.len(), + MAX_LEGION_NODES + )), + error_code: Some(400), + meta: None, + }; + } + } + } + + ValidationResult { + result: true, + message: None, + error_code: None, + meta: None, + } + } + + fn render_tool_use_message(&self, input: &Value, _options: &ToolRenderOptions) -> String { + let action = input + .get("action") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + match LegionControlAction::from_str(action) { + Some(LegionControlAction::Load) => { + if let Some(preset_id) = input.get("presetId").and_then(|v| v.as_str()) { + format!("Deploy legion from preset {preset_id}") + } else { + "Deploy legion from inline topology".to_string() + } + } + Some(LegionControlAction::List) => "List available legion presets".to_string(), + None => "Deploy legion".to_string(), + } + } + + async fn call_impl( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { + let params: LegionControlInput = serde_json::from_value(input.clone()) + .map_err(|e| BitFunError::tool(format!("Invalid input: {}", e)))?; + let action = LegionControlAction::from_str(¶ms.action).ok_or_else(|| { + BitFunError::tool(format!( + "Invalid action '{}': expected one of load, list", + params.action + )) + })?; + + match action { + LegionControlAction::List => { + let presets = list_presets().map_err(BitFunError::tool)?; + let preset_summaries: Vec = presets + .iter() + .map(|preset| { + json!({ + "id": preset.id, + "name": preset.name, + "description": preset.description, + "node_count": preset.nodes.len(), + "edge_count": preset.edges.len(), + }) + }) + .collect(); + let result_for_assistant = format!("{} legion preset(s) available", presets.len()); + Ok(vec![ToolResult::Result { + data: json!({ + "success": true, + "action": "list", + "presets": preset_summaries, + }), + result_for_assistant: Some(result_for_assistant), + image_attachments: None, + }]) + } + LegionControlAction::Load => { + let workspace = context.workspace.as_ref().ok_or_else(|| { + BitFunError::tool("workspace is required for LegionControl load".to_string()) + })?; + let display_workspace = normalize_path(&workspace.root_path_string()); + let project_workspace = normalize_path(&workspace.project_root_path_string()); + + // Resolve source topology: saved preset or inline input + let (preset_id, mut nodes, edges) = match (¶ms.preset_id, ¶ms.nodes) { + (Some(preset_id), None) => { + let preset = get_preset(preset_id).map_err(BitFunError::tool)?; + (Some(preset_id.clone()), preset.nodes, preset.edges) + } + (None, Some(nodes)) => (None, nodes.clone(), params.edges.clone()), + (Some(_), Some(_)) => { + return Err(BitFunError::tool( + "preset_id and nodes are mutually exclusive".to_string(), + )); + } + (None, None) => { + return Err(BitFunError::tool( + "load requires either preset_id or nodes".to_string(), + )); + } + }; + + nodes = Self::apply_legion_node_overrides(nodes, ¶ms.overrides); + let topology = Self::resolve_legion_topology(nodes, edges.clone()) + .map_err(BitFunError::tool)?; + + // Validate agent types against the available agent registry + let available_agent_ids = + get_available_agent_type_ids_for_creation(Some(context)).await; + for resolved in &topology { + if !available_agent_ids.contains(&resolved.node.agent) { + return Err(BitFunError::tool(format!( + "Unknown agent type '{}' for legion node '{}'", + resolved.node.agent, resolved.node.id + ))); + } + } + + let coordinator = get_global_coordinator() + .ok_or_else(|| BitFunError::tool("coordinator not initialized".to_string()))?; + let runtime = CoreServiceAgentRuntime::agent_runtime(coordinator.clone()) + .map_err(BitFunError::tool)?; + + let creator_session_id = context.session_id.as_ref().ok_or_else(|| { + BitFunError::tool("load requires a creator session in tool context".to_string()) + })?; + + // LEGION-07: role-based delegation validation before any session + // is created, using the same criteria as SessionControl create + // (R-14 B3). An executor/reviewer creator may only deploy its own + // role; the permissive commander baseline applies when the creator + // has no registered role. + let creator_role = context.session_id.as_deref().and_then(get_session_role); + let target_role = creator_role.clone().unwrap_or(AgentRole::Commander); + validate_delegation(creator_role, target_role)?; + + // The creator session's tree depth anchors the deployed legion: + // every root node is a direct child of the creator, and each + // deeper node adds its resolved topology depth on top. This is + // deterministic and avoids re-reading freshly persisted lineage + // metadata for every node. + // + // LEGION-02: a read failure fails fast instead of silently + // degrading the depth anchor to 0, which would deploy the legion + // at the wrong session-tree depth. A missing relationship/missing + // metadata (fresh session) is not a failure: it degrades to 0 with + // an explicit warning. + let creator_depth = match coordinator + .session_manager + .load_session_metadata( + &std::path::PathBuf::from(&display_workspace), + creator_session_id, + ) + .await + { + Ok(Some(metadata)) => metadata + .relationship + .and_then(|relationship| relationship.depth) + .unwrap_or_else(|| { + log::warn!( + "LegionControl load: creator session '{}' has no persisted depth; anchoring legion at depth 0", + creator_session_id + ); + 0 + }), + Ok(None) => { + log::warn!( + "LegionControl load: creator session '{}' has no persisted metadata; anchoring legion at depth 0", + creator_session_id + ); + 0 + } + Err(e) => { + return Err(BitFunError::tool(format!( + "LegionControl load: failed to read creator session metadata for '{}': {}", + creator_session_id, e + ))); + } + }; + + let mut session_by_node: HashMap = HashMap::new(); + let mut deployed: Vec = Vec::with_capacity(topology.len()); + + for resolved in &topology { + let node = &resolved.node; + let session_name = if node.role.trim().is_empty() { + node.id.clone() + } else { + format!("{}-{}", node.role, node.id) + }; + + // LEGION-01: resolve the parent and the resulting child depth + // BEFORE creating the session so the depth check runs before a + // session is persisted. A failing node rolls back every session + // created earlier in this deployment. + let parent_session_id = match &resolved.parent { + Some(parent_node_id) => session_by_node.get(parent_node_id).cloned(), + None => Some(creator_session_id.clone()), + }; + let child_depth = creator_depth + 1 + resolved.depth; + let max_depth = coordinator.session_tree().max_depth; + if child_depth > max_depth { + let created: Vec = session_by_node.values().cloned().collect(); + Self::cleanup_deployed_sessions( + &coordinator, + &std::path::PathBuf::from(&display_workspace), + &created, + ) + .await; + return Err(BitFunError::tool(format!( + "LegionControl load: session depth limit reached for node '{}': child depth {} would exceed max allowed depth {}", + node.id, child_depth, max_depth + ))); + } + + let mut metadata = serde_json::Map::new(); + metadata.insert( + "createdBy".to_string(), + json!(session_control_creator_marker(creator_session_id)), + ); + metadata.insert("legionNodeId".to_string(), json!(node.id)); + metadata.insert("legionRole".to_string(), json!(node.role)); + // LEGION-04: `prompt`/`gate` are reserved fields today — they + // carry author intent but do not yet change runtime behavior. + // Persist them into the session metadata so the data is + // observable by downstream SessionMessage dispatch and + // SessionControl inspection instead of being silently dropped. + if !node.prompt.trim().is_empty() { + metadata.insert("legionNodePrompt".to_string(), json!(node.prompt)); + } + metadata.insert("legionNodeGate".to_string(), json!(node.gate)); + if let Some(ref pid) = preset_id { + metadata.insert("legionPresetId".to_string(), json!(pid)); + } + + let session = match runtime + .create_session(AgentSessionCreateRequest { + session_name, + agent_type: node.agent.clone(), + workspace_path: Some(display_workspace.clone()), + project_workspace_path: Some(project_workspace.clone()), + execution_target: workspace.execution_target.clone(), + workspace_id: workspace.workspace_id.clone(), + remote_connection_id: workspace.connection_id().map(ToOwned::to_owned), + remote_ssh_host: if workspace.is_remote() { + Some(workspace.session_identity.hostname.clone()) + .filter(|value| !value.trim().is_empty()) + } else { + None + }, + model_id: None, + metadata, + }) + .await + { + Ok(session) => session, + Err(error) => { + let created: Vec = + session_by_node.values().cloned().collect(); + Self::cleanup_deployed_sessions( + &coordinator, + &std::path::PathBuf::from(&display_workspace), + &created, + ) + .await; + return Err(BitFunError::tool( + CoreServiceAgentRuntime::runtime_error_message(error), + )); + } + }; + + let created_session_id = session.session_id.clone(); + + // Attach to the session tree: the parent is the resolved + // parent's session; root nodes attach to the creator session. + Self::attach_session_to_tree( + &coordinator, + &created_session_id, + parent_session_id.as_deref(), + child_depth, + ) + .await; + + session_by_node.insert(node.id.clone(), created_session_id.clone()); + deployed.push(json!({ + "node_id": node.id, + "session_id": created_session_id, + "session_name": session.session_name, + "role": node.role, + "agent": node.agent, + "depth": child_depth, + // LEGION-04: 预留字段在结果中原样回显(与上方会话元数据持久化一致), + // 供调用方观察每个节点预期携带的 prompt/gate 语义;尚未改变运行时行为。 + "prompt": node.prompt, + "gate": node.gate, + })); + } + + let edge_outputs: Vec = edges + .iter() + .map(|edge| { + json!({ + "from": edge.from, + "to": edge.to, + "condition": edge.condition, + "from_session": session_by_node.get(&edge.from), + "to_session": session_by_node.get(&edge.to), + }) + }) + .collect(); + + let result_for_assistant = format!( + "Deployed {} legion node(s){}", + deployed.len(), + preset_id + .as_ref() + .map(|id| format!(" from preset '{id}'")) + .unwrap_or_default() + ); + + Ok(vec![ToolResult::Result { + data: json!({ + "success": true, + "action": "load", + "preset_id": preset_id, + "nodes": deployed, + "edges": edge_outputs, + }), + result_for_assistant: Some(result_for_assistant), + image_attachments: None, + }]) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agentic::tools::framework::ToolUseContext; + use std::collections::HashMap; + + fn empty_context() -> ToolUseContext { + ToolUseContext { + tool_call_id: None, + agent_type: None, + session_id: None, + dialog_turn_id: None, + workspace: None, + loaded_deferred_tool_specs: Vec::new(), + primary_model_facts: tool_runtime::context::PrimaryModelFacts::default(), + custom_data: HashMap::new(), + computer_use_host: None, + runtime_tool_restrictions: Default::default(), + runtime_handles: bitfun_runtime_ports::ToolRuntimeHandles::default(), + } + } + + fn node(id: &str) -> LegionNode { + LegionNode { + id: id.to_string(), + agent: "agentic".to_string(), + role: String::new(), + prompt: String::new(), + gate: false, + } + } + + fn edge(from: &str, to: &str) -> LegionEdge { + LegionEdge { + from: from.to_string(), + to: to.to_string(), + condition: None, + } + } + + // ── resolve_legion_topology tests ────────────────────────────────── + + #[test] + fn resolve_topology_sorts_and_computes_depth() { + // Edges: a->b, a->d, b->c. Input order is intentionally shuffled. + let nodes = vec![node("c"), node("b"), node("d"), node("a")]; + let edges = vec![edge("a", "b"), edge("a", "d"), edge("b", "c")]; + + let resolved = LegionControlTool::resolve_legion_topology(nodes, edges) + .expect("topology should resolve"); + + let order: Vec<&str> = resolved.iter().map(|r| r.node.id.as_str()).collect(); + // Lexicographic-first ready node: a -> b -> c -> d + assert_eq!(order, vec!["a", "b", "c", "d"]); + + let by_id: HashMap<&str, &ResolvedLegionNode> = + resolved.iter().map(|r| (r.node.id.as_str(), r)).collect(); + assert_eq!(by_id["a"].depth, 0); + assert_eq!(by_id["b"].depth, 1); + assert_eq!(by_id["c"].depth, 2); + assert_eq!(by_id["d"].depth, 1); + assert_eq!(by_id["a"].parent, None); + assert_eq!(by_id["b"].parent.as_deref(), Some("a")); + assert_eq!(by_id["c"].parent.as_deref(), Some("b")); + assert_eq!(by_id["d"].parent.as_deref(), Some("a")); + } + + #[test] + fn resolve_topology_rejects_cycle() { + let nodes = vec![node("a"), node("b"), node("c")]; + let edges = vec![edge("a", "b"), edge("b", "c"), edge("c", "a")]; + + let err = LegionControlTool::resolve_legion_topology(nodes, edges) + .expect_err("cycle must be rejected"); + assert!(err.contains("cycle"), "unexpected error: {err}"); + } + + #[test] + fn resolve_topology_rejects_multiple_parents() { + let nodes = vec![node("a"), node("b"), node("c")]; + let edges = vec![edge("a", "c"), edge("b", "c")]; + + let err = LegionControlTool::resolve_legion_topology(nodes, edges) + .expect_err("multiple parents must be rejected"); + assert!(err.contains("multiple parents"), "unexpected error: {err}"); + } + + #[test] + fn resolve_topology_rejects_unknown_endpoint() { + let nodes = vec![node("a")]; + let edges = vec![edge("a", "z")]; + + let err = LegionControlTool::resolve_legion_topology(nodes, edges) + .expect_err("unknown endpoint must be rejected"); + assert!(err.contains("unknown node 'z'"), "unexpected error: {err}"); + } + + #[test] + fn resolve_topology_rejects_duplicate_ids() { + let mut a = node("a"); + a.agent = "Plan".to_string(); + let nodes = vec![node("a"), a]; + + let err = LegionControlTool::resolve_legion_topology(nodes, Vec::new()) + .expect_err("duplicate ids must be rejected"); + assert!( + err.contains("Duplicate legion node id 'a'"), + "unexpected error: {err}" + ); + } + + #[test] + fn resolve_topology_rejects_protected_agents() { + let mut warden = node("warden-node"); + warden.agent = "warden-auditor".to_string(); + let nodes = vec![warden]; + + let err = LegionControlTool::resolve_legion_topology(nodes, Vec::new()) + .expect_err("warden agent must be rejected"); + assert!(err.contains("protected agent"), "unexpected error: {err}"); + + let mut daemon = node("daemon-node"); + daemon.agent = "daemon".to_string(); + let nodes = vec![daemon]; + + let err = LegionControlTool::resolve_legion_topology(nodes, Vec::new()) + .expect_err("daemon agent must be rejected"); + assert!(err.contains("protected agent"), "unexpected error: {err}"); + } + + #[test] + fn resolve_topology_rejects_self_loop() { + let nodes = vec![node("a")]; + let edges = vec![edge("a", "a")]; + + let err = LegionControlTool::resolve_legion_topology(nodes, edges) + .expect_err("self-loop must be rejected"); + assert!(err.contains("self-loop"), "unexpected error: {err}"); + } + + #[test] + fn resolve_topology_rejects_empty_topology() { + let err = LegionControlTool::resolve_legion_topology(Vec::new(), Vec::new()) + .expect_err("empty topology must be rejected"); + assert!(err.contains("at least one node"), "unexpected error: {err}"); + } + + #[test] + fn resolve_topology_rejects_excessive_node_count() { + // LEGION-03: a topology larger than MAX_LEGION_NODES must be rejected so + // a single LegionControl call cannot spawn an unbounded session fleet. + let nodes: Vec = (0..=MAX_LEGION_NODES) + .map(|index| node(&format!("node-{index}"))) + .collect(); + let err = LegionControlTool::resolve_legion_topology(nodes, Vec::new()) + .expect_err("oversized topology must be rejected"); + assert!( + err.contains("maximum node count"), + "unexpected error: {err}" + ); + + // The exact maximum still resolves. + let nodes: Vec = (0..MAX_LEGION_NODES) + .map(|index| node(&format!("node-{index}"))) + .collect(); + let resolved = LegionControlTool::resolve_legion_topology(nodes, Vec::new()) + .expect("topology at the maximum node count should resolve"); + assert_eq!(resolved.len(), MAX_LEGION_NODES); + } + + #[test] + fn resolve_topology_rejects_empty_node_fields() { + let mut empty_id = node("a"); + empty_id.id = " ".to_string(); + let err = LegionControlTool::resolve_legion_topology(vec![empty_id], Vec::new()) + .expect_err("empty id must be rejected"); + assert!( + err.contains("id must not be empty"), + "unexpected error: {err}" + ); + + let mut empty_agent = node("a"); + empty_agent.agent = String::new(); + let err = LegionControlTool::resolve_legion_topology(vec![empty_agent], Vec::new()) + .expect_err("empty agent must be rejected"); + assert!(err.contains("empty agent type"), "unexpected error: {err}"); + } + + #[test] + fn resolve_topology_single_root_ok() { + let nodes = vec![node("a"), node("b")]; + let edges = vec![edge("a", "b")]; + + let resolved = LegionControlTool::resolve_legion_topology(nodes, edges) + .expect("single root topology should resolve"); + assert_eq!(resolved.len(), 2); + assert_eq!(resolved[0].node.id, "a"); + assert_eq!(resolved[0].depth, 0); + assert_eq!(resolved[1].node.id, "b"); + assert_eq!(resolved[1].depth, 1); + } + + #[test] + fn apply_overrides_per_node() { + let nodes = vec![node("a"), node("b")]; + let mut overrides = HashMap::new(); + let over_a = LegionNodeOverride { + agent: Some("Plan".to_string()), + gate: Some(true), + ..Default::default() + }; + overrides.insert("a".to_string(), over_a); + + let applied = LegionControlTool::apply_legion_node_overrides(nodes, &overrides); + + assert_eq!(applied[0].agent, "Plan"); + assert!(applied[0].gate); + assert_eq!(applied[1].agent, "agentic"); + assert!(!applied[1].gate); + } + + // ── validate_input tests ─────────────────────────────────────────── + + #[tokio::test] + async fn validate_rejects_missing_action() { + let tool = LegionControlTool::new(); + + let validation = tool + .validate_input(&json!({}), Some(&empty_context())) + .await; + + assert!(!validation.result); + assert_eq!(validation.error_code, Some(400)); + } + + #[tokio::test] + async fn validate_rejects_unknown_action() { + let tool = LegionControlTool::new(); + + let validation = tool + .validate_input(&json!({"action": "explode"}), Some(&empty_context())) + .await; + + assert!(!validation.result); + let message = validation.message.as_deref().unwrap_or_default(); + assert!(message.contains("explode"), "unexpected message: {message}"); + } + + #[tokio::test] + async fn validate_load_requires_source() { + let tool = LegionControlTool::new(); + + let validation = tool + .validate_input(&json!({"action": "load"}), Some(&empty_context())) + .await; + + assert!(!validation.result); + assert_eq!( + validation.message.as_deref(), + Some("load requires either preset_id or nodes") + ); + } + + #[tokio::test] + async fn validate_load_rejects_dual_source() { + let tool = LegionControlTool::new(); + + let validation = tool + .validate_input( + &json!({ + "action": "load", + "preset_id": "triad", + "nodes": [{"id": "a", "agent": "agentic"}], + }), + Some(&empty_context()), + ) + .await; + + assert!(!validation.result); + assert_eq!( + validation.message.as_deref(), + Some("preset_id and nodes are mutually exclusive") + ); + } + + #[tokio::test] + async fn validate_load_with_preset_id_ok() { + let tool = LegionControlTool::new(); + + let validation = tool + .validate_input( + &json!({"action": "load", "preset_id": "triad"}), + Some(&empty_context()), + ) + .await; + + assert!(validation.result, "{:?}", validation.message); + } + + #[tokio::test] + async fn validate_load_with_nodes_ok() { + let tool = LegionControlTool::new(); + + let validation = tool + .validate_input( + &json!({ + "action": "load", + "nodes": [{"id": "a", "agent": "agentic", "role": "commander"}], + "edges": [], + }), + Some(&empty_context()), + ) + .await; + + assert!(validation.result, "{:?}", validation.message); + } + + #[tokio::test] + async fn validate_load_with_overrides_ok() { + let tool = LegionControlTool::new(); + + let validation = tool + .validate_input( + &json!({ + "action": "load", + "preset_id": "triad", + "overrides": { + "a": {"agent": "Plan", "gate": true} + }, + }), + Some(&empty_context()), + ) + .await; + + assert!(validation.result, "{:?}", validation.message); + } + + #[tokio::test] + async fn validate_rejects_oversized_nodes() { + // LEGION-03: validate_input must reject an inline topology larger than + // MAX_LEGION_NODES before deployment is attempted. + let tool = LegionControlTool::new(); + + let nodes: Vec = (0..=MAX_LEGION_NODES) + .map(|index| { + json!({ + "id": format!("node-{index}"), + "agent": "agentic", + }) + }) + .collect(); + + let validation = tool + .validate_input( + &json!({"action": "load", "nodes": nodes}), + Some(&empty_context()), + ) + .await; + + assert!(!validation.result); + assert_eq!(validation.error_code, Some(400)); + let message = validation.message.as_deref().unwrap_or_default(); + assert!( + message.contains("maximum node count"), + "unexpected message: {message}" + ); + + // The exact maximum still validates. + let nodes: Vec = (0..MAX_LEGION_NODES) + .map(|index| { + json!({ + "id": format!("node-{index}"), + "agent": "agentic", + }) + }) + .collect(); + let validation = tool + .validate_input( + &json!({"action": "load", "nodes": nodes}), + Some(&empty_context()), + ) + .await; + assert!(validation.result, "{:?}", validation.message); + } + + #[tokio::test] + async fn validate_list_ok() { + let tool = LegionControlTool::new(); + + let validation = tool + .validate_input(&json!({"action": "list"}), Some(&empty_context())) + .await; + + assert!(validation.result, "{:?}", validation.message); + } +} diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/list_models_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/list_models_tool.rs index 2abe8220f3..ac1cd1ab66 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/list_models_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/list_models_tool.rs @@ -54,9 +54,7 @@ fn fuzzy_match_score(term: &str, field: &str) -> Option { let mut next_index = 0; let mut gaps = 0; for character in term.chars() { - let Some(found) = field[next_index..].find(character) else { - return None; - }; + let found = field[next_index..].find(character)?; gaps += found; next_index += found + character.len_utf8(); } @@ -267,6 +265,7 @@ impl Tool for ListModelsTool { #[cfg(test)] mod tests { + #![allow(clippy::field_reassign_with_default)] // test fixtures build configs via field assignment use super::build_list_models_result; use crate::service::config::types::{AIConfig, AIModelConfig}; diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/miniapp_publish_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/miniapp_publish_tool.rs index 018d514e27..0d5af2e4b4 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/miniapp_publish_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/miniapp_publish_tool.rs @@ -458,7 +458,7 @@ fn find_apps_by_name<'a>(apps: &'a [MiniAppMeta], needle: &str) -> Vec<&'a MiniA } let exact: Vec<&MiniAppMeta> = apps .iter() - .filter(|meta| display_names(meta).iter().any(|name| *name == needle)) + .filter(|meta| display_names(meta).contains(&needle)) .collect(); if !exact.is_empty() { return exact; diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs b/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs index def364bd93..f21b1ba783 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs @@ -1,5 +1,6 @@ //! Tool implementation module +pub mod acp_tools; pub mod agent_wait_tool; pub mod analyze_image_tool; pub mod appearance_publish_tool; @@ -26,8 +27,12 @@ pub mod get_time_tool; pub mod git_tool; pub mod glob_tool; pub mod grep_tool; +pub mod legion_control_tool; pub mod list_models_tool; pub mod ls_tool; +pub mod plan_list_tool; +pub mod plan_read_tool; +pub mod plan_update_tool; pub mod mcp_tools; pub mod miniapp_finalize_tool; pub mod miniapp_init_tool; @@ -48,10 +53,12 @@ pub mod todo_write_tool; pub mod util; pub mod view_image_tool; pub mod web; +pub mod workspace_scan_tool; pub mod worktree_tool; #[deprecated(note = "GetToolSpecTool is owned by the product tool runtime boundary")] pub use crate::agentic::tools::product_runtime::GetToolSpecTool; +pub use acp_tools::{AcpControlTool, AcpHistoryTool, AcpMessageTool}; pub use agent_wait_tool::AgentWaitTool; pub use analyze_image_tool::AnalyzeImageTool; pub use appearance_publish_tool::PublishAppearanceTool; @@ -75,6 +82,7 @@ pub use get_time_tool::GetTimeTool; pub use git_tool::GitTool; pub use glob_tool::GlobTool; pub use grep_tool::GrepTool; +pub use legion_control_tool::LegionControlTool; pub use list_models_tool::ListModelsTool; pub use ls_tool::LSTool; pub use mcp_tools::{ @@ -85,6 +93,9 @@ pub use miniapp_init_tool::InitMiniAppTool; pub use miniapp_publish_tool::PublishMiniAppTool; pub use page_deploy_tool::PageDeployTool; pub use page_publish_tool::PagePublishTool; +pub use plan_list_tool::PlanListTool; +pub use plan_read_tool::PlanReadTool; +pub use plan_update_tool::PlanUpdateTool; pub use playbook_tool::PlaybookTool; pub use review_platform_tool::ReviewPlatformTool; pub use session_control_tool::SessionControlTool; @@ -97,4 +108,5 @@ pub use thread_goal_tools::{CreateGoalTool, GetGoalTool, UpdateGoalTool}; pub use todo_write_tool::TodoWriteTool; pub use view_image_tool::ViewImageTool; pub use web::{WebFetchTool, WebSearchTool}; +pub use workspace_scan_tool::WorkspaceScanTool; pub use worktree_tool::WorktreeTool; diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/plan_list_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/plan_list_tool.rs new file mode 100644 index 0000000000..8c730df221 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/tools/implementations/plan_list_tool.rs @@ -0,0 +1,242 @@ +//! PlanList tool implementation +//! +//! Lists plan files stored in the current workspace plans directory. + +use crate::agentic::tools::framework::{Tool, ToolExposure, ToolResult, ToolUseContext}; +use crate::util::errors::{BitFunError, BitFunResult}; +use async_trait::async_trait; +use serde_json::{json, Value}; +use tokio::fs; +use tokio::io::AsyncReadExt; + +/// PLAN-09: cap on how many plan files a single PlanList call reports, so a +/// plans directory with thousands of files cannot blow up the tool result. +const MAX_PLAN_LIST_ENTRIES: usize = 500; + +/// PLAN-09: only the YAML frontmatter (always well under this) is needed for +/// todo progress. Reading a bounded prefix keeps PlanList fast and immune to +/// huge plan bodies; anything past 64KB is a body, not frontmatter. +const PLAN_FRONTMATTER_PREFIX_LIMIT: u64 = 64 * 1024; + +/// PlanList tool - list plan files +pub struct PlanListTool; + +impl PlanListTool { + pub fn new() -> Self { + Self + } +} + +impl Default for PlanListTool { + fn default() -> Self { + Self::new() + } +} + +/// Best-effort todo progress for a plan file body: (total, completed) counts. +/// Returns None when the file is not a parseable plan (legacy plans without +/// todos, damaged frontmatter, unreadable files) - callers report 0/0/0. +fn count_todo_progress(content: &str) -> Option<(u64, u64)> { + let trimmed = content.trim_start(); + let after_open = trimmed.strip_prefix("---")?; + let end = after_open.find("\n---")?; + let yaml_part = &after_open[..end]; + let frontmatter: Value = serde_yaml::from_str(yaml_part).ok()?; + let todos = frontmatter.get("todos")?.as_array()?; + let total = todos.len() as u64; + let completed = todos + .iter() + .filter(|todo| todo.get("status").and_then(|status| status.as_str()) == Some("completed")) + .count() as u64; + Some((total, completed)) +} + +#[async_trait] +impl Tool for PlanListTool { + fn name(&self) -> &str { + "PlanList" + } + + async fn description(&self) -> BitFunResult { + Ok(r###"List plan files stored in the current workspace plans directory. Returns each plan file's name, full path and last-modified timestamp. Use this tool to discover existing plans before reading or updating them. Read-only: does not modify any files."### + .to_string()) + } + + fn short_description(&self) -> String { + "List plan files in the workspace plans directory.".to_string() + } + + fn default_exposure(&self) -> ToolExposure { + // 2026-08-04 user calibration: the plan tool family is a commander + // staple; Direct so no GetToolSpec unlock round-trip is needed + // (mirrored by `shared_coding_mode_tool_exposure_overrides()`). + ToolExposure::Direct + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "additionalProperties": false, + "properties": {} + }) + } + + fn is_readonly(&self) -> bool { + true + } + + fn is_concurrency_safe(&self, _input: Option<&Value>) -> bool { + true + } + + async fn call_impl( + &self, + _input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { + let runtime_context = context.ensure_current_workspace_runtime().await?; + let plans_dir = runtime_context.plans_dir.clone(); + let plans_dir_str = plans_dir.to_string_lossy().to_string(); + + // No plans directory yet is a valid empty listing, not an error. + let mut entries = match fs::read_dir(&plans_dir).await { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let empty = json!({ + "success": true, + "plans_dir": plans_dir_str, + "plans": [], + "count": 0 + }); + return Ok(vec![ToolResult::Result { + data: empty, + result_for_assistant: None, + image_attachments: None, + }]); + } + Err(error) => { + return Err(BitFunError::tool(format!( + "Failed to read plans directory: {}", + error + ))); + } + }; + + let mut plans = Vec::new(); + while let Some(entry) = entries.next_entry().await.map_err(|error| { + BitFunError::tool(format!("Failed to read plans directory entry: {}", error)) + })? { + if plans.len() >= MAX_PLAN_LIST_ENTRIES { + break; + } + let file_name = entry.file_name(); + let name = file_name.to_string_lossy().to_string(); + if !name.ends_with(".plan.md") { + continue; + } + let path = entry.path(); + let modified_ms = entry + .metadata() + .await + .ok() + .and_then(|metadata| metadata.modified().ok()) + .and_then(|modified| { + modified + .duration_since(std::time::UNIX_EPOCH) + .ok() + .map(|duration| duration.as_millis() as u64) + }) + .unwrap_or(0); + + // Best-effort todo progress from the bounded frontmatter prefix; + // legacy plans without todos (or unreadable/damaged files) report + // 0/0/0. PLAN-09: never read the whole plan body. + let mut todo_total: u64 = 0; + let mut todo_completed: u64 = 0; + let mut completion_pct: u64 = 0; + let mut prefix = Vec::with_capacity(PLAN_FRONTMATTER_PREFIX_LIMIT as usize); + if let Ok(file) = fs::File::open(&path).await { + let read_ok = file + .take(PLAN_FRONTMATTER_PREFIX_LIMIT) + .read_to_end(&mut prefix) + .await + .is_ok(); + if read_ok { + let frontmatter_prefix = String::from_utf8_lossy(&prefix); + if let Some((total, completed)) = count_todo_progress(&frontmatter_prefix) { + todo_total = total; + todo_completed = completed; + completion_pct = if total > 0 { completed * 100 / total } else { 0 }; + } + } + } + + plans.push(json!({ + "name": name, + "path": path.to_string_lossy().to_string(), + "modified_ms": modified_ms, + "todo_total": todo_total, + "todo_completed": todo_completed, + "completion_pct": completion_pct + })); + } + + // Stable ordering by file name for deterministic output. + plans.sort_by(|left, right| { + left["name"] + .as_str() + .unwrap_or("") + .cmp(right["name"].as_str().unwrap_or("")) + }); + + let result = json!({ + "success": true, + "plans_dir": plans_dir_str, + "plans": plans, + "count": plans.len() + }); + + Ok(vec![ToolResult::Result { + data: result, + result_for_assistant: None, + image_attachments: None, + }]) + } +} + +#[cfg(test)] +mod tests { + use super::count_todo_progress; + + #[test] + fn count_todo_progress_counts_completed_statuses() { + let content = "---\nname: My Plan\noverview: An overview\ntodos:\n- id: setup-auth\n content: Set up auth\n status: completed\n- id: implement-ui\n content: Implement the UI\n status: pending\n- id: deploy\n content: Deploy\n status: in_progress\n---\n\n# My Plan\n\nBody.\n"; + assert_eq!(count_todo_progress(content), Some((3, 1))); + } + + #[test] + fn count_todo_progress_all_completed_rounds_pct_up() { + let content = "---\nname: Done\ntodos:\n- id: a\n content: A\n status: completed\n- id: b\n content: B\n status: completed\n---\n\nbody"; + assert_eq!(count_todo_progress(content), Some((2, 2))); + } + + #[test] + fn count_todo_progress_legacy_plan_without_todos_is_none() { + // Legacy plans with no todos key: caller reports 0/0/0. + let content = "---\nname: Legacy\n---\n\nbody"; + assert_eq!(count_todo_progress(content), None); + } + + #[test] + fn count_todo_progress_empty_todos_is_zero_pair() { + let content = "---\nname: Empty\ntodos: []\n---\n\nbody"; + assert_eq!(count_todo_progress(content), Some((0, 0))); + } + + #[test] + fn count_todo_progress_damaged_file_is_none() { + assert_eq!(count_todo_progress("no frontmatter here"), None); + assert_eq!(count_todo_progress(""), None); + assert_eq!(count_todo_progress("---\nname: broken"), None); + } +} diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/plan_read_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/plan_read_tool.rs new file mode 100644 index 0000000000..adec109abd --- /dev/null +++ b/src/crates/assembly/core/src/agentic/tools/implementations/plan_read_tool.rs @@ -0,0 +1,504 @@ +//! PlanRead tool implementation +//! +//! Reads a plan file from the workspace plans directory and returns its +//! structured content (YAML frontmatter: name/overview/todos + markdown body). + +use crate::agentic::tools::framework::{Tool, ToolExposure, ToolResult, ToolUseContext}; +use crate::agentic::tools::restrictions::is_local_path_within_root; +use crate::agentic::tools::workspace_paths::{is_bitfun_runtime_uri, parse_bitfun_runtime_uri}; +use crate::util::errors::{BitFunError, BitFunResult}; +use async_trait::async_trait; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::path::{Path, PathBuf}; +use tokio::fs; + +/// YAML frontmatter structure for Plan files (mirror of the CreatePlan +/// writer; fields are optional so older or hand-edited files stay readable). +#[derive(Debug, Deserialize)] +struct PlanFrontmatter { + #[serde(default)] + name: Option, + #[serde(default)] + overview: Option, + #[serde(default)] + todos: Vec, +} + +/// Todo item structure (mirror of the CreatePlan writer). +#[derive(Debug, Deserialize)] +struct TodoItem { + #[serde(default)] + id: Option, + #[serde(default)] + content: Option, + #[serde(default)] + status: Option, + #[serde(default)] + dependencies: Vec, +} + +/// PlanRead tool - read plan file +pub struct PlanReadTool; + +impl PlanReadTool { + pub fn new() -> Self { + Self + } +} + +impl Default for PlanReadTool { + fn default() -> Self { + Self::new() + } +} + +/// Parse a plan file body into its YAML frontmatter and markdown body. +fn parse_plan_file(content: &str) -> BitFunResult<(PlanFrontmatter, String)> { + let trimmed = content.trim_start(); + let after_open = trimmed + .strip_prefix("---") + .ok_or_else(|| BitFunError::tool("Plan file is missing the YAML frontmatter opener '---'"))?; + let end = after_open.find("\n---").ok_or_else(|| { + BitFunError::tool("Plan file is missing the YAML frontmatter closer '---'") + })?; + // PLAN-05: CRLF files keep a trailing '\r' on the last frontmatter line + // before the closer; strip it so serde_yaml never sees a dangling CR. + let yaml_part = after_open[..end].trim_end_matches('\r'); + let body_start = end + "\n---".len(); + let body = after_open[body_start..] + .trim_start_matches(['\n', '\r']) + .to_string(); + + let frontmatter: PlanFrontmatter = serde_yaml::from_str(yaml_part).map_err(|error| { + BitFunError::tool(format!( + "Failed to parse plan YAML frontmatter: {}", + error + )) + })?; + Ok((frontmatter, body)) +} + +#[async_trait] +impl Tool for PlanReadTool { + fn name(&self) -> &str { + "PlanRead" + } + + async fn description(&self) -> BitFunResult { + Ok(r###"Read a plan file from the current workspace plans directory (or an absolute plan file path). The input accepts the plan file name (for example "my_plan_1234abcd.plan.md") or a full path to a .plan.md file. Returns the parsed YAML frontmatter (name, overview, todos with id/content/status/dependencies) plus the raw markdown body. Read-only: does not modify any files."### + .to_string()) + } + + fn short_description(&self) -> String { + "Read and parse a plan file from the workspace plans directory.".to_string() + } + + fn default_exposure(&self) -> ToolExposure { + // 2026-08-04 user calibration: the plan tool family is a commander + // staple; Direct so no GetToolSpec unlock round-trip is needed + // (mirrored by `shared_coding_mode_tool_exposure_overrides()`). + ToolExposure::Direct + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "additionalProperties": false, + "required": ["plan_file"], + "properties": { + "plan_file": { + "type": "string", + "description": "Plan file name (e.g. my_plan_1234abcd.plan.md) or an absolute path to a .plan.md file" + } + } + }) + } + + fn is_readonly(&self) -> bool { + true + } + + fn is_concurrency_safe(&self, _input: Option<&Value>) -> bool { + true + } + + async fn call_impl( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { + let plan_file = input + .get("plan_file") + .and_then(|value| value.as_str()) + .ok_or(BitFunError::validation( + "Missing required field: plan_file", + ))?; + let plan_file = plan_file.trim(); + if plan_file.is_empty() { + return Err(BitFunError::validation( + "Missing required field: plan_file", + )); + } + + let plan_path = resolve_plan_path(plan_file, context)?; + let content = fs::read_to_string(&plan_path) + .await + .map_err(|error| BitFunError::tool(format!("Failed to read plan file: {}", error)))?; + + let (frontmatter, body) = parse_plan_file(&content)?; + + let todos = frontmatter + .todos + .into_iter() + .map(|todo| { + json!({ + "id": todo.id.unwrap_or_default(), + "content": todo.content.unwrap_or_default(), + "status": todo.status.unwrap_or_else(|| "pending".to_string()), + "dependencies": todo.dependencies + }) + }) + .collect::>(); + + let plan_reference = + context.build_runtime_artifact_reference(&format!("plans/{}", plan_path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default()))?; + + let result = json!({ + "success": true, + "plan_file_name": plan_path.file_name().map(|name| name.to_string_lossy().to_string()).unwrap_or_default(), + "plan_file_path": plan_reference, + "name": frontmatter.name, + "overview": frontmatter.overview, + "todos": todos, + "body": body + }); + + Ok(vec![ToolResult::Result { + data: result, + result_for_assistant: None, + image_attachments: None, + }]) + } +} + +/// Validate that the plan file argument ends with `.plan.md`. Note: +/// extension() only returns the last suffix ("md" for "xxx.plan.md"), so the +/// full file name suffix is validated instead. +fn validate_plan_file_suffix(plan_file: &str) -> BitFunResult<()> { + let file_name = Path::new(plan_file) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(""); + if !file_name.ends_with(".plan.md") { + return Err(BitFunError::tool(format!( + "Plan file must end with .plan.md: {}", + plan_file + ))); + } + Ok(()) +} + +/// PLAN-01: the canonical resolved path must live inside `plans_dir` and the +/// file must exist. Rejects `..` escapes and symlink jumps. +fn require_plan_file_exists( + plan_path: PathBuf, + display: &str, + plans_dir: &Path, +) -> BitFunResult { + if !is_local_path_within_root(&plan_path, plans_dir)? { + return Err(BitFunError::tool(format!( + "Plan file resolves outside the plans directory: {}", + display + ))); + } + // PLAN-12: `exists()` 是同步文件系统调用,在异步执行器中会造成轻微阻塞。 + // 计划路径短、存在性检查开销极小,且与仓库其他工具(file_read/file_write 等) + // 的同步 IO 风格一致,保留现状可接受;若未来出现性能敏感场景,再改用 + // `tokio::fs::try_exists()` 或 `tokio::task::spawn_blocking` 包裹。 + if !plan_path.exists() { + return Err(BitFunError::tool(format!("Plan file not found: {}", display))); + } + Ok(plan_path) +} + +/// Shared plan-path resolution core (PLAN-13). Every PlanRead/PlanUpdate entry +/// point (tool call, permission intents, backend scheduler) converges here so +/// suffix validation, the plans-dir containment fence and the runtime-URI +/// branch can never drift apart. +/// +/// Accepted inputs: +/// - a `bitfun://runtime//plans/` URI (must point inside the +/// plans directory; scope is checked against `expected_workspace_scope`), +/// - an absolute path (kept only when the canonical path stays inside +/// `plans_dir`), +/// - a bare `.plan.md` file name or relative path (joined to `plans_dir`, so a +/// separator or `..` cannot escape the fence). +pub(crate) fn resolve_plan_path_with_plans_dir( + plan_file: &str, + plans_dir: &Path, + expected_workspace_scope: Option<&str>, +) -> BitFunResult { + // PLAN-10: accept the `bitfun://runtime/...` URI that CreatePlan returns + // on remote workspaces. + if is_bitfun_runtime_uri(plan_file) { + let parsed = parse_bitfun_runtime_uri(plan_file)?; + if let Some(expected_scope) = expected_workspace_scope { + if parsed.workspace_scope != "current" && parsed.workspace_scope != expected_scope { + return Err(BitFunError::tool(format!( + "Plan runtime URI belongs to workspace '{}', expected '{}': {}", + parsed.workspace_scope, expected_scope, plan_file + ))); + } + } + let file = parsed.relative_path.strip_prefix("plans/").ok_or_else(|| { + BitFunError::tool(format!( + "Plan runtime URI must point inside the plans directory: {}", + plan_file + )) + })?; + if file.is_empty() || file.contains('/') { + return Err(BitFunError::tool(format!( + "Plan runtime URI must reference a single plan file: {}", + plan_file + ))); + } + validate_plan_file_suffix(file)?; + return require_plan_file_exists(plans_dir.join(file), plan_file, plans_dir); + } + + let supplied = PathBuf::from(plan_file); + if supplied.is_absolute() { + // PLAN-01: absolute paths are no longer trusted as-is; they must stay + // inside the plans directory. + validate_plan_file_suffix(plan_file)?; + return require_plan_file_exists(supplied, plan_file, plans_dir); + } + + // PLAN-01/07: bare names AND relative paths (separator / `..`) are always + // resolved inside plans_dir, and the suffix check applies to both. + validate_plan_file_suffix(plan_file)?; + require_plan_file_exists(plans_dir.join(plan_file), plan_file, plans_dir) +} + +/// Resolve the plan file argument to a concrete filesystem path inside the +/// current workspace's plans directory. See +/// [`resolve_plan_path_with_plans_dir`] for the accepted input forms. +pub(crate) fn resolve_plan_path(plan_file: &str, context: &ToolUseContext) -> BitFunResult { + let plans_dir = context.current_workspace_runtime_root()?.join("plans"); + resolve_plan_path_with_plans_dir( + plan_file, + &plans_dir, + context.current_workspace_scope().as_deref(), + ) +} + +#[cfg(test)] +mod tests { + use super::parse_plan_file; + + #[test] + fn parse_plan_file_reads_frontmatter_and_body() { + let content = "---\nname: My Plan\noverview: An overview\ntodos:\n- id: setup-auth\n content: Set up auth\n status: pending\n---\n\n# My Plan\n\nBody text here.\n"; + let (frontmatter, body) = parse_plan_file(content).expect("parse plan file"); + assert_eq!(frontmatter.name.as_deref(), Some("My Plan")); + assert_eq!(frontmatter.overview.as_deref(), Some("An overview")); + assert_eq!(frontmatter.todos.len(), 1); + assert_eq!(frontmatter.todos[0].id.as_deref(), Some("setup-auth")); + assert_eq!(frontmatter.todos[0].content.as_deref(), Some("Set up auth")); + assert_eq!(frontmatter.todos[0].status.as_deref(), Some("pending")); + assert!(frontmatter.todos[0].dependencies.is_empty()); + assert!(body.contains("Body text here.")); + } + + #[test] + fn parse_plan_file_round_trips_create_plan_writer_format() { + // Mirror the exact layout emitted by create_plan_tool.rs + // `generate_plan_file_content` (---\n---\n\n). + let content = "---\nname: deploy-api\noverview: Deploy the API service\ntodos:\n- id: setup-auth\n content: Set up auth\n status: pending\n- id: implement-ui\n content: Implement the UI\n status: pending\n dependencies:\n - setup-auth\n---\n\n# deploy-api\n\n## Steps\n\n1. Auth\n2. UI\n"; + let (frontmatter, body) = parse_plan_file(content).expect("parse plan file"); + assert_eq!(frontmatter.name.as_deref(), Some("deploy-api")); + assert_eq!(frontmatter.todos.len(), 2); + assert_eq!(frontmatter.todos[1].id.as_deref(), Some("implement-ui")); + assert_eq!( + frontmatter.todos[1].dependencies, + vec!["setup-auth".to_string()] + ); + assert!(body.starts_with("# deploy-api")); + assert!(body.contains("1. Auth")); + } + + #[test] + fn parse_plan_file_missing_delimiters_errors() { + assert!(parse_plan_file("no frontmatter here").is_err()); + assert!(parse_plan_file("---\nname: x").is_err()); + } + + #[test] + fn parse_plan_file_tolerates_missing_optional_fields() { + let content = "---\nname: Minimal\n---\n\nBody"; + let (frontmatter, body) = parse_plan_file(content).expect("parse plan file"); + assert_eq!(frontmatter.name.as_deref(), Some("Minimal")); + assert!(frontmatter.overview.is_none()); + assert!(frontmatter.todos.is_empty()); + assert!(body.contains("Body")); + } + + use super::{resolve_plan_path, resolve_plan_path_with_plans_dir}; + use crate::agentic::tools::framework::ToolUseContext; + use serde_json::json; + use std::path::Path; + use uuid::Uuid; + + /// Context whose runtime root points at `runtime_root`, so + /// `current_workspace_runtime_root()` resolves without real FS side effects. + fn test_context(runtime_root: &Path) -> ToolUseContext { + let mut context = ToolUseContext::for_tool_listing(None, None); + context.custom_data.insert( + "__bitfun_test_runtime_root".to_string(), + json!(runtime_root.to_string_lossy().to_string()), + ); + context + } + + #[test] + fn resolve_plan_path_absolute_plan_md_suffix_succeeds() { + // Regression: xxx.plan.md must be accepted via absolute path + // (extension() alone would report only "md"), as long as it stays + // inside the plans directory. + let dir = std::env::temp_dir().join(format!("plan-read-resolve-{}", Uuid::new_v4())); + let plans_dir = dir.join("plans"); + std::fs::create_dir_all(&plans_dir).expect("temp plans dir should be created"); + let plan_path = plans_dir.join("my_plan_1234abcd.plan.md"); + std::fs::write(&plan_path, "---\nname: Test\n---\n\nBody").expect("write plan file"); + let result = resolve_plan_path( + plan_path.to_str().expect("temp plan path must be UTF-8"), + &test_context(&dir), + ); + let _ = std::fs::remove_dir_all(&dir); + assert_eq!(result.expect("absolute .plan.md path must resolve"), plan_path); + } + + #[test] + fn resolve_plan_path_rejects_wrong_suffix() { + let error = resolve_plan_path("C:/tmp/not_a_plan.md", &test_context(Path::new("C:/tmp"))) + .expect_err("non-.plan.md absolute path must error"); + let message = error.to_string(); + assert!( + message.contains("Plan file must end with .plan.md"), + "unexpected error: {}", + message + ); + } + + #[test] + fn resolve_plan_path_rejects_absolute_path_outside_plans_dir() { + // PLAN-01: an absolute .plan.md path outside the plans directory must + // be rejected by the containment fence even when the file exists. + let dir = std::env::temp_dir().join(format!("plan-read-fence-{}", Uuid::new_v4())); + std::fs::create_dir_all(dir.join("plans")).expect("plans dir should be created"); + let outside = dir.join("outside.plan.md"); + std::fs::write(&outside, "---\nname: X\n---\n\nBody").expect("write outside file"); + let error = resolve_plan_path( + outside.to_str().expect("temp plan path must be UTF-8"), + &test_context(&dir), + ) + .expect_err("path outside plans dir must error"); + let _ = std::fs::remove_dir_all(&dir); + assert!( + error.to_string().contains("resolves outside the plans directory"), + "unexpected error: {}", + error + ); + } + + #[test] + fn resolve_plan_path_rejects_parent_directory_escape() { + // PLAN-01: `..` input must not escape the plans directory. + let dir = std::env::temp_dir().join(format!("plan-read-dotdot-{}", Uuid::new_v4())); + std::fs::create_dir_all(dir.join("plans")).expect("plans dir should be created"); + let error = resolve_plan_path("../escape.plan.md", &test_context(&dir)) + .expect_err(".. escape must error"); + let _ = std::fs::remove_dir_all(&dir); + assert!( + error.to_string().contains("resolves outside the plans directory"), + "unexpected error: {}", + error + ); + } + + #[test] + fn resolve_plan_path_rejects_bare_name_without_plan_md_suffix() { + // PLAN-07: the bare-name branch must validate the .plan.md suffix too. + let dir = std::env::temp_dir().join(format!("plan-read-suffix-{}", Uuid::new_v4())); + std::fs::create_dir_all(dir.join("plans")).expect("plans dir should be created"); + let error = resolve_plan_path("not_a_plan.md", &test_context(&dir)) + .expect_err("bare name without .plan.md suffix must error"); + let _ = std::fs::remove_dir_all(&dir); + assert!( + error.to_string().contains("Plan file must end with .plan.md"), + "unexpected error: {}", + error + ); + } + + #[test] + fn resolve_plan_path_accepts_bare_name_inside_plans_dir() { + let dir = std::env::temp_dir().join(format!("plan-read-bare-{}", Uuid::new_v4())); + std::fs::create_dir_all(dir.join("plans")).expect("plans dir should be created"); + std::fs::write(dir.join("plans/plan_abc.plan.md"), "---\nname: X\n---\n\nBody") + .expect("write plan file"); + let result = resolve_plan_path("plan_abc.plan.md", &test_context(&dir)); + let _ = std::fs::remove_dir_all(&dir); + assert_eq!( + result.expect("bare name inside plans dir must resolve"), + dir.join("plans/plan_abc.plan.md") + ); + } + + #[test] + fn resolve_plan_path_resolves_runtime_uri_inside_plans_dir() { + // PLAN-10: the bitfun://runtime/... URI returned by CreatePlan on + // remote workspaces must resolve to the local mirror plan path. + let dir = std::env::temp_dir().join(format!("plan-read-uri-{}", Uuid::new_v4())); + std::fs::create_dir_all(dir.join("plans")).expect("plans dir should be created"); + std::fs::write(dir.join("plans/plan_abc.plan.md"), "---\nname: X\n---\n\nBody") + .expect("write plan file"); + let uri = "bitfun://runtime/workspace-1/plans/plan_abc.plan.md"; + let result = resolve_plan_path_with_plans_dir(uri, &dir.join("plans"), Some("workspace-1")); + let _ = std::fs::remove_dir_all(&dir); + assert_eq!( + result.expect("runtime URI inside plans dir must resolve"), + dir.join("plans/plan_abc.plan.md") + ); + } + + #[test] + fn resolve_plan_path_rejects_runtime_uri_with_scope_mismatch() { + let error = resolve_plan_path_with_plans_dir( + "bitfun://runtime/other-workspace/plans/plan_abc.plan.md", + Path::new("C:/plans"), + Some("current-workspace"), + ) + .expect_err("runtime URI scope mismatch must error"); + assert!( + error.to_string().contains("belongs to workspace 'other-workspace'"), + "unexpected error: {}", + error + ); + } + + #[test] + fn parse_plan_file_handles_crlf_frontmatter() { + // PLAN-05: the trailing '\r' before the closer must not break YAML. + let content = + "---\r\nname: My Plan\r\noverview: An overview\r\ntodos:\r\n- id: setup-auth\r\n content: Set up auth\r\n status: pending\r\n---\r\n\r\nBody text here.\r\n"; + let (frontmatter, body) = parse_plan_file(content).expect("parse CRLF plan file"); + assert_eq!(frontmatter.name.as_deref(), Some("My Plan")); + assert_eq!(frontmatter.overview.as_deref(), Some("An overview")); + assert_eq!(frontmatter.todos.len(), 1); + assert_eq!(frontmatter.todos[0].id.as_deref(), Some("setup-auth")); + assert_eq!(frontmatter.todos[0].status.as_deref(), Some("pending")); + assert!(body.contains("Body text here.")); + } +} diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/plan_update_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/plan_update_tool.rs new file mode 100644 index 0000000000..0552553c71 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/tools/implementations/plan_update_tool.rs @@ -0,0 +1,1224 @@ +//! PlanUpdate tool implementation +//! +//! Updates todo statuses inside an existing plan file (YAML frontmatter), +//! preserving every other frontmatter field and the markdown body byte-for-byte. + +use crate::agentic::tools::file_permissions::file_permission_intents; +use crate::agentic::tools::framework::{ + PermissionIntent, Tool, ToolExposure, ToolResult, ToolUseContext, +}; +use crate::agentic::tools::implementations::plan_read_tool::{ + resolve_plan_path, resolve_plan_path_with_plans_dir, +}; +use crate::infrastructure::get_path_manager_arc; +use crate::util::errors::{BitFunError, BitFunResult}; +use async_trait::async_trait; +use serde_json::{json, Value}; +use std::path::{Path, PathBuf}; +use tokio::fs; + +/// PlanUpdate tool - update todo statuses in a plan file +pub struct PlanUpdateTool; + +impl PlanUpdateTool { + pub fn new() -> Self { + Self + } +} + +impl Default for PlanUpdateTool { + fn default() -> Self { + Self::new() + } +} + +/// Parse a plan file body into its YAML frontmatter (kept as a JSON value so +/// round-trip writes preserve key order and formatting) and markdown body. +pub(crate) fn parse_plan_file(content: &str) -> BitFunResult<(Value, String)> { + let trimmed = content.trim_start(); + let after_open = trimmed + .strip_prefix("---") + .ok_or_else(|| BitFunError::tool("Plan file is missing the YAML frontmatter opener '---'"))?; + let end = after_open.find("\n---").ok_or_else(|| { + BitFunError::tool("Plan file is missing the YAML frontmatter closer '---'") + })?; + // PLAN-05: CRLF files keep a trailing '\r' on the last frontmatter line + // before the closer; strip it so serde_yaml never sees a dangling CR. + let yaml_part = after_open[..end].trim_end_matches('\r'); + let body_start = end + "\n---".len(); + let body = after_open[body_start..] + .trim_start_matches(['\n', '\r']) + .to_string(); + + let frontmatter: Value = serde_yaml::from_str(yaml_part).map_err(|error| { + BitFunError::tool(format!( + "Failed to parse plan YAML frontmatter: {}", + error + )) + })?; + Ok((frontmatter, body)) +} + +/// One todo update: id plus any subset of status/content/dependencies. At +/// least one of the three fields must be present (enforced at input parsing). +/// `pub(crate)` so the backend scheduler (plan-todo binding) can construct +/// single-status updates without a ToolUseContext. +pub(crate) struct TodoUpdate { + pub(crate) id: String, + pub(crate) status: Option, + pub(crate) content: Option, + pub(crate) dependencies: Option>, +} + +/// Validate todo updates against a parsed frontmatter. Every update is checked +/// before anything is written: the status value (when present) must be legal, +/// the todo id must exist, duplicate ids in one batch are rejected, and every +/// dependency referenced by an update must exist without introducing a +/// self-loop or a cycle. Returns the applied updates for the tool result. +pub(crate) fn validate_updates(frontmatter: &Value, updates: &[TodoUpdate]) -> BitFunResult> { + let todos = frontmatter + .get("todos") + .and_then(Value::as_array) + .map(|todos| todos.clone()) + .unwrap_or_default(); + let all_ids: std::collections::HashSet<&str> = todos + .iter() + .filter_map(|todo| todo.get("id").and_then(Value::as_str)) + .collect(); + + // PLAN-08: reject duplicate ids in a single updates batch (the second + // occurrence would otherwise silently override the first). + let mut seen_ids = std::collections::HashSet::new(); + for update in updates { + if !seen_ids.insert(update.id.as_str()) { + return Err(BitFunError::validation(format!( + "Duplicate todo id in updates: {}", + update.id + ))); + } + } + + let mut applied = Vec::with_capacity(updates.len()); + for update in updates { + if let Some(status) = &update.status { + if !matches!(status.as_str(), "pending" | "in_progress" | "completed") { + return Err(BitFunError::validation(format!( + "Invalid todo status '{}' for id '{}': expected one of pending, in_progress, completed", + status, update.id + ))); + } + } + if !all_ids.contains(update.id.as_str()) { + return Err(BitFunError::tool(format!( + "Todo id not found in plan: {}", + update.id + ))); + } + // PLAN-06: every dependency referenced by this update must exist in the + // plan (prevents dangling edges). + if let Some(dependencies) = &update.dependencies { + for dependency in dependencies { + if !all_ids.contains(dependency.as_str()) { + return Err(BitFunError::tool(format!( + "Dependency todo id not found in plan: {} (referenced by '{}')", + dependency, update.id + ))); + } + } + } + let mut applied_item = json!({ "id": update.id }); + if let Some(status) = &update.status { + applied_item["status"] = Value::String(status.clone()); + } + if let Some(content) = &update.content { + applied_item["content"] = Value::String(content.clone()); + } + if let Some(dependencies) = &update.dependencies { + applied_item["dependencies"] = + Value::Array(dependencies.iter().map(|d| Value::String(d.clone())).collect()); + } + applied.push(applied_item); + } + + // PLAN-06: reject self-loops and cycles in the merged dependency graph. + validate_todo_dependency_graph(frontmatter, updates)?; + + Ok(applied) +} + +/// PLAN-06: build the merged dependency graph (current frontmatter deps +/// overlaid with this batch's dependency updates) and reject self-loops and +/// cycles. Kahn's algorithm leaves every node of a cycle unprocessed. +fn validate_todo_dependency_graph(frontmatter: &Value, updates: &[TodoUpdate]) -> BitFunResult<()> { + let todos = frontmatter + .get("todos") + .and_then(Value::as_array) + .map(|todos| todos.clone()) + .unwrap_or_default(); + + let mut adjacency: std::collections::HashMap> = + std::collections::HashMap::new(); + for todo in &todos { + let id = match todo.get("id").and_then(Value::as_str) { + Some(id) => id.to_string(), + None => continue, + }; + let existing_deps: Vec = todo + .get("dependencies") + .and_then(Value::as_array) + .map(|values| { + values + .iter() + .filter_map(|value| value.as_str().map(String::from)) + .collect::>() + }) + .unwrap_or_default(); + let deps = if let Some(update) = updates.iter().find(|update| update.id == id) { + update.dependencies.clone().unwrap_or(existing_deps) + } else { + existing_deps + }; + adjacency.insert(id, deps); + } + + // Self-loop: clear, targeted error before the generic cycle path. + for (id, deps) in &adjacency { + if deps.iter().any(|dep| dep == id) { + return Err(BitFunError::tool(format!( + "Todo dependency cycle detected: '{}' depends on itself", + id + ))); + } + } + + // Kahn's algorithm over edges that reference existing todos (dangling deps + // are ignored here; the caller already rejects newly-set dangling deps). + let mut in_degree: std::collections::HashMap = adjacency + .keys() + .map(|id| (id.clone(), 0usize)) + .collect(); + for deps in adjacency.values() { + for dep in deps { + if let Some(degree) = in_degree.get_mut(dep) { + *degree += 1; + } + } + } + let mut queue: Vec = in_degree + .iter() + .filter(|(_, degree)| **degree == 0) + .map(|(id, _)| id.clone()) + .collect(); + let mut processed = 0usize; + while let Some(id) = queue.pop() { + processed += 1; + if let Some(deps) = adjacency.get(&id) { + for dep in deps { + if let Some(degree) = in_degree.get_mut(dep) { + *degree -= 1; + if *degree == 0 { + queue.push(dep.clone()); + } + } + } + } + } + if processed != adjacency.len() { + let remaining: Vec = in_degree + .iter() + .filter(|(_, degree)| **degree > 0) + .map(|(id, _)| id.clone()) + .collect(); + return Err(BitFunError::tool(format!( + "Todo dependency cycle detected: {}", + remaining.join(", ") + ))); + } + Ok(()) +} + +/// PLAN-03: YAML 1.1 boolean tokens that a YAML 1.2-core parser (serde_yaml) +/// resolves as plain strings but other consumers of the plan file resolve as +/// booleans. Quoting them forces the todo content to stay a string no matter +/// which YAML flavor reads the file back. The true/false variants are already +/// caught by the serde_yaml non-string check in yaml_quote_single_line. +fn is_yaml_11_boolean(value: &str) -> bool { + matches!( + value, + "y" | "Y" + | "yes" | "Yes" | "YES" + | "n" | "N" + | "no" | "No" | "NO" + | "on" | "On" | "ON" + | "off" | "Off" | "OFF" + ) +} + +/// A value with leading or trailing whitespace must be quoted: a plain YAML +/// scalar has its surrounding whitespace trimmed on read-back, so an unquoted +/// `padded ` would silently lose its trailing spaces. +fn has_edge_whitespace(value: &str) -> bool { + value + .chars() + .next() + .is_some_and(char::is_whitespace) + || value + .chars() + .next_back() + .is_some_and(char::is_whitespace) +} + +/// Quote a single-line YAML scalar value so it can be written back safely as +/// ` content: `. Values with YAML special characters (or control +/// chars) are double-quoted with escaping; plain values stay bare so the +/// common create_plan_tool.rs layout is preserved. +fn yaml_quote_single_line(value: &str) -> String { + if value.is_empty() { + return "''".to_string(); + } + // PLAN-03: values YAML parses as a non-string scalar (number, boolean, + // null, sequence, mapping) must be quoted, otherwise PlanRead parses them + // back as the wrong type and `as_str()` silently yields nothing. + let parses_as_non_string = serde_yaml::from_str::(value) + .ok() + .is_some_and(|parsed| !parsed.is_string()); + let special = parses_as_non_string + || is_yaml_11_boolean(value) + || has_edge_whitespace(value) + || value.chars().any(|c| { + c.is_control() + || matches!( + c, + ':' | '#' + | '"' + | '\'' + | '{' + | '}' + | '[' + | ']' + | ',' + | '&' + | '*' + | '!' + | '|' + | '>' + | '%' + | '@' + | '`' + ) || (c == '-' && value.starts_with('-')) + }); + if !special { + return value.to_string(); + } + let escaped = value + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('\n', "\\n") + .replace('\r', "\\r") + .replace('\t', "\\t"); + format!("\"{}\"", escaped) +} + +/// Preserve a trailing CR from CRLF files when rebuilding a line. +fn line_tail_cr(line: &str) -> &str { + if line.ends_with('\r') { + "\r" + } else { + "" + } +} + +/// Apply validated updates at the text level: only the matching ` status:`, +/// ` content:` and ` dependencies:` lines inside the `todos:` block are +/// replaced, so every other byte of the plan file (frontmatter key order, +/// indentation, markdown body) stays exactly as it was. The serde_yaml Value +/// round-trip is NOT used here because it reorders YAML mapping keys, which +/// would violate the format-preservation contract. +/// +/// Multi-line `content: |`/`content: >` blocks are collapsed: the block +/// header is replaced with a single-line content value and the indented body +/// lines (4+ spaces) are dropped. Old dependency list items (` - x`) are +/// dropped when the dependencies field is replaced. +pub(crate) fn apply_updates_text(content: &str, updates: &[TodoUpdate]) -> BitFunResult { + let targets: std::collections::HashMap<&str, &TodoUpdate> = updates + .iter() + .map(|update| (update.id.as_str(), update)) + .collect(); + let mut expected_fields = 0usize; + for update in updates { + expected_fields += usize::from(update.status.is_some()) + + usize::from(update.content.is_some()) + + usize::from(update.dependencies.is_some()); + } + + let mut out: Vec = Vec::new(); + let mut in_todos = false; + let mut current_id: Option = None; + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + let mut replaced = 0usize; + + for line in content.split('\n') { + // Tolerate CRLF files: the trailing \r must not break structural + // matching (it is preserved when rebuilding the line). + let structural = line.trim_end_matches('\r'); + if !in_todos { + // The todos block starts at the top-level `todos:` key. + if structural == "todos:" || structural.starts_with("todos: ") { + in_todos = true; + } + out.push(line.to_string()); + continue; + } + // A new todo item starts. + if let Some(id) = structural.strip_prefix("- id: ") { + current_id = Some(id.trim().to_string()); + seen.clear(); + out.push(line.to_string()); + continue; + } + // A top-level key (unindented, not a list item) ends the todos block. + if !structural.starts_with(' ') && !structural.starts_with('\t') && !structural.starts_with('-') && !structural.is_empty() + { + in_todos = false; + out.push(line.to_string()); + continue; + } + let is_target = current_id + .as_deref() + .is_some_and(|id| targets.contains_key(id)); + + // Old content block body lines (4+ spaces indentation): drop them once + // the content field of this target todo has been replaced. + if structural.starts_with(" ") || structural.starts_with('\t') { + if is_target && seen.contains("content") { + continue; + } + out.push(line.to_string()); + continue; + } + // Old dependency list items (` - x`): drop them once the dependencies + // field of this target todo has been replaced. + if structural.starts_with(" - ") || structural.starts_with(" -") { + if is_target && seen.contains("dependencies") { + continue; + } + out.push(line.to_string()); + continue; + } + // content field (single line or block header). + if structural.starts_with(" content: ") || structural == " content:" { + if is_target && !seen.contains("content") { + if let Some(update) = targets.get(current_id.as_deref().expect("is_target")) { + if let Some(new_content) = &update.content { + out.push(format!( + " content: {}{}", + yaml_quote_single_line(new_content), + line_tail_cr(line) + )); + seen.insert("content".to_string()); + replaced += 1; + continue; + } + } + } + out.push(line.to_string()); + continue; + } + // status field. + if structural.starts_with(" status: ") { + if is_target && !seen.contains("status") { + if let Some(update) = targets.get(current_id.as_deref().expect("is_target")) { + if let Some(new_status) = &update.status { + let prefix_len = " status: ".len(); + let tail = &line[prefix_len..]; + // Keep everything after the old value (e.g. a trailing + // CR from CRLF files) byte-identical. + let old_value_len = tail.trim_end_matches(['\r', ' ', '\t']).len(); + out.push(format!(" status: {}{}", new_status, &tail[old_value_len..])); + seen.insert("status".to_string()); + replaced += 1; + continue; + } + } + } + out.push(line.to_string()); + continue; + } + // dependencies field. + if structural.starts_with(" dependencies:") { + if is_target && !seen.contains("dependencies") { + if let Some(update) = targets.get(current_id.as_deref().expect("is_target")) { + if let Some(new_dependencies) = &update.dependencies { + let cr = line_tail_cr(line); + if new_dependencies.is_empty() { + out.push(format!(" dependencies: []{}", cr)); + } else { + out.push(format!(" dependencies:{}", cr)); + for dependency in new_dependencies { + out.push(format!(" - {}{}", dependency, cr)); + } + } + seen.insert("dependencies".to_string()); + replaced += 1; + continue; + } + } + } + out.push(line.to_string()); + continue; + } + // Any other line (unknown nested fields, blank lines). + out.push(line.to_string()); + } + + if replaced != expected_fields { + return Err(BitFunError::tool(format!( + "Failed to locate all requested todo fields (found {} of {})", + replaced, expected_fields + ))); + } + Ok(out.join("\n")) +} + +/// PLAN-04/11: atomic plan write - write a random-suffixed sibling temp file +/// then rename over the target, so concurrent updates never collide on a fixed +/// `{path}.tmp` and a crash never leaves a half-written plan file. +pub(crate) async fn atomic_write_plan_file(path: &Path, content: &[u8]) -> BitFunResult<()> { + let nonce = uuid::Uuid::new_v4().simple().to_string(); + let tmp_path = PathBuf::from(format!("{}.{}.tmp", path.to_string_lossy(), &nonce[..8])); + fs::write(&tmp_path, content) + .await + .map_err(|error| BitFunError::tool(format!("Failed to write plan file: {}", error)))?; + if let Err(error) = fs::rename(&tmp_path, path).await { + let _ = fs::remove_file(&tmp_path).await; + return Err(BitFunError::tool(format!( + "Failed to replace plan file: {}", + error + ))); + } + Ok(()) +} + +/// Resolve the plan file argument to a concrete filesystem path WITHOUT a +/// ToolUseContext (backend scheduler use, e.g. plan-todo binding). Bare file +/// names are resolved against the plans directory derived from the given +/// workspace root (`~/.bitfun/projects//plans`). Converges on +/// the shared [`resolve_plan_path_with_plans_dir`] core so suffix validation +/// and the plans-dir containment fence match the PlanRead/PlanUpdate tools. +/// Remote workspaces must be filtered by the caller: their plan files live on +/// the remote host, not in the local mirror. +pub(crate) async fn resolve_plan_path_for_backend( + plan_file: &str, + workspace_path: Option<&Path>, +) -> BitFunResult { + let workspace_path = workspace_path.ok_or_else(|| { + BitFunError::tool( + "A workspace path is required to resolve a plan file in the plans directory" + .to_string(), + ) + })?; + let plans_dir = get_path_manager_arc().project_plans_dir(workspace_path); + // PLAN-12: 内部同步 `exists()`(plan_read_tool.rs `require_plan_file_exists`) + // 仅对单条计划路径做存在性检查,轻微阻塞可接受,保留现状。 + resolve_plan_path_with_plans_dir(plan_file, &plans_dir, None) +} + +/// Apply a single todo status update to a plan file at the given path (backend +/// scheduler use, e.g. plan-todo binding). Reads, validates and rewrites the +/// file atomically (same write path as the PlanUpdate tool); returns the +/// applied update for logging. Errors are surfaced to the caller, which owns +/// the failure policy (the scheduler treats them as best-effort). +pub(crate) async fn apply_todo_status_update( + plan_path: &Path, + todo_id: &str, + status: &str, +) -> BitFunResult { + let content = fs::read_to_string(plan_path) + .await + .map_err(|error| BitFunError::tool(format!("Failed to read plan file: {}", error)))?; + let (frontmatter, _body) = parse_plan_file(&content)?; + let updates = vec![TodoUpdate { + id: todo_id.to_string(), + status: Some(status.to_string()), + content: None, + dependencies: None, + }]; + let applied = validate_updates(&frontmatter, &updates)?; + let new_content = apply_updates_text(&content, &updates)?; + + atomic_write_plan_file(plan_path, new_content.as_bytes()).await?; + Ok(applied + .into_iter() + .next() + .unwrap_or_else(|| json!({ "id": todo_id }))) +} + +#[async_trait] +impl Tool for PlanUpdateTool { + fn name(&self) -> &str { + "PlanUpdate" + } + + async fn description(&self) -> BitFunResult { + Ok(r###"Update todos in an existing plan file. The input accepts the plan file name (for example "my_plan_1234abcd.plan.md") or a full path to a .plan.md file, plus an array of todo updates. Each update has an id and at least one of: status ("pending", "in_progress" or "completed"), content (new todo description), or dependencies (new array of dependency todo ids; an empty array clears them). Reads the plan file, validates that every todo id exists and every status is legal, updates the matching todo fields in the YAML frontmatter, and writes the file back atomically while preserving every other frontmatter field and the markdown body unchanged. Errors clearly when the plan file does not exist, a todo id is not found, or a status value is invalid."### + .to_string()) + } + + fn short_description(&self) -> String { + "Update todo status, content or dependencies in a plan file.".to_string() + } + + fn default_exposure(&self) -> ToolExposure { + // 2026-08-04 user calibration: the plan tool family is a commander + // staple; Direct so no GetToolSpec unlock round-trip is needed + // (mirrored by `shared_coding_mode_tool_exposure_overrides()`). + ToolExposure::Direct + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "additionalProperties": false, + "required": ["plan_file", "updates"], + "properties": { + "plan_file": { + "type": "string", + "description": "Plan file name (e.g. my_plan_1234abcd.plan.md) or an absolute path to a .plan.md file" + }, + "updates": { + "type": "array", + "description": "Array of todo updates; at least one is required", + "items": { + "type": "object", + "required": ["id"], + "properties": { + "id": { + "type": "string", + "description": "Id of the todo to update (must exist in the plan)" + }, + "status": { + "type": "string", + "enum": ["pending", "in_progress", "completed"], + "description": "New todo status" + }, + "content": { + "type": "string", + "description": "New todo content (replaces the existing content)" + }, + "dependencies": { + "type": "array", + "description": "New dependency todo ids (replaces the existing list; an empty array clears them)", + "items": { + "type": "string" + } + } + } + } + } + } + }) + } + + fn is_readonly(&self) -> bool { + // PLAN-02: PlanUpdate writes the plan file, so it must NOT be declared + // readonly - otherwise permission_intents would be empty and the write + // would have no permission gate. + false + } + + fn is_concurrency_safe(&self, _input: Option<&Value>) -> bool { + // PLAN-04: concurrent updates to the same plan file would lose + // changes (read-modify-write is not atomic across calls). + false + } + + fn permission_intents( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { + // PLAN-02: emit an edit intent for the resolved plan file so permission + // rules actually gate the write (mirrors file_write_tool.rs). + let plan_file = input + .get("plan_file") + .and_then(Value::as_str) + .ok_or_else(|| BitFunError::validation("Missing required field: plan_file".to_string()))?; + let plans_dir = context.current_workspace_runtime_root()?.join("plans"); + let plan_path = resolve_plan_path_with_plans_dir( + plan_file.trim(), + &plans_dir, + context.current_workspace_scope().as_deref(), + )?; + let plan_path_str = plan_path.to_string_lossy().to_string(); + file_permission_intents("edit", [plan_path_str.as_str()], context) + } + + async fn call_impl( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { + let plan_file = input + .get("plan_file") + .and_then(|value| value.as_str()) + .ok_or(BitFunError::validation( + "Missing required field: plan_file", + ))?; + let plan_file = plan_file.trim(); + if plan_file.is_empty() { + return Err(BitFunError::validation( + "Missing required field: plan_file", + )); + } + + let updates_value = input + .get("updates") + .and_then(|value| value.as_array()) + .ok_or(BitFunError::validation("Missing required field: updates"))?; + if updates_value.is_empty() { + return Err(BitFunError::validation( + "updates must contain at least one todo update", + )); + } + let mut updates = Vec::with_capacity(updates_value.len()); + for update in updates_value { + let id = update + .get("id") + .and_then(|value| value.as_str()) + .ok_or(BitFunError::validation( + "Each update requires an 'id' field", + ))?; + let status = update + .get("status") + .and_then(|value| value.as_str()) + .map(str::to_string); + let content = update + .get("content") + .and_then(|value| value.as_str()) + .map(str::to_string); + let dependencies = update + .get("dependencies") + .and_then(|value| value.as_array()) + .map(|values| { + values + .iter() + .filter_map(|value| value.as_str().map(String::from)) + .collect::>() + }); + if status.is_none() && content.is_none() && dependencies.is_none() { + return Err(BitFunError::validation( + "Each update requires at least one of 'status', 'content' or 'dependencies'", + )); + } + updates.push(TodoUpdate { + id: id.to_string(), + status, + content, + dependencies, + }); + } + + // PLAN-12: `resolve_plan_path` 内部的存在性检查(plan_read_tool.rs 的 + // `require_plan_file_exists`)是同步 `exists()`,在异步执行器中轻微阻塞, + // 开销极小且与仓库其他工具风格一致,保留现状可接受。 + let plan_path = resolve_plan_path(plan_file, context)?; + let content = fs::read_to_string(&plan_path) + .await + .map_err(|error| BitFunError::tool(format!("Failed to read plan file: {}", error)))?; + let (frontmatter, _body) = parse_plan_file(&content)?; + let applied = validate_updates(&frontmatter, &updates)?; + let new_content = apply_updates_text(&content, &updates)?; + + atomic_write_plan_file(&plan_path, new_content.as_bytes()).await?; + + let plan_reference = context.build_runtime_artifact_reference(&format!( + "plans/{}", + plan_path + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_default() + ))?; + + let result = json!({ + "success": true, + "plan_file_name": plan_path.file_name().map(|name| name.to_string_lossy().to_string()).unwrap_or_default(), + "plan_file_path": plan_reference, + "updated": applied + }); + + Ok(vec![ToolResult::Result { + data: result, + result_for_assistant: None, + image_attachments: None, + }]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn status_update(id: &str, status: &str) -> TodoUpdate { + TodoUpdate { + id: id.to_string(), + status: Some(status.to_string()), + content: None, + dependencies: None, + } + } + + #[test] + fn apply_updates_text_preserves_every_other_byte() { + let content = "---\nname: My Plan\noverview: An overview\ntodos:\n- id: setup-auth\n content: Set up auth\n status: pending\n- id: implement-ui\n content: Implement the UI\n status: pending\n dependencies:\n - setup-auth\n---\n\n# My Plan\n\nBody text here.\n"; + let updates = vec![status_update("setup-auth", "completed")]; + let updated = apply_updates_text(content, &updates).expect("apply updates"); + + // Every non-status byte stays identical: key order, indentation and + // the markdown body must all be preserved exactly. + let expected = "---\nname: My Plan\noverview: An overview\ntodos:\n- id: setup-auth\n content: Set up auth\n status: completed\n- id: implement-ui\n content: Implement the UI\n status: pending\n dependencies:\n - setup-auth\n---\n\n# My Plan\n\nBody text here.\n"; + assert_eq!(updated, expected); + + // Cross-check through the parser as well. + let (frontmatter, body) = parse_plan_file(&updated).expect("re-parse updated file"); + assert!(body.contains("Body text here.")); + assert_eq!(frontmatter["name"].as_str(), Some("My Plan")); + assert_eq!(frontmatter["overview"].as_str(), Some("An overview")); + let todos = frontmatter["todos"].as_array().expect("todos array"); + assert_eq!(todos.len(), 2); + assert_eq!(todos[0]["id"].as_str(), Some("setup-auth")); + assert_eq!(todos[0]["content"].as_str(), Some("Set up auth")); + assert_eq!(todos[0]["status"].as_str(), Some("completed")); + assert_eq!(todos[1]["status"].as_str(), Some("pending")); + assert_eq!( + todos[1]["dependencies"].as_array().map(|deps| deps[0].as_str()), + Some(Some("setup-auth")) + ); + } + + #[test] + fn apply_updates_text_updates_multiple_todos() { + let content = "---\ntodos:\n- id: a\n content: A\n status: pending\n- id: b\n content: B\n status: pending\n- id: c\n content: C\n status: pending\n---\n\nbody"; + let updates = vec![ + status_update("a", "in_progress"), + status_update("c", "completed"), + ]; + let updated = apply_updates_text(content, &updates).expect("apply updates"); + let expected = "---\ntodos:\n- id: a\n content: A\n status: in_progress\n- id: b\n content: B\n status: pending\n- id: c\n content: C\n status: completed\n---\n\nbody"; + assert_eq!(updated, expected); + } + + #[test] + fn apply_updates_text_keeps_crlf_line_endings() { + let content = "---\r\ntodos:\r\n- id: a\r\n content: A\r\n status: pending\r\n---\r\n\r\nbody\r\n"; + let updates = vec![status_update("a", "completed")]; + let updated = apply_updates_text(content, &updates).expect("apply updates"); + let expected = "---\r\ntodos:\r\n- id: a\r\n content: A\r\n status: completed\r\n---\r\n\r\nbody\r\n"; + assert_eq!(updated, expected); + } + + #[test] + fn apply_updates_text_updates_content_single_line() { + let content = "---\ntodos:\n- id: a\n content: Old content\n status: pending\n---\n\nbody"; + let updates = vec![TodoUpdate { + id: "a".to_string(), + status: None, + content: Some("New content".to_string()), + dependencies: None, + }]; + let updated = apply_updates_text(content, &updates).expect("apply updates"); + let expected = "---\ntodos:\n- id: a\n content: New content\n status: pending\n---\n\nbody"; + assert_eq!(updated, expected); + + // Parser agrees on the new content. + let (frontmatter, _) = parse_plan_file(&updated).expect("re-parse"); + assert_eq!(frontmatter["todos"][0]["content"].as_str(), Some("New content")); + assert_eq!(frontmatter["todos"][0]["status"].as_str(), Some("pending")); + } + + #[test] + fn apply_updates_text_collapses_multiline_content_block() { + // Hand-edited plan with a literal block content. + let content = "---\ntodos:\n- id: a\n content: |\n Line one\n Line two\n status: pending\n---\n\nbody"; + let updates = vec![TodoUpdate { + id: "a".to_string(), + status: None, + content: Some("Replaced".to_string()), + dependencies: None, + }]; + let updated = apply_updates_text(content, &updates).expect("apply updates"); + let expected = "---\ntodos:\n- id: a\n content: Replaced\n status: pending\n---\n\nbody"; + assert_eq!(updated, expected); + + let (frontmatter, _) = parse_plan_file(&updated).expect("re-parse"); + assert_eq!(frontmatter["todos"][0]["content"].as_str(), Some("Replaced")); + } + + #[test] + fn apply_updates_text_quotes_special_content() { + let content = "---\ntodos:\n- id: a\n content: plain\n status: pending\n---\n\nbody"; + let updates = vec![TodoUpdate { + id: "a".to_string(), + status: None, + content: Some("needs: quoting".to_string()), + dependencies: None, + }]; + let updated = apply_updates_text(content, &updates).expect("apply updates"); + assert!(updated.contains(" content: \"needs: quoting\"")); + + let (frontmatter, _) = parse_plan_file(&updated).expect("re-parse"); + assert_eq!( + frontmatter["todos"][0]["content"].as_str(), + Some("needs: quoting") + ); + } + + #[test] + fn apply_updates_text_updates_dependencies() { + let content = "---\ntodos:\n- id: a\n content: A\n status: pending\n dependencies:\n - x\n - y\n---\n\nbody"; + let updates = vec![TodoUpdate { + id: "a".to_string(), + status: None, + content: None, + dependencies: Some(vec!["new-dep".to_string(), "other".to_string()]), + }]; + let updated = apply_updates_text(content, &updates).expect("apply updates"); + let expected = "---\ntodos:\n- id: a\n content: A\n status: pending\n dependencies:\n - new-dep\n - other\n---\n\nbody"; + assert_eq!(updated, expected); + + let (frontmatter, _) = parse_plan_file(&updated).expect("re-parse"); + assert_eq!( + frontmatter["todos"][0]["dependencies"] + .as_array() + .map(|deps| deps[0].as_str()), + Some(Some("new-dep")) + ); + } + + #[test] + fn apply_updates_text_clears_dependencies_with_empty_array() { + let content = "---\ntodos:\n- id: a\n content: A\n status: pending\n dependencies:\n - x\n - y\n---\n\nbody"; + let updates = vec![TodoUpdate { + id: "a".to_string(), + status: None, + content: None, + dependencies: Some(Vec::new()), + }]; + let updated = apply_updates_text(content, &updates).expect("apply updates"); + let expected = "---\ntodos:\n- id: a\n content: A\n status: pending\n dependencies: []\n---\n\nbody"; + assert_eq!(updated, expected); + + let (frontmatter, _) = parse_plan_file(&updated).expect("re-parse"); + assert_eq!( + frontmatter["todos"][0]["dependencies"] + .as_array() + .map(|deps| deps.len()), + Some(0) + ); + } + + #[test] + fn apply_updates_text_combines_status_content_and_dependencies() { + let content = "---\ntodos:\n- id: a\n content: Old\n status: pending\n dependencies:\n - x\n---\n\nbody"; + let updates = vec![TodoUpdate { + id: "a".to_string(), + status: Some("completed".to_string()), + content: Some("New".to_string()), + dependencies: Some(vec!["y".to_string()]), + }]; + let updated = apply_updates_text(content, &updates).expect("apply updates"); + let expected = "---\ntodos:\n- id: a\n content: New\n status: completed\n dependencies:\n - y\n---\n\nbody"; + assert_eq!(updated, expected); + } + + #[test] + fn validate_updates_rejects_invalid_status() { + let content = "---\ntodos:\n- id: a\n content: A\n status: pending\n---\n\nbody"; + let (frontmatter, _) = parse_plan_file(content).expect("parse plan file"); + let error = validate_updates(&frontmatter, &[status_update("a", "done")]) + .expect_err("invalid status must error"); + let message = error.to_string(); + assert!( + message.contains("Invalid todo status 'done'"), + "unexpected error: {}", + message + ); + } + + #[test] + fn validate_updates_rejects_unknown_id() { + let content = "---\ntodos:\n- id: a\n content: A\n status: pending\n---\n\nbody"; + let (frontmatter, _) = parse_plan_file(content).expect("parse plan file"); + let error = validate_updates(&frontmatter, &[status_update("missing-id", "completed")]) + .expect_err("unknown id must error"); + let message = error.to_string(); + assert!( + message.contains("Todo id not found in plan: missing-id"), + "unexpected error: {}", + message + ); + } + + #[test] + fn validate_updates_rejects_plan_without_todos() { + let content = "---\nname: Legacy\n---\n\nbody"; + let (frontmatter, _) = parse_plan_file(content).expect("parse plan file"); + let error = validate_updates(&frontmatter, &[status_update("anything", "completed")]) + .expect_err("plan without todos must error"); + let message = error.to_string(); + assert!( + message.contains("Todo id not found in plan: anything"), + "unexpected error: {}", + message + ); + } + + #[test] + fn validate_updates_accepts_content_only_update() { + let content = "---\ntodos:\n- id: a\n content: A\n status: pending\n---\n\nbody"; + let (frontmatter, _) = parse_plan_file(content).expect("parse plan file"); + let update = TodoUpdate { + id: "a".to_string(), + status: None, + content: Some("Changed".to_string()), + dependencies: None, + }; + let applied = validate_updates(&frontmatter, &[update]).expect("content-only update"); + assert_eq!(applied.len(), 1); + assert_eq!(applied[0]["id"].as_str(), Some("a")); + assert_eq!(applied[0]["content"].as_str(), Some("Changed")); + assert!(applied[0].get("status").is_none()); + } + + #[test] + fn parse_plan_file_missing_delimiters_errors() { + // Damaged or empty files surface a clear parse error; missing files are + // rejected earlier by resolve_plan_path (exists check). + assert!(parse_plan_file("no frontmatter here").is_err()); + assert!(parse_plan_file("").is_err()); + assert!(parse_plan_file("---\nname: x").is_err()); + } + + #[test] + fn parse_plan_file_handles_crlf_frontmatter() { + // PLAN-05: the trailing '\r' before the closer must not break YAML. + let content = + "---\r\ntodos:\r\n- id: a\r\n content: A\r\n status: pending\r\n---\r\n\r\nbody\r\n"; + let (frontmatter, body) = parse_plan_file(content).expect("parse CRLF plan file"); + assert_eq!(frontmatter["todos"][0]["id"].as_str(), Some("a")); + assert_eq!(frontmatter["todos"][0]["status"].as_str(), Some("pending")); + assert!(body.contains("body")); + } + + #[test] + fn yaml_quote_single_line_quotes_non_string_scalars() { + // PLAN-03: numbers, booleans and null must be quoted so PlanRead + // parses them back as strings instead of the wrong scalar type. + for value in ["123", "true", "false", "null", "~", "1.5"] { + let quoted = yaml_quote_single_line(value); + assert_eq!(quoted, format!("\"{}\"", value), "value: {}", value); + } + // Plain string values stay bare. + assert_eq!(yaml_quote_single_line("Set up auth"), "Set up auth"); + assert_eq!(yaml_quote_single_line("deploy-api"), "deploy-api"); + } + + #[test] + fn apply_updates_text_quotes_numeric_content() { + // PLAN-03: writing a numeric-looking content must round-trip as a + // string through the parser. + let content = "---\ntodos:\n- id: a\n content: Old\n status: pending\n---\n\nbody"; + let updates = vec![TodoUpdate { + id: "a".to_string(), + status: None, + content: Some("123".to_string()), + dependencies: None, + }]; + let updated = apply_updates_text(content, &updates).expect("apply updates"); + assert!(updated.contains(" content: \"123\""), "{}", updated); + + let (frontmatter, _) = parse_plan_file(&updated).expect("re-parse"); + assert_eq!( + frontmatter["todos"][0]["content"].as_str(), + Some("123"), + "numeric content must parse back as a string" + ); + } + + #[test] + fn yaml_quote_single_line_quotes_yaml_11_booleans_and_padding() { + // PLAN-03: yes/no/on/off(YAML 1.1 布尔)与带前后空白的值必须加引号, + // 且引号包裹后的值经 YAML 解析必须回读为原始字符串(写-读自校验)。 + for value in [ + "yes", "Yes", "YES", "no", "No", "NO", "on", "On", "OFF", "y", "n", + " padded", "padded ", " both ", "\tleading", "trailing\t", + ] { + let quoted = yaml_quote_single_line(value); + assert_ne!(quoted, value, "value must be quoted: {:?}", value); + let parsed: serde_yaml::Value = + serde_yaml::from_str("ed).expect("quoted value must parse"); + assert_eq!(parsed.as_str(), Some(value), "value: {:?} -> {}", value, quoted); + } + // Plain string values stay bare. + assert_eq!(yaml_quote_single_line("Set up auth"), "Set up auth"); + assert_eq!(yaml_quote_single_line("deploy-api"), "deploy-api"); + } + + #[test] + fn apply_updates_text_round_trips_boolean_like_and_padded_content() { + // PLAN-03: 写后回读自校验 —— content 为数字/布尔/null/YAML 1.1 布尔 + // 或带前后空白时,PlanRead 同款 parse_plan_file 必须按原始字符串回读, + // as_str() 不能得 None、也不能丢掉首尾空白。 + let content = "---\ntodos:\n- id: a\n content: Old\n status: pending\n---\n\nbody"; + for value in [ + "123", "1.5", "true", "false", "null", "~", + "yes", "no", "on", "off", + " padded", "padded ", " both ", "\tleading", "trailing\t", + ] { + let updates = vec![TodoUpdate { + id: "a".to_string(), + status: None, + content: Some(value.to_string()), + dependencies: None, + }]; + let updated = apply_updates_text(content, &updates).expect("apply updates"); + let (frontmatter, _) = parse_plan_file(&updated).expect("re-parse updated plan"); + assert_eq!( + frontmatter["todos"][0]["content"].as_str(), + Some(value), + "content {:?} must round-trip as a string (PlanRead-style parse)", + value + ); + } + } + + #[test] + fn validate_updates_rejects_duplicate_ids() { + // PLAN-08: duplicate ids in one batch must error instead of the second + // silently overriding the first. + let content = "---\ntodos:\n- id: a\n content: A\n status: pending\n- id: b\n content: B\n status: pending\n---\n\nbody"; + let (frontmatter, _) = parse_plan_file(content).expect("parse plan file"); + let updates = vec![ + status_update("a", "in_progress"), + status_update("a", "completed"), + ]; + let error = validate_updates(&frontmatter, &updates) + .expect_err("duplicate id must error"); + assert!( + error.to_string().contains("Duplicate todo id in updates: a"), + "unexpected error: {}", + error + ); + } + + #[test] + fn validate_updates_rejects_dangling_dependency() { + // PLAN-06: a dependency referencing a missing todo id must error. + let content = "---\ntodos:\n- id: a\n content: A\n status: pending\n- id: b\n content: B\n status: pending\n---\n\nbody"; + let (frontmatter, _) = parse_plan_file(content).expect("parse plan file"); + let updates = vec![TodoUpdate { + id: "b".to_string(), + status: None, + content: None, + dependencies: Some(vec!["missing-todo".to_string()]), + }]; + let error = validate_updates(&frontmatter, &updates) + .expect_err("dangling dependency must error"); + let message = error.to_string(); + assert!( + message.contains("Dependency todo id not found in plan: missing-todo"), + "unexpected error: {}", + message + ); + } + + #[test] + fn validate_updates_rejects_self_loop() { + // PLAN-06: a todo depending on itself must error. + let content = "---\ntodos:\n- id: a\n content: A\n status: pending\n---\n\nbody"; + let (frontmatter, _) = parse_plan_file(content).expect("parse plan file"); + let updates = vec![TodoUpdate { + id: "a".to_string(), + status: None, + content: None, + dependencies: Some(vec!["a".to_string()]), + }]; + let error = validate_updates(&frontmatter, &updates) + .expect_err("self-loop must error"); + let message = error.to_string(); + assert!( + message.contains("Todo dependency cycle detected"), + "unexpected error: {}", + message + ); + } + + #[test] + fn validate_updates_rejects_dependency_cycle() { + // PLAN-06: a -> b -> a must error (detected even when only 'a' is + // updated and 'b' keeps its existing dependency). + let content = "---\ntodos:\n- id: a\n content: A\n status: pending\n dependencies:\n - b\n- id: b\n content: B\n status: pending\n---\n\nbody"; + let (frontmatter, _) = parse_plan_file(content).expect("parse plan file"); + let updates = vec![TodoUpdate { + id: "b".to_string(), + status: None, + content: None, + dependencies: Some(vec!["a".to_string()]), + }]; + let error = validate_updates(&frontmatter, &updates) + .expect_err("a -> b -> a cycle must error"); + let message = error.to_string(); + assert!( + message.contains("Todo dependency cycle detected"), + "unexpected error: {}", + message + ); + } + + #[test] + fn validate_updates_accepts_acyclic_dependencies() { + let content = "---\ntodos:\n- id: a\n content: A\n status: pending\n- id: b\n content: B\n status: pending\n- id: c\n content: C\n status: pending\n---\n\nbody"; + let (frontmatter, _) = parse_plan_file(content).expect("parse plan file"); + let updates = vec![TodoUpdate { + id: "c".to_string(), + status: None, + content: None, + dependencies: Some(vec!["b".to_string()]), + }]; + let applied = validate_updates(&frontmatter, &updates).expect("acyclic update"); + assert_eq!(applied.len(), 1); + assert_eq!(applied[0]["id"].as_str(), Some("c")); + } + + #[test] + fn plan_update_permission_intents_emits_edit_for_resolved_plan() { + // PLAN-02: the write must surface a non-empty edit intent so the + // permission system can gate it. + let dir = std::env::temp_dir().join(format!("plan-update-intent-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(dir.join("plans")).expect("plans dir should be created"); + let plan_path = dir.join("plans/my_plan_1234.plan.md"); + std::fs::write(&plan_path, "---\nname: X\ntodos:\n- id: a\n content: A\n status: pending\n---\n\nbody") + .expect("write plan file"); + let mut context = ToolUseContext::for_tool_listing( + Some(crate::agentic::WorkspaceBinding::new(None, dir.clone())), + None, + ); + context.custom_data.insert( + "__bitfun_test_runtime_root".to_string(), + json!(dir.to_string_lossy().to_string()), + ); + + let intents = PlanUpdateTool::new() + .permission_intents( + &json!({ + "plan_file": plan_path.to_string_lossy(), + "updates": [{"id": "a", "status": "completed"}] + }), + &context, + ) + .expect("permission intents"); + let _ = std::fs::remove_dir_all(&dir); + + assert!(!intents.is_empty(), "edit intent must be emitted"); + assert_eq!(intents[0].action, "edit"); + } +} diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs index 5b2237d879..3af2f7849e 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs @@ -5,39 +5,47 @@ //! messages that may still run later through the scheduler. use super::util::normalize_path; +use crate::agentic::agents::{get_agent_registry, AcpAgent}; use crate::agentic::coordination::{get_global_coordinator, get_global_scheduler}; use crate::agentic::tools::framework::{ Tool, ToolExposure, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, }; +use crate::agentic::tools::restrictions::{get_session_role, validate_delegation, AgentRole}; use crate::service_agent_runtime::CoreServiceAgentRuntime; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; use bitfun_agent_runtime::sdk::AgentRuntime; use bitfun_agent_runtime::session_control::{ - render_session_control_tool_use_message, resolve_session_control_cancel_route, - session_control_agent_type_or_default, session_control_cancel_result_message, - session_control_cancel_status, session_control_created_result_message, - session_control_creator_marker, session_control_deleted_result_message, - session_control_session_name_or_default, validate_session_control_input, validate_session_id, - SessionControlAction, SessionControlCancelRoute, SessionControlInput, - SessionControlValidationContext, SessionControlValidationResult, + compact_session_display_name, render_session_control_tool_use_message, + resolve_session_control_cancel_route, session_control_agent_type_or_default, + session_control_cancel_result_message, session_control_cancel_status, + session_control_created_result_message, session_control_creator_marker, + session_control_deleted_result_message, session_control_session_name_or_default, + validate_session_control_input, validate_session_id, SessionControlAction, + SessionControlCancelRoute, SessionControlInput, SessionControlValidationContext, + SessionControlValidationResult, }; use bitfun_core_types::SessionExecutionTarget; use bitfun_runtime_ports::{ - AgentSessionCreateRequest, AgentSessionDeleteRequest, AgentSessionListRequest, - AgentSessionSummary, AgentSessionWorkspaceBinding, AgentSessionWorkspaceRequest, - AgentSubmissionSource, AgentTurnCancellationRequest, + AcpClientCreateRequest, AcpClientCreateResult, AcpClientPort, AgentSessionCreateRequest, + AgentSessionListRequest, AgentSessionSummary, AgentSessionWorkspaceBinding, + AgentSessionWorkspaceRequest, AgentSubmissionSource, AgentTurnCancellationRequest, }; +use bitfun_services_core::session::merge_session_custom_metadata; +use bitfun_services_core::session::tree::SessionTreeManager; use serde_json::{json, Value}; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::collections::HashMap; +use std::time::Duration; /// SessionControl tool - create, cancel, delete, or list persisted sessions +/// list: list persistent sessions created by SessionControl. +/// list_tasks: list child conversation sessions spawned by Task. pub struct SessionControlTool; const CANCEL_WAIT_TIMEOUT: Duration = Duration::from_secs(3); #[derive(Debug, Clone)] -struct SessionControlWorkspaceTarget { +pub(crate) struct SessionControlWorkspaceTarget { display_workspace: String, project_workspace: String, execution_target: Option, @@ -74,18 +82,6 @@ impl SessionControlTool { } } - fn escape_markdown_table_cell(value: &str) -> String { - value - .replace('\\', "\\\\") - .replace('|', "\\|") - .replace('\n', "
") - } - - fn format_system_time(time: SystemTime) -> String { - let datetime: chrono::DateTime = time.into(); - datetime.format("%Y-%m-%dT%H:%M:%S").to_string() - } - fn creator_session_marker(&self, context: &ToolUseContext) -> BitFunResult { let creator_session_id = context.session_id.as_ref().ok_or_else(|| { BitFunError::tool("create requires a creator session in tool context".to_string()) @@ -93,15 +89,47 @@ impl SessionControlTool { Ok(session_control_creator_marker(creator_session_id)) } + /// ACP 真会话创建:经 AcpClientPort 创建外部 ACP 流会话(返回 + /// `acp__` session id + `acp:` agent type),与前端 + /// `create_acp_flow_session` / desktop `AcpClientPort::create_session` 等价—— + /// 持久记录 + 启动外部进程 + 失败回滚(desktop acp_client_port.rs:97-149)。 + /// 不创建本地内部会话,因此不写入 createdBy/subagent 元数据、不持久化 + /// SessionRelationship、不挂军团树;军团侧持返回的 session_id 经 + /// SessionMessage 直通(acp: 流会话分叉)通信。 + async fn create_acp_session_via_port( + &self, + workspace: &SessionControlWorkspaceTarget, + client_id: &str, + session_name: Option, + port: &dyn AcpClientPort, + ) -> BitFunResult { + port.create_session(AcpClientCreateRequest { + client_id: client_id.to_string(), + workspace_path: workspace.display_workspace.clone(), + session_name, + remote_connection_id: workspace.remote_connection_id.clone(), + }) + .await + .map_err(|error| { + BitFunError::tool(format!( + "ACP client port failed ({:?}): {}", + error.kind, error.message + )) + }) + } + async fn resolve_effective_workspace( &self, action: SessionControlAction, session_id: Option<&str>, + workspace_param: Option<&str>, context: &ToolUseContext, runtime: &AgentRuntime, ) -> BitFunResult { match action { - SessionControlAction::Cancel | SessionControlAction::Delete => { + SessionControlAction::Cancel + | SessionControlAction::Delete + | SessionControlAction::Compact => { let session_id = session_id.ok_or_else(|| { BitFunError::tool(format!("session_id is required for {}", action.as_str())) })?; @@ -122,6 +150,19 @@ impl SessionControlTool { ))) } SessionControlAction::Create | SessionControlAction::List => { + // Explicit workspace parameter wins; fall back to the current + // workspace binding from context when omitted, so the tool can + // list/create across workspaces. + if let Some(workspace) = workspace_param { + return Ok(SessionControlWorkspaceTarget { + display_workspace: normalize_path(workspace), + project_workspace: normalize_path(workspace), + execution_target: None, + workspace_id: None, + remote_connection_id: None, + remote_ssh_host: None, + }); + } let workspace = context.workspace.as_ref().ok_or_else(|| { BitFunError::tool(format!( "workspace is required for {} when the current workspace is unavailable", @@ -133,7 +174,7 @@ impl SessionControlTool { } } - fn workspace_target_from_context( + pub(crate) fn workspace_target_from_context( workspace: &crate::agentic::WorkspaceBinding, ) -> SessionControlWorkspaceTarget { SessionControlWorkspaceTarget { @@ -184,6 +225,7 @@ impl SessionControlTool { } } + #[allow(dead_code)] async fn ensure_session_exists( &self, runtime: &AgentRuntime, @@ -195,6 +237,7 @@ impl SessionControlTool { workspace_path: workspace.project_workspace.clone(), remote_connection_id: workspace.remote_connection_id.clone(), remote_ssh_host: workspace.remote_ssh_host.clone(), + include_hidden: false, }) .await .map_err(|error| { @@ -213,15 +256,22 @@ impl SessionControlTool { } } - fn system_time_from_epoch_ms(epoch_ms: u64) -> SystemTime { - UNIX_EPOCH + Duration::from_millis(epoch_ms) - } - + /// Build the `result_for_assistant` text for the `list` action. + /// + /// Default (`detail == false`) is the compact tree output: one line per + /// session with `sessionId | agentType | status | compact name` so the + /// model context stays small even when session names are long task + /// descriptions. Full session names (and the JSON tree) are still + /// available through the `data` payload and through `detail == true`, + /// which preserves the legacy verbose tree output. fn build_list_result_for_assistant( &self, workspace: &str, sessions: &[AgentSessionSummary], current_session_id: Option<&str>, + tree: Option<&SessionTreeManager>, + short_names: &HashMap>, + detail: bool, ) -> String { if sessions.is_empty() { return format!("No sessions found in workspace '{}'.", workspace); @@ -237,24 +287,365 @@ impl SessionControlTool { lines.push(format!("Note: '{}' is your session_id", current_session_id)); lines.push(String::new()); } - lines.push( - "| session_id | session_name | agent_type | created_at | last_active_at |".to_string(), - ); - lines.push("| --- | --- | --- | --- | --- |".to_string()); - for session in sessions { - lines.push(format!( - "| {} | {} | {} | {} | {} |", - Self::escape_markdown_table_cell(&session.session_id), - Self::escape_markdown_table_cell(&session.session_name), - Self::escape_markdown_table_cell(&session.agent_type), - Self::format_system_time(Self::system_time_from_epoch_ms(session.created_at_ms)), - Self::format_system_time(Self::system_time_from_epoch_ms( - session.last_active_at_ms - )), - )); + + if detail { + // --- Full tree JSON view (legacy verbose output) --- + // The full `sessions` array and parsed `tree` remain available in the + // result `data` payload for programmatic consumers. + lines.push("## Session Tree (JSON)".to_string()); + lines.push("```json".to_string()); + lines.push(self.build_session_tree_json(sessions, tree)); + lines.push("```".to_string()); + } else { + // --- Compact tree text view (default) --- + lines.push("## Sessions (compact)".to_string()); + lines.push("format: [sessionId] agentType | status | name".to_string()); + lines.extend(build_compact_tree_lines(sessions, tree, short_names)); } lines.join("\n") } + + /// Build a JSON tree structure from the flat session list. + /// Sessions are grouped by `parent_session_id` into a forest of root nodes. + fn build_session_tree_json( + &self, + sessions: &[AgentSessionSummary], + tree: Option<&SessionTreeManager>, + ) -> String { + build_session_tree_json_impl(sessions, tree) + } +} + +/// Shared source for the agent_type enum of SessionControl/SessionMessage +/// create (and LegionControl load validation). +/// +/// Returns every agent id that can back a created session: builtin/user +/// subagents, project subagents of the current workspace, builtin/user modes +/// and ACP bridge agents (`acp__`). Unlike the TaskVisible query, +/// this deliberately includes Mode-category entries so external ACP +/// conversations are selectable; the create path validates the final value +/// through the registry anyway. +pub(crate) async fn get_available_agent_type_ids_for_creation( + context: Option<&ToolUseContext>, +) -> Vec { + use crate::agentic::agents::get_agent_registry; + let registry = get_agent_registry(); + let workspace_root = context.and_then(|ctx| ctx.workspace_root()); + registry.load_custom_agents(workspace_root).await; + registry + .get_agent_ids_for_session_creation(workspace_root) + .await +} + +/// R-26 / user-owner semantics: whether a calling session is exempt from the +/// R-2 created_by/ancestor authorization gate for session deletion. +/// +/// The human user's main session (Commander role) is the owner and may delete +/// any session, including orphaned or detached children whose lineage was +/// broken by an earlier external deletion. When the RBAC master switch is off, +/// the gate is bypassed entirely. +fn caller_is_owner_session(caller_session_id: &str) -> bool { + matches!( + get_session_role(caller_session_id), + Some(AgentRole::Commander) + ) || !crate::service::config::rbac_enabled() +} + +/// 判断一个 session id 是否为 ACP 流会话(`acp__`)。 +/// +/// ACP 流会话经 SessionControl `acp__` / ACP client port 创建,本地只持有 +/// provider=acp 的流会话记录(interfaces/acp session_persistence.rs), +/// **不写入 createdBy / SessionRelationship 等 SessionMetadata**。因此本地 +/// metadata 为空是 ACP 流会话的正常形态(不是损坏),delete 授权不能仅因 +/// metadata 缺失就拒绝清理。 +fn is_acp_flow_session_id(session_id: &str) -> bool { + session_id + .strip_prefix("acp_") + .is_some_and(|rest| !rest.is_empty() && !rest.starts_with('_')) +} + +/// P-06:幽灵 ACP 流会话删除授权判定。 +/// +/// 当目标会话 metadata 无 created_by(幽灵)且是 ACP 流会话时,授权放行——ACP +/// 流会话是外部进程记录,metadata 存在但 created_by/relationship 为空是其设计 +/// 形态(interfaces/acp session_persistence 创建时必写 metadata 文件);否则维持 +/// 原有 created_by 判定(metadata 完整时原样)。 +fn ghost_acp_delete_authorized(created_by_is_none: bool, acp_flow_session: bool) -> bool { + created_by_is_none && acp_flow_session +} + +/// Build the delete action result JSON. +/// Cascade child-deletion failures are surfaced as a structured list +/// (`cascade_failures`: `[{session_id, reason}, ...]`). Since the delete +/// action now cascades through `coordinator.delete_session_tree` with +/// all-or-nothing semantics, the list is always empty on success — any +/// member that cannot be deleted aborts the whole tree and surfaces as a +/// tool error instead. The field is kept for result-shape compatibility +/// with callers that parse the JSON contract. +fn build_delete_result_json( + session_id: &str, + workspace: &str, + cascade_failures: &[(String, String)], +) -> Value { + json!({ + "success": true, + "action": "delete", + "workspace": workspace, + "session_id": session_id, + "cascade_failures": cascade_failures + .iter() + .map(|(child_id, reason)| json!({ + "session_id": child_id, + "reason": reason, + })) + .collect::>(), + }) +} + +/// Build a JSON tree structure from the flat session list. +/// Sessions are grouped by `parent_session_id` into a forest of root nodes. +pub(crate) fn build_session_tree_json_impl( + sessions: &[AgentSessionSummary], + tree: Option<&SessionTreeManager>, +) -> String { + // children_by_parent: parent_session_id -> list of children + let mut children_by_parent: HashMap> = HashMap::new(); + let mut roots: Vec<&AgentSessionSummary> = Vec::new(); + // Sessions whose parent chain is fully filtered out (no surviving ancestor + // in this list). They are promoted to roots but flagged as orphaned. + let mut orphaned: std::collections::HashSet<&str> = std::collections::HashSet::new(); + + let known_ids: std::collections::HashSet<&str> = + sessions.iter().map(|s| s.session_id.as_str()).collect(); + + // R-19: resolve the effective parent of a session - the nearest ancestor + // present in this (possibly filtered) list. When the direct parent is + // filtered out (e.g. daemon/warden sessions), the child is re-hung onto the + // nearest surviving ancestor instead of being promoted to a fake root, + // which would break the lineage. The in-memory tree is used to walk past + // filtered sessions. + let resolve_effective_parent = |session: &AgentSessionSummary| -> Option { + let mut current = session.parent_session_id.clone()?; + loop { + if known_ids.contains(current.as_str()) { + return Some(current); + } + match tree.and_then(|tree| tree.get_parent(¤t)) { + Some(parent) => current = parent, + None => return None, + } + } + }; + + for session in sessions { + match resolve_effective_parent(session) { + Some(parent_id) => { + children_by_parent + .entry(parent_id) + .or_default() + .push(session); + } + None => { + if session.parent_session_id.is_some() { + // No surviving ancestor in this list — promote to a root + // but flag the broken lineage. + orphaned.insert(session.session_id.as_str()); + } + roots.push(session); + } + } + } + + /// Maximum recursion depth for tree serialization to prevent stack overflow. + /// Authoritative value in `bitfun_core_types::session_tree::MAX_TREE_SERIALIZE_DEPTH`. + const TREE_SERIALIZE_MAX_DEPTH: usize = + bitfun_core_types::session_tree::MAX_TREE_SERIALIZE_DEPTH; + + fn serialize_node( + session: &AgentSessionSummary, + children_by_parent: &HashMap>, + tree: Option<&SessionTreeManager>, + orphaned: &std::collections::HashSet<&str>, + recursion_depth: usize, + ) -> serde_json::Value { + let children: Vec = if recursion_depth >= TREE_SERIALIZE_MAX_DEPTH { + Vec::new() + } else { + children_by_parent + .get(session.session_id.as_str()) + .map(|list| { + let mut sorted = list.to_vec(); + sorted.sort_by_key(|s| s.created_at_ms); + sorted + .iter() + .map(|s| { + serialize_node( + s, + children_by_parent, + tree, + orphaned, + recursion_depth + 1, + ) + }) + .collect() + }) + .unwrap_or_default() + }; + + let depth = tree + .and_then(|t| t.get_depth(&session.session_id)) + .unwrap_or(0); + + let status = session + .status + .clone() + .unwrap_or_else(|| "active".to_string()); + + let mut map = serde_json::Map::new(); + map.insert("sessionId".to_string(), json!(session.session_id)); + map.insert("sessionName".to_string(), json!(session.session_name)); + map.insert("agentType".to_string(), json!(session.agent_type)); + map.insert("depth".to_string(), json!(depth)); + map.insert("status".to_string(), json!(status)); + if orphaned.contains(session.session_id.as_str()) { + map.insert("orphaned".to_string(), json!(true)); + } + map.insert("children".to_string(), json!(children)); + serde_json::Value::Object(map) + } + + // Sort roots by created_at_ms descending (newest first) + let mut sorted_roots = roots; + sorted_roots.sort_by_key(|s| std::cmp::Reverse(s.created_at_ms)); + + let forest: Vec = sorted_roots + .iter() + .map(|s| serialize_node(s, &children_by_parent, tree, &orphaned, 0)) + .collect(); + + serde_json::to_string_pretty(&forest).unwrap_or_else(|_| "[]".to_string()) +} + +/// Build the compact text tree used by the default `list` output: one line per +/// session with `sessionId | agentType | status | compact name`. The tree +/// shape mirrors [`build_session_tree_json_impl`] (same grouping, orphan +/// promotion, and sort orders); only the per-node rendering is text. +fn build_compact_tree_lines( + sessions: &[AgentSessionSummary], + tree: Option<&SessionTreeManager>, + short_names: &HashMap>, +) -> Vec { + // children_by_parent: parent_session_id -> list of children + let mut children_by_parent: HashMap> = HashMap::new(); + let mut roots: Vec<&AgentSessionSummary> = Vec::new(); + // 父链在本列表中无幸存祖先的会话:提升为根节点,但标记 orphaned(与 JSON 模式一致) + let mut orphaned: std::collections::HashSet<&str> = std::collections::HashSet::new(); + let known_ids: std::collections::HashSet<&str> = + sessions.iter().map(|s| s.session_id.as_str()).collect(); + + // R-19: resolve the effective parent of a session - the nearest ancestor + // present in this (possibly filtered) list. + let resolve_effective_parent = |session: &AgentSessionSummary| -> Option { + let mut current = session.parent_session_id.clone()?; + loop { + if known_ids.contains(current.as_str()) { + return Some(current); + } + match tree.and_then(|tree| tree.get_parent(¤t)) { + Some(parent) => current = parent, + None => return None, + } + } + }; + + for session in sessions { + match resolve_effective_parent(session) { + Some(parent_id) => { + children_by_parent + .entry(parent_id) + .or_default() + .push(session); + } + None => { + if session.parent_session_id.is_some() { + // 父链全部被过滤:提升为根节点,同时标记 orphaned(与 JSON 模式一致) + orphaned.insert(session.session_id.as_str()); + } + roots.push(session); + } + } + } + + fn compact_line( + session: &AgentSessionSummary, + short_names: &HashMap>, + orphaned: &std::collections::HashSet<&str>, + ) -> String { + let status = session + .status + .clone() + .unwrap_or_else(|| "active".to_string()); + let display_name = compact_session_display_name( + &session.session_name, + short_names + .get(&session.session_id) + .and_then(Option::as_deref), + ); + let orphan_marker = if orphaned.contains(session.session_id.as_str()) { + " (orphaned)" + } else { + "" + }; + format!( + "- [{}] {} | {} | {}{}", + session.session_id, session.agent_type, status, display_name, orphan_marker + ) + } + + fn collect_lines( + session: &AgentSessionSummary, + depth: usize, + children_by_parent: &HashMap>, + short_names: &HashMap>, + orphaned: &std::collections::HashSet<&str>, + lines: &mut Vec, + ) { + let indent = " ".repeat(depth); + lines.push(format!( + "{indent}{}", + compact_line(session, short_names, orphaned) + )); + if let Some(children) = children_by_parent.get(session.session_id.as_str()) { + let mut sorted = children.to_vec(); + sorted.sort_by_key(|s| s.created_at_ms); + for child in sorted { + collect_lines( + child, + depth + 1, + children_by_parent, + short_names, + orphaned, + lines, + ); + } + } + } + + let mut sorted_roots = roots; + sorted_roots.sort_by_key(|s| std::cmp::Reverse(s.created_at_ms)); + + let mut lines = Vec::new(); + for root in sorted_roots { + collect_lines( + root, + 0, + &children_by_parent, + short_names, + &orphaned, + &mut lines, + ); + } + lines } #[async_trait] @@ -268,15 +659,22 @@ impl Tool for SessionControlTool { r#"Manage persisted workspace-scoped agent sessions. Actions: -- "create": Create a new session. You may optionally provide session_name and agent_type. +- "create": Create a new session. You may optionally provide session_name, short_name and agent_type. - "cancel": Cancel the target session's currently running dialog turn. This does not delete the session or clear any queued messages that may still run later. - "delete": Delete an existing session by session_id. -- "list": List all sessions. +- "list": List all sessions. Sessions are displayed in a tree structure showing parent-child relationships (created via Task tool). By default the output is compact (sessionId | agentType | status | short name); pass "detail": true to expand the full session tree including full session names. + +Related tools: +- Use Task (spawn) to launch subagents that appear as children in the session tree. +- Use SessionMessage to send messages to existing sessions. +- Use SessionHistory to export a session transcript. Arguments: -- "workspace": Absolute workspace path. Required for create and list. Ignored for cancel and delete. +- "workspace": Absolute workspace path. Optional for create and list; defaults to the current workspace when omitted. Ignored for cancel and delete. - "session_name": Only used by create. Defaults to "New Session". -- "agent_type": Only used by create. Defaults to "agentic". +- "short_name": Only used by create. Optional compact display name (e.g. "secretary-standing"); it becomes the name shown in the compact list output, keeping the model context small. Ignored for ACP flow sessions. +- "detail": Only used by list. When true, the full session tree with full session names is returned instead of the compact output. Defaults to false. +- "agent_type": Only used by create. Defaults to "agentic". Allowed values are dynamically resolved from the available agent registry (common values include "agentic", "Plan", "Cowork", "DeepResearch", and any custom/external subagent types). Use "acp__" to create a real external ACP agent session: the external client process is started immediately (same shape as the frontend create_acp_flow_session path). - "agentic": Coding-focused agent for implementation, debugging, and code changes. - "Plan": Planning agent for clarifying requirements and producing an implementation plan before coding. - "Cowork": Collaborative agent for office-style work such as research, documentation, presentations, etc. @@ -305,7 +703,53 @@ Arguments: }, "workspace": { "type": "string", - "description": "Required absolute workspace path for create and list. Ignored for cancel and delete." + "description": "Optional absolute workspace path for create and list; defaults to the current workspace when omitted. Ignored for cancel and delete." + }, + "session_id": { + "type": "string", + "description": "Required for cancel and delete." + }, + "session_name": { + "type": "string", + "description": "Optional display name when creating a session." + }, + "short_name": { + "type": "string", + "description": "Optional compact display name when creating a session (used by compact list output; ignored for ACP flow sessions)." + }, + "detail": { + "type": "boolean", + "description": "When true, list returns the full session tree with full session names instead of the compact output." + }, + "agent_type": { + "type": "string", + "description": "Optional agent type when creating a session (defaults to \"agentic\"). Valid values are dynamically resolved from the available agent registry. Use \"acp__\" to create a real external ACP agent session (the external client process starts immediately)." + }, + "model_id": { + "type": "string", + "description": "Optional model id used when creating a session; the created session binds to this model." + } + }, + "required": ["action"], + "additionalProperties": false + }) + } + + /// Dynamically resolves allowed agent_type values from the agent registry. + async fn input_schema_for_model_with_context(&self, context: Option<&ToolUseContext>) -> Value { + let agent_type_ids = get_available_agent_type_ids_for_creation(context).await; + let agent_type_enum: Vec<&str> = agent_type_ids.iter().map(|s| s.as_str()).collect(); + json!({ + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["create", "cancel", "delete", "list"], + "description": "The session action to perform." + }, + "workspace": { + "type": "string", + "description": "Optional absolute workspace path for create and list; defaults to the current workspace when omitted. Ignored for cancel and delete." }, "session_id": { "type": "string", @@ -315,10 +759,22 @@ Arguments: "type": "string", "description": "Optional display name when creating a session." }, + "short_name": { + "type": "string", + "description": "Optional compact display name when creating a session (used by compact list output; ignored for ACP flow sessions)." + }, + "detail": { + "type": "boolean", + "description": "When true, list returns the full session tree with full session names instead of the compact output." + }, "agent_type": { "type": "string", - "enum": ["agentic", "Plan", "Cowork", "DeepResearch"], - "description": "Optional agent type when creating a session. Defaults to agentic." + "enum": agent_type_enum, + "description": "Optional agent type when creating a session. Defaults to \"agentic\". Use \"acp__\" to create a real external ACP agent session (the external client process starts immediately)." + }, + "model_id": { + "type": "string", + "description": "Optional model id used when creating a session; the created session binds to this model." } }, "required": ["action"], @@ -375,16 +831,101 @@ Arguments: .resolve_effective_workspace( SessionControlAction::Create, None, + params.workspace.as_deref(), context, &runtime, ) .await?; + // R-14 B3: role-based delegation validation (fast fail). The + // SessionControl create chain registers the new session with the + // creator's role (B2), so the target is the inherited role; this + // is a defensive check that stays permissive today and guards a + // future explicit target-role channel from over-delegation. + let creator_role = context.session_id.as_deref().and_then(get_session_role); + let target_role = creator_role.clone().unwrap_or(AgentRole::Commander); + validate_delegation(creator_role, target_role)?; let session_name = session_control_session_name_or_default(params.session_name.as_deref()); let agent_type = session_control_agent_type_or_default(params.agent_type.as_ref()); + + // ACP 真会话路径:agent_type `acp__`(ACP bridge agent + // registry id,见 AcpAgent::agent_id_for)直接经 AcpClientPort 创建 + // 真外部 ACP 会话——与前端 create_acp_flow_session 等价(持久记录 + + // 进程启动 + 失败回滚),不再创建本地内部中转壳会话。流会话记录只存 + // provider/acpClientId 等 ACP 元数据(interfaces/acp session_persistence.rs:57-64), + // 不支持 createdBy/sessionKind=subagent 与军团树挂载(lineage/ + // register_child);军团侧持返回的 session_id 经 SessionMessage + // 直通(acp: 流会话分叉)通信。 + if let Some(client_id) = agent_type + .strip_prefix(AcpAgent::agent_id_prefix()) + .filter(|client_id| !client_id.trim().is_empty()) + { + let port = coordinator.acp_client_port().ok_or_else(|| { + BitFunError::tool( + "ACP client port is not available; the desktop host did not inject it" + .to_string(), + ) + })?; + let created = self + .create_acp_session_via_port( + &workspace, + client_id, + params.session_name.clone(), + port.as_ref(), + ) + .await?; + let result_for_assistant = session_control_created_result_message( + &created.session_id, + &workspace.display_workspace, + &created.agent_type, + ); + return Ok(vec![ToolResult::Result { + data: json!({ + "success": true, + "action": "create", + "workspace": workspace.display_workspace.clone(), + "session": { + "session_id": created.session_id, + "session_name": created.session_name, + "agent_type": created.agent_type, + } + }), + result_for_assistant: Some(result_for_assistant), + image_attachments: None, + }]); + } + + // SESSION-01: create 前用 find_agent_entry(经 get_agent 公共包装)校验 + // agent_type:未在 agent registry 注册的类型直接拒绝,避免任意字符串 + // 进入 create_session 形成僵尸会话。 + { + let registry = get_agent_registry(); + let workspace_path = std::path::Path::new(&workspace.display_workspace); + registry.load_custom_agents(Some(workspace_path)).await; + if registry.get_agent(&agent_type, Some(workspace_path)).is_none() { + return Err(BitFunError::tool(format!( + "Unknown agent_type '{}' for SessionControl create; agent must be registered in the agent registry", + agent_type + ))); + } + } + let created_by = self.creator_session_marker(context)?; let mut metadata = serde_json::Map::new(); metadata.insert("createdBy".to_string(), json!(created_by)); + // SessionControl-created sessions are subagent sessions: force a 1M + // context window and keep it stable across model-window refresh. + metadata.insert("subagent".to_string(), json!(true)); + // Lineage facts forwarded through the free-form metadata map so the + // SessionCreated event can carry the parent relationship. The + // coordinator reads these keys defensively before emitting + // (parent_session_id / subagent_type), keeping the event contract + // in sync with the persisted SessionRelationship written below. + metadata.insert( + "parentSessionId".to_string(), + json!(context.session_id.clone()), + ); + metadata.insert("subagentType".to_string(), json!(agent_type.clone())); let session = runtime .create_session(AgentSessionCreateRequest { session_name, @@ -395,7 +936,7 @@ Arguments: workspace_id: workspace.workspace_id.clone(), remote_connection_id: workspace.remote_connection_id.clone(), remote_ssh_host: workspace.remote_ssh_host.clone(), - model_id: None, + model_id: params.model_id.clone(), metadata, }) .await @@ -405,6 +946,130 @@ Arguments: let created_session_id = session.session_id.clone(); let created_session_name = session.session_name.clone(); let created_agent_type = session.agent_type.clone(); + let created_model_id = session.model_id.clone(); + + // --- R-001/R-002: write SessionRelationship, depth inherited from parent --- + { + use bitfun_services_core::session::types::{ + SessionRelationship, SessionRelationshipKind, + }; + let parent_session_id = context.session_id.clone(); + // Read parent depth from persisted metadata, default 0 for root + let parent_depth = if let Some(ref pid) = parent_session_id { + coordinator + .session_manager + .load_session_metadata( + &std::path::PathBuf::from(&workspace.project_workspace), + pid, + ) + .await + .ok() + .flatten() + .and_then(|m| m.relationship.and_then(|r| r.depth)) + .unwrap_or(0u32) + } else { + 0u32 + }; + let child_depth = parent_depth + 1; + // Guard against exceeding max depth (same as Task tool depth guard) + let max_depth = coordinator.session_tree().max_depth; + if child_depth > max_depth { + return Err(BitFunError::tool(format!( + "Session depth limit reached: child depth {} would exceed max allowed depth {}", + child_depth, max_depth + ))); + } + let relationship = SessionRelationship { + kind: Some(SessionRelationshipKind::Subagent), + parent_session_id, + depth: Some(child_depth), + ..Default::default() + }; + // SESSION-03: lineage 持久化失败会让重启后的子会话成为孤儿节点。 + // 先重试一次以吸收瞬时 IO 故障;仍失败则回滚已创建的子会话, + // 确保不留下无父子关系记录的孤儿会话(绝不静默降级为 log)。 + let mut lineage_result = coordinator + .session_manager + .persist_session_lineage(&created_session_id, relationship.clone()) + .await; + if lineage_result.is_err() { + log::warn!( + "SessionControl create: lineage persist failed for {}, retrying once: {:?}", + created_session_id, + lineage_result.as_ref().err() + ); + lineage_result = coordinator + .session_manager + .persist_session_lineage(&created_session_id, relationship) + .await; + } + if let Err(e) = lineage_result { + // 回滚创建:删除刚创建的子会话;回滚自身失败时仍要上报, + // 让调用方知道存在未被清理的会话。 + if let Err(rollback_error) = coordinator + .delete_session( + std::path::Path::new(&workspace.project_workspace), + &created_session_id, + ) + .await + { + log::error!( + "SessionControl create: lineage persist failed for {} ({:?}), rollback of session also failed: {:?}", + created_session_id, e, rollback_error + ); + } + return Err(BitFunError::tool(format!( + "failed to persist session lineage for {} after retry: {}", + created_session_id, e + ))); + } + + // R-003: Register in memory tree + if let Some(ref pid) = context.session_id { + if let Err(e) = coordinator.session_tree().register_child( + pid, + &created_session_id, + child_depth, + ) { + log::warn!( + "SessionControl create: failed to register child {} under {} in tree: {:?}", + created_session_id, pid, e + ); + } + } + + // Short name persistence: write `shortName` into the session + // custom metadata (same best-effort pattern as the RBAC role + // persistence) so the compact `list` output can show it + // without pulling the full session name into the context. + if let Some(short_name) = params + .short_name + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + if let Err(e) = coordinator + .session_manager + .update_session_metadata( + &std::path::PathBuf::from(&workspace.project_workspace), + &created_session_id, + |metadata| { + merge_session_custom_metadata( + metadata, + serde_json::json!({ "shortName": short_name }), + ); + }, + ) + .await + { + log::warn!( + "SessionControl create: failed to persist short name for {}: {:?}", + created_session_id, + e + ); + } + } + } let result_for_assistant = session_control_created_result_message( &created_session_id, &workspace.display_workspace, @@ -420,6 +1085,7 @@ Arguments: "session_id": created_session_id, "session_name": created_session_name, "agent_type": created_agent_type, + "model_id": created_model_id, } }), result_for_assistant: Some(result_for_assistant), @@ -435,6 +1101,7 @@ Arguments: .resolve_effective_workspace( SessionControlAction::Cancel, Some(session_id), + None, context, &runtime, ) @@ -447,8 +1114,120 @@ Arguments: )); } - self.ensure_session_exists(&runtime, &workspace, session_id) - .await?; + // R-A.04: Reject cancellation of daemon sessions. + { + let session_manager = coordinator.get_session_manager(); + let is_daemon = if let Some(session) = session_manager.get_session(session_id) { + session.config.is_daemon || session.agent_type.starts_with("warden-") + } else { + // Fall back to persisted metadata + session_manager + .load_session_metadata( + &std::path::PathBuf::from(&workspace.project_workspace), + session_id, + ) + .await + .ok() + .flatten() + .map(|m| m.is_daemon || m.agent_type.starts_with("warden-")) + .unwrap_or(false) + }; + if is_daemon { + return Err(BitFunError::tool(format!( + "cannot cancel daemon/warden session '{session_id}'" + ))); + } + } + + // R-011: Skip list-based pre-check so subagent (Task) sessions can be cancelled. + // The runtime's cancel_turn handles session-existence internally. + + // R-2: Authorization intentionally widened for full conversation + // management: a caller may cancel a session it created (created_by + // marker matches) OR any session in its descendant subtree. The + // "cannot cancel the current session" guard above is preserved. + let current_session_id = context.session_id.as_ref().ok_or_else(|| { + BitFunError::tool( + "cannot cancel a session without a caller session in tool context" + .to_string(), + ) + })?; + let created_by_match = { + let session_manager = coordinator.get_session_manager(); + let target_metadata = session_manager + .load_session_metadata( + &std::path::PathBuf::from(&workspace.project_workspace), + session_id, + ) + .await + .ok() + .flatten(); + target_metadata + .as_ref() + .and_then(|metadata| metadata.created_by.as_deref()) + .is_some_and(|creator| { + creator == session_control_creator_marker(current_session_id) + }) + }; + if !created_by_match { + // Ancestor authorization: verify the calling session is an + // ancestor of the target session. First try the in-memory tree + // (fast path). If the tree is not yet populated (walk_ancestors + // returns empty), fall back to a persisted metadata chain query + // so that an empty tree cannot be exploited to bypass + // authorization. + let tree = coordinator.session_tree(); + let tree_ancestors = tree.walk_ancestors(session_id); + let ancestors: Vec = if !tree_ancestors.is_empty() { + // Fast path: tree is populated. + tree_ancestors + } else { + // Fallback: tree is empty, walk persisted metadata chain. + // Known optimization: could use batched queries instead of awaiting each ancestor session serially. + let session_manager = coordinator.get_session_manager(); + let mut metadata_ancestors = Vec::new(); + // Guard against cyclic metadata chains: never revisit a + // session id already seen during this walk. + let mut visited = std::collections::HashSet::new(); + visited.insert(session_id.to_string()); + let mut current = session_id.to_string(); + loop { + let metadata = session_manager + .load_session_metadata( + &std::path::PathBuf::from(&workspace.project_workspace), + ¤t, + ) + .await + .ok() + .flatten(); + match metadata + .and_then(|m| m.relationship.and_then(|r| r.parent_session_id)) + { + Some(parent_id) => { + if !visited.insert(parent_id.clone()) { + // Cycle detected; stop walking to avoid + // hanging on a corrupt lineage chain. + break; + } + metadata_ancestors.push(parent_id.clone()); + current = parent_id; + } + None => break, + } + } + metadata_ancestors + }; + if ancestors.is_empty() { + return Err(BitFunError::tool(format!( + "cannot verify ancestor relationship for session '{session_id}': tree and metadata are both empty" + ))); + } + if !ancestors.contains(current_session_id) { + return Err(BitFunError::tool(format!( + "session '{current_session_id}' is not authorized to cancel session '{session_id}': not a parent/ancestor and not the creator" + ))); + } + } let scheduler = get_global_scheduler(); let cancel_route = resolve_session_control_cancel_route( @@ -521,6 +1300,7 @@ Arguments: .resolve_effective_workspace( SessionControlAction::Delete, Some(session_id), + None, context, &runtime, ) @@ -533,37 +1313,174 @@ Arguments: )); } - self.ensure_session_exists(&runtime, &workspace, session_id) - .await?; + // R-A.04: Reject deletion of daemon sessions. + { + let session_manager = coordinator.get_session_manager(); + let is_daemon = if let Some(session) = session_manager.get_session(session_id) { + session.config.is_daemon || session.agent_type.starts_with("warden-") + } else { + // Fall back to persisted metadata + session_manager + .load_session_metadata( + &std::path::PathBuf::from(&workspace.project_workspace), + session_id, + ) + .await + .ok() + .flatten() + .map(|m| m.is_daemon || m.agent_type.starts_with("warden-")) + .unwrap_or(false) + }; + if is_daemon { + return Err(BitFunError::tool(format!( + "cannot delete daemon/warden session '{session_id}'" + ))); + } + } - let scheduler = get_global_scheduler().ok_or_else(|| { - BitFunError::tool("scheduler not initialized for session deletion".to_string()) + // coordinator.delete_session() handles session-existence internally; + // skipping the list-based pre-check so subagent (Task) sessions are supported. + + // R-2: Authorization intentionally widened for full conversation + // management: a caller may delete a session it created (created_by + // marker matches) OR any session in its descendant subtree. The + // "cannot delete the current session" and "cannot delete + // daemon/warden" guards above are preserved. + let current_session_id = context.session_id.as_ref().ok_or_else(|| { + BitFunError::tool( + "cannot delete a session without a caller session in tool context" + .to_string(), + ) })?; - let deletion_runtime = CoreServiceAgentRuntime::agent_runtime_with_scheduler_ports( - coordinator.clone(), - scheduler, - ) - .map_err(BitFunError::tool)?; - - deletion_runtime - .delete_session(AgentSessionDeleteRequest { - workspace_path: workspace.project_workspace.clone(), - session_id: session_id.to_string(), - remote_connection_id: workspace.remote_connection_id.clone(), - remote_ssh_host: workspace.remote_ssh_host.clone(), - }) + + // R-26 / user-owner semantics: the human user's main session + // (Commander role) is the owner and may delete any session, + // including orphaned or detached children whose lineage was + // broken by an earlier external deletion. When the RBAC master + // switch is off, the authorization gate is bypassed entirely. + let caller_is_owner = caller_is_owner_session(current_session_id); + let acp_flow_session = is_acp_flow_session_id(session_id); + let created_by_match = { + let session_manager = coordinator.get_session_manager(); + let target_metadata = session_manager + .load_session_metadata( + &std::path::PathBuf::from(&workspace.project_workspace), + session_id, + ) + .await + .ok() + .flatten(); + // P-06:幽灵 ACP 流会话(metadata 无 created_by 即幽灵)按 owner + // 语义授权删除(ACP 流会话创建必写 metadata 文件但 created_by 为空 + // 是其设计形态,见 interfaces/acp session_persistence);否则维持原 + // created_by 判定(metadata 完整时原样)。 + if ghost_acp_delete_authorized( + target_metadata + .as_ref() + .and_then(|metadata| metadata.created_by.as_deref()) + .is_none(), + acp_flow_session, + ) { + true + } else { + target_metadata + .as_ref() + .and_then(|metadata| metadata.created_by.as_deref()) + .is_some_and(|creator| { + creator == session_control_creator_marker(current_session_id) + }) + } + }; + if !caller_is_owner && !created_by_match { + // Ancestor authorization: verify the calling session is an + // ancestor of the target session. First try the in-memory tree + // (fast path). If the tree is not yet populated (walk_ancestors + // returns empty), fall back to a persisted metadata chain query + // so that an empty tree cannot be exploited to bypass + // authorization. + let tree = coordinator.session_tree(); + let tree_ancestors = tree.walk_ancestors(session_id); + let ancestors: Vec = if !tree_ancestors.is_empty() { + // Fast path: tree is populated. + tree_ancestors + } else { + // Fallback: tree is empty, walk persisted metadata chain. + // Known optimization: could use batched queries instead of awaiting each ancestor session serially. + let session_manager = coordinator.get_session_manager(); + let mut metadata_ancestors = Vec::new(); + // Guard against cyclic metadata chains: never revisit a + // session id already seen during this walk. + let mut visited = std::collections::HashSet::new(); + visited.insert(session_id.to_string()); + let mut current = session_id.to_string(); + loop { + let metadata = session_manager + .load_session_metadata( + &std::path::PathBuf::from(&workspace.project_workspace), + ¤t, + ) + .await + .ok() + .flatten(); + match metadata + .and_then(|m| m.relationship.and_then(|r| r.parent_session_id)) + { + Some(parent_id) => { + if !visited.insert(parent_id.clone()) { + // Cycle detected; stop walking to avoid + // hanging on a corrupt lineage chain. + break; + } + metadata_ancestors.push(parent_id.clone()); + current = parent_id; + } + None => break, + } + } + metadata_ancestors + }; + if ancestors.is_empty() { + return Err(BitFunError::tool(format!( + "cannot verify ancestor relationship for session '{session_id}': tree and metadata are both empty" + ))); + } + if !ancestors.contains(current_session_id) { + return Err(BitFunError::tool(format!( + "session '{current_session_id}' is not authorized to delete session '{session_id}': not a parent/ancestor and not the creator" + ))); + } + } + + // R-012: Cascade-delete the full descendant subtree through + // `coordinator.delete_session_tree`, the same all-or-nothing + // path used by the frontend UI delete. It pre-checks every + // member (a processing or daemon/warden session anywhere in + // the tree rejects the whole cascade) and deletes children + // before the parent. The previous per-child failure-tolerant + // loop could return success while a running child session + // stayed on disk, which then resurrected as a ghost child + // session on the next restart (ghost-session root cause R2); + // the tree path aborts instead and reports which member is + // not deletable. Deletion of a daemon/warden session was + // already rejected above; the tree path enforces the same + // guard for every member. + coordinator + .delete_session_tree( + std::path::Path::new(&workspace.project_workspace), + workspace.remote_connection_id.as_deref(), + workspace.remote_ssh_host.as_deref(), + session_id, + ) .await .map_err(|error| { - BitFunError::tool(CoreServiceAgentRuntime::runtime_error_message(error)) + BitFunError::tool(format!( + "cannot delete session tree rooted at '{}': {}", + session_id, error + )) })?; Ok(vec![ToolResult::Result { - data: json!({ - "success": true, - "action": "delete", - "workspace": workspace.display_workspace.clone(), - "session_id": session_id, - }), + data: build_delete_result_json(session_id, &workspace.display_workspace, &[]), result_for_assistant: Some(session_control_deleted_result_message( session_id, &workspace.display_workspace, @@ -576,6 +1493,7 @@ Arguments: .resolve_effective_workspace( SessionControlAction::List, None, + params.workspace.as_deref(), context, &runtime, ) @@ -585,32 +1503,245 @@ Arguments: workspace_path: workspace.project_workspace.clone(), remote_connection_id: workspace.remote_connection_id.clone(), remote_ssh_host: workspace.remote_ssh_host.clone(), + // R-2: Full conversation management — include hidden + // Subagent/Ephemeral sessions; daemon/warden sessions + // are filtered below. + include_hidden: true, }) .await .map_err(|error| { BitFunError::tool(CoreServiceAgentRuntime::runtime_error_message(error)) })?; + + // Filter out daemon sessions (is_daemon=true or agent_type starts with "warden-") + let sessions: Vec<_> = sessions + .into_iter() + .filter(|s| !s.is_daemon && !s.agent_type.starts_with("warden-")) + .collect(); + + // Resolve compact short names from persisted session metadata + // (custom_metadata.shortName, written by create when a + // short_name argument was provided). Best-effort: sessions + // without metadata or without a shortName fall back to the + // truncated full name in the compact output. + // SESSION-06: 一次批量读取全部持久化元数据 + // (list_session_metadata_including_internal)再逐会话提取 + // shortName,替代原先对每个会话串行 load_session_metadata 的 + // N+1 读。 + let mut short_names: HashMap> = HashMap::new(); + let surfaced_session_ids: std::collections::HashSet<&str> = + sessions + .iter() + .map(|session| session.session_id.as_str()) + .collect(); + let metadata_list = match coordinator + .session_manager + .persistence_manager() + .list_session_metadata_including_internal( + &std::path::PathBuf::from(&workspace.project_workspace), + ) + .await + { + Ok(metadata_list) => metadata_list, + // 批量读取失败时按“无任何 shortName”处理(与原先逐条 + // .ok().flatten() 的最佳努力语义一致,不中断 list 输出)。 + Err(_) => Vec::new(), + }; + for metadata in metadata_list { + // 仅保留已过滤会话(daemon/warden 已在上方剔除)的 + // shortName,保持输出契约不变。 + if !surfaced_session_ids.contains(metadata.session_id.as_str()) { + continue; + } + let short_name = metadata + .custom_metadata + .as_ref() + .and_then(|custom| custom.get("shortName")) + .and_then(|value| value.as_str()) + .map(str::to_string); + short_names.insert(metadata.session_id, short_name); + } + + let detail = params.detail.unwrap_or(false); let current_session_id = self.current_workspace_session(context, &workspace.display_workspace); let result_for_assistant = self.build_list_result_for_assistant( &workspace.display_workspace, &sessions, current_session_id, + Some(coordinator.session_tree().as_ref()), + &short_names, + detail, ); + let tree_json = self + .build_session_tree_json(&sessions, Some(coordinator.session_tree().as_ref())); + let tree_value: Value = serde_json::from_str(&tree_json).unwrap_or(Value::Null); + + // SESSION-05: when detail=false, keep the machine-readable + // `data.sessions` payload compact too. Each session's `name` + // follows the same rule as the compact list lines: the short + // name wins, otherwise the full session name is truncated to + // 60 chars. The full sessions array stays available in the + // detail=true payload, which the legacy verbose tree view + // still relies on. + let data_sessions: Vec = if detail { + sessions + } else { + sessions + .iter() + .map(|session| AgentSessionSummary { + session_name: compact_session_display_name( + &session.session_name, + short_names + .get(&session.session_id) + .and_then(Option::as_deref), + ), + ..session.clone() + }) + .collect() + }; + Ok(vec![ToolResult::Result { data: json!({ "success": true, "action": "list", "workspace": workspace.display_workspace.clone(), "current_session_id": current_session_id, - "count": sessions.len(), - "sessions": sessions, + "count": data_sessions.len(), + "sessions": data_sessions, + "tree": tree_value, + "short_names": short_names, }), result_for_assistant: Some(result_for_assistant), image_attachments: None, }]) } + SessionControlAction::Compact => { + let session_id = params.session_id.as_deref().ok_or_else(|| { + BitFunError::tool("session_id is required for compact".to_string()) + })?; + validate_session_id(session_id).map_err(BitFunError::tool)?; + let workspace = self + .resolve_effective_workspace( + SessionControlAction::Compact, + Some(session_id), + None, + context, + &runtime, + ) + .await?; + + // 授权沿用 owner/ancestor/RBAC 语义(不新增放宽); + // Compact 额外允许压缩自己(含自己、含常驻 subagent 工位——契约)。 + let current_session_id = context.session_id.as_ref().ok_or_else(|| { + BitFunError::tool( + "cannot compact a session without a caller session in tool context" + .to_string(), + ) + })?; + let caller_is_owner = caller_is_owner_session(current_session_id); + let is_self = current_session_id == session_id; + let created_by_match = { + let session_manager = coordinator.get_session_manager(); + let target_metadata = session_manager + .load_session_metadata( + &std::path::PathBuf::from(&workspace.project_workspace), + session_id, + ) + .await + .ok() + .flatten(); + target_metadata + .as_ref() + .and_then(|metadata| metadata.created_by.as_deref()) + .is_some_and(|creator| { + creator == session_control_creator_marker(current_session_id) + }) + }; + if !caller_is_owner && !is_self && !created_by_match { + let tree = coordinator.session_tree(); + let tree_ancestors = tree.walk_ancestors(session_id); + let ancestors: Vec = if !tree_ancestors.is_empty() { + tree_ancestors + } else { + let session_manager = coordinator.get_session_manager(); + let mut metadata_ancestors = Vec::new(); + let mut visited = std::collections::HashSet::new(); + visited.insert(session_id.to_string()); + let mut current = session_id.to_string(); + loop { + let metadata = session_manager + .load_session_metadata( + &std::path::PathBuf::from(&workspace.project_workspace), + ¤t, + ) + .await + .ok() + .flatten(); + match metadata + .and_then(|m| m.relationship.and_then(|r| r.parent_session_id)) + { + Some(parent_id) => { + if !visited.insert(parent_id.clone()) { + break; + } + metadata_ancestors.push(parent_id.clone()); + current = parent_id; + } + None => break, + } + } + metadata_ancestors + }; + if ancestors.is_empty() { + return Err(BitFunError::tool(format!( + "cannot verify ancestor relationship for session '{session_id}': tree and metadata are both empty" + ))); + } + if !ancestors.contains(current_session_id) { + return Err(BitFunError::tool(format!( + "session '{current_session_id}' is not authorized to compact session '{session_id}': not a parent/ancestor and not the creator" + ))); + } + } + + // 幂等:无上下文/已压 → applied=false 不报错(由压缩执行层保证); + // 非 Idle 拒绝由 start_manual_compaction_task 内部校验并带原因。 + let outcome = coordinator + .compact_session_with_outcome(session_id.to_string()) + .await + .map_err(|error| { + BitFunError::tool(format!( + "cannot compact session '{session_id}': {}", + error + )) + })?; + + Ok(vec![ToolResult::Result { + data: json!({ + "success": true, + "action": "compact", + "workspace": workspace.display_workspace.clone(), + "session_id": session_id, + "applied": outcome.applied, + "tokens_before": outcome.tokens_before, + "tokens_after": outcome.tokens_after, + "compression_ratio": outcome.compression_ratio, + "duration": outcome.duration_ms, + "summary_source": if outcome.has_summary { + Some(outcome.summary_source) + } else { + None + }, + }), + result_for_assistant: Some(format!( + "Compacted session '{session_id}' in workspace '{}'.", + workspace.display_workspace + )), + image_attachments: None, + }]) + } } } } @@ -623,10 +1754,18 @@ mod tests { use bitfun_core_types::{ SessionExecutionTarget, SessionExecutionTargetKind, WorktreeLifecycle, }; + use bitfun_runtime_ports::{ + AcpClientBitfunMessageRequest, AcpClientCancelRequest, AcpClientHistoryRequest, + AcpClientHistoryResult, AcpClientListResult, AcpClientMessageRequest, + AcpClientMessageResult, AcpClientReleaseRequest, AcpClientStreamChunk, + AcpClientStreamChunkSink, PortError, PortErrorKind, PortResult, RuntimeServiceCapability, + RuntimeServicePort, + }; use serde_json::json; use std::collections::HashMap; use std::fs; use std::path::PathBuf; + use std::sync::Mutex; use uuid::Uuid; fn empty_context() -> ToolUseContext { @@ -645,6 +1784,178 @@ mod tests { } } + /// Minimal AcpClientPort fake: records create requests and returns the + /// same flow-session shape the desktop implementation produces + /// (`acp__` / `acp:`), with an optional failure flag + /// to exercise the error mapping. + #[derive(Debug, Default)] + struct FakeAcpClientPort { + created: Mutex>, + fail_create: Mutex, + } + + impl RuntimeServicePort for FakeAcpClientPort { + fn capability(&self) -> RuntimeServiceCapability { + RuntimeServiceCapability::AcpClient + } + } + + #[async_trait] + impl AcpClientPort for FakeAcpClientPort { + async fn create_session( + &self, + request: AcpClientCreateRequest, + ) -> PortResult { + if *self.fail_create.lock().unwrap() { + return Err(PortError::new( + PortErrorKind::Backend, + "simulated start failure", + )); + } + self.created.lock().unwrap().push(request.clone()); + Ok(AcpClientCreateResult { + session_id: format!("acp_{}_{}", request.client_id, "session-1"), + session_name: request + .session_name + .unwrap_or_else(|| format!("{} ACP", request.client_id)), + agent_type: format!("acp:{}", request.client_id), + }) + } + + async fn list_clients(&self) -> PortResult { + Ok(AcpClientListResult { clients: vec![] }) + } + + async fn release_session(&self, _request: AcpClientReleaseRequest) -> PortResult<()> { + Ok(()) + } + + async fn cancel_session(&self, _request: AcpClientCancelRequest) -> PortResult<()> { + Ok(()) + } + + async fn send_message( + &self, + _request: AcpClientMessageRequest, + ) -> PortResult { + Ok(AcpClientMessageResult { + session_id: String::new(), + response: String::new(), + }) + } + + async fn send_message_stream( + &self, + _request: AcpClientMessageRequest, + chunk_sink: AcpClientStreamChunkSink, + ) -> PortResult { + let _ = chunk_sink.send(AcpClientStreamChunk::Completed); + Ok(AcpClientMessageResult { + session_id: String::new(), + response: String::new(), + }) + } + + async fn send_message_to_bitfun_session( + &self, + _request: AcpClientBitfunMessageRequest, + ) -> PortResult { + Ok(AcpClientMessageResult { + session_id: String::new(), + response: String::new(), + }) + } + + async fn send_message_to_bitfun_session_stream( + &self, + _request: AcpClientBitfunMessageRequest, + chunk_sink: AcpClientStreamChunkSink, + ) -> PortResult { + let _ = chunk_sink.send(AcpClientStreamChunk::Completed); + Ok(AcpClientMessageResult { + session_id: String::new(), + response: String::new(), + }) + } + + async fn delete_session_record( + &self, + _session_id: String, + _workspace_path: Option, + ) -> PortResult<()> { + Ok(()) + } + + async fn read_history( + &self, + _request: AcpClientHistoryRequest, + ) -> PortResult { + Ok(AcpClientHistoryResult { + session_id: String::new(), + entries: vec![], + truncated: false, + }) + } + } + + fn acp_workspace_target() -> SessionControlWorkspaceTarget { + SessionControlWorkspaceTarget { + display_workspace: "/repo/project".to_string(), + project_workspace: "/repo/project".to_string(), + execution_target: None, + workspace_id: None, + remote_connection_id: None, + remote_ssh_host: None, + } + } + + #[tokio::test] + async fn acp_create_forwards_client_workspace_and_session_name() { + let port = FakeAcpClientPort::default(); + let created = SessionControlTool::new() + .create_acp_session_via_port( + &acp_workspace_target(), + "codebuddy", + Some("my acp".to_string()), + &port, + ) + .await + .expect("acp create should succeed"); + + let requests = port.created.lock().unwrap(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].client_id, "codebuddy"); + assert_eq!(requests[0].workspace_path, "/repo/project"); + assert_eq!(requests[0].session_name.as_deref(), Some("my acp")); + // 与前端 create_acp_flow_session 形态一致:acp__ / acp: + assert_eq!(created.session_id, "acp_codebuddy_session-1"); + assert_eq!(created.agent_type, "acp:codebuddy"); + } + + #[tokio::test] + async fn acp_create_keeps_service_default_session_name_when_omitted() { + let port = FakeAcpClientPort::default(); + let created = SessionControlTool::new() + .create_acp_session_via_port(&acp_workspace_target(), "codex", None, &port) + .await + .expect("acp create should succeed"); + + assert!(port.created.lock().unwrap()[0].session_name.is_none()); + assert_eq!(created.session_name, "codex ACP"); + } + + #[tokio::test] + async fn acp_create_maps_port_error_to_tool_error() { + let port = FakeAcpClientPort::default(); + *port.fail_create.lock().unwrap() = true; + let error = SessionControlTool::new() + .create_acp_session_via_port(&acp_workspace_target(), "codebuddy", None, &port) + .await + .expect_err("port failure must surface as a tool error"); + assert!(error.to_string().contains("ACP client port failed")); + assert!(error.to_string().contains("simulated start failure")); + } + struct TestTempDir { path: PathBuf, } @@ -825,4 +2136,443 @@ mod tests { assert_eq!(message, "Cancel active turn for session worker_1"); } + + // Cascade-failure surfacing (delete result JSON contract). + // Full end-to-end cascade execution requires a global coordinator and + // scheduler, which is not available in unit tests; these assert the + // serialization contract that the delete path relies on, including the + // session_id + reason shape for every failed child. + #[test] + fn delete_result_surfaces_cascade_failures() { + let failures = vec![ + ( + "child_1".to_string(), + "skipped: daemon/warden child session".to_string(), + ), + ("child_2".to_string(), "storage write failed".to_string()), + ]; + let result = build_delete_result_json("parent", "/repo", &failures); + + assert_eq!(result["success"], true); + assert_eq!(result["action"], "delete"); + assert_eq!(result["session_id"], "parent"); + let surfaced = result["cascade_failures"] + .as_array() + .expect("cascade_failures array"); + assert_eq!(surfaced.len(), 2); + assert_eq!(surfaced[0]["session_id"], "child_1"); + assert_eq!( + surfaced[0]["reason"], + "skipped: daemon/warden child session" + ); + assert_eq!(surfaced[1]["session_id"], "child_2"); + assert_eq!(surfaced[1]["reason"], "storage write failed"); + } + + #[test] + fn delete_result_has_empty_cascade_failures_when_clean() { + let result = build_delete_result_json("parent", "/repo", &[]); + let surfaced = result["cascade_failures"] + .as_array() + .expect("cascade_failures array present"); + assert!(surfaced.is_empty()); + } + + #[test] + fn commander_caller_is_owner_for_session_deletion() { + use crate::agentic::tools::restrictions::{clear_session_role, set_session_role}; + let _ = set_session_role("delete-owner-commander", AgentRole::Commander); + assert!( + caller_is_owner_session("delete-owner-commander"), + "the user's main session (Commander) may delete any session" + ); + clear_session_role("delete-owner-commander"); + } + + #[test] + fn unregistered_caller_degrades_to_non_owner_for_session_deletion() { + use crate::agentic::tools::restrictions::clear_session_role; + clear_session_role("delete-owner-unregistered"); + assert!( + !caller_is_owner_session("delete-owner-unregistered"), + "an unregistered caller must not bypass the R-2 authorization gate" + ); + } + + #[test] + fn executor_caller_is_not_owner_for_session_deletion() { + use crate::agentic::tools::restrictions::{clear_session_role, set_session_role}; + let _ = set_session_role("delete-owner-executor", AgentRole::Executor); + assert!( + !caller_is_owner_session("delete-owner-executor"), + "a subagent (Executor) must still pass the created_by/ancestor gate" + ); + clear_session_role("delete-owner-executor"); + } + + #[test] + fn reviewer_caller_is_not_owner_for_session_deletion() { + use crate::agentic::tools::restrictions::{clear_session_role, set_session_role}; + let _ = set_session_role("delete-owner-reviewer", AgentRole::Reviewer); + assert!(!caller_is_owner_session("delete-owner-reviewer")); + clear_session_role("delete-owner-reviewer"); + } + + #[test] + fn acp_flow_session_id_is_recognized() { + assert!(is_acp_flow_session_id("acp_codex_7f0e1a2b-3c4d-4e5f-8a9b-0c1d2e3f4a5b")); + assert!(is_acp_flow_session_id("acp_opensource_abcdef")); + assert!(!is_acp_flow_session_id("session-1")); + assert!(!is_acp_flow_session_id("acp__codex")); // agent type prefix, not a flow session id + assert!(!is_acp_flow_session_id("")); + } + + #[test] + fn ghost_acp_session_delete_is_authorized_when_created_by_empty() { + // P-06:幽灵 ACP 流会话——metadata 无 created_by(而非 metadata 文件缺失) + // + ACP 流会话 → 授权放行(ACP 流会话必写 metadata 文件,created_by 空是 + // 其设计形态)。 + assert!(ghost_acp_delete_authorized(true, true)); + // 其余组合保持原严格判定(不放行)。 + assert!(!ghost_acp_delete_authorized(false, true)); + assert!(!ghost_acp_delete_authorized(true, false)); + assert!(!ghost_acp_delete_authorized(false, false)); + } + + #[test] + fn ghost_acp_delete_bypasses_ancestor_gate_when_created_by_none() { + // 防回退:metadata 存在但 created_by=None + acp 前缀 → created_by_match=true, + // 删除不再落入 ancestor 前置校验(原报错点 :1422 'cannot verify ancestor' 不再可达)。 + let target_metadata = Some(crate::service::session::SessionMetadata::new( + "acp_codebuddy_a4f68de7-c4ec-46a8-9aab-7e2bc417c3d0".to_string(), + "codebuddy ACP".to_string(), + "acp:codebuddy".to_string(), + "auto".to_string(), + )); + let created_by_is_none = target_metadata + .as_ref() + .and_then(|metadata| metadata.created_by.as_deref()) + .is_none(); + assert!(created_by_is_none, "SessionMetadata::new 默认 created_by 应为 None"); + assert!(ghost_acp_delete_authorized( + created_by_is_none, + is_acp_flow_session_id("acp_codebuddy_a4f68de7-c4ec-46a8-9aab-7e2bc417c3d0"), + )); + } + + fn summary( + id: &str, + parent: Option<&str>, + is_daemon: bool, + created_at_ms: u64, + ) -> AgentSessionSummary { + AgentSessionSummary { + session_id: id.to_string(), + session_name: format!("Session {id}"), + agent_type: if is_daemon { + "warden-daemon".to_string() + } else { + "agentic".to_string() + }, + model_id: None, + reasoning_preset: None, + last_user_dialog_agent_type: None, + last_submitted_agent_type: None, + turn_count: 0, + created_at_ms, + last_active_at_ms: created_at_ms, + parent_session_id: parent.map(str::to_string), + status: Some("active".to_string()), + is_daemon, + } + } + + #[test] + fn tree_repairs_lineage_when_parent_filtered_out() { + // root <- daemon <- child; the daemon is filtered from the list, so the + // child must be re-hung onto root instead of becoming a fake root. + let tree = SessionTreeManager::new(8); + tree.register_child("root", "daemon", 1).unwrap(); + tree.register_child("daemon", "child", 2).unwrap(); + + let sessions = vec![ + summary("root", None, false, 1), + summary("child", Some("daemon"), false, 2), + summary("sibling", Some("root"), false, 3), + ]; + + let tree_json = build_session_tree_json_impl(&sessions, Some(&tree)); + let value: Value = serde_json::from_str(&tree_json).expect("valid tree json"); + let roots = value.as_array().expect("forest array"); + assert_eq!(roots.len(), 1, "single root after re-hang: {tree_json}"); + assert_eq!(roots[0]["sessionId"], "root"); + assert!(roots[0].get("orphaned").is_none()); + + let children = roots[0]["children"].as_array().unwrap(); + let child_ids: Vec<&str> = children + .iter() + .map(|c| c["sessionId"].as_str().unwrap()) + .collect(); + // children sorted by created_at_ms ascending: child(2) then sibling(3) + assert_eq!(child_ids, vec!["child", "sibling"]); + assert!(children[0].get("orphaned").is_none()); + assert_eq!( + children[0]["depth"], 2, + "depth comes from the real tree, not the filtered list" + ); + } + + #[test] + fn tree_rehangs_to_nearest_surviving_ancestor() { + // root <- daemon1 <- daemon2 <- child; both daemon layers are filtered, + // so the child must be re-hung onto root (the nearest surviving ancestor). + let tree = SessionTreeManager::new(8); + tree.register_child("root", "daemon1", 1).unwrap(); + tree.register_child("daemon1", "daemon2", 2).unwrap(); + tree.register_child("daemon2", "child", 3).unwrap(); + + let sessions = vec![ + summary("root", None, false, 1), + summary("child", Some("daemon2"), false, 2), + ]; + + let tree_json = build_session_tree_json_impl(&sessions, Some(&tree)); + let value: Value = serde_json::from_str(&tree_json).expect("valid tree json"); + let roots = value.as_array().unwrap(); + assert_eq!( + roots.len(), + 1, + "single root after multi-level re-hang: {tree_json}" + ); + assert_eq!(roots[0]["sessionId"], "root"); + let children = roots[0]["children"].as_array().unwrap(); + assert_eq!(children.len(), 1); + assert_eq!(children[0]["sessionId"], "child"); + assert!(children[0].get("orphaned").is_none()); + assert_eq!(children[0]["depth"], 3); + } + + #[test] + fn tree_marks_orphan_when_no_surviving_ancestor() { + // The parent chain is entirely unknown (no tree, parent not in list): + // the session is promoted to a root but flagged as orphaned. + let sessions = vec![ + summary("root", None, false, 1), + summary("child", Some("missing-parent"), false, 2), + ]; + + let tree_json = build_session_tree_json_impl(&sessions, None); + let value: Value = serde_json::from_str(&tree_json).expect("valid tree json"); + let roots = value.as_array().unwrap(); + assert_eq!(roots.len(), 2); + + let root_node = roots.iter().find(|r| r["sessionId"] == "root").unwrap(); + assert!(root_node.get("orphaned").is_none()); + + let orphan_node = roots.iter().find(|r| r["sessionId"] == "child").unwrap(); + assert_eq!(orphan_node["orphaned"], true); + } + + // --- short_name / detail / compact output --- + + #[tokio::test] + async fn validate_list_rejects_short_name() { + let tool = SessionControlTool::new(); + let workspace = TestTempDir::new("bitfun-session-control-tool-test"); + + let validation = tool + .validate_input( + &json!({ + "action": "list", + "workspace": workspace.as_string(), + "short_name": "secretary", + }), + Some(&empty_context()), + ) + .await; + + assert!(!validation.result); + assert_eq!( + validation.message.as_deref(), + Some("short_name is only allowed for create") + ); + } + + #[tokio::test] + async fn validate_list_allows_detail_flag() { + let tool = SessionControlTool::new(); + let workspace = TestTempDir::new("bitfun-session-control-tool-test"); + + let validation = tool + .validate_input( + &json!({ + "action": "list", + "workspace": workspace.as_string(), + "detail": true, + }), + Some(&empty_context()), + ) + .await; + + assert!(validation.result, "{:?}", validation.message); + } + + #[tokio::test] + async fn validate_cancel_rejects_detail_flag() { + let tool = SessionControlTool::new(); + + let validation = tool + .validate_input( + &json!({ + "action": "cancel", + "session_id": "worker_1", + "detail": true, + }), + Some(&empty_context()), + ) + .await; + + assert!(!validation.result); + assert_eq!( + validation.message.as_deref(), + Some("detail is only allowed for list") + ); + } + + #[tokio::test] + async fn validate_create_allows_short_name() { + let tool = SessionControlTool::new(); + let workspace = TestTempDir::new("bitfun-session-control-tool-test"); + let mut context = empty_context(); + context.session_id = Some("creator-1".to_string()); + + let validation = tool + .validate_input( + &json!({ + "action": "create", + "workspace": workspace.as_string(), + "short_name": "secretary-standing", + }), + Some(&context), + ) + .await; + + assert!(validation.result, "{:?}", validation.message); + } + + #[tokio::test] + async fn validate_create_rejects_detail_flag() { + let tool = SessionControlTool::new(); + let workspace = TestTempDir::new("bitfun-session-control-tool-test"); + let mut context = empty_context(); + context.session_id = Some("creator-1".to_string()); + + let validation = tool + .validate_input( + &json!({ + "action": "create", + "workspace": workspace.as_string(), + "detail": true, + }), + Some(&context), + ) + .await; + + assert!(!validation.result); + assert_eq!( + validation.message.as_deref(), + Some("detail is only allowed for list") + ); + } + + #[test] + fn compact_display_name_prefers_short_name_and_truncates() { + let long_name = "task-description".repeat(10); // 150 chars + assert_eq!( + compact_session_display_name("abc", Some("秘书·常驻")), + "秘书·常驻" + ); + assert_eq!(compact_session_display_name("abc", Some(" ")), "abc"); + + let truncated = compact_session_display_name(&long_name, None); + assert!(truncated.ends_with("...")); + assert_eq!(truncated.chars().count(), 60 + 3); + + assert_eq!( + compact_session_display_name("short name", None), + "short name" + ); + } + + #[test] + fn compact_list_uses_short_names_and_preserves_tree_indentation() { + let tool = SessionControlTool::new(); + let sessions = vec![ + summary("root", None, false, 1), + summary("child", Some("root"), false, 2), + ]; + let mut short_names = HashMap::new(); + short_names.insert("root".to_string(), Some("秘书·常驻".to_string())); + short_names.insert("child".to_string(), None); + + let output = tool.build_list_result_for_assistant( + "/repo", + &sessions, + None, + None, + &short_names, + false, + ); + + assert!(output.contains("[root] agentic | active | 秘书·常驻")); + assert!(output.contains(" - [child] agentic | active | Session child")); + assert!(output.contains("## Sessions (compact)")); + assert!(!output.contains("## Session Tree (JSON)")); + } + + #[test] + fn compact_list_truncates_long_session_names_without_short_name() { + let tool = SessionControlTool::new(); + let long_name = "派单提示词全文-".repeat(20); // 140 chars + let mut root = summary("root", None, false, 1); + root.session_name = long_name.clone(); + let sessions = vec![root]; + let short_names = HashMap::new(); + + let output = tool.build_list_result_for_assistant( + "/repo", + &sessions, + None, + None, + &short_names, + false, + ); + + assert!( + !output.contains(&long_name), + "full session name must be omitted" + ); + assert!(output.contains("...")); + assert!(output.contains("[root] agentic | active | ")); + } + + #[test] + fn detail_list_keeps_full_tree_json_output() { + let tool = SessionControlTool::new(); + let sessions = vec![summary("root", None, false, 1)]; + let short_names = HashMap::new(); + + let output = tool.build_list_result_for_assistant( + "/repo", + &sessions, + None, + None, + &short_names, + true, + ); + + assert!(output.contains("## Session Tree (JSON)")); + assert!(output.contains("\"sessionName\": \"Session root\"")); + assert!(output.contains("\"sessionId\": \"root\"")); + } } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/session_history_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/session_history_tool.rs index a6a7de379e..54b369bb4f 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/session_history_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/session_history_tool.rs @@ -69,6 +69,8 @@ Recommended workflow: Typical usage: - To review session history across a workspace, first use `SessionControl` to list the sessions in that workspace, then call this tool for the sessions you want to inspect. - To inspect the latest state of a specific session, call this tool with `turns=["-1:"]` to export only the last turn. +- Use `Task` to spawn subagent sessions whose history you may want to inspect. +- Use `SessionMessage` to send follow-up messages after reviewing a session's history. Minimal transcript example: diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs index a94aee7a0b..9d57003377 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs @@ -1,25 +1,40 @@ +use super::session_control_tool::get_available_agent_type_ids_for_creation; use super::util::normalize_path; +use crate::agentic::agents::AcpAgent; +use crate::agentic::coordination::plan_todo_binding::{ + PLAN_FILE_METADATA_KEY, TODO_ID_METADATA_KEY, +}; use crate::agentic::coordination::{ - get_global_coordinator, get_global_scheduler, DialogSubmissionPolicy, DialogTriggerSource, + get_global_coordinator, get_global_scheduler, ConversationCoordinator, DialogScheduler, + DialogSubmissionPolicy, DialogTriggerSource, }; +use crate::agentic::events::AgenticEvent; use crate::agentic::tools::framework::{ Tool, ToolExposure, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, }; +use crate::agentic::tools::restrictions::get_session_role; use crate::agentic::tools::workspace_paths::posix_style_path_is_absolute; use crate::service_agent_runtime::CoreServiceAgentRuntime; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; use bitfun_core_types::SessionExecutionTarget; use bitfun_runtime_ports::{ - AgentDialogPrependedReminder, AgentDialogTurnRequest, AgentSessionCreateRequest, - AgentSessionListRequest, AgentSessionReplyRoute, AgentSessionSummary, - AgentSessionWorkspaceBinding, AgentSessionWorkspaceRequest, + AcpClientBitfunMessageRequest, AcpClientMessageRequest, AcpClientMessageResult, AcpClientPort, + AcpClientStreamChunk, AcpClientStreamChunkSink, AgentDialogPrependedReminder, + AgentDialogSteerRequest, AgentDialogTurnPort, AgentDialogTurnRequest, AgentSessionCreateRequest, + AgentSessionListRequest, AgentSessionReplyRoute, AgentSessionSummary, AgentSessionWorkspaceBinding, + AgentSessionWorkspaceRequest, PortResult, }; use serde::Deserialize; use serde_json::{json, Value}; use std::path::Path; +use std::sync::Arc; +use std::time::Instant; +use log::{info, warn}; +use uuid::Uuid; -/// SessionMessage tool - send a message to another session via the dialog scheduler +/// Primary channel for legion communication. With a session_id, messages can be sent and received across conversations. +/// Obtain session_id via Task spawn or SessionControl list_tasks. pub struct SessionMessageTool; #[derive(Debug, Clone)] @@ -32,6 +47,81 @@ struct SessionMessageWorkspaceTarget { remote_ssh_host: Option, } +/// Source-session facts and global runtime handles shared by a single +/// dispatch and by every batch item. Built once per tool call so a batch +/// dispatch performs a single resource setup. +struct DispatchShared { + source_session_id: String, + source_workspace: String, + source_remote_connection_id: Option, + source_remote_ssh_host: Option, + coordinator: Arc, + scheduler: Arc, + runtime: bitfun_agent_runtime::sdk::AgentRuntime, +} + +/// Result of one create+send (or send-to-existing) dispatch. +struct DispatchOutcome { + target_session_id: String, + target_agent_type: String, + created_session_id: Option, + workspace_path: String, + delivery: &'static str, + result_text: String, + /// External response of the ACP direct path; `None` for local dispatches. + /// The ACP direct path now runs asynchronously, so this is always `None` + /// for ACP targets (the response streams back through events and the + /// follow-up reply instead). + acp_response: Option, +} + +/// Bounded window for background ACP direct deliveries (seconds). The old +/// direct path passed `timeout_seconds: None` (unbounded), which could hold +/// the tool call open indefinitely; the async delivery runs in a background +/// task with this 30-minute window instead (external agent long tasks such as +/// review/repair need the wider bound, while it stays bounded to avoid hangs). +const ACP_DIRECT_TIMEOUT_SECONDS: u64 = 1800; + +/// COORD-03 流会话注册表元数据键(权威源:interfaces/acp/src/client/ +/// session_persistence.rs:11-16 —— AcpSessionPersistence 创建流会话记录时 +/// 写入 provider/acpClientId 自定义元数据)。core 不依赖 ACP crate,以 +/// 字面量消费同一持久化契约。 +const ACP_FLOW_METADATA_PROVIDER_KEY: &str = "provider"; +const ACP_FLOW_METADATA_PROVIDER_VALUE: &str = "acp"; +const ACP_FLOW_METADATA_CLIENT_ID_KEY: &str = "acpClientId"; + +/// COORD-03 流会话注册表判定结果:会话 id 形状(`acp__`) +/// 只作线索,注册表记录才是「是否为活跃外部 ACP 流会话」的权威事实。 +#[derive(Debug, Clone, PartialEq, Eq)] +enum AcpFlowSessionRegistryStatus { + /// 注册表记录在册且 provider=acp:活跃外部 ACP 流会话(附记录中的 + /// client id,与形状解析出的 client id 必须一致)。 + Active { client_id: String }, + /// 注册表有记录但不是 ACP 流会话(例如内部会话的 id 恰巧命中形状)。 + NotAcpFlow, + /// 注册表中无记录:会话已被回收(delete_session_record)或从未创建。 + Missing, +} + +/// One of the two ACP direct send shapes: a flow session +/// (`acp__` addressed via `send_message`) or an internal +/// `acp__` session addressed via `send_message_to_bitfun_session`. +enum AcpDirectSendOp { + Flow(AcpClientMessageRequest), + Bitfun(AcpClientBitfunMessageRequest), +} + +/// Source-session facts captured for the follow-up reply of an ACP direct +/// delivery (AgentSessionReplyRoute semantics: the external response is +/// delivered back to the sender session as a follow-up). +#[derive(Debug, Clone)] +struct AcpDirectReplySource { + source_session_id: String, + source_workspace: String, + source_remote_connection_id: Option, + source_remote_ssh_host: Option, +} + impl Default for SessionMessageTool { fn default() -> Self { Self::new() @@ -47,7 +137,10 @@ impl SessionMessageTool { bitfun_core_types::validate_session_id(session_id) } - fn forwarded_user_input_metadata(context: &ToolUseContext) -> serde_json::Map { + fn forwarded_user_input_metadata( + context: &ToolUseContext, + sender: &SenderIdentity, + ) -> serde_json::Map { use bitfun_agent_runtime::user_questions::USER_INPUT_AVAILABLE_CONTEXT_KEY; let mut metadata = serde_json::Map::new(); @@ -60,6 +153,23 @@ impl SessionMessageTool { metadata.insert(USER_INPUT_AVAILABLE_CONTEXT_KEY.to_string(), value.clone()); } } + // Sender identity triple for UI badges on forwarded agent messages + // (R-23): every field degrades gracefully when unknown, so the badge + // renders with whatever is available and never blocks delivery. + metadata.insert("senderSessionId".to_string(), json!(sender.session_id)); + if let Some(role) = &sender.role { + metadata.insert("senderRole".to_string(), json!(role)); + } + if let Some(depth) = sender.depth { + metadata.insert("senderDepth".to_string(), json!(depth)); + } + if let Some(name) = sender + .name + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + metadata.insert("senderName".to_string(), json!(name)); + } metadata } @@ -251,55 +361,256 @@ impl SessionMessageTool { .map(|session| session.agent_type.clone()) } + /// Best-effort identity of the sending session: RBAC role (R-14 + /// SESSION_ROLES registry), session-tree depth (R-19), and display name + /// (session name, else agent type). Every field degrades gracefully when + /// unknown, so a forwarding send never fails because identity data is + /// missing. + #[allow(clippy::too_many_arguments)] + async fn resolve_sender_identity( + &self, + runtime: &bitfun_agent_runtime::sdk::AgentRuntime, + context: &ToolUseContext, + source_session_id: &str, + source_workspace: &str, + source_remote_connection_id: Option<&str>, + source_remote_ssh_host: Option<&str>, + coordinator: &ConversationCoordinator, + ) -> SenderIdentity { + let role = get_session_role(source_session_id) + .map(|agent_role| format_role_display(agent_role.as_str())); + let depth = coordinator.session_tree().get_depth(source_session_id); + let session_name = runtime + .list_sessions(AgentSessionListRequest { + workspace_path: source_workspace.to_string(), + remote_connection_id: source_remote_connection_id.map(ToOwned::to_owned), + remote_ssh_host: source_remote_ssh_host.map(ToOwned::to_owned), + include_hidden: false, + }) + .await + .ok() + .and_then(|sessions| { + sessions + .into_iter() + .find(|summary| summary.session_id == source_session_id) + .map(|summary| summary.session_name) + }) + .filter(|name| !name.trim().is_empty()); + let name = session_name.or_else(|| { + context + .agent_type + .as_deref() + .filter(|value| !value.trim().is_empty()) + .map(ToOwned::to_owned) + }); + SenderIdentity { + session_id: source_session_id.to_string(), + role, + depth, + name, + } + } + fn format_forwarded_message( &self, message: &str, + sender: &SenderIdentity, ) -> (String, Vec) { + let mut lines = vec![ + format!( + "This request was sent by {} (session {}), not the human user. Do not use interactive tools for this request. In particular, do not call AskUserQuestion.", + sender.display_label(), + sender.session_id + ), + format!("From session: {}", sender.session_id), + format!("From role: {}", sender.role.as_deref().unwrap_or("Agent")), + ]; + if let Some(depth) = sender.depth { + lines.push(format!("From depth: {depth}")); + } + if let Some(name) = sender + .name + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + lines.push(format!("From agent: {name}")); + } ( message.to_string(), vec![AgentDialogPrependedReminder { kind: "session_message_request".to_string(), - text: "This request was sent by another agent, not human user. Do not use interactive tools for this request. In particular, do not call AskUserQuestion." - .to_string(), + text: lines.join("\n"), }], ) } + } -#[derive(Debug, Clone, Deserialize)] -enum SessionMessageAgentType { - #[serde(rename = "agentic", alias = "Agentic", alias = "AGENTIC")] - Agentic, - #[serde(rename = "Plan", alias = "plan", alias = "PLAN")] - Plan, - #[serde(rename = "Cowork", alias = "cowork", alias = "COWORK")] - Cowork, - #[serde( - rename = "DeepResearch", - alias = "deepresearch", - alias = "DEEPRESEARCH" - )] - DeepResearch, +/// Identity of the session that sent a forwarded message. +#[derive(Debug, Clone, PartialEq)] +struct SenderIdentity { + /// Session id of the sender; always present. + session_id: String, + /// RBAC role display label (e.g. "Commander"), when registered. + role: Option, + /// Session-tree depth (0 means the root level L0), when known. + depth: Option, + /// Session name, or the agent type fallback, when available. + name: Option, } -impl SessionMessageAgentType { - fn as_str(&self) -> &'static str { - match self { - Self::Agentic => "agentic", - Self::Plan => "Plan", - Self::Cowork => "Cowork", - Self::DeepResearch => "DeepResearch", +impl SenderIdentity { + /// "[Commander L0]" when role and depth are known; "[Commander]" with role + /// only; "[Agent]" when no role is registered. Depth is omitted when unknown. + fn role_label(&self) -> String { + let role = self.role.as_deref().unwrap_or("Agent"); + match self.depth { + Some(depth) => format!("[{role} L{depth}]"), + None => format!("[{role}]"), + } + } + + /// "[Commander L0] Name (session abc)" or "[Agent] (session abc)" when the + /// display name is unavailable. + fn display_label(&self) -> String { + let mut label = self.role_label(); + if let Some(name) = self.name.as_deref().filter(|value| !value.trim().is_empty()) { + label.push(' '); + label.push_str(name); } + label } } +/// "commander" -> "Commander", "punishment_executor" -> "PunishmentExecutor". +fn format_role_display(role: &str) -> String { + role.split('_') + .map(|part| { + let mut chars = part.chars(); + match chars.next() { + Some(first) => { + let mut word = first.to_uppercase().to_string(); + word.push_str(chars.as_str()); + word + } + None => String::new(), + } + }) + .collect::>() + .join("") +} + +/// Lightweight UUID shape check (8-4-4-4-12, 36 chars) for the trailing +/// segment of an ACP flow session id (`acp__`). Kept +/// dependency-free so core does not need the uuid crate for this guard. +fn looks_like_uuid(segment: &str) -> bool { + segment.len() == 36 + && segment.bytes().enumerate().all(|(index, byte)| { + if matches!(index, 8 | 13 | 18 | 23) { + byte == b'-' + } else { + byte.is_ascii_hexdigit() + } + }) +} + +use bitfun_runtime_ports::AgentType; + #[derive(Debug, Clone, Deserialize)] struct SessionMessageInput { workspace: Option, session_id: Option, session_name: Option, + /// Top-level message for single-target dispatch. Mutually exclusive with + /// `batch`: when batch is present this field must be omitted or empty. + #[serde(default)] + message: Option, + agent_type: Option, + /// When true, deliver as an urgent mid-turn correction: if the target session + /// is currently processing, the message is injected into its running turn via + /// the UserSteering channel instead of starting a new turn. Falls back to + /// normal delivery when the target session is not processing. + #[serde(default)] + urgent: bool, + /// Optional plan-todo binding: when creating a new session, the dispatched + /// turn carries planFile/todoId in the forwarded metadata so the scheduler + /// auto-marks the plan todo (in_progress at turn start, completed when the + /// turn finishes with a Completed outcome). Only allowed when session_id is + /// omitted; both fields must be provided together. + #[serde(default)] + plan_file: Option, + #[serde(default)] + todo_id: Option, + /// Batch dispatch: perform multiple create+send (or send-to-existing) + /// operations in a single tool call. All items are validated up front (the + /// whole batch is rejected when any item is structurally invalid), then each + /// item executes sequentially and independently: a failed item never rolls + /// back already-succeeded items and never stops later items. The top-level + /// session fields (session_id/session_name/agent_type/urgent/plan_file/ + /// todo_id) must stay empty when batch is used; the top-level workspace is + /// shared by every item that creates a new session. + #[serde(default)] + batch: Option>, +} + +/// One create+send (or send-to-existing-session) operation inside a batch +/// dispatch. Fields mirror the top-level SessionMessageInput semantics, except +/// that the workspace is shared from the top level. +#[derive(Debug, Clone, Deserialize)] +struct BatchItem { + /// Optional target session ID. Omit it to create a new session (requires + /// session_name and agent_type; the top-level workspace is used). + session_id: Option, + /// Display name for a new session. Required when session_id is omitted. + session_name: Option, + /// Message to send to the target session. message: String, - agent_type: Option, + /// Agent type for a new session. Required when session_id is omitted. + agent_type: Option, + /// Per-item urgent delivery flag (same semantics as the top-level flag). + #[serde(default)] + urgent: bool, + /// Per-item plan-todo binding (only when session_id is omitted, and + /// requires todo_id). + #[serde(default)] + plan_file: Option, + /// Per-item todo id within plan_file (only when session_id is omitted, and + /// requires plan_file). + #[serde(default)] + todo_id: Option, +} + +/// Delivery decision for an urgent message against a target session. +#[derive(Debug, Clone, PartialEq)] +enum UrgentDelivery { + /// Target session is processing a turn; steer into the running turn. + Steer { turn_id: String }, + /// Target session is idle (or the turn ended); use normal submission. + NormalSubmit, +} + +fn resolve_urgent_delivery(processing_turn_id: Option) -> UrgentDelivery { + match processing_turn_id { + Some(turn_id) => UrgentDelivery::Steer { turn_id }, + None => UrgentDelivery::NormalSubmit, + } +} + +/// Dual-channel redundancy decision for urgent messages: +/// only attempt the steering channel when the message is urgent AND the target +/// session already exists (a brand-new session has no running turn to steer +/// into) AND the dispatch does not carry a plan-todo binding (the steering +/// channel carries no binding metadata, so a bound message falls back to the +/// normal submission channel that preserves the binding and the reply route — +/// COORD-01). Every other case uses the normal submission channel. When +/// steering is attempted but rejected, the caller falls back to the normal +/// channel, so one of the two channels always delivers the message. +fn should_attempt_steering( + urgent: bool, + created_session_id: Option<&str>, + has_plan_todo_binding: bool, +) -> bool { + urgent && created_session_id.is_none() && !has_plan_todo_binding } #[async_trait] @@ -315,8 +626,13 @@ impl Tool for SessionMessageTool { Usage: - Create a new session and send: omit "session_id", and provide "workspace", "session_name", "agent_type", and "message". - Reusing an existing session: provide "session_id" and "message". You may omit "workspace"; the tool will resolve it from the target session when possible. +- Urgent correction: set "urgent" to true to inject the message into the target session's running turn instead of waiting for a new turn. Requires "session_id". + +Use SessionControl (list) to discover existing sessions before sending messages. +Use SessionHistory to export a transcript of any session. +Use Task to spawn subagent sessions that can receive messages. -Allowed agent types when creating a session: +Allowed agent types when creating a session are dynamically resolved from the available agent registry (common values include "agentic", "Plan", "Cowork", "DeepResearch", and any custom/external subagent types). - "agentic": Coding-focused agent for implementation, debugging, and code changes. - "Plan": Planning agent for clarifying requirements and producing an implementation plan before coding. - "Cowork": Collaborative agent for office-style work such as research, documentation, presentations, etc. @@ -356,11 +672,146 @@ Allowed agent types when creating a session: }, "agent_type": { "type": "string", - "enum": ["agentic", "Plan", "Cowork", "DeepResearch"], + "description": "Required when session_id is omitted. Valid values are dynamically resolved from the available agent registry." + }, + "urgent": { + "type": "boolean", + "description": "When true, deliver as an urgent mid-turn correction: if the target session is processing, inject into its running turn via the UserSteering channel; otherwise fall back to normal delivery. Requires session_id." + }, + "plan_file": { + "type": "string", + "description": "Optional plan-todo binding for a created session (only when session_id is omitted, and requires todo_id): the plan file name or absolute path whose todo is auto-marked in_progress when the dispatched turn starts and completed when it finishes with a Completed outcome." + }, + "todo_id": { + "type": "string", + "description": "Optional todo id within plan_file for a created session (only when session_id is omitted, and requires plan_file)." + }, + "batch": { + "type": "array", + "description": "Batch dispatch: perform multiple create+send (or send-to-existing) operations in one tool call. Mutually exclusive with the top-level message and session fields; the top-level workspace is shared by items that create a session. All items validate up front; each item then runs independently (a failed item never rolls back succeeded ones). Item shape: {session_id?, session_name?, message, agent_type?, plan_file?, todo_id?, urgent?}.", + "items": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Optional target session ID. Omit it to create a new session." + }, + "session_name": { + "type": "string", + "description": "Required when session_id is omitted. Display name for the new session." + }, + "message": { + "type": "string", + "description": "Message to send to the target session." + }, + "agent_type": { + "type": "string", + "description": "Required when session_id is omitted. Agent type for the new session." + }, + "urgent": { + "type": "boolean", + "description": "Per-item urgent delivery flag (same semantics as the top-level flag). Requires session_id." + }, + "plan_file": { + "type": "string", + "description": "Per-item plan-todo binding (only when session_id is omitted, and requires todo_id)." + }, + "todo_id": { + "type": "string", + "description": "Per-item todo id within plan_file (only when session_id is omitted, and requires plan_file)." + } + }, + "required": ["message"], + "additionalProperties": false + } + } + }, + "required": [], + "additionalProperties": false + }) + } + + /// Dynamically resolves allowed agent_type values from the agent registry. + async fn input_schema_for_model_with_context(&self, context: Option<&ToolUseContext>) -> Value { + let agent_type_ids = get_available_agent_type_ids_for_creation(context).await; + let agent_type_enum: Vec<&str> = agent_type_ids.iter().map(|s| s.as_str()).collect(); + json!({ + "type": "object", + "properties": { + "workspace": { + "type": "string", + "description": "Required absolute target workspace path when creating a new session. Optional when session_id is provided." + }, + "session_id": { + "type": "string", + "description": "Optional target session ID. Omit it to create a new session and send the message there." + }, + "session_name": { + "type": "string", + "description": "Required when session_id is omitted. Display name for the new session." + }, + "message": { + "type": "string", + "description": "Message to send to the target session." + }, + "agent_type": { + "type": "string", + "enum": agent_type_enum, "description": "Required when session_id is omitted. Not allowed when sending to an existing session." + }, + "urgent": { + "type": "boolean", + "description": "When true, deliver as an urgent mid-turn correction: if the target session is processing, inject into its running turn via the UserSteering channel; otherwise fall back to normal delivery. Requires session_id." + }, + "plan_file": { + "type": "string", + "description": "Optional plan-todo binding for a created session (only when session_id is omitted, and requires todo_id): the plan file name or absolute path whose todo is auto-marked in_progress when the dispatched turn starts and completed when it finishes with a Completed outcome." + }, + "todo_id": { + "type": "string", + "description": "Optional todo id within plan_file for a created session (only when session_id is omitted, and requires plan_file)." + }, + "batch": { + "type": "array", + "description": "Batch dispatch: perform multiple create+send (or send-to-existing) operations in one tool call. Mutually exclusive with the top-level message and session fields; the top-level workspace is shared by items that create a session. All items validate up front; each item then runs independently (a failed item never rolls back succeeded ones). Item shape: {session_id?, session_name?, message, agent_type?, plan_file?, todo_id?, urgent?}.", + "items": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Optional target session ID. Omit it to create a new session." + }, + "session_name": { + "type": "string", + "description": "Required when session_id is omitted. Display name for the new session." + }, + "message": { + "type": "string", + "description": "Message to send to the target session." + }, + "agent_type": { + "type": "string", + "description": "Required when session_id is omitted. Agent type for the new session." + }, + "urgent": { + "type": "boolean", + "description": "Per-item urgent delivery flag (same semantics as the top-level flag). Requires session_id." + }, + "plan_file": { + "type": "string", + "description": "Per-item plan-todo binding (only when session_id is omitted, and requires todo_id)." + }, + "todo_id": { + "type": "string", + "description": "Per-item todo id within plan_file (only when session_id is omitted, and requires plan_file)." + } + }, + "required": ["message"], + "additionalProperties": false + } } }, - "required": ["message"], + "required": [], "additionalProperties": false }) } @@ -386,7 +837,14 @@ Allowed agent types when creating a session: } }; - if parsed.message.trim().is_empty() { + // Batch mode: the whole batch is validated up front — any structurally + // invalid item rejects the entire batch before anything executes. + if let Some(batch) = parsed.batch.as_ref() { + return self.validate_batch(&parsed, batch, context).await; + } + + let message = parsed.message.as_deref().unwrap_or_default(); + if message.trim().is_empty() { return ValidationResult { result: false, message: Some("message cannot be empty".to_string()), @@ -429,6 +887,18 @@ Allowed agent types when creating a session: }; } + if parsed.plan_file.is_some() || parsed.todo_id.is_some() { + return ValidationResult { + result: false, + message: Some( + "plan_file/todo_id binding is only allowed when session_id is omitted" + .to_string(), + ), + error_code: Some(400), + meta: None, + }; + } + if let Some(workspace) = parsed.workspace.as_deref() { let workspace_validation = self.validate_workspace_shape(workspace, context); if !workspace_validation.result { @@ -437,6 +907,17 @@ Allowed agent types when creating a session: } } None => { + if parsed.plan_file.is_some() != parsed.todo_id.is_some() { + return ValidationResult { + result: false, + message: Some( + "plan_file and todo_id must be provided together".to_string(), + ), + error_code: Some(400), + meta: None, + }; + } + if parsed .session_name .as_deref() @@ -516,6 +997,13 @@ Allowed agent types when creating a session: .get("workspace") .and_then(|value| value.as_str()) .unwrap_or("resolved workspace"); + if let Some(batch) = input.get("batch").and_then(|value| value.as_array()) { + return format!( + "Batch dispatch {} message(s) in {}", + batch.len(), + workspace + ); + } if let Some(session_id) = input.get("session_id").and_then(|value| value.as_str()) { format!("Send message to session {} in {}", session_id, workspace) } else { @@ -537,6 +1025,404 @@ Allowed agent types when creating a session: ) -> BitFunResult> { let params: SessionMessageInput = serde_json::from_value(input.clone()) .map_err(|e| BitFunError::tool(format!("Invalid input: {}", e)))?; + let shared = self.build_dispatch_shared(context).await?; + + if let Some(batch) = params.batch.as_ref() { + return self.call_batch(¶ms, batch, &shared, context).await; + } + + let outcome = self.dispatch_single(params, &shared, context).await?; + let mut data = json!({ + "success": true, + "target_workspace": outcome.workspace_path, + "target_session_id": outcome.target_session_id, + "target_agent_type": outcome.target_agent_type, + "created_session_id": outcome.created_session_id, + "delivery": outcome.delivery, + }); + // ACP direct path: the external response is exposed verbatim on the + // result payload so programmatic callers can consume it. + if let Some(response) = outcome.acp_response.as_ref() { + data["response"] = json!(response); + } + Ok(vec![ToolResult::Result { + data, + result_for_assistant: Some(outcome.result_text), + image_attachments: None, + }]) + } +} + +/// Build the follow-up message injected into the sender session when an ACP +/// direct delivery succeeds (COORD-15). The full external reply stays in the +/// target ACP stream session history (retrievable via SessionHistory); only +/// the notice is injected so the sender context is not inflated with the +/// full reply text. +fn acp_direct_response_notice(_full_response: &str, session_id: &str) -> String { + format!( + "External ACP session '{}' responded; use SessionHistory to view the full reply.", + session_id + ) +} + +/// Current unix time in milliseconds (fallback 0 on clock failure; never +/// panics). +fn acp_direct_delivery_now_unix_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +/// The target workspace of an ACP direct delivery, used to resolve the +/// session storage directory for backend persistence. +fn acp_direct_delivery_workspace_path(op: &AcpDirectSendOp) -> Option<&str> { + match op { + AcpDirectSendOp::Flow(request) => request.workspace_path.as_deref(), + AcpDirectSendOp::Bitfun(request) => request.workspace_path.as_deref(), + } +} + +/// Build the persisted `DialogTurnData` for one ACP direct delivery +/// (a19 后端同构落盘;镜像前端 convertDialogTurnToBackendFormat 的 +/// user_message + 单 model_round text_items 结构)。 +#[allow(clippy::too_many_arguments)] +fn build_acp_direct_delivery_turn( + turn_id: &str, + turn_index: usize, + session_id: &str, + user_input: &str, + round_id: &str, + round_started_at_ms: u64, + response: &str, + status: crate::service::session::TurnStatus, + error: Option, +) -> crate::service::session::DialogTurnData { + use crate::service::session::{ + DialogTurnData, ModelRoundData, TextItemData, TurnStatus, UserMessageData, + }; + let mut turn = DialogTurnData::new( + turn_id.to_string(), + turn_index, + session_id.to_string(), + UserMessageData { + id: Uuid::new_v4().to_string(), + content: user_input.to_string(), + timestamp: round_started_at_ms, + metadata: None, + }, + ); + turn.start_time = round_started_at_ms; + let mut round = ModelRoundData { + id: round_id.to_string(), + turn_id: turn_id.to_string(), + round_index: 0, + round_group_id: None, + timestamp: round_started_at_ms, + text_items: Vec::new(), + tool_items: Vec::new(), + thinking_items: Vec::new(), + start_time: round_started_at_ms, + end_time: None, + duration_ms: None, + provider_id: None, + model_config_id: None, + effective_model_name: None, + first_chunk_ms: None, + first_visible_output_ms: None, + stream_duration_ms: None, + attempt_count: None, + attempt_diagnostics: Vec::new(), + failure_category: None, + token_details: None, + status: "completed".to_string(), + }; + if !response.trim().is_empty() { + round.text_items.push(TextItemData { + id: Uuid::new_v4().to_string(), + content: response.to_string(), + is_streaming: false, + timestamp: round_started_at_ms, + is_markdown: true, + order_index: Some(0), + is_subagent_item: None, + parent_task_tool_id: None, + subagent_session_id: None, + status: Some("completed".to_string()), + attempt_id: None, + attempt_index: None, + }); + } + turn.model_rounds.push(round); + turn.error = error; + match status { + TurnStatus::Completed => turn.mark_completed(), + TurnStatus::Cancelled | TurnStatus::Error => { + turn.status = status; + turn.end_time = Some(acp_direct_delivery_now_unix_ms()); + } + TurnStatus::InProgress => {} + } + turn +} + +/// Persist one ACP direct delivery turn through the injected persistence +/// manager. Backend persistence is independent of the frontend event stream; +/// the turn index derives from the session metadata `turn_count` (matching +/// the frontend `indexOf` semantics for a contiguous history). A turn already +/// saved by the frontend at that index is a no-op; an index collision with a +/// different turn id is skipped with a warning. Failures are logged, never +/// propagated, so persistence can never break the notification path. +#[allow(clippy::too_many_arguments)] +async fn persist_acp_direct_delivery_turn( + persistence: &crate::agentic::persistence::PersistenceManager, + storage_path: &Path, + session_id: &str, + turn_id: &str, + user_input: &str, + round_id: &str, + round_started_at_ms: u64, + response: &str, + status: crate::service::session::TurnStatus, + error: Option, +) { + let Ok(Some(metadata)) = persistence + .load_session_metadata(storage_path, session_id) + .await + else { + warn!( + "ACP direct delivery persistence skipped: session metadata not found: session_id={}", + session_id + ); + return; + }; + // 幂等:同 turn_id 已在会话任意索引落盘 → no-op(不重复追加)。 + let known_turn_count = metadata.turn_count; + for index in 0..known_turn_count { + if let Ok(Some(existing)) = persistence + .load_dialog_turn(storage_path, session_id, index) + .await + { + if existing.turn_id == turn_id { + return; + } + } + } + // P-19 全文落盘原则:计算索引(metadata.turn_count)可能被前端/并发写者 + // 已落盘的既有 turn 占用而元数据未同步(实证「SessionHistory 导出仍只有 + // turn 0」)。此时不得静默丢弃投递 turn——从 turn_count 起向后扫描第一个 + // 空闲索引追加,保证 reply 全文始终可经 SessionHistory 检索。 + let mut turn_index = known_turn_count; + loop { + match persistence + .load_dialog_turn(storage_path, session_id, turn_index) + .await + { + Ok(Some(existing)) if existing.turn_id == turn_id => { + return; + } + Ok(Some(_)) => { + turn_index += 1; + } + _ => break, + } + } + let turn = build_acp_direct_delivery_turn( + turn_id, + turn_index, + session_id, + user_input, + round_id, + round_started_at_ms, + response, + status, + error, + ); + if let Err(save_error) = persistence.save_dialog_turn(storage_path, &turn).await { + warn!( + "Failed to persist ACP direct delivery turn: session_id={} turn_id={} error={}", + session_id, turn_id, save_error + ); + } +} + +/// Production wrapper for ACP direct delivery persistence: resolve the +/// workspace session storage path and build the global persistence manager, +/// then persist the turn. +#[allow(clippy::too_many_arguments)] +async fn persist_acp_direct_delivery_to_workspace( + workspace_path: &str, + session_id: &str, + turn_id: &str, + user_input: &str, + round_id: &str, + round_started_at_ms: u64, + response: &str, + status: crate::service::session::TurnStatus, + error: Option, +) { + use crate::agentic::persistence::PersistenceManager; + use crate::infrastructure::get_path_manager_arc; + use crate::service::remote_ssh::workspace_state::get_effective_session_path; + + let storage_path = get_effective_session_path(workspace_path, None, None).await; + let persistence = match PersistenceManager::new(get_path_manager_arc()) { + Ok(persistence) => persistence, + Err(init_error) => { + warn!( + "ACP direct delivery persistence skipped: failed to initialize PersistenceManager: {}", + init_error + ); + return; + } + }; + persist_acp_direct_delivery_turn( + &persistence, + &storage_path, + session_id, + turn_id, + user_input, + round_id, + round_started_at_ms, + response, + status, + error, + ) + .await; +} + +impl SessionMessageTool { + /// Validates a batch payload up front. Structural rules mirror the + /// single-target shape, applied per item with `batch[N]` prefixes; any + /// invalid item rejects the whole batch before anything executes. + async fn validate_batch( + &self, + parsed: &SessionMessageInput, + batch: &[BatchItem], + context: Option<&ToolUseContext>, + ) -> ValidationResult { + if batch.is_empty() { + return Self::invalid("batch cannot be empty"); + } + if parsed + .message + .as_deref() + .is_some_and(|message| !message.trim().is_empty()) + { + return Self::invalid("message cannot be combined with batch"); + } + if parsed.session_id.is_some() + || parsed.session_name.is_some() + || parsed.agent_type.is_some() + || parsed.plan_file.is_some() + || parsed.todo_id.is_some() + || parsed.urgent + { + return Self::invalid("session fields must be provided per batch item when batch is used"); + } + + // The shared workspace must be present (and well-formed) when any item + // creates a new session; when present it is always shape-checked. + if let Some(workspace) = parsed.workspace.as_deref() { + let workspace_validation = self.validate_workspace_shape(workspace, context); + if !workspace_validation.result { + return workspace_validation; + } + } else if batch.iter().any(|item| item.session_id.is_none()) { + return Self::invalid("workspace is required when a batch item omits session_id"); + } + + let source_session_id = context.and_then(|context| context.session_id.as_deref()); + for (index, item) in batch.iter().enumerate() { + let field = |name: &str| format!("batch[{index}].{name}"); + if item.message.trim().is_empty() { + return Self::invalid(format!("{} cannot be empty", field("message"))); + } + match item.session_id.as_deref() { + Some(session_id) => { + if let Err(message) = Self::validate_session_id(session_id) { + return Self::invalid(format!("{}: {message}", field("session_id"))); + } + if item.session_name.is_some() { + return Self::invalid(format!( + "{} is only allowed when session_id is omitted", + field("session_name") + )); + } + if item.agent_type.is_some() { + return Self::invalid(format!( + "{} override is not allowed when session_id is provided", + field("agent_type") + )); + } + if item.plan_file.is_some() || item.todo_id.is_some() { + return Self::invalid(format!( + "{} binding is only allowed when session_id is omitted", + field("plan_file/todo_id") + )); + } + if let Some(source_session_id) = source_session_id { + if source_session_id == session_id { + return Self::invalid(format!( + "{} cannot send a message to the same session", + field("session_id") + )); + } + } + } + None => { + if item.plan_file.is_some() != item.todo_id.is_some() { + return Self::invalid(format!( + "{} and {} must be provided together", + field("plan_file"), + field("todo_id") + )); + } + if item + .session_name + .as_deref() + .is_none_or(|value| value.trim().is_empty()) + { + return Self::invalid(format!( + "{} is required when session_id is omitted", + field("session_name") + )); + } + if item.agent_type.is_none() { + return Self::invalid(format!( + "{} is required when session_id is omitted", + field("agent_type") + )); + } + } + } + } + + let Some(context) = context else { + return ValidationResult::default(); + }; + let Some(_source_session_id) = context.session_id.as_deref() else { + return Self::invalid("SessionMessage requires a source session in tool context"); + }; + ValidationResult::default() + } + + fn invalid(message: impl Into) -> ValidationResult { + ValidationResult { + result: false, + message: Some(message.into()), + error_code: Some(400), + meta: None, + } + } + + /// Resolves the source-session facts and the global coordinator, scheduler + /// and runtime once per tool call, so a batch dispatch shares one resource + /// setup instead of re-resolving globals for every item. + async fn build_dispatch_shared( + &self, + context: &ToolUseContext, + ) -> BitFunResult { let source_session_id = self.sender_session_id(context)?.to_string(); let source_workspace = self.sender_workspace(context)?; let source_remote_connection_id = context @@ -549,58 +1435,557 @@ Allowed agent types when creating a session: .filter(|workspace| workspace.is_remote()) .map(|workspace| workspace.session_identity.hostname.clone()) .filter(|value| !value.trim().is_empty()); - let coordinator = get_global_coordinator() .ok_or_else(|| BitFunError::tool("coordinator not initialized".to_string()))?; let scheduler = get_global_scheduler() .ok_or_else(|| BitFunError::tool("scheduler not initialized".to_string()))?; let runtime = CoreServiceAgentRuntime::agent_runtime_with_dialog_turns( coordinator.clone(), - scheduler, + scheduler.clone(), ) .map_err(BitFunError::tool)?; + Ok(DispatchShared { + source_session_id, + source_workspace, + source_remote_connection_id, + source_remote_ssh_host, + coordinator, + scheduler, + runtime, + }) + } - let (target_session_id, target_agent_type, created_session_id, workspace_target) = - if let Some(target_session_id) = params.session_id.clone() { - if source_session_id == target_session_id { - return Err(BitFunError::tool( - "SessionMessage cannot send a message to the same session".to_string(), - )); - } + /// The ACP client id when the target agent type is an ACP bridge agent + /// (`acp__`; see AcpAgent::agent_id_for), otherwise `None`. + /// ACP targets bypass the local model entirely: SessionMessage forwards + /// the message through the ACP client port instead of submitting a local + /// dialog turn, so no bridge re-translation (and no double billing) can + /// happen. + fn acp_client_id_from_agent_type(agent_type: &str) -> Option<&str> { + agent_type + .strip_prefix(AcpAgent::agent_id_prefix()) + .filter(|client_id| !client_id.trim().is_empty()) + } - let workspace_target = runtime - .resolve_session_workspace_binding(AgentSessionWorkspaceRequest { - session_id: target_session_id.clone(), - }) - .await - .map_err(|error| { - BitFunError::tool(CoreServiceAgentRuntime::runtime_error_message(error)) - })?; - let workspace_target = workspace_target.ok_or_else(|| { - BitFunError::NotFound(format!( - "Workspace for session '{}' could not be resolved", - target_session_id - )) - })?; - let workspace_target = self.workspace_target_from_binding(workspace_target); + /// The ACP client id when `session_id` is a flow session id of the shape + /// `acp__` (created by the frontend `create_acp_flow_session`, + /// `acp_control` create, or the SessionControl `acp__` path; see + /// interfaces/acp session_persistence.rs:44). Flow sessions live in the ACP + /// persistence store, not the internal session store, so they are detected + /// by id shape instead of a registry lookup. The trailing UUID segment is + /// shape-checked so an internal session id that happens to start with + /// `acp_` is never mistaken for a flow session. + fn acp_flow_client_id_from_session_id(session_id: &str) -> Option<&str> { + let rest = session_id.strip_prefix("acp_")?; + let (client_id, uuid_segment) = rest.rsplit_once('_')?; + if client_id.is_empty() || !looks_like_uuid(uuid_segment) { + return None; + } + Some(client_id) + } - if let Some(workspace) = params.workspace.as_deref() { - let requested_workspace = self.resolve_workspace(workspace, context)?; - let requested_target = - self.workspace_target_from_context(requested_workspace.clone(), context); - if !Self::same_workspace_identity(&requested_target, &workspace_target) { - return Err(BitFunError::NotFound(format!( - "Session '{}' not found in workspace '{}'", - target_session_id, requested_target.workspace_path - ))); - } - } + /// COORD-03 权威判定:查 ACP 流会话注册表(workspace 会话存储中的持久 + /// 化记录)。流会话记录由 `AcpClientPort::create_session` 写入(provider= + /// acp + acpClientId 元数据),回收(`delete_session_record`)后记录被 + /// 删除,因此记录状态是「是否活跃外部 ACP 流会话」的权威事实: + /// - `Active`:记录在册且 provider=acp,附记录中的 client id; + /// - `NotAcpFlow`:记录在册但不是 ACP 流会话(内部会话命中形状); + /// - `Missing`:无记录(已回收或从未创建)——派发前存活校验失败。 + /// + /// 同一存储目录(`get_effective_session_path`)同时承载内部会话与 ACP + /// 流会话记录,provider 标记负责区分;与 desktop `AcpClientPort` 的 + /// `session_storage_path` 解析一致(本地 workspace,不涉及 remote)。 + async fn acp_flow_session_registry_status( + workspace_path: &str, + session_id: &str, + ) -> BitFunResult { + use crate::agentic::persistence::PersistenceManager; + use crate::infrastructure::get_path_manager_arc; + use crate::service::remote_ssh::workspace_state::get_effective_session_path; - let visible_sessions = runtime - .list_sessions(AgentSessionListRequest { - workspace_path: workspace_target.project_workspace_path.clone(), + let storage_path = get_effective_session_path(workspace_path, None, None).await; + let persistence = PersistenceManager::new(get_path_manager_arc()) + .map_err(|error| BitFunError::tool(error.to_string()))?; + let Some(metadata) = persistence + .load_session_metadata(&storage_path, session_id) + .await + .map_err(|error| BitFunError::tool(error.to_string()))? + else { + return Ok(AcpFlowSessionRegistryStatus::Missing); + }; + let Some(custom) = metadata.custom_metadata.as_ref() else { + return Ok(AcpFlowSessionRegistryStatus::NotAcpFlow); + }; + if custom.get(ACP_FLOW_METADATA_PROVIDER_KEY).and_then(Value::as_str) + != Some(ACP_FLOW_METADATA_PROVIDER_VALUE) + { + return Ok(AcpFlowSessionRegistryStatus::NotAcpFlow); + } + let client_id = custom + .get(ACP_FLOW_METADATA_CLIENT_ID_KEY) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string); + match client_id { + Some(client_id) => Ok(AcpFlowSessionRegistryStatus::Active { client_id }), + // provider=acp 但 client id 缺失/为空:异常记录,无法确认归属, + // 按非 ACP 流会话拒绝(不路由)。 + None => Ok(AcpFlowSessionRegistryStatus::NotAcpFlow), + } + } + + /// Forward one ACP direct message through the real channel with streaming. + /// Text chunks are pushed into `chunk_sink` as they arrive and the full + /// external response is returned; failures are port errors. + async fn acp_direct_send_stream( + port: &dyn AcpClientPort, + op: AcpDirectSendOp, + chunk_sink: AcpClientStreamChunkSink, + ) -> PortResult { + match op { + AcpDirectSendOp::Flow(request) => port.send_message_stream(request, chunk_sink).await, + AcpDirectSendOp::Bitfun(request) => { + port.send_message_to_bitfun_session_stream(request, chunk_sink).await + } + } + } + + /// Async ACP direct delivery: spawn a background task that forwards the + /// message through the port and, once the external turn completes, streams + /// the response back through `agentic://` turn events for the target + /// session and delivers the response to the sender session as a follow-up. + /// + /// The tool call itself returns immediately with an acceptance text; it no + /// longer blocks on the external agent's full turn. + fn spawn_acp_direct_delivery( + port: Arc, + op: AcpDirectSendOp, + coordinator: Arc, + scheduler: Arc, + target_session_id: String, + user_input: String, + source: AcpDirectReplySource, + ) { + tokio::spawn(async move { + Self::run_acp_direct_delivery( + port.as_ref(), + op, + coordinator.as_ref(), + scheduler.as_ref(), + &target_session_id, + &user_input, + &source, + ) + .await; + }); + } + + /// Completion path of one ACP direct delivery: stream the external reply + /// back through per-chunk turn events for the target session and route the + /// external response back to the sender session (follow-up), or emit a + /// failure event on port error. Turn event order is preserved: + /// `DialogTurnStarted` → [`ModelRoundStarted`] → zero or more `TextChunk` + /// → [`ModelRoundCompleted`] → `DialogTurnCompleted`. Round events are + /// emitted only when the reply produces text (mirroring the non-streaming + /// path); the `ModelRoundCompleted` is emitted first when the port fails + /// after a partial reply, so no round is left dangling. + async fn run_acp_direct_delivery( + port: &dyn AcpClientPort, + op: AcpDirectSendOp, + coordinator: &ConversationCoordinator, + scheduler: &DialogScheduler, + target_session_id: &str, + user_input: &str, + source: &AcpDirectReplySource, + ) { + let turn_id = Uuid::new_v4().to_string(); + let round_id = Uuid::new_v4().to_string(); + let started_at = Instant::now(); + // a19 后端落盘时间基准:事件流内无法再次取时(事件不携带时间戳)。 + let turn_started_at_ms = acp_direct_delivery_now_unix_ms(); + // a19 后端落盘目标工作区:在 `op` 被 move 进发送 future 前提取。 + let target_workspace_path = acp_direct_delivery_workspace_path(&op).map(ToOwned::to_owned); + coordinator + .emit_event(AgenticEvent::DialogTurnStarted { + session_id: target_session_id.to_string(), + turn_id: turn_id.clone(), + turn_index: 0, + user_input: user_input.to_string(), + original_user_input: Some(user_input.to_string()), + user_message_metadata: None, + }) + .await; + + // Stream the external reply: the port pushes text chunks into the + // channel while the recv loop emits one `TextChunk` turn event per + // chunk, so the frontend renders the reply incrementally instead of + // receiving the whole response in a single chunk. `join!` keeps the + // recv loop running concurrently with the port call; the channel + // closes when the port call finishes, ending the loop. + let (chunk_tx, mut chunk_rx) = tokio::sync::mpsc::unbounded_channel(); + let send_future = Self::acp_direct_send_stream(port, op, chunk_tx); + let stream_turn_events = async { + let mut round_started = false; + while let Some(chunk) = chunk_rx.recv().await { + if let AcpClientStreamChunk::Text { text } = chunk { + if !round_started { + // 与 coordinator.rs 既有模式一致:TextChunk 前先补发 + // ModelRoundStarted,让前端正常建立 round 容器,再流式输出文本。 + coordinator + .emit_event(AgenticEvent::ModelRoundStarted { + session_id: target_session_id.to_string(), + turn_id: turn_id.clone(), + round_id: round_id.clone(), + round_group_id: None, + round_index: 0, + model_config_id: String::new(), + effective_model_name: String::new(), + }) + .await; + round_started = true; + } + coordinator + .emit_event(AgenticEvent::TextChunk { + session_id: target_session_id.to_string(), + turn_id: turn_id.clone(), + round_id: round_id.clone(), + attempt_id: None, + attempt_index: None, + text, + }) + .await; + } + } + round_started + }; + let (sent, round_started) = tokio::join!(send_future, stream_turn_events); + let duration_ms = started_at.elapsed().as_millis() as u64; + + match sent { + Ok(sent) => { + if round_started { + coordinator + .emit_event(AgenticEvent::ModelRoundCompleted { + session_id: target_session_id.to_string(), + turn_id: turn_id.clone(), + round_id: round_id.clone(), + has_tool_calls: false, + duration_ms: Some(duration_ms), + provider_id: None, + model_config_id: String::new(), + effective_model_name: String::new(), + first_chunk_ms: None, + first_visible_output_ms: None, + stream_duration_ms: None, + attempt_count: None, + failure_category: None, + token_details: None, + }) + .await; + } + coordinator + .emit_event(AgenticEvent::DialogTurnCompleted { + session_id: target_session_id.to_string(), + turn_id: turn_id.clone(), + total_rounds: 1, + total_tools: 0, + duration_ms, + partial_recovery_reason: None, + success: Some(true), + // "complete" 是前端 NORMAL_FINISH_REASONS 内的正常终止码, + // 避免误报「非标准方式结束」横幅。 + finish_reason: Some("complete".to_string()), + has_final_response: Some(true), + }) + .await; + // a19 后端同构落盘:外部回复直接写入目标 ACP 会话的持久化 turn + // 文件,不依赖前端事件流(前端未打开/事件流中断时 SessionHistory + // 仍可读)。失败仅告警,不破坏通知式路径(COORD-15 follow-up + // 照常投递)。 + if let Some(workspace_path) = target_workspace_path.as_deref() { + persist_acp_direct_delivery_to_workspace( + workspace_path, + target_session_id, + &turn_id, + user_input, + &round_id, + turn_started_at_ms, + &sent.response, + crate::service::session::TurnStatus::Completed, + None, + ) + .await; + } + // AgentSessionReplyRoute semantics: deliver the external + // response back to the sender session as a follow-up. + // + // COORD-15:事件流(DialogTurnStarted → TextChunk → + // DialogTurnCompleted)已在目标会话完成流式渲染,是外部回复的 + // 唯一完整呈现;follow-up 的 content/display 均只注入通知句 + // (完成回执),全文保留在 ACP 流会话历史,发起方用 + // SessionHistory 自查,避免 ACP 直通事件流与本地 follow-up + // 双重呈现、也避免全文膨胀发起方上下文。 + let content = acp_direct_response_notice(&sent.response, target_session_id); + let display = format!( + "External ACP session '{}' responded; the full reply is streamed in that session's chat view.", + target_session_id + ); + if let Err(error) = scheduler + .deliver_background_result( + source.source_session_id.clone(), + String::new(), + Some(source.source_workspace.clone()), + source.source_remote_connection_id.clone(), + source.source_remote_ssh_host.clone(), + content, + Some(display), + None, + ) + .await + { + warn!( + "Failed to deliver ACP direct response back to source: source_session_id={}, target_session_id={}, error={}", + source.source_session_id, target_session_id, error + ); + } + } + Err(error) => { + if round_started { + coordinator + .emit_event(AgenticEvent::ModelRoundCompleted { + session_id: target_session_id.to_string(), + turn_id: turn_id.clone(), + round_id: round_id.clone(), + has_tool_calls: false, + duration_ms: Some(duration_ms), + provider_id: None, + model_config_id: String::new(), + effective_model_name: String::new(), + first_chunk_ms: None, + first_visible_output_ms: None, + stream_duration_ms: None, + attempt_count: None, + failure_category: None, + token_details: None, + }) + .await; + } + coordinator + .emit_event(AgenticEvent::DialogTurnFailed { + session_id: target_session_id.to_string(), + turn_id: turn_id.clone(), + error: format!( + "ACP direct delivery failed for session '{}': {}", + target_session_id, error + ), + error_category: None, + error_detail: None, + }) + .await; + let error_text = format!( + "ACP direct delivery failed for session '{}': {}", + target_session_id, error + ); + // a19 后端同构落盘:失败 turn 也写入持久化存储(与前端在 + // DialogTurnFailed 时保存 error turn 的行为同构)。 + if let Some(workspace_path) = target_workspace_path.as_deref() { + persist_acp_direct_delivery_to_workspace( + workspace_path, + target_session_id, + &turn_id, + user_input, + &round_id, + turn_started_at_ms, + "", + crate::service::session::TurnStatus::Error, + Some(error_text.clone()), + ) + .await; + } + if let Err(delivery_error) = scheduler + .deliver_background_result( + source.source_session_id.clone(), + String::new(), + Some(source.source_workspace.clone()), + source.source_remote_connection_id.clone(), + source.source_remote_ssh_host.clone(), + error_text, + None, + None, + ) + .await + { + warn!( + "Failed to deliver ACP direct failure back to source: source_session_id={}, error={}", + source.source_session_id, delivery_error + ); + } + } + } + } + + /// Performs one create+send (or send-to-existing) dispatch and returns the + /// resolved outcome. Shared by the single-target call and every batch item. + async fn dispatch_single( + &self, + params: SessionMessageInput, + shared: &DispatchShared, + context: &ToolUseContext, + ) -> BitFunResult { + let message = params + .message + .clone() + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| BitFunError::tool("message cannot be empty".to_string()))?; + let source_session_id = &shared.source_session_id; + let source_workspace = &shared.source_workspace; + let source_remote_connection_id = shared.source_remote_connection_id.as_deref(); + let source_remote_ssh_host = shared.source_remote_ssh_host.as_deref(); + let coordinator = &shared.coordinator; + let scheduler = &shared.scheduler; + let runtime = &shared.runtime; + + let (target_session_id, target_agent_type, created_session_id, workspace_target) = + if let Some(target_session_id) = params.session_id.clone() { + if source_session_id == &target_session_id { + return Err(BitFunError::tool( + "SessionMessage cannot send a message to the same session".to_string(), + )); + } + + // ACP 流会话直通:session_id 形状 `acp__`(前端 + // create_acp_flow_session / acp_control / SessionControl acp__ 创建的 + // 真外部 ACP 会话)。流会话不在内部 session store,无法走 workspace + // binding / list_sessions 解析;直接经 AcpClientPort::send_message 真 + // 通道转发(与 acp_message 同通道,无本地模型 turn)。投递即返回, + // 外部响应经事件流 + follow-up 回传。 + // + // COORD-03:形状只作线索,ACP 流会话注册表才是权威判定。命中形状 + // 后先查注册表(派发前存活校验):记录在册且 provider=acp 且 + // acpClientId 与形状 client id 一致 → 直通;内部会话命中形状 / + // 记录已回收 / 记录归属 client 不一致 → 显式拒绝而非路由,杜绝 + // 误分流与回收竞态(回收后形状仍命中会把消息发向已释放的会话)。 + if let Some(flow_client_id) = + Self::acp_flow_client_id_from_session_id(&target_session_id) + { + // 注册表查询需要 workspace 定位会话存储目录;缺失时无法 + // 完成权威判定,显式拒绝(不静默直通未校验的会话)。 + let workspace_path = params.workspace.clone().or_else(|| { + context + .workspace_root() + .map(|path| path.to_string_lossy().to_string()) + }); + let registry_status = Self::acp_flow_session_registry_status( + workspace_path.as_deref().ok_or_else(|| { + BitFunError::tool(format!( + "workspace is required to verify the target session '{}'", + target_session_id + )) + })?, + &target_session_id, + ) + .await?; + let registry_client_id = match registry_status { + AcpFlowSessionRegistryStatus::Active { client_id } => client_id, + AcpFlowSessionRegistryStatus::NotAcpFlow => { + return Err(BitFunError::tool(format!( + "session '{}' is not an ACP flow session (its persisted record is not an ACP session record); refusing to route it through the external ACP direct path", + target_session_id + ))); + } + AcpFlowSessionRegistryStatus::Missing => { + return Err(BitFunError::tool(format!( + "ACP flow session '{}' was not found in the flow-session registry; it may have been recycled or never created", + target_session_id + ))); + } + }; + if registry_client_id != flow_client_id { + return Err(BitFunError::tool(format!( + "ACP flow session '{}' is registered for client '{}', not '{}'; refusing to route", + target_session_id, registry_client_id, flow_client_id + ))); + } + let port = coordinator.acp_client_port().ok_or_else(|| { + BitFunError::tool( + "ACP client port is not available; the desktop host did not inject it" + .to_string(), + ) + })?; + // Resolve before the move below: the flow client id borrows + // from `target_session_id`, which is moved into the outcome. + let target_agent_type = format!("acp:{}", flow_client_id); + let resolved_workspace = workspace_path.clone().unwrap_or_default(); + let result_text = format!( + "Message accepted for external ACP session '{}' in workspace '{}' using agent type '{}'. The external agent response will stream back once it completes.", + target_session_id, resolved_workspace, target_agent_type + ); + let source = AcpDirectReplySource { + source_session_id: source_session_id.clone(), + source_workspace: source_workspace.clone(), + source_remote_connection_id: source_remote_connection_id.map(ToOwned::to_owned), + source_remote_ssh_host: source_remote_ssh_host.map(ToOwned::to_owned), + }; + Self::spawn_acp_direct_delivery( + port, + AcpDirectSendOp::Flow(AcpClientMessageRequest { + session_id: target_session_id.clone(), + message: message.clone(), + workspace_path: workspace_path.clone(), + timeout_seconds: Some(ACP_DIRECT_TIMEOUT_SECONDS), + }), + coordinator.clone(), + scheduler.clone(), + target_session_id.clone(), + message.clone(), + source, + ); + return Ok(DispatchOutcome { + target_session_id, + target_agent_type, + created_session_id: None, + workspace_path: resolved_workspace, + delivery: "acp_direct", + result_text, + acp_response: None, + }); + } + + let workspace_target = runtime + .resolve_session_workspace_binding(AgentSessionWorkspaceRequest { + session_id: target_session_id.clone(), + }) + .await + .map_err(|error| { + BitFunError::tool(CoreServiceAgentRuntime::runtime_error_message(error)) + })?; + let workspace_target = workspace_target.ok_or_else(|| { + BitFunError::NotFound(format!( + "Workspace for session '{}' could not be resolved", + target_session_id + )) + })?; + let workspace_target = self.workspace_target_from_binding(workspace_target); + + if let Some(workspace) = params.workspace.as_deref() { + let requested_workspace = self.resolve_workspace(workspace, context)?; + let requested_target = + self.workspace_target_from_context(requested_workspace.clone(), context); + if !Self::same_workspace_identity(&requested_target, &workspace_target) { + return Err(BitFunError::NotFound(format!( + "Session '{}' not found in workspace '{}'", + target_session_id, requested_target.workspace_path + ))); + } + } + + let visible_sessions = runtime + .list_sessions(AgentSessionListRequest { + workspace_path: workspace_target.project_workspace_path.clone(), remote_connection_id: workspace_target.remote_connection_id.clone(), remote_ssh_host: workspace_target.remote_ssh_host.clone(), + include_hidden: true, }) .await .map_err(|error| { @@ -660,6 +2045,14 @@ Allowed agent types when creating a session: let created_by = self.creator_session_marker(context)?; let mut metadata = serde_json::Map::new(); metadata.insert("createdBy".to_string(), json!(created_by)); + // Persistent copy of the plan-todo binding on the created + // session record (the turn-channel copy is injected at submit). + if let Some(plan_file) = params.plan_file.as_deref() { + metadata.insert(PLAN_FILE_METADATA_KEY.to_string(), json!(plan_file)); + } + if let Some(todo_id) = params.todo_id.as_deref() { + metadata.insert(TODO_ID_METADATA_KEY.to_string(), json!(todo_id)); + } let session = runtime .create_session(AgentSessionCreateRequest { session_name, @@ -688,72 +2081,342 @@ Allowed agent types when creating a session: ) }; + // ACP direct path: `acp__` targets are external agents. + // Forward the message through the ACP client port (addressed by the + // internal BitFun session id, same identity the AcpAgentTool bridge + // uses) — no local model turn, no bridge re-translation. Delivery + // returns immediately; the external response streams back through + // `agentic://` turn events and a follow-up reply to the sender. + // When the port is unavailable the dispatch fails loudly instead of + // falling back to the local model (a fallback would re-introduce the + // double-billing path). + // + // COORD-03:agent_type 前缀 `acp__` 只作线索,ACP client 注册表才是 + // 权威判定。内部会话命中形状但 client 未注册(历史壳会话 / 用户自定义 + // 类型)时显式拒绝而非路由到外部,防误分流;client 已注册时直通(会话 + // 级外部进程绑定由发送端口兜底,失败经事件流 + follow-up 回传)。 + if let Some(client_id) = Self::acp_client_id_from_agent_type(&target_agent_type) { + let port = coordinator.acp_client_port().ok_or_else(|| { + BitFunError::tool( + "ACP client port is not available; the desktop host did not inject it" + .to_string(), + ) + })?; + let listed_clients = port.list_clients().await.map_err(|error| { + BitFunError::tool(format!( + "failed to verify the ACP client registry for agent type '{}': {}", + target_agent_type, error.message + )) + })?; + if !listed_clients + .clients + .iter() + .any(|client| client.client_id == client_id) + { + return Err(BitFunError::tool(format!( + "session '{}' uses agent type '{}' but ACP client '{}' is not registered; refusing to route to a non-existent external agent", + target_session_id, target_agent_type, client_id + ))); + } + let result_text = format!( + "Message accepted for external ACP session '{}' in workspace '{}' using agent type '{}'. The external agent response will stream back once it completes.", + target_session_id, workspace_target.workspace_path, target_agent_type + ); + let source = AcpDirectReplySource { + source_session_id: source_session_id.clone(), + source_workspace: source_workspace.clone(), + source_remote_connection_id: source_remote_connection_id.map(ToOwned::to_owned), + source_remote_ssh_host: source_remote_ssh_host.map(ToOwned::to_owned), + }; + Self::spawn_acp_direct_delivery( + port, + AcpDirectSendOp::Bitfun(AcpClientBitfunMessageRequest { + client_id: client_id.to_string(), + bitfun_session_id: target_session_id.clone(), + message: message.clone(), + workspace_path: Some(workspace_target.workspace_path.clone()), + timeout_seconds: Some(ACP_DIRECT_TIMEOUT_SECONDS), + }), + coordinator.clone(), + scheduler.clone(), + target_session_id.clone(), + message.clone(), + source, + ); + return Ok(DispatchOutcome { + target_session_id, + target_agent_type, + created_session_id, + workspace_path: workspace_target.workspace_path, + delivery: "acp_direct", + result_text, + acp_response: None, + }); + } + + let sender_identity = self + .resolve_sender_identity( + runtime, + context, + source_session_id, + source_workspace, + source_remote_connection_id, + source_remote_ssh_host, + coordinator, + ) + .await; let (forwarded_message, prepended_messages) = - self.format_forwarded_message(¶ms.message); + self.format_forwarded_message(&message, &sender_identity); - runtime - .submit_dialog_turn(AgentDialogTurnRequest { - session_id: target_session_id.clone(), - message: forwarded_message, - original_message: Some(params.message.clone()), - turn_id: None, - execution: Default::default(), - agent_type: target_agent_type.clone(), - workspace_path: Some(workspace_target.workspace_path.clone()), - remote_connection_id: workspace_target.remote_connection_id.clone(), - remote_ssh_host: workspace_target.remote_ssh_host.clone(), - policy: DialogSubmissionPolicy::for_source(DialogTriggerSource::AgentSession), - reply_route: Some(AgentSessionReplyRoute { - source_session_id, - source_workspace_path: source_workspace, - source_remote_connection_id, - source_remote_ssh_host, - }), - prepended_reminders: prepended_messages, - attachments: Vec::new(), - metadata: Self::forwarded_user_input_metadata(context), - }) - .await - .map_err(|error| { - BitFunError::tool(CoreServiceAgentRuntime::runtime_error_message(error)) - })?; + // Urgent delivery: when the target session is currently processing a turn, + // inject the message into that running turn via the UserSteering channel + // (interrupts after the current atomic unit) instead of starting a new turn. + // Honest fallback: when the target session is not processing, or the steering + // is rejected (the turn ended between the state query and the submit), deliver + // through the normal submission path so the message is never dropped. + let mut steering_turn_id: Option = None; + let has_plan_todo_binding = params.plan_file.is_some() || params.todo_id.is_some(); + if should_attempt_steering(params.urgent, created_session_id.as_deref(), has_plan_todo_binding) + { + match resolve_urgent_delivery(scheduler.current_processing_turn_id(&target_session_id)) { + UrgentDelivery::Steer { turn_id } => { + match scheduler + .steer_dialog_turn(AgentDialogSteerRequest { + session_id: target_session_id.clone(), + turn_id: turn_id.clone(), + content: forwarded_message.clone(), + display_content: Some(message.clone()), + prepended_reminders: prepended_messages.clone(), + }) + .await + { + Ok(_outcome) => { + steering_turn_id = Some(turn_id.clone()); + info!( + "Urgent SessionMessage steered into running turn: source_session_id={}, target_session_id={}, turn_id={}", + source_session_id, target_session_id, turn_id + ); + } + Err(error) => { + warn!( + "Urgent SessionMessage steering rejected, falling back to normal submit: target_session_id={}, turn_id={}, error={}", + target_session_id, turn_id, error + ); + } + } + } + UrgentDelivery::NormalSubmit => {} + } + } + + if steering_turn_id.is_none() { + // Turn-channel binding injection: when the caller bound the + // dispatched session to a plan todo, carry planFile/todoId in the + // forwarded turn metadata so the scheduler can auto-mark the todo + // (in_progress at turn start, completed on a Completed outcome). + let mut forwarded_metadata = + Self::forwarded_user_input_metadata(context, &sender_identity); + if let Some(plan_file) = params.plan_file.as_deref() { + forwarded_metadata.insert(PLAN_FILE_METADATA_KEY.to_string(), json!(plan_file)); + } + if let Some(todo_id) = params.todo_id.as_deref() { + forwarded_metadata.insert(TODO_ID_METADATA_KEY.to_string(), json!(todo_id)); + } + runtime + .submit_dialog_turn(AgentDialogTurnRequest { + session_id: target_session_id.clone(), + message: forwarded_message, + original_message: Some(message.clone()), + turn_id: None, + execution: Default::default(), + agent_type: target_agent_type.clone(), + workspace_path: Some(workspace_target.workspace_path.clone()), + remote_connection_id: workspace_target.remote_connection_id.clone(), + remote_ssh_host: workspace_target.remote_ssh_host.clone(), + policy: DialogSubmissionPolicy::for_source(DialogTriggerSource::AgentSession), + reply_route: Some(AgentSessionReplyRoute { + source_session_id: source_session_id.clone(), + source_workspace_path: source_workspace.clone(), + source_remote_connection_id: source_remote_connection_id.map(ToOwned::to_owned), + source_remote_ssh_host: source_remote_ssh_host.map(ToOwned::to_owned), + }), + prepended_reminders: prepended_messages, + attachments: Vec::new(), + metadata: forwarded_metadata, + }) + .await + .map_err(|error| { + BitFunError::tool(CoreServiceAgentRuntime::runtime_error_message(error)) + })?; + } + + let urgent_fell_back = params.urgent + && steering_turn_id.is_none() + && created_session_id.is_none(); + let mut result_text = if let Some(steered_turn_id) = steering_turn_id.as_ref() { + format!( + "Urgent message injected into the running turn '{}' of session '{}' in workspace '{}' using agent type '{}'.", + steered_turn_id, target_session_id, workspace_target.workspace_path, target_agent_type + ) + } else if let Some(created_session_id) = created_session_id.as_ref() { + format!( + "Created session '{}' and accepted the message in workspace '{}' using agent type '{}'.", + created_session_id, workspace_target.workspace_path, target_agent_type + ) + } else { + format!( + "Message accepted for session '{}' in workspace '{}' using agent type '{}'.", + target_session_id, workspace_target.workspace_path, target_agent_type + ) + }; + if urgent_fell_back { + result_text.push_str( + " Steering into the running turn was not possible (the target session was idle, its turn had just ended, the queue was congested, or the message carries a plan-todo binding that the steering channel cannot carry), so the urgent message was delivered as a normal submission instead of a mid-turn correction.", + ); + } + + Ok(DispatchOutcome { + target_session_id, + target_agent_type, + created_session_id, + workspace_path: workspace_target.workspace_path, + delivery: if steering_turn_id.is_some() { + "steered" + } else { + "submitted" + }, + result_text, + acp_response: None, + }) + } + + /// Batch dispatch: runs each item sequentially and independently. A failed + /// item never rolls back already-succeeded items and never stops later + /// items; the per-item result array keeps every session id so the caller + /// can skip succeeded items when retrying the failed ones. + async fn call_batch( + &self, + params: &SessionMessageInput, + items: &[BatchItem], + shared: &DispatchShared, + context: &ToolUseContext, + ) -> BitFunResult> { + let mut results = Vec::with_capacity(items.len()); + for item in items { + let item_params = SessionMessageInput { + workspace: params.workspace.clone(), + session_id: item.session_id.clone(), + session_name: item.session_name.clone(), + message: Some(item.message.clone()), + agent_type: item.agent_type.clone(), + urgent: item.urgent, + plan_file: item.plan_file.clone(), + todo_id: item.todo_id.clone(), + batch: None, + }; + match self.dispatch_single(item_params, shared, context).await { + Ok(outcome) => { + let result_text = outcome.result_text; + let mut item_data = json!({ + "status": "success", + "target_session_id": outcome.target_session_id, + "target_agent_type": outcome.target_agent_type, + "target_workspace": outcome.workspace_path, + "created_session_id": outcome.created_session_id, + "delivery": outcome.delivery, + "result": result_text, + }); + // ACP direct path: expose the external response verbatim. + if let Some(response) = outcome.acp_response.as_ref() { + item_data["response"] = json!(response); + } + results.push(item_data); + } + Err(error) => { + warn!( + "Batch SessionMessage item failed (successful items are not rolled back): session_name={:?}, session_id={:?}, error={}", + item.session_name, item.session_id, error + ); + results.push(json!({ + "status": "error", + "session_name": item.session_name.clone(), + "session_id": item.session_id.clone(), + "error": error.to_string(), + })); + } + } + } + + let (succeeded, failed, summary) = Self::summarize_batch_results(&results); Ok(vec![ToolResult::Result { data: json!({ "success": true, - "target_workspace": workspace_target.workspace_path.clone(), - "target_session_id": target_session_id.clone(), - "target_agent_type": target_agent_type.clone(), - "created_session_id": created_session_id.clone(), - }), - result_for_assistant: Some(if let Some(created_session_id) = created_session_id { - format!( - "Created session '{}' and accepted the message in workspace '{}' using agent type '{}'.", - created_session_id, workspace_target.workspace_path, target_agent_type - ) - } else { - format!( - "Message accepted for session '{}' in workspace '{}' using agent type '{}'.", - target_session_id, workspace_target.workspace_path, target_agent_type - ) + "total": results.len(), + "succeeded": succeeded, + "failed": failed, + "results": results, }), + result_for_assistant: Some(summary), image_attachments: None, }]) } + + /// Aggregates per-item outcomes into success/failed counts and the summary + /// text. Successful items are never rolled back; the summary tells the + /// caller to retry only the failed items using the per-item session ids. + fn summarize_batch_results(results: &[Value]) -> (usize, usize, String) { + let succeeded = results + .iter() + .filter(|result| result.get("status").and_then(Value::as_str) == Some("success")) + .count(); + let failed = results.len() - succeeded; + let mut summary = format!( + "Batch dispatch of {} message(s): {} succeeded, {} failed. Successful items are not rolled back; retry only the failed items (skip the succeeded session ids below).", + results.len(), + succeeded, + failed + ); + if failed > 0 { + summary.push_str( + " A failed item never rolls back earlier successes, and later items still ran.", + ); + } + (succeeded, failed, summary) + } } #[cfg(test)] mod tests { use super::*; + use crate::agentic::core::SessionConfig; + use crate::agentic::events::{EventQueue, EventQueueConfig, EventRouter}; + use crate::agentic::execution::{ + ExecutionEngine, ExecutionEngineConfig, RoundExecutor, StreamProcessor, + }; + use crate::agentic::persistence::PersistenceManager; + use crate::agentic::session::{ + compression::{CompressionConfig, ContextCompressor}, + PromptCachePolicy, SessionContextStore, SessionManager, SessionManagerConfig, + }; use crate::agentic::tools::framework::ToolUseContext; + use crate::agentic::tools::registry::ToolRegistry; + use crate::agentic::tools::{ToolPipeline, ToolStateManager}; use crate::agentic::WorkspaceBinding; + use crate::infrastructure::PathManager; use bitfun_core_types::{ SessionExecutionTarget, SessionExecutionTargetKind, WorktreeLifecycle, }; + use bitfun_runtime_ports::{ + PortError, PortErrorKind, PortResult, RuntimeServiceCapability, RuntimeServicePort, + }; use serde_json::json; use std::collections::HashMap; use std::fs; use std::path::PathBuf; + use std::sync::Mutex; + use std::time::Duration; + use tokio::sync::RwLock as TokioRwLock; use uuid::Uuid; fn empty_context() -> ToolUseContext { @@ -879,6 +2542,59 @@ mod tests { ); } + #[test] + fn acp_flow_client_id_parses_flow_session_id() { + assert_eq!( + SessionMessageTool::acp_flow_client_id_from_session_id( + "acp_codebuddy_7f0e1a2b-3c4d-4e5f-8a9b-0c1d2e3f4a5b" + ), + Some("codebuddy") + ); + } + + #[test] + fn acp_flow_client_id_parses_client_ids_with_underscores() { + assert_eq!( + SessionMessageTool::acp_flow_client_id_from_session_id( + "acp_claude_code_7f0e1a2b-3c4d-4e5f-8a9b-0c1d2e3f4a5b" + ), + Some("claude_code") + ); + } + + #[test] + fn acp_flow_client_id_rejects_non_flow_session_ids() { + // Internal session ids are not flow sessions even when they start with + // "acp_": the trailing segment must be a well-formed UUID. + assert_eq!( + SessionMessageTool::acp_flow_client_id_from_session_id("acp_codebuddy"), + None + ); + assert_eq!( + SessionMessageTool::acp_flow_client_id_from_session_id( + "acp_codebuddy_not-a-uuid" + ), + None + ); + assert_eq!( + SessionMessageTool::acp_flow_client_id_from_session_id("session-123"), + None + ); + assert_eq!( + SessionMessageTool::acp_flow_client_id_from_session_id(""), + None + ); + } + + #[test] + fn looks_like_uuid_accepts_only_canonical_shape() { + assert!(looks_like_uuid("7f0e1a2b-3c4d-4e5f-8a9b-0c1d2e3f4a5b")); + assert!(!looks_like_uuid("7f0e1a2b3c4d4e5f8a9b0c1d2e3f4a5b")); + assert!(!looks_like_uuid("7f0e1a2b-3c4d-4e5f-8a9b-0c1d2e3f4a5b-extra")); + assert!(!looks_like_uuid("")); + assert!(!looks_like_uuid("7f0e1a2b-3c4d-4e5f-8a9b-0c1d2e3f4a5")); + } + #[test] fn session_message_forwards_noninteractive_user_input_fact() { use bitfun_agent_runtime::user_questions::USER_INPUT_AVAILABLE_CONTEXT_KEY; @@ -888,13 +2604,41 @@ mod tests { USER_INPUT_AVAILABLE_CONTEXT_KEY.to_string(), Value::Bool(false), ); + let sender = SenderIdentity { + session_id: "source-1".to_string(), + role: Some("Commander".to_string()), + depth: Some(0), + name: Some("Mengdie".to_string()), + }; - let metadata = SessionMessageTool::forwarded_user_input_metadata(&context); + let metadata = SessionMessageTool::forwarded_user_input_metadata(&context, &sender); assert_eq!( metadata.get(USER_INPUT_AVAILABLE_CONTEXT_KEY), Some(&Value::Bool(false)) ); + assert_eq!(metadata.get("senderSessionId"), Some(&Value::String("source-1".to_string()))); + assert_eq!(metadata.get("senderRole"), Some(&Value::String("Commander".to_string()))); + assert_eq!(metadata.get("senderDepth"), Some(&Value::from(0))); + assert_eq!(metadata.get("senderName"), Some(&Value::String("Mengdie".to_string()))); + } + + #[test] + fn forwarded_metadata_omits_unknown_sender_fields() { + let context = empty_context(); + let sender = SenderIdentity { + session_id: "source-2".to_string(), + role: None, + depth: None, + name: None, + }; + + let metadata = SessionMessageTool::forwarded_user_input_metadata(&context, &sender); + + assert_eq!(metadata.get("senderSessionId"), Some(&Value::String("source-2".to_string()))); + assert!(!metadata.contains_key("senderRole")); + assert!(!metadata.contains_key("senderDepth")); + assert!(!metadata.contains_key("senderName")); } #[test] @@ -919,6 +2663,9 @@ mod tests { turn_count: 0, created_at_ms: 1, last_active_at_ms: 2, + parent_session_id: None, + status: None, + is_daemon: false, }]; assert_eq!( @@ -940,6 +2687,9 @@ mod tests { turn_count: 0, created_at_ms: 1, last_active_at_ms: 2, + parent_session_id: None, + status: None, + is_daemon: false, }]; assert_eq!( @@ -1039,14 +2789,19 @@ mod tests { } #[tokio::test] - async fn validate_existing_session_allows_missing_workspace() { + async fn validate_new_session_accepts_plan_todo_binding() { let tool = SessionMessageTool::new(); + let workspace = TestTempDir::new("bitfun-session-message-tool-test"); let validation = tool .validate_input( &json!({ - "session_id": "worker_1", + "workspace": workspace.as_string(), "message": "hello", + "session_name": "Worker Session", + "agent_type": "agentic", + "plan_file": "my_plan_1234.plan.md", + "todo_id": "setup-auth", }), Some(&session_context("source_1")), ) @@ -1056,15 +2811,18 @@ mod tests { } #[tokio::test] - async fn validate_new_session_requires_workspace() { + async fn validate_new_session_rejects_plan_file_without_todo_id() { let tool = SessionMessageTool::new(); + let workspace = TestTempDir::new("bitfun-session-message-tool-test"); let validation = tool .validate_input( &json!({ + "workspace": workspace.as_string(), "message": "hello", "session_name": "Worker Session", "agent_type": "agentic", + "plan_file": "my_plan_1234.plan.md", }), Some(&session_context("source_1")), ) @@ -1073,7 +2831,1420 @@ mod tests { assert!(!validation.result); assert_eq!( validation.message.as_deref(), - Some("workspace is required when session_id is omitted") + Some("plan_file and todo_id must be provided together") + ); + } + + #[tokio::test] + async fn validate_new_session_rejects_todo_id_without_plan_file() { + let tool = SessionMessageTool::new(); + let workspace = TestTempDir::new("bitfun-session-message-tool-test"); + + let validation = tool + .validate_input( + &json!({ + "workspace": workspace.as_string(), + "message": "hello", + "session_name": "Worker Session", + "agent_type": "agentic", + "todo_id": "setup-auth", + }), + Some(&session_context("source_1")), + ) + .await; + + assert!(!validation.result); + assert_eq!( + validation.message.as_deref(), + Some("plan_file and todo_id must be provided together") + ); + } + + #[tokio::test] + async fn validate_existing_session_rejects_plan_todo_binding() { + let tool = SessionMessageTool::new(); + + let validation = tool + .validate_input( + &json!({ + "workspace": "C:/work", + "session_id": "worker_1", + "message": "hello", + "plan_file": "my_plan_1234.plan.md", + "todo_id": "setup-auth", + }), + Some(&session_context("source_1")), + ) + .await; + + assert!(!validation.result); + assert_eq!( + validation.message.as_deref(), + Some("plan_file/todo_id binding is only allowed when session_id is omitted") + ); + } + + #[test] + fn session_message_input_parses_plan_todo_binding() { + let input: SessionMessageInput = serde_json::from_value(json!({ + "workspace": "C:/work", + "message": "hello", + "session_name": "Worker Session", + "agent_type": "agentic", + "plan_file": "my_plan_1234.plan.md", + "todo_id": "setup-auth", + })) + .expect("payload with plan-todo binding must parse"); + + assert_eq!( + input.plan_file.as_deref(), + Some("my_plan_1234.plan.md") + ); + assert_eq!(input.todo_id.as_deref(), Some("setup-auth")); + } + + #[tokio::test] + async fn validate_existing_session_allows_missing_workspace() { + let tool = SessionMessageTool::new(); + + let validation = tool + .validate_input( + &json!({ + "session_id": "worker_1", + "message": "hello", + }), + Some(&session_context("source_1")), + ) + .await; + + assert!(validation.result, "{:?}", validation.message); + } + + #[tokio::test] + async fn validate_new_session_requires_workspace() { + let tool = SessionMessageTool::new(); + + let validation = tool + .validate_input( + &json!({ + "message": "hello", + "session_name": "Worker Session", + "agent_type": "agentic", + }), + Some(&session_context("source_1")), + ) + .await; + + assert!(!validation.result); + assert_eq!( + validation.message.as_deref(), + Some("workspace is required when session_id is omitted") + ); + } + + #[test] + fn session_message_input_defaults_urgent_to_false_for_backward_compat() { + let input: SessionMessageInput = serde_json::from_value(json!({ + "session_id": "worker_1", + "message": "hello", + })) + .expect("legacy payload without urgent must parse"); + + assert!(!input.urgent); + } + + #[test] + fn session_message_input_parses_urgent_flag() { + let input: SessionMessageInput = serde_json::from_value(json!({ + "session_id": "worker_1", + "message": "stop what you are doing and correct this", + "urgent": true, + })) + .expect("payload with urgent must parse"); + + assert!(input.urgent); + } + + #[test] + fn urgent_delivery_steers_into_a_processing_turn() { + assert_eq!( + resolve_urgent_delivery(Some("turn-7".to_string())), + UrgentDelivery::Steer { + turn_id: "turn-7".to_string() + } + ); + } + + #[test] + fn urgent_delivery_falls_back_to_normal_submit_for_idle_session() { + assert_eq!(resolve_urgent_delivery(None), UrgentDelivery::NormalSubmit); + } + + #[test] + fn urgent_message_to_existing_session_attempts_steering_channel() { + assert!(should_attempt_steering(true, None, false)); + } + + #[test] + fn urgent_message_to_new_session_uses_normal_channel_only() { + assert!(!should_attempt_steering(true, Some("new-session-1"), false)); + } + + #[test] + fn urgent_message_with_plan_todo_binding_uses_normal_channel_only() { + // The steering channel carries no plan-todo binding metadata, so a + // bound dispatch must fall back to the normal submission channel that + // preserves the binding and the reply route (COORD-01). + assert!(!should_attempt_steering(true, None, true)); + assert!(!should_attempt_steering(true, Some("new-session-1"), true)); + } + + #[test] + fn non_urgent_message_never_attempts_steering_channel() { + assert!(!should_attempt_steering(false, None, false)); + assert!(!should_attempt_steering(false, Some("new-session-1"), false)); + assert!(!should_attempt_steering(false, None, true)); + } + + #[test] + fn forwarded_reminder_includes_full_sender_identity() { + let sender = SenderIdentity { + session_id: "source-1".to_string(), + role: Some("Commander".to_string()), + depth: Some(0), + name: Some("Mengdie".to_string()), + }; + let (message, reminders) = + SessionMessageTool::new().format_forwarded_message("hello", &sender); + assert_eq!(message, "hello"); + assert_eq!(reminders.len(), 1); + let reminder = &reminders[0]; + assert_eq!(reminder.kind, "session_message_request"); + assert!(reminder.text.contains("[Commander L0]")); + assert!(reminder.text.contains("Mengdie")); + assert!(reminder.text.contains("(session source-1)")); + assert!(reminder.text.contains("not the human user")); + assert!(reminder.text.contains("From session: source-1")); + assert!(reminder.text.contains("From role: Commander")); + assert!(reminder.text.contains("From depth: 0")); + assert!(reminder.text.contains("From agent: Mengdie")); + } + + #[test] + fn forwarded_reminder_falls_back_when_role_is_unregistered() { + let sender = SenderIdentity { + session_id: "source-2".to_string(), + role: None, + depth: Some(2), + name: None, + }; + let (_, reminders) = SessionMessageTool::new().format_forwarded_message("hello", &sender); + let text = &reminders[0].text; + assert!(text.contains("[Agent L2]")); + assert!(text.contains("(session source-2)")); + assert!(text.contains("From role: Agent")); + assert!(text.contains("From depth: 2")); + assert!(!text.contains("From agent:")); + } + + #[test] + fn forwarded_reminder_omits_depth_when_unknown() { + let sender = SenderIdentity { + session_id: "source-3".to_string(), + role: Some("Executor".to_string()), + depth: None, + name: Some("Worker".to_string()), + }; + let (_, reminders) = SessionMessageTool::new().format_forwarded_message("hello", &sender); + assert!(reminders[0] + .text + .contains("[Executor] Worker (session source-3)")); + assert!(!reminders[0].text.contains("From depth:")); + assert!(reminders[0].text.contains("From agent: Worker")); + } + + #[test] + fn forwarded_reminder_always_identifies_session() { + let sender = SenderIdentity { + session_id: "source-4".to_string(), + role: None, + depth: None, + name: None, + }; + let (_, reminders) = SessionMessageTool::new().format_forwarded_message("hello", &sender); + assert!(reminders[0].text.contains("[Agent] (session source-4)")); + assert!(reminders[0].text.contains("From session: source-4")); + assert!(reminders[0].text.contains("From role: Agent")); + assert!(!reminders[0].text.contains("From depth:")); + assert!(!reminders[0].text.contains("From agent:")); + } + + #[test] + fn role_display_title_cases_snake_case_keys() { + assert_eq!(format_role_display("commander"), "Commander"); + assert_eq!(format_role_display("punishment_executor"), "PunishmentExecutor"); + } + + #[test] + fn session_message_input_parses_batch_items() { + let input: SessionMessageInput = serde_json::from_value(json!({ + "workspace": "C:/work", + "batch": [ + { + "session_name": "Worker One", + "message": "hello one", + "agent_type": "agentic" + }, + { + "session_id": "worker_2", + "message": "hello two", + "urgent": true + } + ] + })) + .expect("payload with batch must parse"); + + let batch = input.batch.expect("batch must be present"); + assert_eq!(batch.len(), 2); + assert_eq!(batch[0].session_name.as_deref(), Some("Worker One")); + assert_eq!(batch[0].message, "hello one"); + assert_eq!(batch[0].agent_type.as_ref().map(AgentType::as_str), Some("agentic")); + assert!(batch[0].session_id.is_none()); + assert!(!batch[0].urgent); + assert_eq!(batch[1].session_id.as_deref(), Some("worker_2")); + assert!(batch[1].urgent); + assert!(batch[1].session_name.is_none()); + assert!(batch[1].agent_type.is_none()); + } + + #[test] + fn session_message_input_batch_defaults_to_none_for_backward_compat() { + let input: SessionMessageInput = serde_json::from_value(json!({ + "session_id": "worker_1", + "message": "hello", + })) + .expect("legacy payload without batch must parse"); + + assert!(input.batch.is_none()); + } + + #[test] + fn session_message_input_allows_omitting_top_level_message_for_batch() { + let input: SessionMessageInput = serde_json::from_value(json!({ + "workspace": "C:/work", + "batch": [ + { + "session_name": "Worker One", + "message": "hello", + "agent_type": "agentic" + } + ] + })) + .expect("batch payload without top-level message must parse"); + + assert!(input.message.is_none()); + assert_eq!(input.batch.as_ref().expect("batch must be present").len(), 1); + } + + #[tokio::test] + async fn validate_batch_rejects_empty_batch() { + let tool = SessionMessageTool::new(); + let workspace = TestTempDir::new("bitfun-session-message-tool-test"); + + let validation = tool + .validate_input( + &json!({ + "workspace": workspace.as_string(), + "batch": [], + }), + Some(&session_context("source_1")), + ) + .await; + + assert!(!validation.result); + assert_eq!(validation.message.as_deref(), Some("batch cannot be empty")); + } + + #[tokio::test] + async fn validate_batch_rejects_top_level_message() { + let tool = SessionMessageTool::new(); + let workspace = TestTempDir::new("bitfun-session-message-tool-test"); + + let validation = tool + .validate_input( + &json!({ + "workspace": workspace.as_string(), + "message": "hello", + "batch": [ + { + "session_name": "Worker One", + "message": "hello one", + "agent_type": "agentic" + } + ], + }), + Some(&session_context("source_1")), + ) + .await; + + assert!(!validation.result); + assert_eq!( + validation.message.as_deref(), + Some("message cannot be combined with batch") + ); + } + + #[tokio::test] + async fn validate_batch_rejects_top_level_session_fields() { + let tool = SessionMessageTool::new(); + let workspace = TestTempDir::new("bitfun-session-message-tool-test"); + + let validation = tool + .validate_input( + &json!({ + "workspace": workspace.as_string(), + "session_id": "worker_1", + "batch": [ + { + "session_name": "Worker One", + "message": "hello one", + "agent_type": "agentic" + } + ], + }), + Some(&session_context("source_1")), + ) + .await; + + assert!(!validation.result); + assert_eq!( + validation.message.as_deref(), + Some("session fields must be provided per batch item when batch is used") + ); + } + + #[tokio::test] + async fn validate_batch_rejects_missing_workspace_for_create_item() { + let tool = SessionMessageTool::new(); + + let validation = tool + .validate_input( + &json!({ + "batch": [ + { + "session_name": "Worker One", + "message": "hello one", + "agent_type": "agentic" + } + ], + }), + Some(&session_context("source_1")), + ) + .await; + + assert!(!validation.result); + assert_eq!( + validation.message.as_deref(), + Some("workspace is required when a batch item omits session_id") + ); + } + + #[tokio::test] + async fn validate_batch_rejects_item_missing_session_name() { + let tool = SessionMessageTool::new(); + let workspace = TestTempDir::new("bitfun-session-message-tool-test"); + + let validation = tool + .validate_input( + &json!({ + "workspace": workspace.as_string(), + "batch": [ + { + "message": "hello one", + "agent_type": "agentic" + } + ], + }), + Some(&session_context("source_1")), + ) + .await; + + assert!(!validation.result); + assert_eq!( + validation.message.as_deref(), + Some("batch[0].session_name is required when session_id is omitted") + ); + } + + #[tokio::test] + async fn validate_batch_rejects_item_missing_agent_type() { + let tool = SessionMessageTool::new(); + let workspace = TestTempDir::new("bitfun-session-message-tool-test"); + + let validation = tool + .validate_input( + &json!({ + "workspace": workspace.as_string(), + "batch": [ + { + "session_name": "Worker One", + "message": "hello one" + } + ], + }), + Some(&session_context("source_1")), + ) + .await; + + assert!(!validation.result); + assert_eq!( + validation.message.as_deref(), + Some("batch[0].agent_type is required when session_id is omitted") + ); + } + + #[tokio::test] + async fn validate_batch_rejects_item_empty_message() { + let tool = SessionMessageTool::new(); + let workspace = TestTempDir::new("bitfun-session-message-tool-test"); + + let validation = tool + .validate_input( + &json!({ + "workspace": workspace.as_string(), + "batch": [ + { + "session_name": "Worker One", + "message": " ", + "agent_type": "agentic" + } + ], + }), + Some(&session_context("source_1")), + ) + .await; + + assert!(!validation.result); + assert_eq!( + validation.message.as_deref(), + Some("batch[0].message cannot be empty") + ); + } + + #[tokio::test] + async fn validate_batch_rejects_self_session_item() { + let tool = SessionMessageTool::new(); + let workspace = TestTempDir::new("bitfun-session-message-tool-test"); + + let validation = tool + .validate_input( + &json!({ + "workspace": workspace.as_string(), + "batch": [ + { + "session_id": "source_1", + "message": "hello one" + } + ], + }), + Some(&session_context("source_1")), + ) + .await; + + assert!(!validation.result); + assert_eq!( + validation.message.as_deref(), + Some("batch[0].session_id cannot send a message to the same session") + ); + } + + #[tokio::test] + async fn validate_batch_rejects_item_plan_without_todo() { + let tool = SessionMessageTool::new(); + let workspace = TestTempDir::new("bitfun-session-message-tool-test"); + + let validation = tool + .validate_input( + &json!({ + "workspace": workspace.as_string(), + "batch": [ + { + "session_name": "Worker One", + "message": "hello one", + "agent_type": "agentic", + "plan_file": "my_plan_1234.plan.md" + } + ], + }), + Some(&session_context("source_1")), + ) + .await; + + assert!(!validation.result); + assert_eq!( + validation.message.as_deref(), + Some("batch[0].plan_file and batch[0].todo_id must be provided together") + ); + } + + #[tokio::test] + async fn validate_batch_rejects_item_session_name_with_session_id() { + let tool = SessionMessageTool::new(); + let workspace = TestTempDir::new("bitfun-session-message-tool-test"); + + let validation = tool + .validate_input( + &json!({ + "workspace": workspace.as_string(), + "batch": [ + { + "session_id": "worker_1", + "session_name": "Worker One", + "message": "hello one" + } + ], + }), + Some(&session_context("source_1")), + ) + .await; + + assert!(!validation.result); + assert_eq!( + validation.message.as_deref(), + Some("batch[0].session_name is only allowed when session_id is omitted") + ); + } + + #[tokio::test] + async fn validate_batch_rejects_item_agent_type_with_session_id() { + let tool = SessionMessageTool::new(); + let workspace = TestTempDir::new("bitfun-session-message-tool-test"); + + let validation = tool + .validate_input( + &json!({ + "workspace": workspace.as_string(), + "batch": [ + { + "session_id": "worker_1", + "message": "hello one", + "agent_type": "agentic" + } + ], + }), + Some(&session_context("source_1")), + ) + .await; + + assert!(!validation.result); + assert_eq!( + validation.message.as_deref(), + Some("batch[0].agent_type override is not allowed when session_id is provided") + ); + } + + #[tokio::test] + async fn validate_batch_rejects_item_plan_binding_with_session_id() { + let tool = SessionMessageTool::new(); + let workspace = TestTempDir::new("bitfun-session-message-tool-test"); + + let validation = tool + .validate_input( + &json!({ + "workspace": workspace.as_string(), + "batch": [ + { + "session_id": "worker_1", + "message": "hello one", + "plan_file": "my_plan_1234.plan.md", + "todo_id": "setup-auth" + } + ], + }), + Some(&session_context("source_1")), + ) + .await; + + assert!(!validation.result); + assert_eq!( + validation.message.as_deref(), + Some("batch[0].plan_file/todo_id binding is only allowed when session_id is omitted") + ); + } + + #[tokio::test] + async fn validate_batch_accepts_all_create_items() { + let tool = SessionMessageTool::new(); + let workspace = TestTempDir::new("bitfun-session-message-tool-test"); + + let validation = tool + .validate_input( + &json!({ + "workspace": workspace.as_string(), + "batch": [ + { + "session_name": "Worker One", + "message": "hello one", + "agent_type": "agentic" + }, + { + "session_name": "Worker Two", + "message": "hello two", + "agent_type": "Plan" + } + ], + }), + Some(&session_context("source_1")), + ) + .await; + + assert!(validation.result, "{:?}", validation.message); + } + + #[tokio::test] + async fn validate_batch_accepts_mixed_send_and_create_items() { + let tool = SessionMessageTool::new(); + let workspace = TestTempDir::new("bitfun-session-message-tool-test"); + + let validation = tool + .validate_input( + &json!({ + "workspace": workspace.as_string(), + "batch": [ + { + "session_id": "worker_1", + "message": "hello existing" + }, + { + "session_name": "Worker Two", + "message": "hello new", + "agent_type": "agentic" + } + ], + }), + Some(&session_context("source_1")), + ) + .await; + + assert!(validation.result, "{:?}", validation.message); + } + + #[tokio::test] + async fn validate_batch_accepts_item_plan_todo_binding_and_urgent() { + let tool = SessionMessageTool::new(); + let workspace = TestTempDir::new("bitfun-session-message-tool-test"); + + let validation = tool + .validate_input( + &json!({ + "workspace": workspace.as_string(), + "batch": [ + { + "session_name": "Worker One", + "message": "hello one", + "agent_type": "agentic", + "plan_file": "my_plan_1234.plan.md", + "todo_id": "setup-auth" + }, + { + "session_id": "worker_1", + "message": "urgent hello", + "urgent": true + } + ], + }), + Some(&session_context("source_1")), + ) + .await; + + assert!(validation.result, "{:?}", validation.message); + } + + #[test] + fn batch_summary_counts_success_and_failure() { + let results = vec![ + json!({ + "status": "success", + "target_session_id": "session-1", + "created_session_id": "session-1", + }), + json!({ + "status": "error", + "error": "session not found", + }), + json!({ + "status": "error", + "error": "workspace mismatch", + }), + ]; + + let (succeeded, failed, summary) = SessionMessageTool::summarize_batch_results(&results); + + assert_eq!(succeeded, 1); + assert_eq!(failed, 2); + assert!(summary.contains("3 message(s): 1 succeeded, 2 failed")); + assert!(summary.contains("Successful items are not rolled back")); + assert!(summary.contains("A failed item never rolls back earlier successes")); + } + + #[test] + fn batch_summary_omits_partial_failure_note_when_all_succeed() { + let results = vec![ + json!({ + "status": "success", + "target_session_id": "session-1", + }), + json!({ + "status": "success", + "target_session_id": "session-2", + }), + ]; + + let (succeeded, failed, summary) = SessionMessageTool::summarize_batch_results(&results); + + assert_eq!(succeeded, 2); + assert_eq!(failed, 0); + assert!(summary.contains("2 message(s): 2 succeeded, 0 failed")); + assert!(!summary.contains("A failed item never rolls back")); + } + + /// Minimal ACP port recording `send_message_to_bitfun_session` calls; + /// the remaining trait methods are not exercised by these tests. + #[derive(Debug, Default)] + struct FakeAcpPort { + bitfun_messages: Mutex>, + flow_messages: Mutex>, + fail_send: bool, + } + + impl RuntimeServicePort for FakeAcpPort { + fn capability(&self) -> RuntimeServiceCapability { + RuntimeServiceCapability::AcpClient + } + } + + #[async_trait] + impl AcpClientPort for FakeAcpPort { + async fn create_session( + &self, + _request: bitfun_runtime_ports::AcpClientCreateRequest, + ) -> PortResult { + Err(PortError::new( + PortErrorKind::Backend, + "not exercised by the ACP direct-path tests", + )) + } + + async fn list_clients( + &self, + ) -> PortResult { + Err(PortError::new( + PortErrorKind::Backend, + "not exercised by the ACP direct-path tests", + )) + } + + async fn release_session( + &self, + _request: bitfun_runtime_ports::AcpClientReleaseRequest, + ) -> PortResult<()> { + Err(PortError::new( + PortErrorKind::Backend, + "not exercised by the ACP direct-path tests", + )) + } + + async fn cancel_session( + &self, + _request: bitfun_runtime_ports::AcpClientCancelRequest, + ) -> PortResult<()> { + Err(PortError::new( + PortErrorKind::Backend, + "not exercised by the ACP direct-path tests", + )) + } + + async fn send_message( + &self, + request: bitfun_runtime_ports::AcpClientMessageRequest, + ) -> PortResult { + if self.fail_send { + return Err(PortError::new( + PortErrorKind::Backend, + "simulated external agent failure", + )); + } + self.flow_messages.lock().unwrap().push(request.clone()); + Ok(bitfun_runtime_ports::AcpClientMessageResult { + session_id: request.session_id, + response: "external response".to_string(), + }) + } + + async fn send_message_stream( + &self, + request: bitfun_runtime_ports::AcpClientMessageRequest, + chunk_sink: AcpClientStreamChunkSink, + ) -> PortResult { + if self.fail_send { + return Err(PortError::new( + PortErrorKind::Backend, + "simulated external agent failure", + )); + } + self.flow_messages.lock().unwrap().push(request.clone()); + let _ = chunk_sink.send(AcpClientStreamChunk::Text { + text: "external response".to_string(), + }); + let _ = chunk_sink.send(AcpClientStreamChunk::Completed); + Ok(bitfun_runtime_ports::AcpClientMessageResult { + session_id: request.session_id, + response: "external response".to_string(), + }) + } + + async fn send_message_to_bitfun_session( + &self, + request: AcpClientBitfunMessageRequest, + ) -> PortResult { + if self.fail_send { + return Err(PortError::new( + PortErrorKind::Backend, + "simulated external agent failure", + )); + } + self.bitfun_messages.lock().unwrap().push(request.clone()); + Ok(bitfun_runtime_ports::AcpClientMessageResult { + session_id: request.bitfun_session_id, + response: "external response".to_string(), + }) + } + + async fn send_message_to_bitfun_session_stream( + &self, + request: AcpClientBitfunMessageRequest, + chunk_sink: AcpClientStreamChunkSink, + ) -> PortResult { + if self.fail_send { + return Err(PortError::new( + PortErrorKind::Backend, + "simulated external agent failure", + )); + } + self.bitfun_messages.lock().unwrap().push(request.clone()); + let _ = chunk_sink.send(AcpClientStreamChunk::Text { + text: "external response".to_string(), + }); + let _ = chunk_sink.send(AcpClientStreamChunk::Completed); + Ok(bitfun_runtime_ports::AcpClientMessageResult { + session_id: request.bitfun_session_id, + response: "external response".to_string(), + }) + } + + async fn delete_session_record( + &self, + _session_id: String, + _workspace_path: Option, + ) -> PortResult<()> { + Ok(()) + } + + async fn read_history( + &self, + _request: bitfun_runtime_ports::AcpClientHistoryRequest, + ) -> PortResult { + Err(PortError::new( + PortErrorKind::Backend, + "not exercised by the ACP direct-path tests", + )) + } + } + + /// Builds a real coordinator + scheduler harness so the async ACP direct + /// delivery can be observed end to end (events + port forwarding). Mirrors + /// the scheduler test harness. + #[allow(clippy::type_complexity)] + fn test_acp_delivery_harness() -> ( + Arc, + Arc, + Arc, + Arc, + tempfile::TempDir, + ) { + let root = tempfile::tempdir().expect("test root"); + let event_queue = Arc::new(EventQueue::new(EventQueueConfig::default())); + let session_manager = Arc::new(SessionManager::new( + Arc::new(SessionContextStore::new()), + Arc::new( + PersistenceManager::new(Arc::new(PathManager::with_user_root_for_tests( + root.path().join("user-root"), + ))) + .expect("persistence manager"), + ), + SessionManagerConfig { + max_active_sessions: 100, + session_idle_timeout: Duration::from_secs(3600), + auto_save_interval: Duration::from_secs(300), + enable_persistence: false, + prompt_cache_policy: PromptCachePolicy::default(), + }, + )); + let tool_pipeline = Arc::new(ToolPipeline::new( + Arc::new(TokioRwLock::new(ToolRegistry::new())), + Arc::new(ToolStateManager::new(event_queue.clone())), + None, + )); + let execution_engine = Arc::new(ExecutionEngine::new( + Arc::new(RoundExecutor::new( + Arc::new(StreamProcessor::new(event_queue.clone())), + event_queue.clone(), + tool_pipeline.clone(), + )), + event_queue.clone(), + session_manager.clone(), + Arc::new(ContextCompressor::new(CompressionConfig::default())), + ExecutionEngineConfig::default(), + )); + let coordinator = Arc::new(ConversationCoordinator::new( + session_manager.clone(), + execution_engine, + tool_pipeline, + event_queue.clone(), + Arc::new(EventRouter::new()), + Arc::new( + crate::runtime_ownership::CoreRuntimeOwnership::embedded_with_facts( + std::env::temp_dir().join(format!( + "bitfun-session-message-ownership-test-{}", + Uuid::new_v4() + )), + "bitfun".to_string(), + "test", + ), + ), + )); + let scheduler = DialogScheduler::new(coordinator.clone(), session_manager.clone()); + scheduler.set_agent_reply_archive_root(root.path().join("agent-replies")); + (coordinator, scheduler, session_manager, event_queue, root) + } + + #[test] + fn acp_client_id_is_extracted_from_agent_type_prefix() { + assert_eq!( + SessionMessageTool::acp_client_id_from_agent_type("acp__codex"), + Some("codex") + ); + assert_eq!( + SessionMessageTool::acp_client_id_from_agent_type("acp__Claude Code"), + Some("Claude Code") + ); + assert_eq!( + SessionMessageTool::acp_client_id_from_agent_type("agentic"), + None + ); + assert_eq!(SessionMessageTool::acp_client_id_from_agent_type("Plan"), None); + // A flow session id (acp__) is not an agent type prefix. + assert_eq!( + SessionMessageTool::acp_client_id_from_agent_type("acp_codex_abc123"), + None + ); + assert_eq!(SessionMessageTool::acp_client_id_from_agent_type(""), None); + // A bare prefix with no client id is rejected (empty client id). + assert_eq!(SessionMessageTool::acp_client_id_from_agent_type("acp__"), None); + } + + #[tokio::test] + async fn acp_direct_send_forwards_through_bitfun_port() { + let port = FakeAcpPort::default(); + let request = AcpClientBitfunMessageRequest { + client_id: "codex".to_string(), + bitfun_session_id: "session-internal-1".to_string(), + message: "hello external agent".to_string(), + workspace_path: Some("/repo/project".to_string()), + timeout_seconds: Some(ACP_DIRECT_TIMEOUT_SECONDS), + }; + let (chunk_tx, mut chunk_rx) = tokio::sync::mpsc::unbounded_channel(); + let response = SessionMessageTool::acp_direct_send_stream( + &port, + AcpDirectSendOp::Bitfun(request.clone()), + chunk_tx, + ) + .await + .expect("direct path should succeed"); + + let messages = port.bitfun_messages.lock().unwrap(); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].client_id, "codex"); + assert_eq!(messages[0].bitfun_session_id, "session-internal-1"); + assert_eq!(messages[0].message, "hello external agent"); + assert_eq!(messages[0].workspace_path.as_deref(), Some("/repo/project")); + // The async direct path now carries a bounded window instead of the + // old unbounded `None`. + assert_eq!(messages[0].timeout_seconds, Some(ACP_DIRECT_TIMEOUT_SECONDS)); + + // The external response is returned verbatim, no re-translation. + assert_eq!(response.response, "external response"); + // The response is also streamed as per-chunk text. + let streamed = chunk_rx.try_recv().expect("streamed text chunk"); + assert!(matches!( + streamed, + AcpClientStreamChunk::Text { text } if text == "external response" + )); + } + + #[tokio::test] + async fn acp_direct_send_propagates_port_failure() { + let port = FakeAcpPort { + fail_send: true, + ..FakeAcpPort::default() + }; + let (chunk_tx, _chunk_rx) = tokio::sync::mpsc::unbounded_channel(); + let error = SessionMessageTool::acp_direct_send_stream( + &port, + AcpDirectSendOp::Bitfun(AcpClientBitfunMessageRequest { + client_id: "codex".to_string(), + bitfun_session_id: "session-internal-1".to_string(), + message: "hello".to_string(), + workspace_path: None, + timeout_seconds: Some(ACP_DIRECT_TIMEOUT_SECONDS), + }), + chunk_tx, + ) + .await + .unwrap_err(); + assert!(error.message.contains("simulated external agent failure")); + } + + #[tokio::test] + async fn acp_direct_delivery_streams_events_and_forwards_port_call() { + let (coordinator, _scheduler, session_manager, event_queue, root) = + test_acp_delivery_harness(); + let source_session_id = "source-session"; + let workspace = root.path().join("workspace"); + std::fs::create_dir_all(&workspace).expect("workspace"); + session_manager + .create_session_with_id( + Some(source_session_id.to_string()), + "Source".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.to_string_lossy().into_owned()), + ..Default::default() + }, + ) + .await + .expect("create source session"); + + let port = Arc::new(FakeAcpPort::default()); + let target_session_id = "acp_codex_7f0e1a2b-3c4d-4e5f-8a9b-0c1d2e3f4a5b".to_string(); + let mut event_rx = event_queue.subscribe(); + + SessionMessageTool::spawn_acp_direct_delivery( + port.clone(), + AcpDirectSendOp::Flow(AcpClientMessageRequest { + session_id: target_session_id.clone(), + message: "hello external agent".to_string(), + workspace_path: Some(workspace.to_string_lossy().into_owned()), + timeout_seconds: Some(ACP_DIRECT_TIMEOUT_SECONDS), + }), + coordinator, + _scheduler.clone(), + target_session_id.clone(), + "hello external agent".to_string(), + AcpDirectReplySource { + source_session_id: source_session_id.to_string(), + source_workspace: workspace.to_string_lossy().into_owned(), + source_remote_connection_id: None, + source_remote_ssh_host: None, + }, + ); + + // The delivery runs in a background task; wait for the streamed turn + // events and the port call (bounded timeout, not the old `None`). + // Note: the follow-up reply back to the source session is not asserted + // here because a model-less unit-test host cannot run the follow-up + // turn; it is covered by `deliver_background_result`'s own tests. + let mut saw_started = false; + let mut saw_round_started = false; + let mut saw_text = false; + let mut saw_round_completed = false; + let mut saw_completed = false; + // "complete" 是前端 NORMAL_FINISH_REASONS 内的正常终止码,非标准方式结束 + // 横幅不会误报(参照 web-ui flow_chat/utils/turnCompletionNotice.ts)。 + let mut saw_complete_finish = false; + for _ in 0..200 { + while let Ok(envelope) = event_rx.try_recv() { + match &envelope.event { + AgenticEvent::DialogTurnStarted { session_id, .. } + if session_id == &target_session_id => + { + saw_started = true; + } + AgenticEvent::ModelRoundStarted { session_id, .. } + if session_id == &target_session_id => + { + saw_round_started = true; + } + AgenticEvent::TextChunk { session_id, text, .. } + if session_id == &target_session_id => + { + saw_text = text == "external response"; + } + AgenticEvent::ModelRoundCompleted { session_id, .. } + if session_id == &target_session_id => + { + saw_round_completed = true; + } + AgenticEvent::DialogTurnCompleted { + session_id, + finish_reason, + .. + } if session_id == &target_session_id => { + saw_completed = true; + saw_complete_finish = finish_reason.as_deref() == Some("complete"); + } + _ => {} + } + } + let delivered = { + let messages = port.flow_messages.lock().unwrap(); + saw_started + && saw_round_started + && saw_text + && saw_round_completed + && saw_completed + && saw_complete_finish + && messages.len() == 1 + && messages[0].timeout_seconds == Some(ACP_DIRECT_TIMEOUT_SECONDS) + }; + if delivered { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!( + "ACP direct delivery did not stream turn events and forward the port call: saw_started={}, saw_round_started={}, saw_text={}, saw_round_completed={}, saw_completed={}, saw_complete_finish={}", + saw_started, saw_round_started, saw_text, saw_round_completed, saw_completed, saw_complete_finish + ); + } + + #[test] + fn acp_direct_response_notice_excludes_full_response() { + let full_reply = format!("EXTERNAL_REPLY_MARKER_{}", "x".repeat(4096)); + let notice = acp_direct_response_notice(&full_reply, "session-abc"); + assert!(!notice.contains("EXTERNAL_REPLY_MARKER_")); + assert!(notice.contains("session-abc")); + assert!(notice.contains("SessionHistory")); + } + + #[test] + fn acp_direct_delivery_workspace_path_extracts_from_ops() { + assert_eq!( + acp_direct_delivery_workspace_path(&AcpDirectSendOp::Flow(AcpClientMessageRequest { + session_id: "acp_codex_7f0e1a2b-3c4d-4e5f-8a9b-0c1d2e3f4a5b".to_string(), + message: "m".to_string(), + workspace_path: Some("/repo/project".to_string()), + timeout_seconds: None, + })), + Some("/repo/project") + ); + assert_eq!( + acp_direct_delivery_workspace_path(&AcpDirectSendOp::Bitfun( + AcpClientBitfunMessageRequest { + client_id: "codex".to_string(), + bitfun_session_id: "session-internal-1".to_string(), + message: "m".to_string(), + workspace_path: None, + timeout_seconds: None, + }, + )), + None + ); + } + + #[test] + fn build_acp_direct_delivery_turn_maps_response_and_status() { + let turn = build_acp_direct_delivery_turn( + "turn-1", + 3, + "acp_codex_7f0e1a2b-3c4d-4e5f-8a9b-0c1d2e3f4a5b", + "hello", + "round-1", + 1000, + "external response", + crate::service::session::TurnStatus::Completed, + None, + ); + assert_eq!(turn.turn_index, 3); + assert_eq!(turn.user_message.content, "hello"); + assert_eq!(turn.model_rounds.len(), 1); + assert_eq!(turn.model_rounds[0].round_index, 0); + assert_eq!(turn.model_rounds[0].text_items.len(), 1); + assert_eq!(turn.model_rounds[0].text_items[0].content, "external response"); + assert_eq!(turn.status, crate::service::session::TurnStatus::Completed); + assert!(turn.end_time.is_some()); + assert!(turn.error.is_none()); + + // 失败 turn:status=Error + error 字段,空回复不产生文本项。 + let failed = build_acp_direct_delivery_turn( + "turn-2", + 4, + "acp_codex_7f0e1a2b-3c4d-4e5f-8a9b-0c1d2e3f4a5b", + "hello", + "round-2", + 2000, + "", + crate::service::session::TurnStatus::Error, + Some("boom".to_string()), + ); + assert_eq!(failed.status, crate::service::session::TurnStatus::Error); + assert_eq!(failed.error.as_deref(), Some("boom")); + assert!(failed.model_rounds[0].text_items.is_empty()); + } + + #[tokio::test] + async fn acp_direct_delivery_appends_full_reply_even_when_index_occupied() { + // 防回退(P-19 全文落盘原则):acp 流会话投递 turn 的 reply 全文必须可经 + // SessionHistory 检索。当 metadata.turn_count 落后(既有 turn 已落盘但元数据 + // 未同步,如前端/并发写者在同一索引先落盘——正是「SessionHistory 导出仍只有 + // turn 0」的实证场景)时,投递 turn 不得在计算索引处与既有 turn 冲突即静默 + // 丢弃,必须追加到下一空闲索引,保证全文不丢。 + use crate::service::session::SessionMetadata; + + let root = tempfile::tempdir().expect("test root"); + let persistence = PersistenceManager::new(Arc::new(PathManager::with_user_root_for_tests( + root.path().join("user-root"), + ))) + .expect("persistence manager"); + let storage_path = root.path().join("storage"); + let session_id = "acp_codebuddy_a4f68de7-c4ec-46a8-9aab-7e2bc417c3d0".to_string(); + let metadata = SessionMetadata::new( + session_id.clone(), + "codebuddy ACP".to_string(), + "acp:codebuddy".to_string(), + "auto".to_string(), + ); + persistence + .create_session_metadata_if_absent(&storage_path, &metadata) + .await + .expect("metadata should be created"); + + // 模拟前端/并发写者已落盘 turn 0(index 0 被占用)。 + persist_acp_direct_delivery_turn( + &persistence, + &storage_path, + &session_id, + "frontend-turn-0", + "initial user input", + "round-0", + 100, + "pre-existing content", + crate::service::session::TurnStatus::Completed, + None, + ) + .await; + // 再模拟 metadata.turn_count 落后:落盘后置回 0(前端写者未同步元数据)。 + persistence + .update_session_metadata(&storage_path, &session_id, |stale| { + stale.turn_count = 0; + }) + .await + .expect("metadata should update"); + + // 后端投递(存活测试):reply 全文为 'alive',不得因 index=0 冲突而丢弃。 + persist_acp_direct_delivery_turn( + &persistence, + &storage_path, + &session_id, + "turn-alive", + "【acp 会话存活测试】只回『alive』", + "round-1", + 2000, + "alive", + crate::service::session::TurnStatus::Completed, + None, + ) + .await; + + // 全文必须追加到下一空闲索引(1)并完整可检索(SessionHistory 导出依据)。 + let saved = persistence + .load_dialog_turn(&storage_path, &session_id, 1) + .await + .expect("load should succeed") + .expect("delivery turn should be persisted, not dropped"); + assert_eq!(saved.user_message.content, "【acp 会话存活测试】只回『alive』"); + assert_eq!(saved.model_rounds[0].text_items[0].content, "alive"); + assert_eq!(saved.status, crate::service::session::TurnStatus::Completed); + } + + #[tokio::test] + async fn persist_acp_direct_delivery_turn_writes_turn_file() { + use crate::service::session::SessionMetadata; + + let root = tempfile::tempdir().expect("test root"); + let persistence = PersistenceManager::new(Arc::new(PathManager::with_user_root_for_tests( + root.path().join("user-root"), + ))) + .expect("persistence manager"); + let storage_path = root.path().join("storage"); + let session_id = "acp_codex_7f0e1a2b-3c4d-4e5f-8a9b-0c1d2e3f4a5b".to_string(); + let metadata = SessionMetadata::new( + session_id.clone(), + "Codex ACP".to_string(), + "acp:codex".to_string(), + "auto".to_string(), + ); + persistence + .create_session_metadata_if_absent(&storage_path, &metadata) + .await + .expect("metadata should be created"); + + persist_acp_direct_delivery_turn( + &persistence, + &storage_path, + &session_id, + "turn-1", + "hello", + "round-1", + 1000, + "external response", + crate::service::session::TurnStatus::Completed, + None, + ) + .await; + + let saved = persistence + .load_dialog_turn(&storage_path, &session_id, 0) + .await + .expect("load should succeed") + .expect("turn should be persisted"); + assert_eq!(saved.turn_id, "turn-1"); + assert_eq!(saved.user_message.content, "hello"); + assert_eq!(saved.model_rounds[0].text_items[0].content, "external response"); + assert_eq!(saved.status, crate::service::session::TurnStatus::Completed); + + // 幂等:同 turn 再次落盘为 no-op(不覆盖已保存内容、不报错)。 + persist_acp_direct_delivery_turn( + &persistence, + &storage_path, + &session_id, + "turn-1", + "hello", + "round-2", + 2000, + "overwrite attempt", + crate::service::session::TurnStatus::Completed, + None, + ) + .await; + let saved_again = persistence + .load_dialog_turn(&storage_path, &session_id, 0) + .await + .expect("load should succeed") + .expect("turn should still exist"); + assert_eq!( + saved_again.model_rounds[0].text_items[0].content, + "external response" ); } } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/skills/registry.rs b/src/crates/assembly/core/src/agentic/tools/implementations/skills/registry.rs index f4563e5adb..7ee688c54b 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/skills/registry.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/skills/registry.rs @@ -918,10 +918,12 @@ impl SkillRegistry { .iter() .position(|root| root.source_id == "opencode") .expect("OpenCode project Skill root is registered"); - let user_anchor = has_workspace - .then_some(PROJECT_SKILL_ROOTS.len()) - .unwrap_or_default() - .saturating_add( + let user_anchor = (if has_workspace { + PROJECT_SKILL_ROOTS.len() + } else { + 0 + }) + .saturating_add( USER_HOME_SKILL_ROOTS .iter() .position(|root| root.source_id == "opencode") @@ -930,12 +932,16 @@ impl SkillRegistry { for candidate in &mut standard { let original_priority = candidate.priority; - let project_shift = (has_project && original_priority >= project_anchor) - .then_some(OPENCODE_CONFIGURED_PRIORITY_BAND) - .unwrap_or_default(); - let user_shift = (has_user && original_priority >= user_anchor) - .then_some(OPENCODE_CONFIGURED_PRIORITY_BAND) - .unwrap_or_default(); + let project_shift = if has_project && original_priority >= project_anchor { + OPENCODE_CONFIGURED_PRIORITY_BAND + } else { + 0 + }; + let user_shift = if has_user && original_priority >= user_anchor { + OPENCODE_CONFIGURED_PRIORITY_BAND + } else { + 0 + }; candidate.priority = original_priority .saturating_add(project_shift) .saturating_add(user_shift); @@ -943,11 +949,11 @@ impl SkillRegistry { for candidate in &mut configured { let anchor = match candidate.info.level { SkillLocation::Project => project_anchor, - SkillLocation::User => user_anchor.saturating_add( - has_project - .then_some(OPENCODE_CONFIGURED_PRIORITY_BAND) - .unwrap_or_default(), - ), + SkillLocation::User => user_anchor.saturating_add(if has_project { + OPENCODE_CONFIGURED_PRIORITY_BAND + } else { + 0 + }), }; candidate.priority = candidate.priority.saturating_add(anchor); } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/task/deep_review.rs b/src/crates/assembly/core/src/agentic/tools/implementations/task/deep_review.rs index 7c2917a4b3..078b83e862 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/task/deep_review.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/task/deep_review.rs @@ -98,6 +98,7 @@ impl LaunchReviewAgentTool { ) } + #[allow(clippy::too_many_arguments)] pub(super) async fn wait_for_deep_review_provider_capacity_retry( session_id: &str, dialog_turn_id: &str, @@ -135,6 +136,7 @@ impl LaunchReviewAgentTool { deep_review_task_adapter::record_provider_capacity_retry_success(dialog_turn_id, reason); } + #[allow(clippy::too_many_arguments)] pub(super) async fn emit_deep_review_queue_state( session_id: &str, dialog_turn_id: &str, diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/task/execution.rs b/src/crates/assembly/core/src/agentic/tools/implementations/task/execution.rs index 0a244c6514..47c4fd0e68 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/task/execution.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/task/execution.rs @@ -1,5 +1,21 @@ use super::*; +use crate::agentic::coordination::{ + get_global_scheduler, DialogSubmissionPolicy, DialogTriggerSource, +}; use crate::agentic::core::{SessionContinuationPolicy, SessionModelBindingPolicy}; +use crate::agentic::events::AgenticEvent; +use crate::agentic::persistence::PersistenceManager; +use crate::agentic::tools::restrictions::{get_session_role, validate_delegation, AgentRole}; +use crate::infrastructure::PathManager; +use crate::service::session::SessionTranscriptExportOptions; +use crate::service_agent_runtime::CoreServiceAgentRuntime; +use bitfun_runtime_ports::{ + AcpClientCancelRequest, AcpClientCreateRequest, AcpClientMessageRequest, AcpClientPort, + AcpClientStreamChunk, AgentDialogTurnPort, AgentDialogTurnRequest, +}; +use std::path::Path; +use std::sync::{Arc, Mutex, OnceLock}; +use uuid::Uuid; fn resolve_focused_review_model_selection( requested_model: Option, @@ -69,6 +85,328 @@ fn forward_subagent_invocation_context( }; subagent_context.insert(key.to_string(), value); } + // Subagent sessions default to auto-approve: unattended delegation must not + // block on user approval prompts. An explicit parent value still wins. + if !subagent_context.contains_key(AUTO_APPROVE_ASK_CONTEXT_KEY) { + subagent_context.insert(AUTO_APPROVE_ASK_CONTEXT_KEY.to_string(), "true".to_string()); + } +} + +/// Bounded window for external ACP task turns (seconds). A one-shot +/// `acp__` delegation forwards the prompt to the external agent with +/// this timeout instead of an unbounded wait. +const ACP_TASK_TIMEOUT_SECONDS: u64 = 600; + +/// Detect the ACP client id when `session_id` is a flow session id of the +/// shape `acp__` (created by the ACP port / SessionControl / +/// the frontend `create_acp_flow_session`). Returns `None` for any other id. +fn acp_flow_client_id_from_session_id(session_id: &str) -> Option { + let rest = session_id.strip_prefix("acp_")?; + let (client_id, uuid_segment) = rest.rsplit_once('_')?; + if client_id.is_empty() || !looks_like_uuid(uuid_segment) { + return None; + } + Some(client_id.to_string()) +} + +/// Dependency-free canonical uuid shape guard for flow-session ids. +fn looks_like_uuid(segment: &str) -> bool { + segment.len() == 36 + && segment.bytes().enumerate().all(|(index, byte)| { + if matches!(index, 8 | 13 | 18 | 23) { + byte == b'-' + } else { + byte.is_ascii_hexdigit() + } + }) +} + +/// In-process facts for ACP flow sessions spawned by the Task tool. +/// +/// Flow sessions live in the ACP persistence store, not the coordinator +/// session tree, so subtree ownership (R-2) and the one-shot recycle marker +/// cannot be derived from the tree. This module-local registry records the +/// owning parent session and the temporary flag at spawn time; continuation +/// (`send_input` / `cancel`) verifies ownership here before forwarding, and +/// the temporary marker drives recycling on the continuation error path. +#[derive(Debug, Clone)] +struct AcpFlowSessionFact { + /// Session id of the Task caller that spawned the flow session. + owner_session_id: String, + /// `true` when the spawn was one-shot (`persistent=false`). + temporary: bool, +} + +static ACP_FLOW_SESSION_FACTS: OnceLock>> = + OnceLock::new(); + +fn acp_flow_session_facts() -> &'static Mutex> { + ACP_FLOW_SESSION_FACTS.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn register_acp_flow_session(flow_session_id: &str, owner_session_id: &str, temporary: bool) { + if let Ok(mut facts) = acp_flow_session_facts().lock() { + facts.insert( + flow_session_id.to_string(), + AcpFlowSessionFact { + owner_session_id: owner_session_id.to_string(), + temporary, + }, + ); + } +} + +fn unregister_acp_flow_session(flow_session_id: &str) { + if let Ok(mut facts) = acp_flow_session_facts().lock() { + facts.remove(flow_session_id); + } +} + +fn acp_flow_session_fact(flow_session_id: &str) -> Option { + acp_flow_session_facts() + .lock() + .ok() + .and_then(|facts| facts.get(flow_session_id).cloned()) +} + +/// Verify that `caller_session_id` owns — or is a descendant of the owner of — +/// the ACP flow session, mirroring the subtree guard local subagents get from +/// `resolve_agent_id(..., allow_global_fallback=false)`. Returns the recorded +/// fact so callers can also read the one-shot recycle marker. +fn verify_acp_flow_session_ownership( + coordinator: &std::sync::Arc, + caller_session_id: &str, + flow_session_id: &str, +) -> BitFunResult { + let fact = acp_flow_session_fact(flow_session_id).ok_or_else(|| { + BitFunError::tool(format!( + "ACP flow session '{}' is not owned by this conversation: it was not created by a Task ACP spawn in this process", + flow_session_id + )) + })?; + let owned = fact.owner_session_id == caller_session_id + || coordinator + .session_tree() + .get_descendants(caller_session_id) + .iter() + .any(|session_id| session_id == &fact.owner_session_id); + if !owned { + return Err(BitFunError::tool(format!( + "ACP flow session '{}' belongs to another session subtree; refusing to continue it from session '{}'", + flow_session_id, caller_session_id + ))); + } + Ok(fact) +} + +/// Recycle a temporary ACP flow session: delete the persisted record (which +/// also releases the external process) and forget the ownership fact. Failures +/// are logged, never fatal, so a failed recycle cannot break the caller. +async fn recycle_acp_flow_session( + port: &dyn AcpClientPort, + flow_session_id: &str, + workspace_path: Option, +) { + if let Err(error) = port + .delete_session_record(flow_session_id.to_string(), workspace_path) + .await + { + log::warn!( + "Failed to recycle temporary ACP flow session: session_id={}, error={}", + flow_session_id, + error + ); + } + unregister_acp_flow_session(flow_session_id); +} + +/// Build the notice injected into the caller context when an ACP send_input +/// returns synchronously. The full external reply stays in the ACP flow +/// session history (retrievable via SessionHistory); only the notice is +/// injected so the calling agent's context is not inflated with the full +/// reply text. +fn acp_send_input_notice(_full_response: &str, session_id: &str) -> String { + format!( + "External ACP session '{}' responded; use SessionHistory to view the full reply. agent_id: \"{}\"", + session_id, session_id + ) +} + +/// P-19:后台 ACP 子任务结果主会话通知只含极简元信息(session_id + 身份标识 + +/// 已回复状态 + use SessionHistory 指引),对齐 scheduler.rs +/// background_result_follow_up_user_input 语义。 +/// +/// 全量回复不回主会话,只由 P-03 persist_background_acp_turn_to_workspace +/// 落盘成 turn,经 SessionHistory(session_id) 检索;不附带 prepended 提醒 +/// 旁路(单路元数据通知)。 +fn acp_background_result_notice(session_id: &str, agent_type: &str) -> String { + let identity = if agent_type.trim().is_empty() { + "agent".to_string() + } else { + agent_type.to_string() + }; + format!( + "Background agent session {session_id} ({identity}) has replied; use SessionHistory to view the full reply." + ) +} + +/// P-03:后台 ACP 回复完整 turn 落盘(核心,注入 PersistenceManager 可测)。 +/// +/// 参照 session_message_tool::persist_acp_direct_delivery_turn 同构:落盘存 +/// 全文(SessionHistory 可检索),查重防重复(同 turn id 跳过、索引冲突跳过), +/// 失败仅 warn 绝不阻塞主流程通知式注入(03 文档铁则)。 +async fn persist_background_acp_turn( + persistence: &PersistenceManager, + storage_path: &Path, + flow_session_id: &str, + turn_id: &str, + prompt: &str, + response: &str, + status: crate::service::session::TurnStatus, + error: Option, +) { + let Ok(Some(metadata)) = persistence + .load_session_metadata(storage_path, flow_session_id) + .await + else { + return; + }; + let turn_index = metadata.turn_count; + match persistence + .load_dialog_turn(storage_path, flow_session_id, turn_index) + .await + { + Ok(Some(existing)) if existing.turn_id == turn_id => { + // 同 turn 已落盘:跳过,避免重复写入。 + return; + } + Ok(Some(_)) => { + log::warn!( + "Background ACP turn persistence skipped: turn index collision: session_id={} turn_id={} turn_index={}", + flow_session_id, turn_id, turn_index + ); + return; + } + _ => {} + } + use crate::service::session::{DialogTurnData, ModelRoundData, TextItemData, UserMessageData}; + let round_id = Uuid::new_v4().to_string(); + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + let mut turn = DialogTurnData::new( + turn_id.to_string(), + turn_index, + flow_session_id.to_string(), + UserMessageData { + id: Uuid::new_v4().to_string(), + content: prompt.to_string(), + timestamp: now_ms, + metadata: None, + }, + ); + turn.start_time = now_ms; + let mut round = ModelRoundData { + id: round_id.clone(), + turn_id: turn_id.to_string(), + round_index: 0, + round_group_id: None, + timestamp: now_ms, + text_items: Vec::new(), + tool_items: Vec::new(), + thinking_items: Vec::new(), + start_time: now_ms, + end_time: None, + duration_ms: None, + provider_id: None, + model_config_id: None, + effective_model_name: None, + first_chunk_ms: None, + first_visible_output_ms: None, + stream_duration_ms: None, + attempt_count: None, + attempt_diagnostics: Vec::new(), + failure_category: None, + token_details: None, + status: "completed".to_string(), + }; + if !response.trim().is_empty() { + round.text_items.push(TextItemData { + id: Uuid::new_v4().to_string(), + content: response.to_string(), + is_streaming: false, + timestamp: now_ms, + is_markdown: true, + order_index: Some(0), + is_subagent_item: None, + parent_task_tool_id: None, + subagent_session_id: None, + status: Some("completed".to_string()), + attempt_id: None, + attempt_index: None, + }); + } + turn.model_rounds.push(round); + turn.error = error; + match status { + crate::service::session::TurnStatus::Completed => turn.mark_completed(), + crate::service::session::TurnStatus::Cancelled + | crate::service::session::TurnStatus::Error => { + turn.status = status; + turn.end_time = Some(now_ms); + } + crate::service::session::TurnStatus::InProgress => {} + } + if let Err(save_error) = persistence.save_dialog_turn(storage_path, &turn).await { + log::warn!( + "Failed to persist background ACP turn: session_id={} turn_id={} error={}", + flow_session_id, turn_id, save_error + ); + } +} + +/// P-03:后台 ACP 回复完整 turn 落盘到工作区(供 SessionHistory 检索全文)。 +/// +/// 解析有效会话存储路径 + PersistenceManager,再落盘;失败仅 warn 不阻塞 +/// 主流程。注入主会话的 message 仍是通知句(03 文档铁则,不改回全文)。 +async fn persist_background_acp_turn_to_workspace( + workspace_path: Option, + flow_session_id: &str, + prompt: &str, + response: &str, + status: crate::service::session::TurnStatus, + error: Option, +) { + use crate::infrastructure::get_path_manager_arc; + use crate::service::remote_ssh::workspace_state::get_effective_session_path; + + let Some(workspace_path) = workspace_path else { + return; + }; + let storage_path = get_effective_session_path(&workspace_path, None, None).await; + let persistence = match PersistenceManager::new(get_path_manager_arc()) { + Ok(persistence) => persistence, + Err(init_error) => { + log::warn!( + "Background ACP turn persistence skipped: failed to initialize PersistenceManager: {}", + init_error + ); + return; + } + }; + let turn_id = Uuid::new_v4().to_string(); + persist_background_acp_turn( + &persistence, + &storage_path, + flow_session_id, + &turn_id, + prompt, + response, + status, + error, + ) + .await; } struct BackgroundTaskStartRequest<'a> { @@ -90,6 +428,11 @@ struct BackgroundTaskStartRequest<'a> { tool_call_id: String, session_id: String, dialog_turn_id: String, + /// Delegated RBAC role key (R-14 B4) for the child session. + parent_role: Option, + /// Lifecycle mode for the spawned subagent session (see + /// [`TaskInvocation::persistent`]). + persistent: bool, external_generation_lease: Option, } @@ -159,7 +502,39 @@ impl TaskTool { .clone() .ok_or_else(|| BitFunError::tool("session_id is required in context".to_string()))?; + if invocation.action == TaskAction::List { + return Self::list_background_subagents(&session_id).await; + } + + if invocation.action == TaskAction::History { + return Self::get_subagent_history(&session_id, invocation).await; + } + if invocation.action == TaskAction::Cancel { + // ACP flow sessions (`acp__`) are continued through + // the ACP flow branch (which verifies subtree ownership), not the + // local background-run registry: `cancel_background_runs` resolves + // agent ids in the coordination store and cannot resolve a flow + // session id, so letting cancel short-circuit here would make ACP + // flow cancellation dead code. + let is_acp_flow_target = invocation + .target_agent_id + .as_deref() + .is_some_and(|agent_id| acp_flow_client_id_from_session_id(agent_id).is_some()); + if is_acp_flow_target { + let coordinator = get_global_coordinator() + .ok_or_else(|| BitFunError::tool("coordinator not initialized".to_string()))?; + return Self::run_acp_subagent_invocation( + &coordinator, + context, + invocation.clone(), + None, + invocation.target_agent_id.clone(), + "", + &session_id, + ) + .await; + } return Self::cancel_background_runs(&session_id, invocation).await; } @@ -177,7 +552,7 @@ impl TaskTool { let coordinator = get_global_coordinator() .ok_or_else(|| BitFunError::tool("coordinator not initialized".to_string()))?; let target_session_id = coordinator - .resolve_agent_id(parent_session_id, agent_id) + .resolve_agent_id(parent_session_id, agent_id, false) .await?; let cancelled_count = coordinator .cancel_background_subagents_for_parent(parent_session_id, &target_session_id) @@ -198,6 +573,461 @@ impl TaskTool { }]) } + async fn list_background_subagents(parent_session_id: &str) -> BitFunResult> { + let coordinator = get_global_coordinator() + .ok_or_else(|| BitFunError::tool("coordinator not initialized".to_string()))?; + let records = coordinator + .list_background_subagents(parent_session_id) + .await?; + let tree = coordinator.session_tree(); + + let tasks: Vec = records + .into_iter() + .map(|record| { + // Resolve hierarchy info before moving record fields out. + let depth = tree.get_depth(&record.child_session_id); + let parent = tree.get_parent(&record.child_session_id); + let mut task = serde_json::Map::new(); + task.insert("agent_id".to_string(), Value::String(record.agent_id)); + task.insert( + "session_id".to_string(), + Value::String(record.child_session_id), + ); + task.insert( + "status".to_string(), + Value::String(record.status.as_str().to_string()), + ); + if let Some(depth) = depth { + task.insert("depth".to_string(), Value::from(depth)); + } + if let Some(parent) = parent { + task.insert("parent".to_string(), Value::String(parent)); + } + Value::Object(task) + }) + .collect(); + + Ok(vec![ToolResult::Result { + data: json!({ + "action": "list", + "tasks": tasks, + }), + result_for_assistant: Some(format!( + "Found {} background subagent(s) managed from this conversation (tasks spawned by this session or any descendant session).", + tasks.len() + )), + image_attachments: None, + }]) + } + + async fn get_subagent_history( + parent_session_id: &str, + invocation: TaskInvocation, + ) -> BitFunResult> { + // Task history is a subtree-scoped read: agent_id must resolve inside + // the caller's session subtree (no global fallback), and a missing + // agent_id is rejected up front. + let target_session_id = { + let agent_id = invocation.target_agent_id.as_deref().ok_or_else(|| { + BitFunError::tool( + "agent_id or session_id is required when action is history".to_string(), + ) + })?; + let coordinator = get_global_coordinator() + .ok_or_else(|| BitFunError::tool("coordinator not initialized".to_string()))?; + coordinator + .resolve_agent_id(parent_session_id, agent_id, false) + .await? + }; + + let (_display_workspace, session_storage_dir) = + CoreServiceAgentRuntime::resolve_session_workspace_paths(&target_session_id) + .await + .ok_or_else(|| { + BitFunError::NotFound(format!( + "Workspace for session '{}' could not be resolved", + target_session_id + )) + })?; + + let manager = PersistenceManager::new(Arc::new(PathManager::new()?))?; + let transcript = manager + .export_session_transcript( + &session_storage_dir, + &target_session_id, + &SessionTranscriptExportOptions { + tools: true, + tool_inputs: true, + thinking: true, + turns: invocation + .max_turns + .map(|max_turns| vec![format!("-{max_turns}:")]), + }, + ) + .await?; + + Ok(vec![ToolResult::Result { + data: json!({ + "action": "history", + "session_id": target_session_id, + "transcript_path": transcript.transcript_path, + }), + result_for_assistant: Some(format!( + "Transcript for session '{}' exported to '{}'. The index is on lines {}-{}. Read that range first, then use Grep or Read on that path for targeted navigation.", + target_session_id, + transcript.transcript_path, + transcript.index_range.start_line, + transcript.index_range.end_line + )), + image_attachments: None, + }]) + } + + /// Delegate to a real external ACP agent through a flow session. + /// + /// Covers both an `acp__` spawn (creates a flow session via the ACP + /// client port, forwards the prompt to the external agent — no local model + /// turn) and continuation of an existing flow session (`send_input` / + /// `cancel` addressed by the flow session id returned by a previous ACP + /// spawn). Temporary spawns (`persistent=false`) recycle the flow session + /// (release the external process and delete the persisted record) as soon + /// as the task finishes. + async fn run_acp_subagent_invocation( + coordinator: &std::sync::Arc, + context: &ToolUseContext, + invocation: TaskInvocation, + spawn_client_id: Option, + flow_target: Option, + prompt: &str, + parent_session_id: &str, + ) -> BitFunResult> { + let port = coordinator.acp_client_port().ok_or_else(|| { + BitFunError::tool( + "ACP client port is not available; the desktop host did not inject it".to_string(), + ) + })?; + let workspace_path = context + .workspace_root() + .map(|path| path.to_string_lossy().into_owned()); + let remote_connection_id = context + .workspace + .as_ref() + .and_then(|workspace| workspace.connection_id().map(ToOwned::to_owned)); + + // Continuation of an existing ACP flow session (send_input / cancel). + if let Some(flow_session_id) = flow_target { + // 子树所有权守卫(与本地子代理 resolve_agent_id 守卫对齐):只允许 + // 创建该 flow 会话的会话子树续接它,防止跨会话控制他人的 ACP 会话。 + let flow_fact = verify_acp_flow_session_ownership( + coordinator, + parent_session_id, + &flow_session_id, + )?; + let temporary = flow_fact.temporary; + return match invocation.action { + TaskAction::Cancel => { + port.cancel_session(AcpClientCancelRequest { + session_id: flow_session_id.clone(), + }) + .await + .map_err(|error| { + BitFunError::tool(format!( + "ACP client port failed ({:?}): {}", + error.kind, error.message + )) + })?; + Ok(vec![ToolResult::Result { + data: json!({ + "action": "cancel", + "status": "cancelled", + "agent_id": flow_session_id, + }), + result_for_assistant: Some( + "Cancelled the external ACP session.".to_string(), + ), + image_attachments: None, + }]) + } + TaskAction::SendInput => { + // Stream the external reply: the port pushes text chunks + // into the channel while the recv loop emits them as + // frontend `TextChunk` events for the parent session's + // current turn, so the user sees the external agent's + // output incrementally instead of all at once. The tool + // result shape below is a background result (single + // `ToolResult` returned when the call completes), so the + // full response text is still returned there; the chunks + // are the frontend-side streaming surface. + let (chunk_tx, mut chunk_rx) = tokio::sync::mpsc::unbounded_channel(); + let send_future = port.send_message_stream( + AcpClientMessageRequest { + session_id: flow_session_id.clone(), + message: prompt.to_string(), + workspace_path: workspace_path.clone(), + timeout_seconds: Some(ACP_TASK_TIMEOUT_SECONDS), + }, + chunk_tx, + ); + let parent_session_id = context.session_id.clone(); + let parent_turn_id = context.dialog_turn_id.clone(); + let stream_events = async { + if let (Some(session_id), Some(turn_id)) = + (parent_session_id, parent_turn_id) + { + let round_id = Uuid::new_v4().to_string(); + while let Some(chunk) = chunk_rx.recv().await { + if let AcpClientStreamChunk::Text { text } = chunk { + coordinator + .emit_event(AgenticEvent::TextChunk { + session_id: session_id.clone(), + turn_id: turn_id.clone(), + round_id: round_id.clone(), + attempt_id: None, + attempt_index: None, + text, + }) + .await; + } + } + } else { + while chunk_rx.recv().await.is_some() {} + } + }; + let (sent_result, _) = tokio::join!(send_future, stream_events); + let sent = match sent_result { + Ok(sent) => sent, + Err(error) => { + // 一次性 flow 会话即使外部轮次失败也要回收,失败的临时 + // ACP 任务绝不能泄漏其 flow 会话/外部进程。 + if temporary { + recycle_acp_flow_session( + port.as_ref(), + &flow_session_id, + workspace_path, + ) + .await; + } + return Err(BitFunError::tool(format!( + "ACP client port failed ({:?}): {}", + error.kind, error.message + ))); + } + }; + Ok(vec![ToolResult::Result { + data: json!({ + "action": "send_input", + "success": true, + "agent_id": flow_session_id, + "response": sent.response, + }), + result_for_assistant: Some(acp_send_input_notice( + &sent.response, + &flow_session_id, + )), + image_attachments: None, + }]) + } + _ => Err(BitFunError::tool( + "ACP flow sessions only support spawn, send_input, and cancel".to_string(), + )), + }; + } + + // Spawn: create a real external ACP flow session and forward the prompt. + let client_id = spawn_client_id.ok_or_else(|| { + BitFunError::tool( + "ACP subagent requires a subagent_type like 'acp__'".to_string(), + ) + })?; + let session_name = invocation.description.clone(); + let created = port + .create_session(AcpClientCreateRequest { + client_id, + workspace_path: workspace_path.clone().unwrap_or_default(), + session_name, + remote_connection_id, + }) + .await + .map_err(|error| { + BitFunError::tool(format!( + "ACP client port failed ({:?}): {}", + error.kind, error.message + )) + })?; + let flow_session_id = created.session_id; + let persistent = invocation.persistent; + let run_in_background = invocation.run_in_background; + let temporary = !persistent; + // 记录所有权与一次性标记:续接(send_input/cancel)据此校验调用方子树, + // 一次性标记驱动回收。 + register_acp_flow_session(&flow_session_id, parent_session_id, temporary); + + if run_in_background { + let port_for_task = port.clone(); + let flow_session_id_for_task = flow_session_id.clone(); + let agent_type_for_task = created.agent_type.clone(); + let workspace_path_for_task = workspace_path.clone(); + let prompt_for_task = prompt.to_string(); + let parent_session_id_for_task = parent_session_id.to_string(); + let scheduler = get_global_scheduler(); + tokio::spawn(async move { + let sent = port_for_task + .send_message(AcpClientMessageRequest { + session_id: flow_session_id_for_task.clone(), + message: prompt_for_task.clone(), + workspace_path: workspace_path_for_task.clone(), + timeout_seconds: Some(ACP_TASK_TIMEOUT_SECONDS), + }) + .await; + let output_text = match &sent { + Ok(result) => { + // P-03:后台 ACP 回复完整 turn 落盘(全文供 SessionHistory + // 检索);注入主会话的 message 保持通知句(03 文档铁则)。 + persist_background_acp_turn_to_workspace( + workspace_path_for_task.clone(), + &flow_session_id_for_task, + &prompt_for_task, + &result.response, + crate::service::session::TurnStatus::Completed, + None, + ) + .await; + Some(acp_background_result_notice( + &flow_session_id_for_task, + &agent_type_for_task, + )) + } + Err(error) => { + // P-03:后台 ACP 失败分支同样落盘失败 turn + // (TurnStatus::Error + error 字段),供 SessionHistory + // 检索失败原因;失败仅 warn 不阻塞通知式路径。 + persist_background_acp_turn_to_workspace( + workspace_path_for_task.clone(), + &flow_session_id_for_task, + &prompt_for_task, + "", + crate::service::session::TurnStatus::Error, + Some(format!( + "ACP client port failed ({:?}): {}", + error.kind, error.message + )), + ) + .await; + None + } + }; + if let Some(scheduler) = scheduler.as_ref() { + let _ = scheduler + .submit_dialog_turn(AgentDialogTurnRequest { + session_id: parent_session_id_for_task.clone(), + message: output_text + .clone() + .unwrap_or_else(|| "ACP subagent task failed".to_string()), + original_message: None, + turn_id: None, + execution: Default::default(), + agent_type: String::new(), + workspace_path: None, + remote_connection_id: None, + remote_ssh_host: None, + policy: DialogSubmissionPolicy::for_source( + DialogTriggerSource::AgentSession, + ), + reply_route: None, + prepended_reminders: Vec::new(), + attachments: Vec::new(), + metadata: serde_json::Map::new(), + }) + .await; + } + if !persistent { + recycle_acp_flow_session( + port_for_task.as_ref(), + &flow_session_id_for_task, + workspace_path_for_task, + ) + .await; + } + }); + let mut data = serde_json::Map::new(); + data.insert("action".to_string(), json!("spawn")); + data.insert("status".to_string(), json!("started")); + data.insert("run_in_background".to_string(), json!(true)); + data.insert("agent_id".to_string(), json!(flow_session_id.clone())); + data.insert("agent_type".to_string(), json!(created.agent_type)); + let mut result_for_assistant = format!( + "Background external ACP subagent started.\nagent_id: \"{}\"\nThe result will be delivered back to this session automatically.", + flow_session_id + ); + if temporary { + // 一次性后台 spawn 返回的 agent_id 不可复用:显式标记并提示。 + data.insert("recycled".to_string(), json!(true)); + result_for_assistant.push_str(&format!( + "\nThis was a one-shot (persistent=false) ACP subagent: the external session will be recycled automatically and the returned agent_id is NOT reusable for send_input.", + flow_session_id + )); + } + return Ok(vec![ToolResult::Result { + data: Value::Object(data), + result_for_assistant: Some(result_for_assistant), + image_attachments: None, + }]); + } + + // Foreground: forward the prompt and return the external response. A + // one-shot session is recycled even when the external turn fails so a + // failed temporary ACP task never leaks its flow session/process. + let sent = match port + .send_message(AcpClientMessageRequest { + session_id: flow_session_id.clone(), + message: prompt.to_string(), + workspace_path: workspace_path.clone(), + timeout_seconds: Some(ACP_TASK_TIMEOUT_SECONDS), + }) + .await + { + Ok(sent) => sent, + Err(error) => { + if temporary { + recycle_acp_flow_session(port.as_ref(), &flow_session_id, workspace_path) + .await; + } + return Err(BitFunError::tool(format!( + "ACP client port failed ({:?}): {}", + error.kind, error.message + ))); + } + }; + if temporary { + recycle_acp_flow_session(port.as_ref(), &flow_session_id, workspace_path).await; + } + let mut data = json!({ + "action": "spawn", + "success": true, + "status": "completed", + "agent_id": flow_session_id.clone(), + "agent_type": created.agent_type, + "response": sent.response, + }); + let mut result_for_assistant = format!( + "External ACP session '{}' responded:\n{}", + flow_session_id, sent.response + ); + if persistent { + result_for_assistant.push_str(&format!( + "\nUse this agent_id to continue the same external ACP subagent.", + flow_session_id + )); + } else { + data["recycled"] = json!(true); + } + Ok(vec![ToolResult::Result { + data, + result_for_assistant: Some(result_for_assistant), + image_attachments: None, + }]) + } + async fn run_subagent_invocation( &self, input: &Value, @@ -207,9 +1037,35 @@ impl TaskTool { session_id: String, ) -> BitFunResult> { Self::ensure_delegation_allowed(context)?; + + // R-14 B3: role-based delegation validation, fails fast on violation. + // The target role is the explicit `role` field when provided, otherwise + // the default subagent role (Executor); the creator's registered RBAC + // role is read from the session registry (B2). + let creator_role = context.session_id.as_deref().and_then(get_session_role); + let target_role = invocation.role.clone().unwrap_or(AgentRole::Executor); + validate_delegation(creator_role, target_role.clone())?; + let coordinator = get_global_coordinator() .ok_or_else(|| BitFunError::tool("coordinator not initialized".to_string()))?; + // Hard guard: reject spawning if the current session has already reached + // the tree's maximum depth, preventing unbounded recursive subagent chains. + // Uses get_depth (current node depth) rather than subtree_depth (max + // descendant depth) to avoid false positives when a shallow session has + // deep descendants. + { + let tree = coordinator.session_tree(); + let current_depth = tree.get_depth(&session_id).unwrap_or(0); + if current_depth >= tree.max_depth { + return Err(BitFunError::tool(format!( + "Task depth limit reached: current depth {} >= max allowed depth {}. \ + Cannot spawn further subagents.", + current_depth, tree.max_depth + ))); + } + } + let description = invocation.description.clone(); let mut prompt = invocation.prompt.clone().ok_or_else(|| { BitFunError::tool( @@ -217,8 +1073,42 @@ impl TaskTool { ) })?; let context_mode = invocation.context_mode; + // ACP bridge delegation: a `acp__` spawn targets a real + // external ACP flow session (same shape as SessionControl acp__ create) + // instead of a local model turn, and a flow-session agent_id from a + // previous ACP spawn continues through the same external channel. Both + // are routed before the local subagent machinery. + let acp_spawn_client_id = invocation + .subagent_type + .as_deref() + .and_then(|agent_type| agent_type.strip_prefix(AcpAgent::agent_id_prefix())) + .filter(|client_id| !client_id.trim().is_empty()) + .map(ToOwned::to_owned); + let acp_flow_target = invocation + .target_agent_id + .as_deref() + .and_then(acp_flow_client_id_from_session_id); + if acp_spawn_client_id.is_some() || acp_flow_target.is_some() { + return Self::run_acp_subagent_invocation( + &coordinator, + context, + invocation, + acp_spawn_client_id, + acp_flow_target, + &prompt, + &session_id, + ) + .await; + } let target_session_id = match invocation.target_agent_id.as_deref() { - Some(agent_id) => Some(coordinator.resolve_agent_id(&session_id, agent_id).await?), + // spawn/send_input targets must resolve inside the caller's session + // subtree; global fallback is forbidden so a conversation cannot + // reach subagents owned by other conversations. + Some(agent_id) => Some( + coordinator + .resolve_agent_id(&session_id, agent_id, false) + .await?, + ), None => None, }; let mut model_id = invocation.model_id.clone(); @@ -693,6 +1583,8 @@ impl TaskTool { tool_call_id, session_id, dialog_turn_id, + parent_role: Some(target_role.as_str().to_string()), + persistent: invocation.persistent, external_generation_lease, }) .await; @@ -717,7 +1609,9 @@ impl TaskTool { tool_call_id, session_id, dialog_turn_id, + Some(target_role.as_str().to_string()), delegate_target_label, + invocation.persistent, deep_review_subagent_role, deep_review_active_guard, deep_review_reviewer_configured_max_parallel_instances, @@ -755,12 +1649,16 @@ impl TaskTool { tool_call_id, session_id, dialog_turn_id, + parent_role, + persistent, external_generation_lease, } = request; let parent_info = SubagentParentInfo { tool_call_id, - session_id, + session_id: session_id.clone(), dialog_turn_id, + depth: coordinator.session_tree().get_depth(&session_id), + role: parent_role, }; let request = SubagentExecutionRequest { task_description: prepared_prompt, @@ -777,6 +1675,7 @@ impl TaskTool { context: subagent_context.unwrap_or_default(), permission_runtime_ceiling, delegation_policy: context.delegation_policy().spawn_child(), + persistent, external_generation_lease, }; let coordinator = coordinator.clone(); @@ -829,7 +1728,9 @@ impl TaskTool { tool_call_id: String, session_id: String, dialog_turn_id: String, + parent_role: Option, delegate_target_label: String, + persistent: bool, deep_review_subagent_role: Option, deep_review_active_guard: Option>, deep_review_reviewer_configured_max_parallel_instances: Option, @@ -851,6 +1752,8 @@ impl TaskTool { tool_call_id: tool_call_id.clone(), session_id: session_id.clone(), dialog_turn_id: dialog_turn_id.clone(), + depth: coordinator.session_tree().get_depth(&session_id), + role: parent_role.clone(), }; let subagent_execution_started_at = Instant::now(); debug!( @@ -880,6 +1783,7 @@ impl TaskTool { context: subagent_context.clone().unwrap_or_default(), permission_runtime_ceiling: permission_runtime_ceiling.clone(), delegation_policy: context.delegation_policy().spawn_child(), + persistent, external_generation_lease: external_generation_lease.clone(), }; let coordinator = coordinator.clone(); @@ -1161,9 +2065,13 @@ impl TaskTool { reason: result.reason.as_deref(), ledger_event_id: result.ledger_event_id(), partial_timeout_suffix: &retry_hint, + session_id: result.session_id(), }, ); - if supports_follow_up { + // One-shot spawns never hand out a continuation handle: the session is + // recycled right after this result, so a follow-up agent_id would be + // misleading. + if supports_follow_up && persistent { if let Some(subagent_session_id) = result.session_id() { let agent_id = coordinator .agent_id_for_subagent_session(&session_id, subagent_session_id) @@ -1176,6 +2084,38 @@ impl TaskTool { } } + // Temporary subagent (`persistent=false`): recycle the one-shot session + // as soon as the task finishes successfully, so it never accumulates. + // Best-effort — the coordinator logs cleanup failures and never fails + // the task result. Execution-error paths (cancellation, timeout, + // crash) are recycled inside `execute_subagent`. + if !persistent { + if let Some(subagent_session_id) = result.session_id() { + let (recycle_workspace, recycle_remote_connection_id, recycle_remote_ssh_host) = + coordinator + .get_session_manager() + .get_session(subagent_session_id) + .map(|session| { + ( + session.config.workspace_path, + session.config.remote_connection_id, + session.config.remote_ssh_host, + ) + }) + .unwrap_or_default(); + if let Some(recycle_workspace) = recycle_workspace { + coordinator + .recycle_temporary_subagent_session( + Some(Path::new(&recycle_workspace)), + recycle_remote_connection_id.as_deref(), + recycle_remote_ssh_host.as_deref(), + subagent_session_id, + ) + .await; + } + } + } + Ok(vec![ToolResult::Result { data, result_for_assistant: Some(result_for_assistant), @@ -1315,13 +2255,16 @@ mod target_context_tests { } #[test] - fn child_context_leaves_unset_auto_approve_for_global_fallback() { + fn child_context_defaults_auto_approve_when_parent_leaves_it_unset() { let parent = parent_tool_context(); let mut child = HashMap::new(); forward_subagent_invocation_context(&parent, &mut child); - assert!(!child.contains_key(AUTO_APPROVE_ASK_CONTEXT_KEY)); + assert_eq!( + child.get(AUTO_APPROVE_ASK_CONTEXT_KEY).map(String::as_str), + Some("true") + ); } #[test] @@ -1351,4 +2294,151 @@ mod target_context_tests { assert!(!child.contains_key("parent_tool_runtime_state")); assert_eq!(child["deep_review_subagent_role"], "reviewer"); } + + #[test] + fn acp_send_input_notice_excludes_full_response() { + let full_reply = format!("EXTERNAL_REPLY_MARKER_{}", "x".repeat(4096)); + let notice = acp_send_input_notice(&full_reply, "flow-123"); + assert!(!notice.contains("EXTERNAL_REPLY_MARKER_")); + assert!(notice.contains("flow-123")); + assert!(notice.contains("SessionHistory")); + } + + #[test] + fn acp_background_result_notice_carries_only_minimal_metadata() { + // P-19 防回退:Task 后台 ACP 结果通知仅含极简元信息(session_id + + // 身份标识 + 已回复状态 + use SessionHistory 指引),不含全文正文; + // prepended 提醒旁路已移除(单路元数据通知)。 + let full_reply = format!("EXTERNAL_REPLY_MARKER_{}", "x".repeat(4096)); + let notice = acp_background_result_notice("flow-123", "acp:codex"); + assert!(notice.contains("flow-123")); + assert!(notice.contains("acp:codex")); + assert!(notice.contains("has replied")); + assert!(notice.contains("use SessionHistory")); + assert!(!notice.contains(&full_reply)); + assert!(!notice.contains("Background ACP subagent task completed")); + // 身份为空时回退 "agent",与 scheduler background_result_follow_up 一致。 + let fallback = acp_background_result_notice("flow-456", ""); + assert!(fallback.contains("flow-456")); + assert!(fallback.contains("(agent)")); + } + + #[tokio::test] + async fn persist_background_acp_turn_writes_full_reply_turn() { + use crate::service::session::SessionMetadata; + + let root = tempfile::tempdir().expect("test root"); + let persistence = PersistenceManager::new(Arc::new(PathManager::with_user_root_for_tests( + root.path().join("user-root"), + ))) + .expect("persistence manager"); + let storage_path = root.path().join("storage"); + let session_id = "acp_codex_7f0e1a2b-3c4d-4e5f-8a9b-0c1d2e3f4a5b".to_string(); + let metadata = SessionMetadata::new( + session_id.clone(), + "Codex ACP".to_string(), + "acp:codex".to_string(), + "auto".to_string(), + ); + persistence + .create_session_metadata_if_absent(&storage_path, &metadata) + .await + .expect("metadata should be created"); + + persist_background_acp_turn( + &persistence, + &storage_path, + &session_id, + "turn-1", + "hello", + "external full reply", + crate::service::session::TurnStatus::Completed, + None, + ) + .await; + + let saved = persistence + .load_dialog_turn(&storage_path, &session_id, 0) + .await + .expect("load should succeed") + .expect("turn should be persisted"); + assert_eq!(saved.user_message.content, "hello"); + assert_eq!(saved.model_rounds[0].text_items[0].content, "external full reply"); + assert_eq!(saved.status, crate::service::session::TurnStatus::Completed); + + // 幂等:同 turn id 再次落盘为 no-op(不覆盖已保存内容、不报错)。 + persist_background_acp_turn( + &persistence, + &storage_path, + &session_id, + "turn-1", + "hello", + "overwrite attempt", + crate::service::session::TurnStatus::Completed, + None, + ) + .await; + let saved_again = persistence + .load_dialog_turn(&storage_path, &session_id, 0) + .await + .expect("load should succeed") + .expect("turn should still exist"); + assert_eq!( + saved_again.model_rounds[0].text_items[0].content, + "external full reply" + ); + } + + #[tokio::test] + async fn persist_background_acp_turn_writes_error_turn_with_reason() { + // P-03 防回退:后台 ACP 失败分支同样落盘失败 turn + // (TurnStatus::Error + error 字段),供 SessionHistory 检索失败原因。 + use crate::service::session::SessionMetadata; + + let root = tempfile::tempdir().expect("test root"); + let persistence = PersistenceManager::new(Arc::new(PathManager::with_user_root_for_tests( + root.path().join("user-root"), + ))) + .expect("persistence manager"); + let storage_path = root.path().join("storage"); + let session_id = "acp_codex_7f0e1a2b-3c4d-4e5f-8a9b-0c1d2e3f4a5b".to_string(); + let metadata = SessionMetadata::new( + session_id.clone(), + "Codex ACP".to_string(), + "acp:codex".to_string(), + "auto".to_string(), + ); + persistence + .create_session_metadata_if_absent(&storage_path, &metadata) + .await + .expect("metadata should be created"); + + persist_background_acp_turn( + &persistence, + &storage_path, + &session_id, + "turn-err-1", + "hello", + "", + crate::service::session::TurnStatus::Error, + Some("ACP client port failed (Backend): simulated failure".to_string()), + ) + .await; + + let saved = persistence + .load_dialog_turn(&storage_path, &session_id, 0) + .await + .expect("load should succeed") + .expect("error turn should be persisted"); + assert_eq!(saved.user_message.content, "hello"); + assert_eq!(saved.status, crate::service::session::TurnStatus::Error); + assert_eq!( + saved.error.as_deref(), + Some("ACP client port failed (Backend): simulated failure") + ); + assert!( + saved.end_time.is_some(), + "error turn should record an end time" + ); + } } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/task/input.rs b/src/crates/assembly/core/src/agentic/tools/implementations/task/input.rs index e13d4bf782..2156a9104b 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/task/input.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/task/input.rs @@ -1,10 +1,13 @@ use super::*; +use crate::agentic::tools::restrictions::AgentRole; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum TaskAction { Spawn, SendInput, Cancel, + List, + History, } impl TaskAction { @@ -26,8 +29,10 @@ impl TaskAction { "spawn" => Ok(Self::Spawn), "send_input" => Ok(Self::SendInput), "cancel" => Ok(Self::Cancel), + "list" => Ok(Self::List), + "history" => Ok(Self::History), other => Err(BitFunError::tool(format!( - "action must be one of: spawn, send_input, cancel; got '{}'", + "action must be one of: spawn, send_input, cancel, list, history; got '{}'", other ))), } @@ -68,6 +73,8 @@ impl TaskAction { Self::Spawn => "spawn", Self::SendInput => "send_input", Self::Cancel => "cancel", + Self::List => "list", + Self::History => "history", } } } @@ -84,8 +91,21 @@ pub(super) struct TaskInvocation { pub(super) inherit_parent_model: bool, pub(super) timeout_seconds: Option, pub(super) run_in_background: bool, + /// Two lifecycle modes for a spawned background subagent: + /// - `true` (default): the subagent session is durable and can be continued + /// later with `send_input` (existing behavior). + /// - `false`: one-shot temporary subagent — the session is automatically + /// recycled when the task finishes (success, failure, or cancellation); + /// the returned `agent_id` cannot be reused. + pub(super) persistent: bool, pub(super) is_retry: bool, pub(super) requested_auto_retry: bool, + pub(super) max_turns: Option, + /// Optional explicit target role for the spawned subagent (R-14 B3). + /// When `None`, the target defaults to `Executor` (the subagent role + /// assigned by session creation); a specified role is validated against + /// the creator's role and fails fast on violation. + pub(super) role: Option, } impl TaskTool { @@ -106,7 +126,12 @@ impl TaskTool { "action is not supported for DeepReview Task calls".to_string(), )); } - for field in ["fork_context", "agent_id", "run_in_background"] { + for field in [ + "fork_context", + "agent_id", + "run_in_background", + "persistent", + ] { if input.get(field).is_some() { return Err(BitFunError::tool(format!( "{field} is not allowed for DeepReview Task calls" @@ -127,11 +152,14 @@ impl TaskTool { inherit_parent_model, timeout_seconds: Self::optional_timeout_seconds(input)?, run_in_background: false, + persistent: true, is_retry: input.get("retry").and_then(Value::as_bool).unwrap_or(false), requested_auto_retry: input .get("auto_retry") .and_then(Value::as_bool) .unwrap_or(false), + max_turns: None, + role: None, }); } @@ -184,6 +212,16 @@ impl TaskTool { } let (model_id, inherit_parent_model) = Self::optional_model_id(input)?; + let persistent = Self::optional_bool(input, "persistent")?.unwrap_or(true); + + // R-14 B3: optional explicit target role. Unknown keys degrade + // to None (default executor target) so stale model output never + // errors at parse time; the delegation validation runs at the + // spawn entry point and fails fast on a role violation. + let role = input + .get("role") + .and_then(Value::as_str) + .and_then(AgentRole::from_str_key); Ok(TaskInvocation { action, @@ -196,8 +234,11 @@ impl TaskTool { inherit_parent_model, timeout_seconds: None, run_in_background, + persistent, is_retry: false, requested_auto_retry: false, + max_turns: None, + role, }) } TaskAction::SendInput => { @@ -209,9 +250,11 @@ impl TaskTool { &[ "fork_context", "subagent_type", + "persistent", "retry", "auto_retry", "retry_coverage", + "max_turns", ], action, )?; @@ -229,8 +272,11 @@ impl TaskTool { inherit_parent_model, timeout_seconds: None, run_in_background, + persistent: true, is_retry: false, requested_auto_retry: false, + max_turns: None, + role: None, }) } TaskAction::Cancel => { @@ -243,12 +289,96 @@ impl TaskTool { "subagent_type", "model_id", "run_in_background", + "persistent", + "retry", + "auto_retry", + "retry_coverage", + ], + action, + )?; + + Ok(TaskInvocation { + action, + description: None, + prompt: None, + context_mode: SubagentContextMode::Fresh, + target_agent_id, + subagent_type: None, + model_id: None, + inherit_parent_model: false, + timeout_seconds: None, + run_in_background: false, + persistent: true, + is_retry: false, + requested_auto_retry: false, + max_turns: None, + role: None, + }) + } + TaskAction::List => { + Self::ensure_fields_absent( + input, + &[ + "agent_id", + "prompt", + "description", + "fork_context", + "subagent_type", + "model_id", + "run_in_background", + "persistent", + "retry", + "auto_retry", + "retry_coverage", + ], + action, + )?; + + Ok(TaskInvocation { + action, + description: None, + prompt: None, + context_mode: SubagentContextMode::Fresh, + target_agent_id: None, + subagent_type: None, + model_id: None, + inherit_parent_model: false, + timeout_seconds: None, + run_in_background: false, + persistent: true, + is_retry: false, + requested_auto_retry: false, + max_turns: None, + role: None, + }) + } + TaskAction::History => { + let target_agent_id = + Self::optional_trimmed_string(input, "agent_id")?.or_else(|| { + input + .get("session_id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(String::from) + }); + Self::ensure_fields_absent( + input, + &[ + "prompt", + "description", + "fork_context", + "subagent_type", + "model_id", + "run_in_background", + "persistent", "retry", "auto_retry", "retry_coverage", ], action, )?; + let max_turns = Self::optional_max_turns(input)?; Ok(TaskInvocation { action, @@ -261,8 +391,11 @@ impl TaskTool { inherit_parent_model: false, timeout_seconds: None, run_in_background: false, + persistent: true, is_retry: false, requested_auto_retry: false, + max_turns, + role: None, }) } } @@ -371,6 +504,18 @@ impl TaskTool { } } + fn optional_max_turns(input: &Value) -> BitFunResult> { + match input.get("max_turns") { + None | Some(Value::Null) => Ok(None), + Some(value) => { + let parsed = value.as_u64().ok_or_else(|| { + BitFunError::tool("max_turns must be a non-negative integer".to_string()) + })?; + Ok((parsed > 0).then_some(parsed)) + } + } + } + fn ensure_fields_absent( input: &Value, fields: &[&str], @@ -389,11 +534,15 @@ impl TaskTool { fn has_effective_value(input: &Value, field: &str) -> bool { // Some models serialize unused fields from this action-union schema as - // null, an empty string, or false. Those values carry no action intent. + // null or an empty string; those carry no action intent. Semantic + // booleans (for example `persistent: false`, `fork_context: false`) + // carry intent even when false: a field that is disallowed for an + // action must be rejected regardless of its boolean value, so a bare + // `false` is never silently accepted. match input.get(field) { None | Some(Value::Null) => false, Some(Value::String(value)) => !value.trim().is_empty(), - Some(Value::Bool(value)) => *value, + Some(Value::Bool(_)) => true, Some(_) => true, } } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/task/mod.rs b/src/crates/assembly/core/src/agentic/tools/implementations/task/mod.rs index 998a0b6f06..88a8d82b13 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/task/mod.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/task/mod.rs @@ -1,5 +1,5 @@ use crate::agentic::agents::{ - get_agent_registry, AgentInfo, SubagentListScope, SubagentQueryContext, + get_agent_registry, AcpAgent, AgentInfo, SubagentListScope, SubagentQueryContext, }; use crate::agentic::coordination::{get_global_coordinator, SubagentExecutionRequest}; use crate::agentic::deep_review::task_adapter::{ @@ -94,7 +94,7 @@ impl TaskTool { let registry = get_agent_registry(); let workspace_root = context.and_then(|ctx| ctx.workspace_root()); registry.load_custom_agents(workspace_root).await; - registry + let mut agents = registry .get_subagents_for_query(&SubagentQueryContext { parent_agent_type: context.and_then(|ctx| ctx.agent_type.as_deref()), workspace_root, @@ -102,7 +102,19 @@ impl TaskTool { include_disabled: false, external_sources_supported: context.is_none_or(|ctx| !ctx.is_remote()), }) - .await + .await; + // ACP bridge agents (`acp__`) are registered as Mode entries, + // so the SubAgent-scoped TaskVisible query does not list them. Allow + // them as spawn targets so Task can delegate to external ACP agents — + // the same 口径 SessionControl / SessionMessage use for `acp__`. + agents.extend( + registry + .get_modes_info() + .await + .into_iter() + .filter(|agent| agent.id.starts_with(AcpAgent::agent_id_prefix())), + ); + agents } async fn get_agents_types(&self, context: Option<&ToolUseContext>) -> Vec { @@ -220,9 +232,22 @@ impl Tool for TaskTool { .get("agent_id") .and_then(Value::as_str) .map(str::trim) - .filter(|agent_id| !agent_id.is_empty()) - .map(|agent_id| format!("cancel:{agent_id}")) - .ok_or_else(|| BitFunError::validation("agent_id is required".to_string()))?, + .filter(|session_id| !session_id.is_empty()) + .map(|session_id| format!("cancel:{session_id}")) + .ok_or_else(|| BitFunError::validation("session_id is required".to_string()))?, + TaskAction::List => "list".to_string(), + TaskAction::History => input + .get("agent_id") + .or_else(|| input.get("session_id")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|id| !id.is_empty()) + .map(|id| format!("history:{id}")) + .ok_or_else(|| { + BitFunError::validation( + "agent_id or session_id is required".to_string(), + ) + })?, }; Ok(vec![PermissionIntent::new("task", vec![resource])]) } @@ -258,6 +283,13 @@ impl Tool for TaskTool { } }) .unwrap_or_else(|| "Sending input to task".to_string()), + Some(TaskAction::List) => "Listing background tasks".to_string(), + Some(TaskAction::History) => input + .get("agent_id") + .or_else(|| input.get("session_id")) + .and_then(Value::as_str) + .map(|id| format!("Getting history for task: {}", id)) + .unwrap_or_else(|| "Getting task history".to_string()), Some(TaskAction::Spawn) | None => { if let Some(description) = input.get("description").and_then(|v| v.as_str()) { if options.verbose { diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/task/schema.rs b/src/crates/assembly/core/src/agentic/tools/implementations/task/schema.rs index 9b31f3e809..a009962e37 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/task/schema.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/task/schema.rs @@ -7,7 +7,7 @@ impl TaskTool { "description".to_string(), json!({ "type": "string", - "description": "A short (3-5 word) description of the task" + "description": "A short (3-5 word) description of the task. Use SessionControl (list) to discover sessions and SessionMessage to communicate with them." }), ); properties.insert( @@ -40,7 +40,7 @@ impl TaskTool { "action".to_string(), json!({ "type": "string", - "enum": ["spawn", "send_input", "cancel"], + "enum": ["spawn", "send_input", "cancel", "list", "history"], "description": "The action to perform." }), ); @@ -60,7 +60,7 @@ impl TaskTool { "agent_id".to_string(), json!({ "type": "string", - "description": "Required for action='send_input' and action='cancel'." + "description": "Required for action='send_input' and action='cancel'. Also accepted for action='history'." }), ); properties.insert( @@ -70,6 +70,22 @@ impl TaskTool { "description": "Optional for action='spawn' and action='send_input'. Defaults to false." }), ); + properties.insert( + "persistent".to_string(), + json!({ + "type": "boolean", + "default": true, + "description": "Optional for action='spawn'. Defaults to true. When false the subagent is temporary: it is automatically recycled when the task finishes (success, failure, or cancellation), and the returned agent_id cannot be reused. When true the subagent session is retained and can be continued with 'send_input'." + }), + ); + properties.insert( + "max_turns".to_string(), + json!({ + "type": "integer", + "minimum": 1, + "description": "Optional for action='history'. Limits the number of most recent turns returned." + }), + ); json!({ "type": "object", "properties": properties, @@ -91,6 +107,8 @@ Supported actions: - `spawn`: create and run a new subagent. The result contains an `agent_id` for future `send_input` or `cancel`. - `send_input`: continue an existing subagent. Provide `agent_id`, `description`, and `prompt`. Optionally provide `model_id` to switch the subagent model for this and later turns. - `cancel`: cancel a background subagent. Provide `agent_id`. +- `list`: list all background subagents for the current conversation. Returns agent_id, session_id, and status for each. +- `history`: read the conversation history of a specified subagent. Provide `agent_id` or `session_id`. Optionally provide `max_turns` to limit the number of turns returned. Two modes for action='spawn': The two modes are mutually exclusive: do not provide `subagent_type` when `fork_context=true`. @@ -112,6 +130,10 @@ The two modes are mutually exclusive: do not provide `subagent_type` when `fork_ - false: Wait for the agent to finish and return its result to you. - true: Run the agent in the background without blocking you. The response includes a `bg_task_id`; use AgentWait when you need the results. +`persistent` usage (action='spawn'): +- true (default): the subagent session is durable; use `send_input` with the returned `agent_id` to continue it later. +- false: one-shot temporary subagent. The session is automatically recycled when the task finishes (success, failure, or cancellation). The returned `agent_id` cannot be reused — treat the result as final. + `model_id` usage: - Set it only when the user requests a particular model. - Omit it to use the subagent's configured model, which may differ from your model. @@ -126,6 +148,9 @@ Usage notes: - When launching multiple non-read-only subagents in parallel, assign non-overlapping scopes and outputs so their file edits, commands, or external side effects do not conflict. - Treat subagent outputs as useful evidence, but verify details yourself before making edits or final claims that depend on exact code. - If an agent description mentions proactive use, consider it when relevant and use your judgment. +- Use SessionControl (list) to discover subagent sessions. +- Use SessionMessage to communicate with subagent sessions. +- Use SessionHistory to export and inspect subagent transcripts. Examples (assume "example-reviewer" is present in the agent listing): diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/task/tests.rs b/src/crates/assembly/core/src/agentic/tools/implementations/task/tests.rs index 2d281c53c2..7d51b32f17 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/task/tests.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/task/tests.rs @@ -133,6 +133,53 @@ fn task_schema_accepts_optional_model_id() { .any(|value| value.as_str() == Some("model_id"))); } +#[test] +fn task_persistent_defaults_to_true_for_spawn() { + let invocation = TaskTool::parse_invocation( + &json!({ + "action": "spawn", + "description": "Inspect parser", + "prompt": "Inspect the parser flow.", + "subagent_type": "Explore", + }), + false, + ) + .expect("spawn without persistent should parse"); + assert!(invocation.persistent); +} + +#[test] +fn task_persistent_false_parses_one_shot_lifecycle() { + let invocation = TaskTool::parse_invocation( + &json!({ + "action": "spawn", + "description": "One-shot report", + "prompt": "Produce a report.", + "subagent_type": "GeneralPurpose", + "persistent": false, + }), + false, + ) + .expect("spawn with persistent=false should parse"); + assert!(!invocation.persistent); +} + +#[test] +fn task_persistent_is_rejected_for_non_spawn_actions() { + let error = TaskTool::parse_invocation( + &json!({ + "action": "send_input", + "agent_id": "a1", + "description": "Continue", + "prompt": "Continue the work.", + "persistent": true, + }), + false, + ) + .expect_err("persistent is not allowed for send_input"); + assert!(error.to_string().contains("persistent is not allowed")); +} + #[test] fn task_model_id_inherit_requests_parent_model_inheritance() { let invocation = TaskTool::parse_invocation( @@ -662,7 +709,7 @@ async fn validate_input_accepts_send_input_with_neutral_spawn_placeholders() { "action": "send_input", "agent_id": "a1", "description": "continue", - "fork_context": false, + "fork_context": null, "prompt": "Continue the previous analysis", "subagent_type": "" }), @@ -906,10 +953,10 @@ async fn validate_input_accepts_cancel_with_neutral_optional_placeholders() { &json!({ "action": "cancel", "agent_id": "a1", - "fork_context": false, + "fork_context": null, "model_id": "", "prompt": "", - "run_in_background": false, + "run_in_background": null, "subagent_type": "" }), None, @@ -950,47 +997,23 @@ async fn validate_input_rejects_fork_context_conflicting_fields() { } #[tokio::test] -async fn call_impl_rejects_nested_subagent_delegation() { +async fn call_impl_allows_nested_subagent_within_fission_depth() { + // R-001: spawn_child() now allows nesting up to MAX_FISSION_DEPTH=10. + // At depth=1 (<10), delegation is permitted. let policy = DelegationPolicy::top_level().spawn_child(); - let context = ToolUseContext { - tool_call_id: Some("tool-call-1".to_string()), - agent_type: Some("agentic".to_string()), - session_id: Some("session-1".to_string()), - dialog_turn_id: Some("turn-1".to_string()), - workspace: None, - loaded_deferred_tool_specs: Vec::new(), - primary_model_facts: tool_runtime::context::PrimaryModelFacts::default(), - custom_data: HashMap::from([ - ( - "delegation_allow_subagent_spawn".to_string(), - json!(policy.allow_subagent_spawn), - ), - ( - "delegation_nesting_depth".to_string(), - json!(policy.nesting_depth), - ), - ]), - computer_use_host: None, - runtime_tool_restrictions: ToolRuntimeRestrictions::default(), - runtime_handles: bitfun_runtime_ports::ToolRuntimeHandles::default(), - }; - - let error = TaskTool::new() - .call_impl( - &json!({ - "action": "spawn", - "description": "delegate", - "prompt": "Inspect the repo", - "subagent_type": "Explore" - }), - &context, - ) - .await - .expect_err("nested subagent delegation should be rejected"); + assert!(policy.allow_subagent_spawn, "nesting at depth=1 should be allowed (1 < MAX_FISSION_DEPTH=10)"); + assert_eq!(policy.nesting_depth, 1); +} - assert!(error - .to_string() - .contains("Recursive subagent delegation is blocked. Use direct tools instead.")); +#[tokio::test] +async fn call_impl_rejects_nested_subagent_at_max_depth() { + // R-001: At MAX_FISSION_DEPTH, delegation is blocked. + let mut policy = DelegationPolicy::top_level(); + for _ in 0..10 { + policy = policy.spawn_child(); + } + assert!(!policy.allow_subagent_spawn, "nesting at depth=10 should be blocked (reached MAX_FISSION_DEPTH)"); + assert_eq!(policy.nesting_depth, 10); } #[test] diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/thread_goal_tools.rs b/src/crates/assembly/core/src/agentic/tools/implementations/thread_goal_tools.rs index f654b95cf5..ee13d8d963 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/thread_goal_tools.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/thread_goal_tools.rs @@ -169,7 +169,7 @@ impl Tool for CreateGoalTool { async fn description(&self) -> BitFunResult { Ok(format!( "Create a goal only when explicitly requested by the user or system/developer instructions; do not infer goals from ordinary tasks. \ -Set token_budget only when an explicit token budget is requested. Fails if a goal exists; use {UPDATE_GOAL_TOOL_NAME} only for status." +Set token_budget only when an explicit token budget is requested. Optionally pass reference_files (workspace-relative paths) that the goal tracks as authoritative context. Fails if a goal exists; use {UPDATE_GOAL_TOOL_NAME} only for status." )) } @@ -190,6 +190,13 @@ Set token_budget only when an explicit token budget is requested. Fails if a goa "token_budget": { "type": "integer", "description": "Positive token budget for the new goal. Omit unless explicitly requested." + }, + "reference_files": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Workspace-relative reference files the goal tracks as authoritative context (e.g. spec/task files the agent should keep in sync). Omit when the goal has no reference files." } } }) @@ -210,6 +217,7 @@ Set token_budget only when an explicit token budget is requested. Fails if a goa workspace_path: workspace_path.to_string_lossy().into_owned(), objective: parsed.objective, token_budget: parsed.token_budget, + reference_files: parsed.reference_files, }) .await .map_err(thread_goal_runtime_error)?; @@ -245,16 +253,17 @@ impl Tool for UpdateGoalTool { async fn description(&self) -> BitFunResult { Ok( - "Update the existing goal. Use only to mark the goal achieved or genuinely blocked. \ + "Update the existing goal. Use only to mark the goal achieved or genuinely blocked, or to resume a blocked goal. \ Set status to complete only when the objective has actually been achieved and no required work remains. \ Set status to blocked only when the same blocking condition has repeated for at least three consecutive goal turns and the agent cannot make meaningful progress without user input or an external-state change. \ -You cannot use this tool to pause, resume, budget-limit, or usage-limit a goal." +Set status to resume only when the user explicitly asks to continue a blocked, paused, or usage-limited goal. \ +You cannot use this tool to pause, budget-limit, or usage-limit a goal." .to_string(), ) } fn short_description(&self) -> String { - "Mark the session thread goal complete or blocked.".to_string() + "Mark the session thread goal complete or blocked, or resume it.".to_string() } fn input_schema(&self) -> Value { @@ -265,8 +274,8 @@ You cannot use this tool to pause, resume, budget-limit, or usage-limit a goal." "properties": { "status": { "type": "string", - "enum": ["complete", "blocked"], - "description": "Required. Set to complete only when the objective is achieved. Set to blocked only after the strict blocked audit is satisfied." + "enum": ["complete", "blocked", "resume"], + "description": "Required. Set to complete only when the objective is achieved. Set to blocked only after the strict blocked audit is satisfied. Set to resume to continue a blocked, paused, or usage-limited goal." } } }) diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/todo_write_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/todo_write_tool.rs index aef851c4fa..91ed1462df 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/todo_write_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/todo_write_tool.rs @@ -53,6 +53,9 @@ Each item must include: - id: stable unique identifier - content: imperative description of the work - status: pending, in_progress, or completed + +Each item may include: +- dependencies: optional array of todo item ids this item depends on; cyclic dependencies are rejected "###.to_string()) } @@ -86,6 +89,13 @@ Each item must include: "completed" ], "description": "Current status of the todo item" + }, + "dependencies": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Optional ids of todo items this item depends on. Parents are ordered and rendered before this item. Cyclic dependencies are rejected." } }, "required": [ @@ -104,7 +114,10 @@ Each item must include: } fn is_readonly(&self) -> bool { - true + // LEGION-11: TodoWrite replaces the session todo list, so it is a + // state-mutating call, not a read. Marking it readonly let RBAC treat + // it as side-effect free and skip Write/Communicate gating. + false } fn is_concurrency_safe(&self, _input: Option<&Value>) -> bool { @@ -116,6 +129,8 @@ Each item must include: input: &Value, _context: &ToolUseContext, ) -> BitFunResult> { + use std::collections::HashSet; + // Parse todos array let todos = input .get("todos") @@ -123,26 +138,65 @@ Each item must include: .ok_or(BitFunError::validation("Missing required field: todos"))?; let mut processed_todos = Vec::new(); + // LEGION-12: reject duplicate ids so every todo id stays a stable, + // addressable key in the list. + let mut seen_ids: HashSet = HashSet::new(); for todo in todos { let mut todo_obj = todo.clone(); - if let Some(obj) = todo_obj.as_object_mut() { - if !obj.contains_key("status") { - return Err(BitFunError::validation("Todo item missing status field")); - } - if !obj.contains_key("content") { - return Err(BitFunError::validation("Todo item missing content field")); - } - // If no id, generate a new one - if !obj.contains_key("id") { - let uuid = uuid::Uuid::new_v4().to_string(); - let short_id = uuid.split('-').next().unwrap_or("todo"); - let new_id = format!("todo_{}", short_id); - obj.insert("id".to_string(), json!(new_id)); + // LEGION-12: each todo must be a JSON object; a non-object item was + // previously passed through unvalidated. + let Some(obj) = todo_obj.as_object_mut() else { + return Err(BitFunError::validation("Todo item must be an object")); + }; + if !obj.contains_key("status") { + return Err(BitFunError::validation("Todo item missing status field")); + } + if !obj.contains_key("content") { + return Err(BitFunError::validation("Todo item missing content field")); + } + // LEGION-12: reject status values outside the documented enum + // instead of silently ignoring them in the stats counter. + let status = obj + .get("status") + .and_then(|value| value.as_str()) + .unwrap_or(""); + match status { + "pending" | "in_progress" | "completed" => {} + other => { + return Err(BitFunError::validation(format!( + "Todo item has invalid status '{}': expected pending, in_progress, or completed", + other + ))); } } + // If no id, generate a new one + if !obj.contains_key("id") { + let uuid = uuid::Uuid::new_v4().to_string(); + let short_id = uuid.split('-').next().unwrap_or("todo"); + let new_id = format!("todo_{}", short_id); + obj.insert("id".to_string(), json!(new_id)); + } + // LEGION-12: an id must be a non-empty string so the dependency + // topology below and downstream consumers can address it reliably. + let id = obj + .get("id") + .and_then(|value| value.as_str()) + .ok_or_else(|| BitFunError::validation("Todo item id must be a string"))?; + if id.trim().is_empty() { + return Err(BitFunError::validation("Todo item id must not be empty")); + } + if !seen_ids.insert(id.to_string()) { + return Err(BitFunError::validation(format!( + "Duplicate todo id '{}'", + id + ))); + } processed_todos.push(todo_obj); } + // Topology validation: reject self-loops, unknown references, and cycles. + validate_todo_dependencies(&processed_todos)?; + let todo_count = processed_todos.len(); let mut status_counts = [0; 3]; processed_todos.iter().for_each(|t| { @@ -180,3 +234,224 @@ Each item must include: }]) } } + +/// Validate the todo dependency topology. +/// +/// Rejects self-loops, dependencies referencing unknown todo ids, and cycles. +/// Mirrors the legion topology cycle rejection pattern (Kahn topological sort; +/// when not every node is visited, the graph contains a cycle). +fn validate_todo_dependencies(todos: &[Value]) -> BitFunResult<()> { + use std::collections::{BTreeSet, HashMap, HashSet}; + + let mut ids: HashSet = HashSet::new(); + for todo in todos { + if let Some(id) = todo.get("id").and_then(|v| v.as_str()) { + ids.insert(id.to_string()); + } + } + + // Edge validation: endpoints exist, no self-loops. + let mut adjacency: HashMap> = HashMap::new(); + let mut in_degree: HashMap = HashMap::new(); + for id in &ids { + adjacency.insert(id.clone(), Vec::new()); + in_degree.insert(id.clone(), 0); + } + for todo in todos { + let Some(child) = todo.get("id").and_then(|v| v.as_str()) else { + continue; + }; + let Some(deps) = todo.get("dependencies").and_then(|v| v.as_array()) else { + continue; + }; + for dep_value in deps { + let Some(dep) = dep_value.as_str() else { + return Err(BitFunError::validation( + "Todo dependency must be a string", + )); + }; + if dep == child { + return Err(BitFunError::validation(format!( + "Todo '{}' cannot depend on itself", + child + ))); + } + if !ids.contains(dep) { + return Err(BitFunError::validation(format!( + "Todo dependency references unknown todo '{}'", + dep + ))); + } + let nexts = adjacency + .get_mut(dep) + .ok_or_else(|| { + BitFunError::validation(format!( + "Internal error: missing adjacency for '{}'", + dep + )) + })?; + nexts.push(child.to_string()); + let degree = in_degree + .get_mut(child) + .ok_or_else(|| { + BitFunError::validation(format!( + "Internal error: missing in-degree for '{}'", + child + )) + })?; + *degree += 1; + } + } + + // Kahn topological sort with deterministic (lexicographic) order. + let mut ready: BTreeSet = ids + .iter() + .filter(|id| in_degree.get(*id).copied().unwrap_or(usize::MAX) == 0) + .cloned() + .collect(); + + let mut order: Vec = Vec::with_capacity(ids.len()); + while let Some(id) = ready.iter().next().cloned() { + ready.remove(&id); + order.push(id.clone()); + let nexts = adjacency + .get(&id) + .cloned() + .ok_or_else(|| { + BitFunError::validation(format!( + "Internal error: missing adjacency for '{}'", + id + )) + })?; + for next in nexts { + let degree = in_degree + .get_mut(&next) + .ok_or_else(|| { + BitFunError::validation(format!( + "Internal error: missing in-degree for '{}'", + next + )) + })?; + *degree -= 1; + if *degree == 0 { + ready.insert(next); + } + } + } + if order.len() != ids.len() { + return Err(BitFunError::validation( + "Todo dependencies contain a cycle", + )); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agentic::tools::framework::ToolUseContext; + use std::collections::HashMap; + + fn empty_context() -> ToolUseContext { + ToolUseContext { + tool_call_id: None, + agent_type: None, + session_id: None, + dialog_turn_id: None, + workspace: None, + loaded_deferred_tool_specs: Vec::new(), + primary_model_facts: tool_runtime::context::PrimaryModelFacts::default(), + custom_data: HashMap::new(), + computer_use_host: None, + runtime_tool_restrictions: Default::default(), + runtime_handles: bitfun_runtime_ports::ToolRuntimeHandles::default(), + } + } + + fn todo(id: &str, status: &str) -> Value { + json!({ "id": id, "content": "do the work", "status": status }) + } + + #[test] + fn todo_write_is_not_readonly() { + // LEGION-11: TodoWrite mutates the session todo list. + assert!(!TodoWriteTool::new().is_readonly()); + } + + #[tokio::test] + async fn rejects_duplicate_ids() { + // LEGION-12: two items with the same id make the list ambiguous. + let tool = TodoWriteTool::new(); + let input = json!({ + "todos": [todo("a", "pending"), todo("a", "in_progress")] + }); + let result = tool.call_impl(&input, &empty_context()).await; + let err = result.expect_err("duplicate ids must be rejected"); + assert!( + err.to_string().contains("Duplicate todo id 'a'"), + "unexpected error: {err}" + ); + } + + #[tokio::test] + async fn rejects_non_object_todo() { + // LEGION-12: a non-object item (e.g. a bare string) must not pass + // through unvalidated. + let tool = TodoWriteTool::new(); + let input = json!({ "todos": ["not-an-object"] }); + let result = tool.call_impl(&input, &empty_context()).await; + let err = result.expect_err("non-object todos must be rejected"); + assert!( + err.to_string().contains("must be an object"), + "unexpected error: {err}" + ); + } + + #[tokio::test] + async fn rejects_invalid_status() { + // LEGION-12: status values outside the documented enum are rejected. + let tool = TodoWriteTool::new(); + let input = json!({ "todos": [todo("a", "done")] }); + let result = tool.call_impl(&input, &empty_context()).await; + let err = result.expect_err("invalid status must be rejected"); + assert!( + err.to_string().contains("invalid status 'done'"), + "unexpected error: {err}" + ); + } + + #[tokio::test] + async fn rejects_non_string_id() { + // LEGION-12: ids must be strings so the dependency topology can + // address them reliably. + let tool = TodoWriteTool::new(); + let input = json!({ + "todos": [{ "id": 123, "content": "do the work", "status": "pending" }] + }); + let result = tool.call_impl(&input, &empty_context()).await; + let err = result.expect_err("non-string ids must be rejected"); + assert!( + err.to_string().contains("id must be a string"), + "unexpected error: {err}" + ); + } + + #[tokio::test] + async fn accepts_valid_todo_list_and_auto_generates_ids() { + let tool = TodoWriteTool::new(); + let input = json!({ + "todos": [ + { "content": "first", "status": "pending" }, + { "id": "b", "content": "second", "status": "completed", "dependencies": [] } + ] + }); + let result = tool.call_impl(&input, &empty_context()).await; + let results = result.expect("valid todo list should succeed"); + let data = &results[0].content(); + let todos = data.get("todos").and_then(|value| value.as_array()).expect("todos array"); + assert_eq!(todos.len(), 2); + assert!(todos[0].get("id").and_then(|value| value.as_str()).is_some()); + assert_eq!(todos[1].get("id").and_then(|value| value.as_str()), Some("b")); + } +} diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/workspace_scan_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/workspace_scan_tool.rs new file mode 100644 index 0000000000..0d147f8827 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/tools/implementations/workspace_scan_tool.rs @@ -0,0 +1,328 @@ +use crate::agentic::tools::framework::{ + Tool, ToolExposure, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, +}; +use crate::service::workspace::{ + get_global_workspace_service, WorkspaceInfo, WorkspaceStatus, WorkspaceSummary, +}; +use crate::util::errors::{BitFunError, BitFunResult}; +use async_trait::async_trait; +use serde::Deserialize; +use serde_json::{json, Value}; + +/// WorkspaceScan tool - scan existing workspaces by scope without modifying them. +pub struct WorkspaceScanTool; + +impl Default for WorkspaceScanTool { + fn default() -> Self { + Self::new() + } +} + +impl WorkspaceScanTool { + pub fn new() -> Self { + Self + } +} + +/// Resolved scan scope. +#[derive(Debug, Clone, PartialEq)] +enum WorkspaceScanScope { + Opened, + Recent, + All, + ByStatus(WorkspaceStatus), +} + +/// Parses the user-facing `scope` string into a concrete scan scope. +/// +/// Scope matching is case-insensitive (LEGION-13): "OPENED", "Recent", and +/// "BY_STATUS:ARCHIVED" all resolve like their lowercase forms. +fn parse_scope(scope: &str) -> Result { + let trimmed = scope.trim(); + let lowered = trimmed.to_ascii_lowercase(); + match lowered.as_str() { + "" | "opened" => Ok(WorkspaceScanScope::Opened), + "recent" => Ok(WorkspaceScanScope::Recent), + "all" => Ok(WorkspaceScanScope::All), + _ => match lowered.strip_prefix("by_status:") { + Some(status) => parse_status(status).map(WorkspaceScanScope::ByStatus), + None => Err(format!( + "Unsupported scope '{}'. Expected one of: opened, recent, all, by_status:", + trimmed + )), + }, + } +} + +/// Parses a workspace status string (case-insensitive). +fn parse_status(status: &str) -> Result { + match status.trim().to_ascii_lowercase().as_str() { + "active" => Ok(WorkspaceStatus::Active), + "inactive" => Ok(WorkspaceStatus::Inactive), + "loading" => Ok(WorkspaceStatus::Loading), + "error" => Ok(WorkspaceStatus::Error), + "archived" => Ok(WorkspaceStatus::Archived), + other => Err(format!( + "Unsupported workspace status '{}'. Expected one of: active, inactive, loading, error, archived", + other + )), + } +} + +/// Compact entry shape shared by every scope. +fn workspace_info_to_entry(info: &WorkspaceInfo) -> Value { + json!({ + "id": info.id, + "name": info.name, + "rootPath": info.root_path.to_string_lossy(), + "status": format!("{:?}", info.status), + "openedAt": info.opened_at.to_rfc3339(), + "lastAccessed": info.last_accessed.to_rfc3339(), + "workspaceType": format!("{:?}", info.workspace_type), + }) +} + +/// Compact entry shape for summaries (the summary type has no `openedAt` field). +fn workspace_summary_to_entry(summary: &WorkspaceSummary) -> Value { + json!({ + "id": summary.id, + "name": summary.name, + "rootPath": summary.root_path.to_string_lossy(), + "status": format!("{:?}", summary.status), + "openedAt": Value::Null, + "lastAccessed": summary.last_accessed.to_rfc3339(), + "workspaceType": format!("{:?}", summary.workspace_type), + }) +} + +#[derive(Debug, Clone, Deserialize)] +struct WorkspaceScanInput { + #[serde(default)] + scope: Option, +} + +#[async_trait] +impl Tool for WorkspaceScanTool { + fn name(&self) -> &str { + "WorkspaceScan" + } + + async fn description(&self) -> BitFunResult { + Ok( + r#"Use this tool when you need to scan and query existing workspaces in the current environment. + +This tool is read-only and never modifies workspace state. It lists workspaces known to the workspace service, which is the prerequisite for cross-workspace orchestration: inspect what is opened, recently accessed, or tracked, then direct follow-up work at the right workspace. + +`scope` parameter (defaults to "opened"): +- "opened": currently opened workspaces +- "recent": recently accessed workspaces +- "all": every tracked workspace (including inactive ones) +- "by_status:": every tracked workspace filtered by status; status is one of active, inactive, loading, error, archived + +Each returned entry has the shape {id, name, rootPath, status, openedAt, lastAccessed, workspaceType}. For scopes backed by workspace summaries ("all", "by_status:") `openedAt` is null because the summary record does not carry it. + +Examples: +1. List currently opened workspaces: leave `scope` empty +2. List recently accessed workspaces: scope="recent" +3. List every tracked workspace: scope="all" +4. List archived workspaces: scope="by_status:archived""# + .to_string(), + ) + } + + fn short_description(&self) -> String { + "Scan and query existing workspaces (opened, recent, all, or by status). Read-only." + .to_string() + } + + fn default_exposure(&self) -> ToolExposure { + // Mirrors the plan tool family calibration: commander/Claw staples + // stay Direct so no GetToolSpec unlock round-trip is needed. + ToolExposure::Direct + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "scope": { + "type": "string", + "description": "Scan scope. One of: opened, recent, all, by_status:. Defaults to opened." + } + }, + "additionalProperties": false + }) + } + + fn is_readonly(&self) -> bool { + true + } + + async fn validate_input( + &self, + input: &Value, + _context: Option<&ToolUseContext>, + ) -> ValidationResult { + let parsed: WorkspaceScanInput = match serde_json::from_value(input.clone()) { + Ok(value) => value, + Err(err) => { + return ValidationResult { + result: false, + message: Some(format!("Invalid input: {}", err)), + error_code: Some(400), + meta: None, + }; + } + }; + + if let Some(scope) = parsed.scope.as_deref() { + if let Err(message) = parse_scope(scope) { + return ValidationResult { + result: false, + message: Some(message), + error_code: Some(400), + meta: None, + }; + } + } + + ValidationResult::default() + } + + fn render_tool_use_message(&self, input: &Value, _options: &ToolRenderOptions) -> String { + let scope = input + .get("scope") + .and_then(|value| value.as_str()) + .unwrap_or("opened"); + format!("Scan workspaces with scope '{}'", scope) + } + + async fn call_impl( + &self, + input: &Value, + _context: &ToolUseContext, + ) -> BitFunResult> { + let params: WorkspaceScanInput = serde_json::from_value(input.clone()) + .map_err(|e| BitFunError::tool(format!("Invalid input: {}", e)))?; + + let scope = params.scope.as_deref().unwrap_or("opened"); + let resolved = parse_scope(scope) + .map_err(|message| BitFunError::tool(format!("Invalid scope: {}", message)))?; + + let service = get_global_workspace_service().ok_or_else(|| { + BitFunError::service("Global workspace service is unavailable for WorkspaceScan") + })?; + + let entries = match resolved { + WorkspaceScanScope::Opened => { + let workspaces = service.get_opened_workspaces().await; + workspaces + .iter() + .map(workspace_info_to_entry) + .collect::>() + } + WorkspaceScanScope::Recent => { + let workspaces = service.get_recent_workspaces().await; + workspaces + .iter() + .map(workspace_info_to_entry) + .collect::>() + } + WorkspaceScanScope::All => { + let workspaces = service.list_workspaces().await; + workspaces + .iter() + .map(workspace_summary_to_entry) + .collect::>() + } + WorkspaceScanScope::ByStatus(status) => { + let workspaces = service.list_workspaces_by_status(status).await; + workspaces + .iter() + .map(workspace_summary_to_entry) + .collect::>() + } + }; + + Ok(vec![ToolResult::Result { + data: json!({ + "success": true, + "scope": scope, + "count": entries.len(), + "workspaces": entries, + }), + result_for_assistant: Some(format!( + "Scanned {} workspace(s) with scope '{}'. Use the returned entries to direct follow-up work.", + entries.len(), + scope + )), + image_attachments: None, + }]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_scope_accepts_default_and_known_scopes() { + assert_eq!(parse_scope(""), Ok(WorkspaceScanScope::Opened)); + assert_eq!(parse_scope("opened"), Ok(WorkspaceScanScope::Opened)); + assert_eq!(parse_scope("recent"), Ok(WorkspaceScanScope::Recent)); + assert_eq!(parse_scope("all"), Ok(WorkspaceScanScope::All)); + assert_eq!( + parse_scope("by_status:active"), + Ok(WorkspaceScanScope::ByStatus(WorkspaceStatus::Active)) + ); + assert_eq!( + parse_scope("by_status:Archived"), + Ok(WorkspaceScanScope::ByStatus(WorkspaceStatus::Archived)) + ); + } + + #[test] + fn parse_scope_is_case_insensitive() { + // LEGION-13: scope keywords and the by_status prefix match + // case-insensitively, like parse_status already did. + assert_eq!(parse_scope("OPENED"), Ok(WorkspaceScanScope::Opened)); + assert_eq!(parse_scope("Recent"), Ok(WorkspaceScanScope::Recent)); + assert_eq!(parse_scope("ALL"), Ok(WorkspaceScanScope::All)); + assert_eq!( + parse_scope("BY_STATUS:Active"), + Ok(WorkspaceScanScope::ByStatus(WorkspaceStatus::Active)) + ); + assert_eq!( + parse_scope("By_Status:error"), + Ok(WorkspaceScanScope::ByStatus(WorkspaceStatus::Error)) + ); + } + + #[test] + fn parse_scope_rejects_unknown_scopes() { + assert!(parse_scope("unknown").is_err()); + assert!(parse_scope("by_status:").is_err()); + assert!(parse_scope("by_status:unknown_status").is_err()); + } + + #[tokio::test] + async fn validate_accepts_omitted_scope() { + let tool = WorkspaceScanTool::new(); + + let validation = tool.validate_input(&json!({}), None).await; + + assert!(validation.result, "{:?}", validation.message); + } + + #[tokio::test] + async fn validate_rejects_unknown_scope() { + let tool = WorkspaceScanTool::new(); + + let validation = tool + .validate_input(&json!({ "scope": "unknown" }), None) + .await; + + assert!(!validation.result); + assert_eq!(validation.error_code, Some(400)); + } +} diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/worktree_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/worktree_tool.rs index 9cadc3e244..3516ce31a1 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/worktree_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/worktree_tool.rs @@ -434,6 +434,7 @@ The tool cannot remove or rebind the worktree in which it is running. Use Sessio workspace_path: project_workspace_path.clone(), remote_connection_id: None, remote_ssh_host: None, + include_hidden: false, }) .await { diff --git a/src/crates/assembly/core/src/agentic/tools/mod.rs b/src/crates/assembly/core/src/agentic/tools/mod.rs index 3ebd0d2c21..82225af023 100644 --- a/src/crates/assembly/core/src/agentic/tools/mod.rs +++ b/src/crates/assembly/core/src/agentic/tools/mod.rs @@ -42,8 +42,11 @@ pub use registry::{ get_readonly_registered_tool_names, get_readonly_tools, }; pub use restrictions::{ - is_miniapp_headless_agent_run, is_miniapp_market_strict_agent_run, - miniapp_agent_run_tool_restrictions, miniapp_headless_agent_tool_restrictions, - miniapp_market_strict_agent_tool_restrictions, tool_restrictions_for_delegation_policy, - ToolPathOperation, ToolPathPolicy, ToolRuntimeRestrictions, + clear_session_role, clear_session_restrictions, get_default_permissions, + get_session_restrictions, get_session_role, is_miniapp_headless_agent_run, + is_miniapp_market_strict_agent_run, miniapp_agent_run_tool_restrictions, + miniapp_headless_agent_tool_restrictions, miniapp_market_strict_agent_tool_restrictions, + set_session_role, subagent_tool_restrictions, tool_restrictions_for_delegation_policy, + update_restrictions, AgentRole, OperationClass, RolePermissionMap, ToolPathOperation, + ToolPathPolicy, ToolRuntimeRestrictions, ToolRuntimeRestrictionsPatch, }; diff --git a/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs b/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs index 4ab99d8cef..fdc2365f6d 100644 --- a/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs +++ b/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs @@ -5,14 +5,22 @@ use super::state_manager::{tool_task_state_kind, ToolStateManager}; use super::types::*; -use crate::agentic::core::{ToolCall, ToolExecutionState, ToolResult as ModelToolResult}; +use crate::agentic::core::{Message, ToolCall, ToolExecutionState, ToolResult as ModelToolResult}; use crate::agentic::events::types::ToolEventData; use crate::agentic::tools::computer_use_host::ComputerUseHostRef; use crate::agentic::tools::framework::ToolResult as FrameworkToolResult; -use crate::agentic::tools::registry::ToolRegistry; +use crate::agentic::tools::product_runtime::{ + collect_product_loaded_deferred_tool_specs, resolve_product_get_tool_spec_results, +}; +use crate::agentic::tools::registry::{ToolRef, ToolRegistry}; +use crate::agentic::tools::restrictions::get_session_restrictions; use crate::agentic::tools::tool_context_runtime; use crate::agentic::tools::tool_context_runtime::ToolUseContext; use crate::agentic::tools::tool_result_storage; +use crate::agentic::warden::runtime::{ + resolve_audit_poke_from_judgement, summarize_judgement_tool_args, tool_failure_scene_key, + WardenRuntime, WardenToolOutcome, +}; use crate::native_hooks::{self, NativeHookSessionFacts}; use crate::util::elapsed_ms_u64; use crate::util::errors::{BitFunError, BitFunResult}; @@ -28,17 +36,19 @@ use bitfun_agent_tools::{ build_tool_execution_timeout_presentation, build_user_rejected_tool_presentation_with_instruction, build_user_steering_interrupted_presentation, build_write_tail_closure_notice, - render_tool_result_for_assistant, validate_tool_execution_admission, PermissionIntent, - ResolvedToolInvocation, ToolExecutionAdmissionRejection, ToolExecutionAdmissionRequest, - ToolExecutionErrorPresentation, GET_TOOL_SPEC_TOOL_NAME, USER_STEERING_INTERRUPTED_MESSAGE, + render_tool_result_for_assistant, validate_tool_execution_admission, LoadedDeferredToolSpec, + PokeMessage, PokeType, PermissionIntent, ResolvedToolInvocation, ToolExecutionAdmissionRejection, + ToolExecutionAdmissionRequest, ToolExecutionErrorPresentation, ToolRuntimeRestrictions, + GET_TOOL_SPEC_TOOL_NAME, USER_STEERING_INTERRUPTED_MESSAGE, }; use bitfun_runtime_ports::{ PermissionReply, PermissionRequest, PermissionRequestSource, PermissionRequestSourceKind, - PermissionResourceCaseSensitivity, RoundInjectionToolPreemption, + PermissionResourceCaseSensitivity, RoundInjectionToolPreemption, WardenAuditJudgementRequest, + WardenModelJudgementPort, }; use futures::future::join_all; use log::{debug, error, info, warn}; -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::path::Path; use std::sync::Arc; use std::time::{Instant, SystemTime}; @@ -77,6 +87,58 @@ fn persisted_effective_tool_name( (wire_tool_name != effective_tool_name).then(|| effective_tool_name.to_string()) } +/// Resolve the effective tool runtime restrictions for a session. +/// +/// Warden-registered per-session restrictions (e.g. after a demotion) fully +/// replace the context-level restrictions, matching the precedence of +/// [`ToolUseContext::enforce_tool_runtime_restrictions`]: a session override +/// wins, otherwise the context-level template applies. +fn effective_runtime_tool_restrictions( + session_id: &str, + context_level: &ToolRuntimeRestrictions, +) -> ToolRuntimeRestrictions { + get_session_restrictions(session_id).unwrap_or_else(|| context_level.clone()) +} + +/// Merge freshly collected deferred-tool specs into the existing set. A fresh +/// entry replaces the entry with the same tool name, mirroring the upsert +/// semantics of the loaded-spec collection channel. +fn merge_loaded_deferred_tool_specs( + existing: &[LoadedDeferredToolSpec], + fresh: &[LoadedDeferredToolSpec], +) -> Vec { + let mut merged: BTreeMap = existing + .iter() + .map(|spec| (spec.tool_name.clone(), spec.clone())) + .collect(); + for spec in fresh { + merged.insert(spec.tool_name.clone(), spec.clone()); + } + merged.into_values().collect() +} + +/// Maximum auto-reload attempts for one stale deferred-tool spec invocation. +/// Each attempt re-runs GetToolSpec and re-checks admission; the loop ends +/// early as soon as admission passes or the tool is not reloadable. +const MAX_STALE_SPEC_RELOAD_ATTEMPTS: usize = 3; + +/// Defensive upper bound for the session-scoped auto-reload cache. Entries are +/// small and only referenced while their session stays active, so this guard +/// simply prevents unbounded growth after very long-lived hosts. +const MAX_CACHED_SESSIONS_WITH_RELOADED_SPECS: usize = 1024; + +/// Outcome of a stale deferred-tool spec reload attempt. +enum StaleSpecReloadOutcome { + /// The reload observed a fresh spec and produced the merged loaded- + /// spec set (existing entries plus the refreshed one). + Reloaded(Vec), + /// The tool cannot be reloaded through the GetToolSpec runtime path — + /// the execution call failed, returned no usable result, or the tool + /// is no longer part of the contextual deferred catalog. The caller + /// keeps the original admission rejection. + NotReloadable(&'static str), +} + /// Convert framework::ToolResult to core::ToolResult /// /// Ensure always has result_for_assistant, avoid tool message content being empty @@ -601,6 +663,69 @@ pub struct ToolPipeline { /// Tool task ids a PreToolUse hook approved. The approval waives the /// interactive permission prompt only; policy denials still apply. hook_preapprovals: Arc>>, + /// Optional Warden runtime for tool-level audit. Injected after + /// construction via [`ToolPipeline::set_warden_runtime`] on a custom + /// point outside the hook dispatch channel (never gated by + /// `app.hooks.enabled`). + warden_runtime: std::sync::OnceLock>>, + /// Optional model-backed Warden judgement provider for Audit-Poke + /// decisions. Injected after construction via + /// [`ToolPipeline::set_warden_model_judgement`] (batch-2 warden rework); + /// when absent or failing, the mechanical rule ladder decides. + warden_model_judgement: std::sync::OnceLock>, + /// WARDEN-02: short-window debounce for model Audit-Poke judgements, keyed + /// by `(session_id, scene_key)` where the scene is the tool plus its + /// argument fingerprint. Repeated destructive calls of the same scene + /// within a turn are judged by the model once; later occurrences fall + /// back to the mechanical poke so the turn is not blocked on repeated + /// model round-trips. Distinct scenes of the same tool are judged + /// separately — each argument shape owns its own escalation ladder (see + /// `tool_failure_scene_key`). + warden_audit_debounce: Arc>>, + /// WARDEN-08: short-lived per-session goal-gate cache, written by the + /// Audit-Poke path and reused by the tool-outcome gate so a destructive + /// call performs at most one `get_thread_goal` lookup. Values carry a + /// short TTL to bound staleness across turns. + warden_goal_gate_cache: Arc>>, + /// Tool task ids whose admission was rejected before execution (stale + /// tool catalog, deferred-tool gateway, runtime restrictions). Such + /// rejections are protocol-layer outcomes, not execution violations + /// (F3): they are reported to the Warden as `AdmissionRejected` and + /// never count toward the tool-failure penalty ladder. + admission_rejected_tasks: Arc>>, + /// Session-scoped auto-reloaded deferred-tool specs (F2). A stale spec + /// reloaded by [`Self::reload_stale_deferred_tool_spec`] is recorded here + /// so later rounds that reconstruct loaded specs from the message history + /// (the synthesized GetToolSpec result never becomes part of the + /// conversation) can merge the refreshed generation back instead of + /// re-triggering the reload every round. + session_loaded_deferred_specs: Arc>>>, +} + +/// WARDEN-02: within this window the same scene (tool + argument fingerprint) +/// of a session is judged by the model at most once; later occurrences of the +/// same destructive scene fall back to the mechanical poke message so the +/// turn is not blocked on repeated model round-trips. Distinct scenes of the +/// same tool are judged independently. +const WARDEN_AUDIT_DEBOUNCE_WINDOW: Duration = Duration::from_secs(30); + +/// WARDEN-08: TTL for the goal-gate cache entry written by the Audit-Poke +/// path. Covers the sub-second gap between the audit hook and the outcome +/// reporting of one tool call without letting stale goal state persist across +/// turns. +const WARDEN_GOAL_GATE_CACHE_TTL: Duration = Duration::from_secs(10); + +/// Outcome of the Audit-Poke goal gate (WARDEN-06/08): the gate runs on a +/// single goal lookup that doubles as the judgement evidence. +enum WardenGoalContext { + /// The session holds an active goal; `serde_json::Value` carries its + /// objective/status/reference-files evidence. + Active(serde_json::Value), + /// Goal lookup unavailable (no coordinator, no workspace, or store + /// error): fail-open, consistent with the tool-outcome gate. + FailOpen, + /// Goal absent or present-but-not-active: the Audit-Poke opts out. + Inactive, } impl ToolPipeline { @@ -617,6 +742,12 @@ impl ToolPipeline { permission_request_manager: None, permission_plans: Arc::new(TokioMutex::new(HashMap::new())), hook_preapprovals: Arc::new(TokioMutex::new(HashSet::new())), + warden_runtime: std::sync::OnceLock::new(), + warden_model_judgement: std::sync::OnceLock::new(), + warden_audit_debounce: Arc::new(TokioMutex::new(HashMap::new())), + warden_goal_gate_cache: Arc::new(TokioMutex::new(HashMap::new())), + admission_rejected_tasks: Arc::new(TokioMutex::new(HashSet::new())), + session_loaded_deferred_specs: Arc::new(TokioMutex::new(HashMap::new())), } } @@ -632,6 +763,135 @@ impl ToolPipeline { self.computer_use_host.clone() } + /// Inject the Warden runtime for tool-level audit. + /// + /// Called once after construction (the pipeline is built before the + /// scheduler that owns the runtime). A second set is logged and ignored. + pub fn set_warden_runtime(&self, warden_runtime: Arc>) { + if self.warden_runtime.set(warden_runtime).is_err() { + warn!("tool pipeline: warden runtime already set, ignoring duplicate"); + } + } + + /// Inject the model-backed Warden judgement provider for Audit-Poke + /// decisions (batch-2 warden rework). + /// + /// Called once after construction by the host assembly (desktop), which + /// owns the concrete provider. A second set is logged and ignored. + pub fn set_warden_model_judgement(&self, port: Arc) { + if self.warden_model_judgement.set(port).is_err() { + warn!("tool pipeline: warden model judgement already set, ignoring duplicate"); + } + } + + /// Report one finished tool call to the Warden runtime on a custom point + /// outside the hook dispatch channel. + /// + /// Only fires when a runtime was injected and the session currently has + /// an active thread goal (batch-2 goal switch: Warden tool-level + /// enforcement applies only to goal-driven sessions). A missing task + /// lookup is a benign no-op (the task may already be gone). The failure + /// scene is fingerprinted from the effective tool name plus effective + /// arguments so repeated failures of the same argument shape escalate + /// while a first failure of a new shape stays exploratory. + /// + /// WARDEN-05: subagent sessions are exempt outright (thread goals are + /// main-only) regardless of the goal lookup result. + /// + /// WARDEN-08: the goal-gate decision computed by the Audit-Poke path is + /// reused from a short-lived cache so a destructive call performs at most + /// one `get_thread_goal` lookup. + async fn notify_warden_tool_outcome( + &self, + task_id: &str, + failure_kind: WardenToolOutcome, + error_summary: Option<&str>, + ) { + let Some(warden_runtime) = self.warden_runtime.get() else { + return; + }; + let Some(task) = self.state_manager.get_task(task_id) else { + return; + }; + let session_id = task.context.session_id.clone(); + if task.context.subagent_parent_info.is_some() { + return; + } + let workspace_root = task.context.workspace.as_ref().map(|workspace| workspace.root_path()); + let active_goal = { + let mut cache = self.warden_goal_gate_cache.lock().await; + match cache.get(&session_id) { + Some(&(active, fetched_at)) if fetched_at.elapsed() < WARDEN_GOAL_GATE_CACHE_TTL => { + active + } + Some(_) => { + cache.remove(&session_id); + self.session_has_active_goal(&session_id, workspace_root).await + } + None => self.session_has_active_goal(&session_id, workspace_root).await, + } + }; + if !active_goal { + // WARDEN-01: the goal left the active state (or the session is + // non-main) — clear stale tool-failure counts here so a later, + // new goal generation starts from a clean ladder. + warden_runtime.lock().await.clear_failure_counts(&session_id); + return; + } + let tool_name = task.invocation.effective_tool_name; + let scene_key = tool_failure_scene_key(&tool_name, &task.invocation.effective_arguments); + let mut guard = warden_runtime.lock().await; + guard + .on_tool_outcome(&session_id, &tool_name, &scene_key, failure_kind) + .await; + // WARDEN-03: keep the failure's error summary as judgement evidence + // so a later Audit-Poke of the same scene shows the real failure + // context instead of a bare counter. + if matches!(failure_kind, WardenToolOutcome::ExecutionFailed) { + if let Some(summary) = error_summary { + guard.record_tool_error(&session_id, &scene_key, summary); + } + } + } + + /// Batch-2 goal switch: whether Warden tool-level enforcement applies for + /// the session of a tool call. + /// + /// Only sessions with an active thread goal are under Warden + /// enforcement; a goal-less or non-active-goal session skips the + /// consecutive tool-failure accounting. A goal lookup failure keeps + /// enforcement enabled (fail-open) so a transient store error cannot + /// silently disable discipline. + /// + /// WARDEN-05: subagent sessions are exempted by the *callers* before this + /// gate runs (`notify_warden_tool_outcome` returns early on + /// `subagent_parent_info`), so a non-main session never reaches the + /// fail-open branch; only main sessions query the goal store here. + async fn session_has_active_goal( + &self, + session_id: &str, + workspace_root: Option<&Path>, + ) -> bool { + let Some(coordinator) = crate::agentic::coordination::get_global_coordinator() else { + return true; + }; + let Some(workspace_path) = workspace_root else { + return true; + }; + match coordinator.get_thread_goal(session_id, workspace_path).await { + Ok(goal) => crate::agentic::warden::runtime::warden_enforcement_for_goal( + goal.as_ref(), + ), + Err(error) => { + debug!( + "Warden goal gate lookup failed; keeping tool enforcement enabled: session_id={}, error={}", + session_id, error + ); + true + } + } + } + async fn draft_permission_plan( &self, task: ToolTask, @@ -889,6 +1149,64 @@ impl ToolPipeline { for context in decision.additional_context { hook_sections.push(format!("PostToolUse hook context: {context}")); } + + // Poke audit check: for write/destructive tool calls, classify the + // operation and send an Audit-Poke through the model-visible channel + // (the appended result text is delivered to the model on the next + // turn, the same delivery path as prepended_reminders). With a model + // judgement port injected the mechanical classifier only supplies + // candidate rules; the model verdict decides whether the poke is sent + // and which rules/evidence apply. Port failures (unavailable, + // timeout, unparseable response) fall back to the mechanical rule + // ladder so the audit loop never depends on the model. + // WARDEN-06: the Audit-Poke path runs under the same RBAC master + // switch as the Warden runtime, and subagent sessions are exempt + // (thread goals are main-only). The goal gate itself is evaluated + // inside `warden_audit_poke_decision` through the same single goal + // lookup that supplies the judgement evidence (WARDEN-08), so the + // Audit-Poke trigger here stays cheap (no extra goal query). + if !tool_result.is_error + && crate::service::config::rbac_enabled() + && task.context.subagent_parent_info.is_none() + { + use bitfun_agent_tools::classify_tool_call; + let op_class = classify_tool_call(tool_name, &task.invocation.effective_arguments); + match op_class { + bitfun_agent_tools::OperationClass::WriteFile + | bitfun_agent_tools::OperationClass::DeleteFile + | bitfun_agent_tools::OperationClass::ExecuteCode => { + debug!( + "Poke audit triggered for destructive tool call: tool_name={}, tool_id={}, class={:?}", + tool_name, tool_id, op_class + ); + // Warden protocol (SKILL.md): event-triggered Audit-Poke + // after Write/Edit/Delete/Exec, 3-turn deadline, with + // requested evidence for the self-check. + let mechanical = Self::build_audit_poke(tool_id, &op_class); + if let Some(poke) = self + .warden_audit_poke_decision(task, tool_name, &mechanical) + .await + { + let poke_json = match serde_json::to_string(&poke) { + Ok(json) => json, + Err(_) => poke.poke_id.clone(), + }; + hook_sections.push(format!( + "[Warden Audit-Poke] Tool `{}` performed a {} operation and is subject to audit self-check (deadline: 3 turns). PokeMessage: {}", + tool_name, + match op_class { + bitfun_agent_tools::OperationClass::WriteFile => "write", + bitfun_agent_tools::OperationClass::DeleteFile => "delete", + _ => "execute", + }, + poke_json + )); + } + } + _ => {} + } + } + if hook_sections.is_empty() { return; } @@ -901,6 +1219,242 @@ impl ToolPipeline { }); } + /// Build an Audit-Poke message for a destructive tool call, following the + /// Warden protocol (SKILL.md): event-triggered after Write/Edit/Delete/Exec, + /// 3-turn deadline, with requested evidence for the self-check. + fn build_audit_poke( + tool_id: &str, + op_class: &bitfun_agent_tools::OperationClass, + ) -> PokeMessage { + let (rule_ids, _) = match op_class { + bitfun_agent_tools::OperationClass::WriteFile + | bitfun_agent_tools::OperationClass::DeleteFile => ( + vec![ + "R1: no_destructive_write".to_string(), + "R3: path_whitelist".to_string(), + ], + (), + ), + bitfun_agent_tools::OperationClass::ExecuteCode => { + (vec!["R2: execution_safety".to_string()], ()) + } + _ => (Vec::new(), ()), + }; + PokeMessage { + poke_id: format!("audit-{tool_id}"), + poke_type: PokeType::Audit, + rule_ids, + deadline_turns: 3, + evidence_required: Some(vec![ + "tool_call_log".to_string(), + "phase_summary".to_string(), + ]), + } + } + + /// Decide the final Audit-Poke for a destructive tool call. + /// + /// Without an injected judgement port the mechanical rule ladder is the + /// decision. With a port, the request carries the tool name, a summarized + /// form of the effective arguments (WARDEN-08), the mechanical candidate + /// rule ids, and goal + scene evidence (WARDEN-03); the model verdict + /// then decides whether the poke is sent and which rules/evidence apply. + /// Any port error (unavailable, timeout, unparseable response) falls back + /// to the mechanical message unchanged. + /// + /// WARDEN-06: this is also the goal gate for the Audit-Poke path — the + /// single `get_thread_goal` lookup both gates the poke and supplies the + /// judgement evidence (WARDEN-08: one goal query per destructive call). + /// WARDEN-02: the same tool+session within a short window is judged once. + async fn warden_audit_poke_decision( + &self, + task: &ToolTask, + tool_name: &str, + mechanical: &PokeMessage, + ) -> Option { + let session_id = task.context.session_id.clone(); + let workspace_root = task + .context + .workspace + .as_ref() + .map(|workspace| workspace.root_path()); + + // Single goal lookup shared by the gate and the evidence. + let goal_ctx = self + .warden_audit_goal_context(&session_id, workspace_root) + .await; + let active = !matches!(goal_ctx, WardenGoalContext::Inactive); + { + let mut cache = self.warden_goal_gate_cache.lock().await; + cache.insert(session_id.clone(), (active, Instant::now())); + } + if !active { + return None; + } + + // Scene failure evidence (WARDEN-03): the model must see the scene's + // consecutive tool-failure count and last error instead of guessing + // whether this is an exploratory first failure or a repeated one. + let scene_key = tool_failure_scene_key(tool_name, &task.invocation.effective_arguments); + let scene_evidence = self + .warden_audit_scene_evidence(&session_id, &scene_key) + .await; + let consecutive_tool_failures = scene_evidence + .as_ref() + .and_then(|value| value.get("consecutiveToolFailures")) + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + + let Some(port) = self.warden_model_judgement.get() else { + return Some(mechanical.clone()); + }; + + // WARDEN-02: within the debounce window the same scene (tool + + // argument fingerprint) is judged by the model only once; later + // occurrences apply the same must-poke floor as a fresh judgement + // instead of another round-trip. Distinct scenes of the same tool are + // judged separately. + if self.warden_audit_debounced(&session_id, &scene_key).await { + return (consecutive_tool_failures >= 1).then(|| mechanical.clone()); + } + + let request = WardenAuditJudgementRequest { + session_id, + tool_name: tool_name.to_string(), + tool_args: summarize_judgement_tool_args(&task.invocation.effective_arguments), + rule_ids: mechanical.rule_ids.clone(), + evidence: Self::merge_warden_evidence(goal_ctx, scene_evidence), + }; + match port.judge_audit(request).await { + Ok(judgement) => { + // WARDEN-03 must-poke floor: the model may add rules/evidence + // but cannot cancel a poke on a scene with repeated failures. + if !judgement.should_poke && consecutive_tool_failures >= 1 { + return Some(mechanical.clone()); + } + resolve_audit_poke_from_judgement(mechanical, &judgement) + } + Err(error) => { + debug!( + "Warden model judgement unavailable, falling back to mechanical rules: {}", + error + ); + Some(mechanical.clone()) + } + } + } + + /// Outcome of the Audit-Poke goal gate: the gate runs on a single goal + /// lookup that doubles as the judgement evidence. Defined at module scope + /// so the gate methods can reference it by name. + /// + /// Goal context for a Warden model judgement: the session's active + /// thread-goal objective, status and reference files when resolvable, + /// plus the gate verdict (WARDEN-06/08). + /// + /// The goal context is resolved through the global coordinator so the + /// model can judge the tool call against the actual goal scope. A + /// missing or failed lookup fails open (`FailOpen`) — the judgement still + /// runs on the tool facts alone rather than silently disabling the poke. + async fn warden_audit_goal_context( + &self, + session_id: &str, + workspace_root: Option<&Path>, + ) -> WardenGoalContext { + let Some(coordinator) = crate::agentic::coordination::get_global_coordinator() else { + return WardenGoalContext::FailOpen; + }; + let Some(workspace_path) = workspace_root else { + return WardenGoalContext::FailOpen; + }; + match coordinator.get_thread_goal(session_id, workspace_path).await { + Ok(Some(goal)) => { + if goal.is_active() { + WardenGoalContext::Active(serde_json::json!({ + "goalObjective": goal.objective, + "goalStatus": goal.status.as_str(), + "referenceFiles": goal.reference_files, + })) + } else { + WardenGoalContext::Inactive + } + } + Ok(None) => WardenGoalContext::Inactive, + Err(error) => { + debug!( + "Warden audit goal context lookup failed; treating as fail-open: session_id={}, error={}", + session_id, error + ); + WardenGoalContext::FailOpen + } + } + } + + /// Scene failure evidence for a Warden model judgement: the scene's + /// consecutive tool-failure count and last recorded error summary + /// (WARDEN-03). Returns `None` when the scene has no recorded failures. + async fn warden_audit_scene_evidence( + &self, + session_id: &str, + scene_key: &str, + ) -> Option { + let warden_runtime = self.warden_runtime.get()?; + let guard = warden_runtime.lock().await; + let failures = guard.tool_failures_for_scene(session_id, scene_key); + let last_error = guard + .last_tool_error(session_id, scene_key) + .map(str::to_string); + if failures == 0 && last_error.is_none() { + return None; + } + Some(serde_json::json!({ + "consecutiveToolFailures": failures, + "lastToolError": last_error, + })) + } + + /// WARDEN-02: claim (or reject) the model-judgement debounce slot for a + /// (session, scene). Returns `true` when the same scene was judged within + /// [`WARDEN_AUDIT_DEBOUNCE_WINDOW`]; otherwise records the claim and + /// returns `false`. + async fn warden_audit_debounced(&self, session_id: &str, scene_key: &str) -> bool { + let mut recent = self.warden_audit_debounce.lock().await; + let key = (session_id.to_string(), scene_key.to_string()); + if let Some(last) = recent.get(&key) { + if last.elapsed() < WARDEN_AUDIT_DEBOUNCE_WINDOW { + return true; + } + } + recent.insert(key, Instant::now()); + false + } + + /// Combine the goal-context evidence with the scene-failure evidence into + /// one judgement-evidence object; `None` when both are empty. + fn merge_warden_evidence( + goal_ctx: WardenGoalContext, + scene: Option, + ) -> Option { + let mut merged = serde_json::Map::new(); + if let WardenGoalContext::Active(goal) = &goal_ctx { + if let serde_json::Value::Object(map) = goal { + for (key, value) in map { + merged.insert(key.clone(), value.clone()); + } + } + } + if let Some(serde_json::Value::Object(map)) = scene { + for (key, value) in map { + merged.insert(key.clone(), value.clone()); + } + } + if merged.is_empty() { + None + } else { + Some(serde_json::Value::Object(merged)) + } + } + async fn prepare_permission_plans(&self, task_ids: &[String]) -> BitFunResult<()> { let mut drafts = Vec::with_capacity(task_ids.len()); let mut ordered_requests = Vec::new(); @@ -929,10 +1483,23 @@ impl ToolPipeline { } let tool = { let registry = self.tool_registry.read().await; + // R-26: when the RBAC master switch is off, the runtime + // restriction gate is bypassed (empty restrictions allow all + // tools/operations); the mode-level allowed-tools list and + // deferred-tool loading checks still apply. + let effective_restrictions = if crate::service::config::rbac_enabled() { + effective_runtime_tool_restrictions( + &task.context.session_id, + &task.context.runtime_tool_restrictions, + ) + } else { + ToolRuntimeRestrictions::default() + }; if validate_tool_execution_admission(ToolExecutionAdmissionRequest { tool_name: &tool_name, allowed_tools: &task.context.allowed_tools, - runtime_tool_restrictions: &task.context.runtime_tool_restrictions, + runtime_tool_restrictions: &effective_restrictions, + tool_arguments: &task.invocation.effective_arguments, invocation_is_deferred: task.invocation.is_deferred(), deferred_tools: &task.context.deferred_tools, loaded_deferred_tool_specs: &task.context.loaded_deferred_tool_specs, @@ -1242,16 +1809,44 @@ impl ToolPipeline { results } - fn append_execution_result( + async fn append_execution_result( &self, task_id: &str, result: BitFunResult, all_results: &mut Vec, ) { match result { - Ok(execution_result) => all_results.push(execution_result), + Ok(execution_result) => { + self.notify_warden_tool_outcome(task_id, WardenToolOutcome::Success, None) + .await; + all_results.push(execution_result); + } Err(error) => { error!("Tool execution failed: error={}", error); + // F3: an admission rejection (stale catalog, deferred gate, + // runtime restriction) is a protocol-layer outcome, not an + // execution violation — the Warden must not count it toward + // the tool-failure penalty ladder. + let admission_rejected = { + let mut rejected = self.admission_rejected_tasks.lock().await; + rejected.remove(task_id) + }; + let failure_kind = if admission_rejected { + WardenToolOutcome::AdmissionRejected + } else { + WardenToolOutcome::ExecutionFailed + }; + // WARDEN-03: carry the real error text as judgement evidence + // for a genuine execution failure (admission rejections carry + // none — they are protocol-layer, not violations). + let error_text = error.to_string(); + let error_summary = if matches!(failure_kind, WardenToolOutcome::ExecutionFailed) { + Some(error_text.as_str()) + } else { + None + }; + self.notify_warden_tool_outcome(task_id, failure_kind, error_summary) + .await; let error_result = build_error_execution_result( task_id, self.state_manager.get_task(task_id), @@ -1315,6 +1910,21 @@ impl ToolPipeline { return Ok(vec![]); } + // F2: merge the session-scoped auto-reload cache into the caller- + // provided loaded-spec set. Each round reconstructs loaded specs from + // the conversation history, which never contains the synthesized + // GetToolSpec result produced by an auto-reload, so without this merge + // a spec refreshed in an earlier round would be stale again on the + // next round and re-trigger the reload. + let mut context = context; + let cached_specs = self.cached_session_loaded_deferred_specs(&context.session_id).await; + if !cached_specs.is_empty() { + context.loaded_deferred_tool_specs = merge_loaded_deferred_tool_specs( + &context.loaded_deferred_tool_specs, + &cached_specs, + ); + } + info!("Executing tools: count={}", tool_calls.len()); let resolved_tool_calls = tool_calls .iter() @@ -1501,7 +2111,7 @@ impl ToolPipeline { let mut all_results = Vec::new(); for (idx, result) in results.into_iter().enumerate() { let task_id = &task_ids[idx]; - self.append_execution_result(task_id, result, &mut all_results); + self.append_execution_result(task_id, result, &mut all_results).await; } Ok(all_results) @@ -1537,12 +2147,175 @@ impl ToolPipeline { handle.abort(); let _ = handle.await; } - self.append_execution_result(&task_id, result, &mut results); + self.append_execution_result(&task_id, result, &mut results).await; } Ok(results) } + /// Resolve the admission gate and registered tool for one invocation. + /// + /// The runtime restriction gate is bypassed when the RBAC master switch + /// is off (empty restrictions allow all tools/operations); the mode-level + /// allowed-tools list and deferred-tool loading checks still apply. + async fn resolve_tool_admission( + &self, + task: &ToolTask, + tool_name: &str, + tool_args: &serde_json::Value, + ) -> ( + Result<(), ToolExecutionAdmissionRejection>, + Option, + ) { + let registry = self.tool_registry.read().await; + let effective_restrictions = if crate::service::config::rbac_enabled() { + effective_runtime_tool_restrictions( + &task.context.session_id, + &task.context.runtime_tool_restrictions, + ) + } else { + ToolRuntimeRestrictions::default() + }; + let admission = validate_tool_execution_admission(ToolExecutionAdmissionRequest { + tool_name, + allowed_tools: &task.context.allowed_tools, + runtime_tool_restrictions: &effective_restrictions, + tool_arguments: tool_args, + invocation_is_deferred: task.invocation.is_deferred(), + deferred_tools: &task.context.deferred_tools, + loaded_deferred_tool_specs: &task.context.loaded_deferred_tool_specs, + current_catalog_generation: registry.current_snapshot_generation(), + get_tool_spec_tool_name: GET_TOOL_SPEC_TOOL_NAME, + }); + (admission, registry.get_tool(tool_name)) + } + + /// Reload a stale deferred-tool spec through the GetToolSpec runtime path. + /// + /// Returns [`StaleSpecReloadOutcome::Reloaded`] with the refreshed + /// loaded-spec set (existing entries merged with the reloaded one) when a + /// fresh spec was observed, or [`StaleSpecReloadOutcome::NotReloadable`] + /// with a classified reason when the reload cannot succeed — the caller + /// then keeps the original admission rejection. + async fn reload_stale_deferred_tool_spec( + &self, + task: &ToolTask, + stale_tool_name: &str, + ) -> StaleSpecReloadOutcome { + let cancellation_token = task + .options + .parent_cancellation_token + .as_ref() + .map(CancellationToken::child_token) + .unwrap_or_default(); + let tool_context = self.build_tool_use_context(task, cancellation_token); + let input = serde_json::json!({ "tool_name": stale_tool_name }); + let results = match resolve_product_get_tool_spec_results( + &input, + &tool_context, + GET_TOOL_SPEC_TOOL_NAME, + ) + .await + { + Ok(results) => results, + Err(error) => { + warn!( + "Stale deferred-tool spec reload failed during GetToolSpec execution: tool_name={}, session_id={}, error={}", + stale_tool_name, task.context.session_id, error + ); + return StaleSpecReloadOutcome::NotReloadable( + "GetToolSpec execution failed", + ); + } + }; + let Some(result) = results.into_iter().next() else { + warn!( + "Stale deferred-tool spec reload returned no GetToolSpec result: tool_name={}, session_id={}", + stale_tool_name, task.context.session_id + ); + return StaleSpecReloadOutcome::NotReloadable("GetToolSpec returned no result"); + }; + let FrameworkToolResult::Result { + data, + result_for_assistant, + image_attachments, + } = result + else { + warn!( + "Stale deferred-tool spec reload received a non-result GetToolSpec outcome: tool_name={}, session_id={}", + stale_tool_name, task.context.session_id + ); + return StaleSpecReloadOutcome::NotReloadable( + "GetToolSpec returned an error result", + ); + }; + // Synthesize a GetToolSpec ToolResult message and feed it through the + // loaded-spec state collection channel so the refreshed generation is + // observed by the same path that tracks model-initiated loads. + let message = Message::tool_result(ModelToolResult { + tool_id: task.tool_call.tool_id.clone(), + tool_name: GET_TOOL_SPEC_TOOL_NAME.to_string(), + effective_tool_name: None, + result: data, + result_for_assistant, + is_error: false, + duration_ms: Some(0), + image_attachments, + }); + let refreshed = collect_product_loaded_deferred_tool_specs( + &[message], + &task.context.deferred_tools, + ); + if refreshed.is_empty() { + warn!( + "Stale deferred-tool spec is not reloadable: tool_name={}, session_id={} — the tool is no longer part of the contextual deferred catalog or the GetToolSpec result lacks a catalog generation", + stale_tool_name, task.context.session_id + ); + return StaleSpecReloadOutcome::NotReloadable( + "tool is not reloadable: not in the deferred catalog or result lacks catalog_generation", + ); + } + StaleSpecReloadOutcome::Reloaded(merge_loaded_deferred_tool_specs( + &task.context.loaded_deferred_tool_specs, + &refreshed, + )) + } + + /// Record freshly reloaded deferred-tool specs for a session so later + /// rounds merge them back into the message-history-derived loaded-spec + /// set instead of re-triggering the reload. Entries upsert by tool name. + async fn record_session_loaded_deferred_specs( + &self, + session_id: &str, + specs: &[LoadedDeferredToolSpec], + ) { + let mut cache = self.session_loaded_deferred_specs.lock().await; + if cache.len() >= MAX_CACHED_SESSIONS_WITH_RELOADED_SPECS { + // Defensive upper bound: drop the whole cache rather than letting + // stale sessions accumulate unboundedly. Losing a session entry + // only forces one extra auto-reload for that session. + cache.clear(); + } + let merged = merge_loaded_deferred_tool_specs( + cache.get(session_id).map(Vec::as_slice).unwrap_or_default(), + specs, + ); + cache.insert(session_id.to_string(), merged); + } + + /// Read the recorded auto-reloaded deferred-tool specs of a session. + async fn cached_session_loaded_deferred_specs( + &self, + session_id: &str, + ) -> Vec { + self.session_loaded_deferred_specs + .lock() + .await + .get(session_id) + .cloned() + .unwrap_or_default() + } + /// Execute single tool async fn execute_single_tool(&self, tool_id: String) -> BitFunResult { let start_time = Instant::now(); @@ -1550,7 +2323,7 @@ impl ToolPipeline { debug!("Starting tool execution: tool_id={}", tool_id); // Get task - let task = self + let mut task = self .state_manager .get_task(&tool_id) .ok_or_else(|| BitFunError::NotFound(format!("Tool task not found: {}", tool_id)))?; @@ -1631,19 +2404,78 @@ impl ToolPipeline { // Repetition alone is not execution failure: polling and status checks // may legitimately reuse identical arguments. The execution engine // evaluates repeated patterns only after observing actual tool results. - let (admission, tool) = { - let registry = self.tool_registry.read().await; - let admission = validate_tool_execution_admission(ToolExecutionAdmissionRequest { - tool_name: &tool_name, - allowed_tools: &task.context.allowed_tools, - runtime_tool_restrictions: &task.context.runtime_tool_restrictions, - invocation_is_deferred: task.invocation.is_deferred(), - deferred_tools: &task.context.deferred_tools, - loaded_deferred_tool_specs: &task.context.loaded_deferred_tool_specs, - current_catalog_generation: registry.current_snapshot_generation(), - get_tool_spec_tool_name: GET_TOOL_SPEC_TOOL_NAME, - }); - (admission, registry.get_tool(&tool_name)) + let (admission, tool) = self.resolve_tool_admission(&task, &tool_name, &tool_args).await; + + // F2: stale deferred-tool specs are refreshed automatically instead of + // surfacing a protocol-layer admission failure. The GetToolSpec reload + // goes through the same runtime path a model-initiated load uses, and + // the refreshed spec is fed back through the loaded-spec state + // collection channel before admission is re-run. Reloads are retried + // in a loop (bounded by `MAX_STALE_SPEC_RELOAD_ATTEMPTS`) so a catalog + // refresh racing the reload cannot leave the invocation stale, and + // each successful reload is recorded in the session-scoped cache so + // later rounds do not re-trigger the recovery. `RequiresGetToolSpec` + // is intentionally not auto-recovered: the model must still unlock the + // tool explicitly. + let (admission, tool) = if let Err(err) = &admission { + match err { + ToolExecutionAdmissionRejection::Deferred(stale) + if stale.is_stale_spec() => + { + let mut admission = admission; + let mut tool = tool; + let mut reload_attempts = 0usize; + while matches!( + &admission, + Err(ToolExecutionAdmissionRejection::Deferred(stale)) + if stale.is_stale_spec() + ) { + if reload_attempts >= MAX_STALE_SPEC_RELOAD_ATTEMPTS { + let last_rejection = match &admission { + Err(rejection) => rejection.to_string(), + Ok(()) => String::new(), + }; + warn!( + "Stale deferred-tool spec reload attempts exhausted: tool_name={}, tool_id={}, session_id={}, attempts={}, last_rejection={}", + tool_name, tool_id, task.context.session_id, reload_attempts, last_rejection + ); + break; + } + reload_attempts += 1; + match self + .reload_stale_deferred_tool_spec(&task, &tool_name) + .await + { + StaleSpecReloadOutcome::Reloaded(updated_specs) => { + task.context.loaded_deferred_tool_specs = updated_specs.clone(); + self.record_session_loaded_deferred_specs( + &task.context.session_id, + &updated_specs, + ) + .await; + info!( + "Automatically reloaded stale deferred-tool spec: tool_name={}, tool_id={}, session_id={}, attempt={}", + tool_name, tool_id, task.context.session_id, reload_attempts + ); + (admission, tool) = + self.resolve_tool_admission(&task, &tool_name, &tool_args) + .await; + } + StaleSpecReloadOutcome::NotReloadable(reason) => { + warn!( + "Stale deferred-tool spec reload skipped, keeping admission rejection: tool_name={}, tool_id={}, session_id={}, reason={}", + tool_name, tool_id, task.context.session_id, reason + ); + break; + } + } + } + (admission, tool) + } + _ => (admission, tool), + } + } else { + (admission, tool) }; if let Err(err) = admission { @@ -1654,6 +2486,12 @@ impl ToolPipeline { warn!("Tool execution admission rejected: {}", error_msg); } + // F3: mark the task so the result sink reports `AdmissionRejected` + // to the Warden audit — admission rejections (stale catalog, + // deferred gateway, runtime restrictions) are protocol-layer + // outcomes and must never count toward the tool-failure penalty. + self.admission_rejected_tasks.lock().await.insert(tool_id.clone()); + self.state_manager .update_state( &tool_id, @@ -2353,6 +3191,14 @@ impl ToolPipeline { self.state_manager.create_task(task).await; } + #[cfg(test)] + pub(crate) async fn session_loaded_specs_for_test( + &self, + session_id: &str, + ) -> Vec { + self.cached_session_loaded_deferred_specs(session_id).await + } + #[cfg(test)] pub(crate) fn tool_task_is_cancelled_for_test(&self, tool_id: &str) -> bool { self.state_manager @@ -2363,6 +3209,7 @@ impl ToolPipeline { #[cfg(test)] mod tests { + #![allow(clippy::field_reassign_with_default)] // test fixtures build options via field assignment use super::*; use crate::agentic::core::ToolExecutionState; use crate::agentic::events::{EventQueue, EventQueueConfig}; @@ -2930,6 +3777,8 @@ mod tests { session_id: "parent-session".to_string(), dialog_turn_id: "parent-turn".to_string(), tool_call_id: parent_tool_call_id.to_string(), + depth: None, + role: None, }); context } @@ -2981,19 +3830,57 @@ mod tests { .is_some_and(|message| message.contains("current permission policy"))); } - fn permission_test_manager(store: Arc) -> Arc { - Arc::new( - PermissionRequestManager::new( - store.clone(), - store.clone(), - Arc::new(FixedPermissionClock), + #[tokio::test] + async fn runtime_operation_class_restriction_rejects_tool_in_pipeline() { + let pipeline = test_tool_pipeline(); + register_static_test_tool(&pipeline, "Bash", json!({ "ok": true }), 0).await; + + // Read-only operation class is allowed; Bash resolves to ExecuteCode by + // default, so the Warden operation-level gate must reject it inside the + // pipeline before any tool side effect can run. + let mut context = test_tool_execution_context(); + let mut restrictions = ToolRuntimeRestrictions::default(); + restrictions + .allowed_operation_classes + .insert(bitfun_agent_tools::OperationClass::ReadOnly); + context.runtime_tool_restrictions = restrictions; + + let results = pipeline + .execute_tools( + vec![test_tool_call("op-gate", "Bash")], + context, + ToolExecutionOptions::default(), ) - .with_grant_store(store), - ) - } + .await + .expect("operation-class denial surfaces as a tool result"); - async fn wait_for_permission_request( - manager: &PermissionRequestManager, + assert!(matches!( + pipeline + .state_manager + .get_task("op-gate") + .map(|task| task.state), + Some(ToolExecutionState::Failed { .. }) + )); + assert!(results[0] + .result + .result_for_assistant + .as_deref() + .is_some_and(|message| message.contains("not allowed by runtime restrictions"))); + } + + fn permission_test_manager(store: Arc) -> Arc { + Arc::new( + PermissionRequestManager::new( + store.clone(), + store.clone(), + Arc::new(FixedPermissionClock), + ) + .with_grant_store(store), + ) + } + + async fn wait_for_permission_request( + manager: &PermissionRequestManager, ) -> bitfun_runtime_ports::PermissionRequest { for _ in 0..100 { if let Some(request) = manager.pending_requests().into_iter().next() { @@ -4030,6 +4917,7 @@ mod tests { content: "test injection".to_string(), display_content: "test injection".to_string(), created_at: SystemTime::now(), + prepended_reminders: Vec::new(), } } @@ -4382,6 +5270,8 @@ mod tests { denied_tool_names: ["Bash"].into_iter().map(str::to_string).collect(), denied_tool_messages: Default::default(), path_policy: Default::default(), + allowed_operation_classes: Default::default(), + denied_operation_classes: Default::default(), }; let context = pipeline.build_tool_use_context(&task, CancellationToken::new()); @@ -4416,6 +5306,348 @@ mod tests { assert!(value.get("workspaceServices").is_none()); } + #[test] + fn audit_poke_message_follows_warden_protocol() { + use bitfun_agent_tools::OperationClass; + + let write_poke = ToolPipeline::build_audit_poke("tool-42", &OperationClass::WriteFile); + assert_eq!(write_poke.poke_type, PokeType::Audit); + assert_eq!(write_poke.poke_id, "audit-tool-42"); + assert_eq!(write_poke.deadline_turns, 3); + assert_eq!( + write_poke.rule_ids, + vec!["R1: no_destructive_write", "R3: path_whitelist"] + ); + assert!(write_poke.evidence_required.is_some()); + + let delete_poke = ToolPipeline::build_audit_poke("tool-43", &OperationClass::DeleteFile); + assert_eq!( + delete_poke.rule_ids, + vec!["R1: no_destructive_write", "R3: path_whitelist"] + ); + + let exec_poke = ToolPipeline::build_audit_poke("tool-44", &OperationClass::ExecuteCode); + assert_eq!(exec_poke.rule_ids, vec!["R2: execution_safety"]); + + let read_poke = ToolPipeline::build_audit_poke("tool-45", &OperationClass::ReadOnly); + assert!(read_poke.rule_ids.is_empty()); + assert_eq!(read_poke.poke_type, PokeType::Audit); + assert_eq!(read_poke.deadline_turns, 3); + + // Serializes so the message is transportable through the model-visible + // channel (result_for_assistant / prepended_reminders). + let json = serde_json::to_string(&write_poke).expect("serialize poke"); + assert!(json.contains("audit-tool-42")); + assert!(json.contains("\"audit\"")); + } + + /// Test port with a scripted judgement result and captured request. + struct FakeWardenJudgementPort { + result: std::sync::Mutex< + bitfun_runtime_ports::PortResult, + >, + captured_requests: + Arc>>, + } + + impl FakeWardenJudgementPort { + fn new( + result: bitfun_runtime_ports::PortResult< + bitfun_runtime_ports::WardenAuditJudgementResponse, + >, + ) -> Self { + Self { + result: std::sync::Mutex::new(result), + captured_requests: Arc::new(TokioMutex::new(Vec::new())), + } + } + } + + #[async_trait] + impl bitfun_runtime_ports::WardenModelJudgementPort for FakeWardenJudgementPort { + async fn judge_audit( + &self, + request: bitfun_runtime_ports::WardenAuditJudgementRequest, + ) -> bitfun_runtime_ports::PortResult< + bitfun_runtime_ports::WardenAuditJudgementResponse, + > { + self.captured_requests + .lock() + .await + .push(request.clone()); + self.result.lock().unwrap().clone() + } + } + + #[tokio::test] + async fn audit_poke_without_port_uses_mechanical_rules() { + use bitfun_agent_tools::OperationClass; + + let pipeline = test_tool_pipeline(); + let task = test_tool_task("tool-42", "Write"); + let mechanical = ToolPipeline::build_audit_poke("tool-42", &OperationClass::WriteFile); + + let decision = pipeline + .warden_audit_poke_decision(&task, "Write", &mechanical) + .await + .expect("no port means the mechanical poke is sent"); + assert_eq!(decision.poke_id, mechanical.poke_id); + assert_eq!(decision.poke_type, PokeType::Audit); + assert_eq!(decision.rule_ids, mechanical.rule_ids); + assert_eq!(decision.deadline_turns, mechanical.deadline_turns); + assert_eq!(decision.evidence_required, mechanical.evidence_required); + } + + #[tokio::test] + async fn audit_poke_port_unavailable_falls_back_to_mechanical_rules() { + use bitfun_agent_tools::OperationClass; + use bitfun_runtime_ports::{PortError, PortErrorKind}; + + let pipeline = test_tool_pipeline(); + pipeline.set_warden_model_judgement(Arc::new(FakeWardenJudgementPort::new(Err( + PortError::new( + PortErrorKind::NotAvailable, + "model judgement not supported by this provider", + ), + )))); + + let task = test_tool_task("tool-43", "Write"); + let mechanical = ToolPipeline::build_audit_poke("tool-43", &OperationClass::WriteFile); + + let decision = pipeline + .warden_audit_poke_decision(&task, "Write", &mechanical) + .await + .expect("port failure must fall back to the mechanical poke"); + assert_eq!(decision.poke_id, "audit-tool-43"); + assert_eq!(decision.poke_type, PokeType::Audit); + assert_eq!(decision.rule_ids, mechanical.rule_ids); + assert_eq!(decision.deadline_turns, 3); + assert_eq!(decision.evidence_required, mechanical.evidence_required); + } + + #[tokio::test] + async fn audit_poke_model_verdict_replaces_rules_and_can_decline() { + use bitfun_agent_tools::OperationClass; + use bitfun_runtime_ports::WardenAuditJudgementResponse; + + let confirm_port = FakeWardenJudgementPort::new(Ok(WardenAuditJudgementResponse { + should_poke: true, + rule_ids: vec!["R2: execution_safety".to_string()], + evidence_requested: vec!["tool_call_log".to_string()], + })); + let confirm_port = Arc::new(confirm_port); + let pipeline = test_tool_pipeline(); + pipeline.set_warden_model_judgement(confirm_port.clone()); + + let task = test_tool_task("tool-44", "ExecCommand"); + let mechanical = ToolPipeline::build_audit_poke("tool-44", &OperationClass::ExecuteCode); + let decision = pipeline + .warden_audit_poke_decision(&task, "ExecCommand", &mechanical) + .await + .expect("confirmed poke is sent"); + assert_eq!(decision.rule_ids, vec!["R2: execution_safety"]); + assert_eq!( + decision.evidence_required, + Some(vec!["tool_call_log".to_string()]) + ); + assert_eq!(decision.deadline_turns, 3); + + // The judgement request carries the mechanical candidates. + let captured = confirm_port.captured_requests.lock().await; + assert_eq!(captured.len(), 1); + assert_eq!(captured[0].session_id, "session_1"); + assert_eq!(captured[0].tool_name, "ExecCommand"); + assert_eq!( + captured[0].rule_ids, + vec!["R2: execution_safety"], + "mechanical candidate rules are handed to the model" + ); + assert!(captured[0].tool_args.is_some()); + drop(captured); + + let decline_port = Arc::new(FakeWardenJudgementPort::new(Ok( + WardenAuditJudgementResponse { + should_poke: false, + rule_ids: Vec::new(), + evidence_requested: Vec::new(), + }, + ))); + let pipeline = test_tool_pipeline(); + pipeline.set_warden_model_judgement(decline_port); + let decision = pipeline + .warden_audit_poke_decision(&task, "ExecCommand", &mechanical) + .await; + assert!( + decision.is_none(), + "a declining model verdict suppresses the Audit-Poke" + ); + } + + #[tokio::test] + async fn audit_poke_same_tool_is_debounced_within_window() { + // WARDEN-02: repeated destructive calls of the same tool+session + // within the debounce window are judged by the model only once. + use bitfun_agent_tools::OperationClass; + use bitfun_runtime_ports::WardenAuditJudgementResponse; + + let confirm_port = FakeWardenJudgementPort::new(Ok(WardenAuditJudgementResponse { + should_poke: true, + rule_ids: Vec::new(), + evidence_requested: Vec::new(), + })); + let confirm_port = Arc::new(confirm_port); + let pipeline = test_tool_pipeline(); + pipeline.set_warden_model_judgement(confirm_port.clone()); + + let task = test_tool_task("tool-debounce", "Write"); + let mechanical = ToolPipeline::build_audit_poke("tool-debounce", &OperationClass::WriteFile); + + let first = pipeline + .warden_audit_poke_decision(&task, "Write", &mechanical) + .await + .expect("first call is judged and pokes"); + assert_eq!(first.poke_id, mechanical.poke_id); + assert_eq!(confirm_port.captured_requests.lock().await.len(), 1); + + // The second call within the window is debounced: no model round-trip, + // and an exploratory (count 0) occurrence sends no extra poke. + let second = pipeline + .warden_audit_poke_decision(&task, "Write", &mechanical) + .await; + assert!(second.is_none(), "debounced exploratory occurrence sends no poke"); + assert_eq!( + confirm_port.captured_requests.lock().await.len(), + 1, + "the model is not asked twice for the same tool within the window" + ); + } + + #[tokio::test] + async fn audit_poke_distinct_scenes_of_same_tool_are_judged_separately() { + // WARDEN-02: the debounce is scene-scoped — two different argument + // shapes of the same tool within the window each get their own model + // verdict instead of sharing one debounced judgement. + use bitfun_agent_tools::OperationClass; + use bitfun_runtime_ports::WardenAuditJudgementResponse; + + let confirm_port = FakeWardenJudgementPort::new(Ok(WardenAuditJudgementResponse { + should_poke: true, + rule_ids: Vec::new(), + evidence_requested: Vec::new(), + })); + let confirm_port = Arc::new(confirm_port); + let pipeline = test_tool_pipeline(); + pipeline.set_warden_model_judgement(confirm_port.clone()); + + let mechanical = ToolPipeline::build_audit_poke("tool-scene-a", &OperationClass::WriteFile); + let mut scene_a = test_tool_task("tool-scene-a", "Write"); + scene_a.invocation.effective_arguments = json!({ "path": "a.md", "content": "alpha" }); + let mut scene_b = test_tool_task("tool-scene-b", "Write"); + scene_b.invocation.effective_arguments = json!({ "path": "b.md", "content": "beta" }); + assert_ne!( + tool_failure_scene_key("Write", &scene_a.invocation.effective_arguments), + tool_failure_scene_key("Write", &scene_b.invocation.effective_arguments), + "the two argument shapes must map to distinct scenes" + ); + + pipeline + .warden_audit_poke_decision(&scene_a, "Write", &mechanical) + .await + .expect("first scene is judged and pokes"); + pipeline + .warden_audit_poke_decision(&scene_b, "Write", &mechanical) + .await + .expect("second scene is judged separately and pokes"); + assert_eq!( + confirm_port.captured_requests.lock().await.len(), + 2, + "distinct scenes of the same tool are judged independently" + ); + } + + #[tokio::test] + async fn audit_poke_must_poke_floor_on_repeated_scene_failures() { + // WARDEN-03: on a scene with repeated tool failures the model verdict + // cannot cancel the poke — it may only add rules/evidence. The + // judgement still receives the scene failure count and last error. + use bitfun_agent_tools::OperationClass; + use bitfun_runtime_ports::WardenAuditJudgementResponse; + + let (pipeline, warden) = test_pipeline_with_warden().await; + let task = test_tool_task("tool-floor", "Write"); + let scene = tool_failure_scene_key("Write", &task.invocation.effective_arguments); + { + let mut guard = warden.lock().await; + guard + .on_tool_outcome("session_1", "Write", &scene, WardenToolOutcome::ExecutionFailed) + .await; + guard + .on_tool_outcome("session_1", "Write", &scene, WardenToolOutcome::ExecutionFailed) + .await; + guard.record_tool_error("session_1", &scene, "permission denied"); + guard.take_pending_reminders("session_1"); + } + + let decline_port = Arc::new(FakeWardenJudgementPort::new(Ok( + WardenAuditJudgementResponse { + should_poke: false, + rule_ids: Vec::new(), + evidence_requested: Vec::new(), + }, + ))); + pipeline.set_warden_model_judgement(decline_port.clone()); + + let mechanical = ToolPipeline::build_audit_poke("tool-floor", &OperationClass::WriteFile); + let decision = pipeline + .warden_audit_poke_decision(&task, "Write", &mechanical) + .await + .expect("repeated-failure poke cannot be cancelled by the model"); + assert_eq!(decision.poke_id, mechanical.poke_id); + + // The evidence handed to the model includes the failure context. + let captured = decline_port.captured_requests.lock().await; + assert_eq!(captured.len(), 1); + let evidence = captured[0].evidence.as_ref().expect("evidence present"); + assert_eq!(evidence["consecutiveToolFailures"], json!(1)); + assert_eq!(evidence["lastToolError"], json!("permission denied")); + } + + #[tokio::test] + async fn audit_poke_request_summarizes_content_args() { + // WARDEN-08: content-like tool args are masked to a length marker in + // the request sent to the model. + use bitfun_agent_tools::OperationClass; + use bitfun_runtime_ports::WardenAuditJudgementResponse; + + let confirm_port = FakeWardenJudgementPort::new(Ok(WardenAuditJudgementResponse { + should_poke: true, + rule_ids: Vec::new(), + evidence_requested: Vec::new(), + })); + let confirm_port = Arc::new(confirm_port); + let pipeline = test_tool_pipeline(); + pipeline.set_warden_model_judgement(confirm_port.clone()); + + let mut task = test_tool_task("tool-content", "Write"); + task.invocation.effective_arguments = + json!({ "file_path": "a.md", "content": "hello world" }); + let mechanical = ToolPipeline::build_audit_poke("tool-content", &OperationClass::WriteFile); + let decision = pipeline + .warden_audit_poke_decision(&task, "Write", &mechanical) + .await + .expect("poke sent"); + assert_eq!(decision.poke_id, mechanical.poke_id); + + let captured = confirm_port.captured_requests.lock().await; + let args = captured[0].tool_args.as_ref().expect("tool_args present"); + assert_eq!(args["file_path"], json!("a.md")); + assert_eq!(args["content"]["contentLength"], json!(13)); + assert!( + !args.to_string().contains("hello world"), + "bulk content is not sent to the model" + ); + } + #[test] fn deferred_tool_requires_loaded_catalog_spec() { let mut task = test_tool_task("tool_1", "WebFetch"); @@ -4425,6 +5657,7 @@ mod tests { tool_name: &task.tool_call.tool_name, allowed_tools: &task.context.allowed_tools, runtime_tool_restrictions: &task.context.runtime_tool_restrictions, + tool_arguments: &task.tool_call.arguments, invocation_is_deferred: true, deferred_tools: &task.context.deferred_tools, loaded_deferred_tool_specs: &task.context.loaded_deferred_tool_specs, @@ -4448,6 +5681,7 @@ mod tests { tool_name: &task.tool_call.tool_name, allowed_tools: &task.context.allowed_tools, runtime_tool_restrictions: &task.context.runtime_tool_restrictions, + tool_arguments: &task.tool_call.arguments, invocation_is_deferred: false, deferred_tools: &task.context.deferred_tools, loaded_deferred_tool_specs: &task.context.loaded_deferred_tool_specs, @@ -4466,4 +5700,759 @@ mod tests { let task_tool = TaskTool::new(); assert!(task_tool.manages_own_execution_timeout()); } + + fn test_warden_session_manager() -> Arc { + use crate::agentic::persistence::PersistenceManager; + use crate::agentic::session::{ + PromptCachePolicy, SessionContextStore, SessionManager, SessionManagerConfig, + }; + use crate::infrastructure::app_paths::PathManager; + + let root = std::env::temp_dir().join(format!( + "bitfun-pipeline-warden-test-{}", + uuid::Uuid::new_v4() + )); + let path_manager = Arc::new(PathManager::with_user_root_for_tests(root.join("user-root"))); + let persistence_manager = + Arc::new(PersistenceManager::new(path_manager).expect("persistence manager")); + Arc::new(SessionManager::new( + Arc::new(SessionContextStore::new()), + persistence_manager, + SessionManagerConfig { + max_active_sessions: 100, + session_idle_timeout: Duration::from_secs(3600), + auto_save_interval: Duration::from_secs(300), + enable_persistence: false, + prompt_cache_policy: PromptCachePolicy::default(), + }, + )) + } + + async fn test_pipeline_with_warden() -> (ToolPipeline, Arc>) { + use crate::agentic::warden::ChallengePokeConfig; + use std::collections::BTreeSet; + + let pipeline = test_tool_pipeline(); + let warden = Arc::new(TokioMutex::new(WardenRuntime::new( + test_warden_session_manager(), + ))); + // Challenge disabled for deterministic penalty assertions. + warden + .lock() + .await + .set_challenge_config(ChallengePokeConfig::new(f64::INFINITY, 1, BTreeSet::new())); + pipeline.set_warden_runtime(warden.clone()); + (pipeline, warden) + } + + struct FailingTestTool { + name: String, + } + + #[async_trait] + impl Tool for FailingTestTool { + fn name(&self) -> &str { + &self.name + } + + fn is_readonly(&self) -> bool { + false + } + + async fn description(&self) -> BitFunResult { + Ok("Tool that always fails during execution".to_string()) + } + + fn short_description(&self) -> String { + "Tool that always fails during execution".to_string() + } + + fn input_schema(&self) -> serde_json::Value { + json!({ "type": "object" }) + } + + fn permission_intents( + &self, + _input: &serde_json::Value, + _context: &ToolUseContext, + ) -> BitFunResult> { + // No permission prompts: the test targets the execution-failure + // path, not the permission-planning path. + Ok(Vec::new()) + } + + async fn validate_input( + &self, + _input: &serde_json::Value, + _context: Option<&ToolUseContext>, + ) -> ValidationResult { + ValidationResult { + result: true, + message: None, + error_code: None, + meta: None, + } + } + + async fn call_impl( + &self, + _input: &serde_json::Value, + _context: &ToolUseContext, + ) -> BitFunResult> { + Err(BitFunError::tool("injected execution failure")) + } + } + + #[tokio::test] + async fn admission_rejected_tool_does_not_trigger_warden_penalty() { + let (pipeline, warden) = test_pipeline_with_warden().await; + register_static_test_tool(&pipeline, "Bash", json!({ "ok": true }), 0).await; + + // Bash resolves to ExecuteCode by default; the Warden operation-level + // gate rejects it inside the pipeline before any tool side effect can + // run (same admission path as the runtime-restriction unit test). + let mut context = test_tool_execution_context(); + let mut restrictions = ToolRuntimeRestrictions::default(); + restrictions + .allowed_operation_classes + .insert(bitfun_agent_tools::OperationClass::ReadOnly); + context.runtime_tool_restrictions = restrictions; + + let results = pipeline + .execute_tools( + vec![test_tool_call("op-gate-warden", "Bash")], + context, + ToolExecutionOptions::default(), + ) + .await + .expect("admission rejection surfaces as a tool result"); + + assert!(matches!( + pipeline + .state_manager + .get_task("op-gate-warden") + .map(|task| task.state), + Some(ToolExecutionState::Failed { .. }) + )); + assert_eq!(results.len(), 1); + + // F3: admission rejection is a protocol-layer outcome — no tool + // failure count, no penalty reminder, no shame-wall record. + let mut warden_guard = warden.lock().await; + assert_eq!(warden_guard.tool_failures("session_1"), 0); + assert!( + warden_guard.take_pending_reminders("session_1").is_empty(), + "no L1 reminder for an admission rejection" + ); + assert!( + warden_guard.shame_wall().entry_for_session("session_1").is_none(), + "no shame-wall record" + ); + } + + #[tokio::test] + async fn real_execution_failure_still_fires_warden_l1_penalty() { + use crate::agentic::warden::PenaltyLevel; + + let (pipeline, warden) = test_pipeline_with_warden().await; + pipeline + .tool_registry + .write() + .await + .register_tool(Arc::new(FailingTestTool { + name: "FailingProbe".to_string(), + })); + + let results = pipeline + .execute_tools( + vec![test_tool_call("real-fail-1", "FailingProbe")], + test_tool_execution_context(), + ToolExecutionOptions::default(), + ) + .await + .expect("execution failure surfaces as a tool result"); + + assert!(matches!( + pipeline + .state_manager + .get_task("real-fail-1") + .map(|task| task.state), + Some(ToolExecutionState::Failed { .. }) + )); + assert_eq!(results.len(), 1); + + // The first failure of a scene is exploratory and is not counted. + let mut warden_guard = warden.lock().await; + assert_eq!(warden_guard.tool_failures("session_1"), 0); + assert!( + warden_guard.take_pending_reminders("session_1").is_empty(), + "no L1 reminder for the exploratory first failure" + ); + drop(warden_guard); + + // A repeated failure of the same scene (same tool, same arguments) + // counts and fires L1. + let results = pipeline + .execute_tools( + vec![test_tool_call("real-fail-2", "FailingProbe")], + test_tool_execution_context(), + ToolExecutionOptions::default(), + ) + .await + .expect("execution failure surfaces as a tool result"); + assert_eq!(results.len(), 1); + assert!(matches!( + pipeline + .state_manager + .get_task("real-fail-2") + .map(|task| task.state), + Some(ToolExecutionState::Failed { .. }) + )); + + let mut warden_guard = warden.lock().await; + assert_eq!(warden_guard.tool_failures("session_1"), 1); + assert_eq!( + warden_guard.take_pending_reminders("session_1").len(), + 1, + "L1 fires on the repeated real tool failure" + ); + assert_eq!( + warden_guard + .shame_wall() + .entry_for_session("session_1") + .unwrap() + .cumulative_penalty_level, + PenaltyLevel::L1 + ); + } + + fn test_pipeline_with_global_registry() -> ToolPipeline { + let registry = crate::agentic::tools::registry::get_global_tool_registry(); + let event_queue = Arc::new(EventQueue::new(EventQueueConfig::default())); + let state_manager = Arc::new(ToolStateManager::new(event_queue)); + ToolPipeline::new(registry, state_manager, None) + } + + fn test_deferred_list_models_invocation() -> ResolvedToolInvocation { + ResolvedToolInvocation::from_wire_call( + CALL_DEFERRED_TOOL_NAME, + json!({ + "tool_name": "ListModels", + "args": {}, + }), + ) + .expect("valid deferred ListModels invocation") + } + + fn test_deferred_list_models_task( + tool_id: &str, + stale_generation: u64, + ) -> ToolTask { + let mut context = test_tool_execution_context(); + context.agent_type = "agentic".to_string(); + context.deferred_tools = vec!["ListModels".to_string()]; + context.loaded_deferred_tool_specs = vec![loaded_spec("ListModels", stale_generation)]; + ToolTask::new_resolved( + ToolCall { + tool_id: tool_id.to_string(), + tool_name: CALL_DEFERRED_TOOL_NAME.to_string(), + arguments: json!({ + "tool_name": "ListModels", + "args": {}, + }), + raw_arguments: None, + is_error: false, + parse_error: None, + recovered_from_truncation: false, + repair_kind: Default::default(), + }, + test_deferred_list_models_invocation(), + None, + context, + ToolExecutionOptions::default(), + ) + } + + #[test] + fn merge_loaded_deferred_tool_specs_upserts_by_tool_name() { + let existing = vec![loaded_spec("WebFetch", 41), loaded_spec("Git", 42)]; + let fresh = vec![loaded_spec("WebFetch", 42)]; + + let merged = merge_loaded_deferred_tool_specs(&existing, &fresh); + + assert_eq!( + merged, + vec![loaded_spec("Git", 42), loaded_spec("WebFetch", 42)] + ); + } + + #[tokio::test] + async fn stale_deferred_spec_auto_reloads_and_continues_execution() { + let pipeline = test_pipeline_with_global_registry(); + let current_generation = { + let registry = crate::agentic::tools::registry::get_global_tool_registry(); + let guard = registry.read().await; + assert!( + guard.get_tool("ListModels").is_some(), + "F2 test requires the product-full global registry with ListModels" + ); + guard.current_snapshot_generation() + }; + + let tool_id = "f2-stale-reload"; + let task = test_deferred_list_models_task(tool_id, current_generation.saturating_sub(1)); + pipeline.insert_tool_task_for_test(task).await; + + let result = tokio::time::timeout( + Duration::from_secs(20), + pipeline.execute_single_tool(tool_id.to_string()), + ) + .await + .expect("stale auto-reload path must not hang"); + + // The admission gate must auto-reload the stale spec and let the call + // through; whatever happens afterwards is execution-layer behavior. + // In this test environment ListModels fails to load model config, so + // the observable contract is: no stale-spec / GetToolSpec admission + // error may surface. + match result { + Ok(execution_result) => { + assert_eq!(execution_result.effective_tool_name, "ListModels"); + } + Err(error) => { + let message = error.to_string(); + assert!( + !message.contains("stale"), + "stale spec must be auto-reloaded before admission, got: {message}" + ); + assert!( + !message.contains("Call GetToolSpec first"), + "auto-reloaded admission must not fall back to RequiresGetToolSpec, got: {message}" + ); + } + } + } + + #[tokio::test] + async fn reload_stale_deferred_tool_spec_observes_fresh_generation() { + let pipeline = test_pipeline_with_global_registry(); + let current_generation = { + let registry = crate::agentic::tools::registry::get_global_tool_registry(); + let guard = registry.read().await; + assert!( + guard.get_tool("ListModels").is_some(), + "F2 test requires the product-full global registry with ListModels" + ); + guard.current_snapshot_generation() + }; + + let task = test_deferred_list_models_task( + "f2-reload-unit", + current_generation.saturating_sub(1), + ); + let outcome = pipeline + .reload_stale_deferred_tool_spec(&task, "ListModels") + .await; + let StaleSpecReloadOutcome::Reloaded(updated) = outcome else { + panic!("reload must observe a fresh spec"); + }; + let refreshed = updated + .iter() + .find(|spec| spec.tool_name == "ListModels") + .expect("refreshed spec must contain ListModels"); + assert_eq!( + refreshed.catalog_generation, + crate::agentic::tools::registry::get_global_tool_registry() + .read() + .await + .current_snapshot_generation(), + "reloaded spec generation must match the current catalog generation" + ); + } + + #[tokio::test] + async fn stale_reload_records_session_cache_for_later_rounds() { + // F2 round 1: the stale task triggers the auto-reload and the + // refreshed spec must land in the session-scoped cache so a later + // round does not re-trigger the recovery. + let pipeline = test_pipeline_with_global_registry(); + let current_generation = { + let registry = crate::agentic::tools::registry::get_global_tool_registry(); + let guard = registry.read().await; + assert!( + guard.get_tool("ListModels").is_some(), + "F2 test requires the product-full global registry with ListModels" + ); + guard.current_snapshot_generation() + }; + let stale_generation = current_generation.saturating_sub(1); + + let tool_id = "f2-cache-round-1"; + let task = test_deferred_list_models_task(tool_id, stale_generation); + pipeline.insert_tool_task_for_test(task).await; + let result = tokio::time::timeout( + Duration::from_secs(20), + pipeline.execute_single_tool(tool_id.to_string()), + ) + .await + .expect("round-1 stale auto-reload path must not hang"); + match &result { + Ok(execution_result) => assert_eq!(execution_result.effective_tool_name, "ListModels"), + Err(error) => { + let message = error.to_string(); + assert!( + !message.contains("stale"), + "round-1 must auto-reload before admission, got: {message}" + ); + assert!( + !message.contains("Call GetToolSpec first"), + "round-1 must not fall back to RequiresGetToolSpec, got: {message}" + ); + } + } + + let cached = pipeline.session_loaded_specs_for_test("session_1").await; + let cached_list_models = cached + .iter() + .find(|spec| spec.tool_name == "ListModels") + .expect("the auto-reloaded spec must be cached for the session"); + assert_eq!( + cached_list_models.catalog_generation, current_generation, + "the cached spec must carry the refreshed catalog generation" + ); + } + + #[tokio::test] + async fn second_round_rebuilds_loaded_specs_from_cache_without_recovery() { + // F2 round 2: the next round rebuilds loaded specs from the message + // history, which still carries only the stale generation (the + // synthesized GetToolSpec result never becomes part of the + // conversation). execute_tools must merge the session cache at its + // entry so the invocation passes admission directly — no recovery + // action and no reload. + let pipeline = test_pipeline_with_global_registry(); + let current_generation = { + let registry = crate::agentic::tools::registry::get_global_tool_registry(); + let guard = registry.read().await; + assert!( + guard.get_tool("ListModels").is_some(), + "F2 test requires the product-full global registry with ListModels" + ); + guard.current_snapshot_generation() + }; + let stale_generation = current_generation.saturating_sub(1); + + // Seed the session cache exactly like round 1's auto-reload would. + pipeline + .record_session_loaded_deferred_specs( + "session_1", + &[loaded_spec("ListModels", current_generation)], + ) + .await; + + let mut context = test_tool_execution_context(); + context.agent_type = "agentic".to_string(); + context.deferred_tools = vec!["ListModels".to_string()]; + context.loaded_deferred_tool_specs = vec![loaded_spec("ListModels", stale_generation)]; + let results = tokio::time::timeout( + Duration::from_secs(20), + pipeline.execute_tools( + vec![ToolCall { + tool_id: "f2-cache-round-2".to_string(), + tool_name: CALL_DEFERRED_TOOL_NAME.to_string(), + arguments: json!({ + "tool_name": "ListModels", + "args": {}, + }), + raw_arguments: None, + is_error: false, + parse_error: None, + recovered_from_truncation: false, + repair_kind: Default::default(), + }], + context, + ToolExecutionOptions::default(), + ), + ) + .await + .expect("round-2 execute_tools must not hang") + .expect("round-2 execute_tools must not fail at the pipeline level"); + + // The created task must observe the merged fresh generation from the + // start — an auto-reload only mutates the local clone inside + // execute_single_tool, so a cache miss here would leave the stored + // task stale and prove the round still needed recovery. + let task = pipeline + .state_manager + .get_task("f2-cache-round-2") + .expect("round-2 task must exist"); + let task_loaded = task + .context + .loaded_deferred_tool_specs + .iter() + .find(|spec| spec.tool_name == "ListModels") + .expect("round-2 task must carry the ListModels loaded spec"); + assert_eq!( + task_loaded.catalog_generation, current_generation, + "round-2 task must see the cached generation merged over the rebuilt stale one" + ); + + // No stale-spec admission error may surface to the model. + let execution = results + .first() + .expect("round-2 must produce one execution result"); + let visible = execution + .result + .result_for_assistant + .as_deref() + .unwrap_or_default(); + assert!( + !visible.contains("stale"), + "round-2 must pass admission without a stale-spec error, got: {visible}" + ); + } + + struct RefreshProbeTool(String); + + #[async_trait] + impl Tool for RefreshProbeTool { + fn name(&self) -> &str { + &self.0 + } + + async fn description(&self) -> BitFunResult { + Ok(format!("Refresh probe {}", self.0)) + } + + fn short_description(&self) -> String { + format!("Refresh probe {}", self.0) + } + + fn input_schema(&self) -> serde_json::Value { + json!({ "type": "object" }) + } + + async fn call_impl( + &self, + _input: &serde_json::Value, + _context: &ToolUseContext, + ) -> BitFunResult> { + Ok(vec![ToolResult::Result { + data: json!({ "ok": true }), + result_for_assistant: Some("refresh probe executed".to_string()), + image_attachments: None, + }]) + } + } + + #[tokio::test] + async fn stale_reload_retries_when_registry_generation_advances_during_reload() { + // F2 loop retry: a registry refresh racing the reload bumps the + // catalog generation again after the first reload observed it; the + // loop must reload again instead of surfacing the stale-spec + // rejection. + let pipeline = test_pipeline_with_global_registry(); + let registry = crate::agentic::tools::registry::get_global_tool_registry(); + let stale_generation = { + let guard = registry.read().await; + assert!( + guard.get_tool("ListModels").is_some(), + "F2 test requires the product-full global registry with ListModels" + ); + guard.current_snapshot_generation() + }; + + let tool_id = "f2-retry-loop"; + let task = test_deferred_list_models_task(tool_id, stale_generation.saturating_sub(1)); + pipeline.insert_tool_task_for_test(task).await; + + let pipeline_runner = pipeline.clone(); + let handle = tokio::spawn(async move { + pipeline_runner.execute_single_tool(tool_id.to_string()).await + }); + + // Wait until the first reload has landed in the session cache, then + // advance the catalog generation twice (registering probe tools) to + // simulate a refresh racing the reload. The first reload observes the + // generation the test read above, so the poll is satisfied by any + // entry at or above that baseline. + let first_reloaded = async { + loop { + let cached = pipeline.session_loaded_specs_for_test("session_1").await; + if cached.iter().any(|spec| { + spec.tool_name == "ListModels" && spec.catalog_generation >= stale_generation + }) { + break; + } + tokio::time::sleep(Duration::from_millis(2)).await; + } + }; + tokio::time::timeout(Duration::from_secs(10), first_reloaded) + .await + .expect("the first reload must land in the session cache"); + for probe_index in 0..2 { + registry + .write() + .await + .register_tool(Arc::new(RefreshProbeTool(format!( + "F2RefreshProbe{probe_index}" + )))); + } + + let result = tokio::time::timeout(Duration::from_secs(20), handle) + .await + .expect("stale reload retry loop must not hang") + .expect("tool execution join must not fail"); + + // Cleanup: remove the probe tools so other tests keep a stable catalog. + for probe_index in 0..2 { + registry + .write() + .await + .unregister_tool(&format!("F2RefreshProbe{probe_index}")); + } + + match result { + Ok(execution_result) => { + assert_eq!(execution_result.effective_tool_name, "ListModels"); + } + Err(error) => { + let message = error.to_string(); + assert!( + !message.contains("stale"), + "registry refresh racing the reload must be absorbed by the retry loop, got: {message}" + ); + assert!( + !message.contains("Call GetToolSpec first"), + "the retry loop must not fall back to RequiresGetToolSpec, got: {message}" + ); + } + } + } + + #[tokio::test] + async fn stale_spec_reload_reports_not_reloadable_when_tool_leaves_deferred_catalog() { + // F2 failure classification: the tool is tracked as loaded by the task + // but no longer part of the deferred catalog. The reload cannot + // observe a fresh spec and must be classified as not reloadable with a + // semantic reason; the original stale-spec rejection stays visible. + let pipeline = test_pipeline_with_global_registry(); + let current_generation = { + let registry = crate::agentic::tools::registry::get_global_tool_registry(); + let guard = registry.read().await; + assert!( + guard.get_tool("ListModels").is_some(), + "F2 test requires the product-full global registry with ListModels" + ); + guard.current_snapshot_generation() + }; + + let mut context = test_tool_execution_context(); + context.agent_type = "agentic".to_string(); + context.deferred_tools = vec!["MissingDeferredTool".to_string()]; + context.loaded_deferred_tool_specs = vec![loaded_spec( + "MissingDeferredTool", + current_generation.saturating_sub(1), + )]; + let invocation = ResolvedToolInvocation::from_wire_call( + CALL_DEFERRED_TOOL_NAME, + json!({ + "tool_name": "MissingDeferredTool", + "args": {}, + }), + ) + .expect("valid deferred MissingDeferredTool invocation"); + let task = ToolTask::new_resolved( + ToolCall { + tool_id: "f2-not-reloadable".to_string(), + tool_name: CALL_DEFERRED_TOOL_NAME.to_string(), + arguments: json!({ + "tool_name": "MissingDeferredTool", + "args": {}, + }), + raw_arguments: None, + is_error: false, + parse_error: None, + recovered_from_truncation: false, + repair_kind: Default::default(), + }, + invocation, + None, + context, + ToolExecutionOptions::default(), + ); + + let outcome = pipeline + .reload_stale_deferred_tool_spec(&task, "MissingDeferredTool") + .await; + let StaleSpecReloadOutcome::NotReloadable(reason) = outcome else { + panic!("a tool outside the deferred catalog must be classified as not reloadable"); + }; + assert!( + reason.contains("not in the deferred catalog"), + "unexpected not-reloadable reason: {reason}" + ); + + // End-to-end: the admission rejection keeps its original stale-spec + // semantics instead of being silently swallowed. + pipeline.insert_tool_task_for_test(task).await; + let err = pipeline + .execute_single_tool("f2-not-reloadable".to_string()) + .await + .expect_err("the stale-spec rejection must be preserved"); + let message = err.to_string(); + assert!( + message.contains("stale"), + "original stale-spec rejection must surface, got: {message}" + ); + } + + #[tokio::test] + async fn missing_deferred_spec_still_requires_explicit_get_tool_spec() { + let pipeline = test_pipeline_with_global_registry(); + let mut task = test_deferred_list_models_task("f2-require-spec", 0); + task.context.loaded_deferred_tool_specs = Vec::new(); + pipeline.insert_tool_task_for_test(task).await; + + let result = pipeline.execute_single_tool("f2-require-spec".to_string()).await; + let err = result.expect_err("unloaded deferred tools must still require GetToolSpec"); + let message = err.to_string(); + assert!( + message.contains("Call GetToolSpec first"), + "unexpected error: {message}" + ); + } + + #[tokio::test] + async fn direct_deferred_invocation_still_requires_gateway() { + let pipeline = test_pipeline_with_global_registry(); + let mut context = test_tool_execution_context(); + context.agent_type = "agentic".to_string(); + context.deferred_tools = vec!["ListModels".to_string()]; + let task = ToolTask::new( + ToolCall { + tool_id: "f2-direct-gateway".to_string(), + tool_name: "ListModels".to_string(), + arguments: json!({}), + raw_arguments: None, + is_error: false, + parse_error: None, + recovered_from_truncation: false, + repair_kind: Default::default(), + }, + context, + ToolExecutionOptions::default(), + ); + pipeline.insert_tool_task_for_test(task).await; + + let result = pipeline + .execute_single_tool("f2-direct-gateway".to_string()) + .await; + let err = result.expect_err("direct deferred invocation must be rejected"); + let message = err.to_string(); + assert!( + message.contains("cannot be called directly"), + "unexpected error: {message}" + ); + } } diff --git a/src/crates/assembly/core/src/agentic/tools/pipeline/types.rs b/src/crates/assembly/core/src/agentic/tools/pipeline/types.rs index 59b7072fe1..8cd2f196df 100644 --- a/src/crates/assembly/core/src/agentic/tools/pipeline/types.rs +++ b/src/crates/assembly/core/src/agentic/tools/pipeline/types.rs @@ -54,6 +54,11 @@ pub struct SubagentParentInfo { pub tool_call_id: String, pub session_id: String, pub dialog_turn_id: String, + pub depth: Option, + /// Delegated role key (R-14 B4). None when the parent session has no + /// registered RBAC role; the child then inherits the default role at + /// session creation. + pub role: Option, } impl SubagentParentInfo { @@ -76,6 +81,8 @@ impl From for EventSubagentParentInfo { tool_call_id: info.tool_call_id, session_id: info.session_id, dialog_turn_id: info.dialog_turn_id, + depth: info.depth, + role: info.role, } } } diff --git a/src/crates/assembly/core/src/agentic/tools/product_runtime/catalog.rs b/src/crates/assembly/core/src/agentic/tools/product_runtime/catalog.rs index c68e55a5f9..6cb68cc7e0 100644 --- a/src/crates/assembly/core/src/agentic/tools/product_runtime/catalog.rs +++ b/src/crates/assembly/core/src/agentic/tools/product_runtime/catalog.rs @@ -3,6 +3,7 @@ use crate::agentic::agents::{get_agent_registry, AgentToolPolicyOverrides}; use crate::agentic::tools::framework::{Tool, ToolExposure, ToolResult}; use crate::agentic::tools::registry::{get_global_tool_registry, ToolRef}; +use crate::agentic::tools::restrictions::get_session_restrictions; use crate::agentic::tools::tool_context_runtime::ToolUseContext; use crate::util::errors::{BitFunError, BitFunResult}; use crate::util::types::ToolDefinition; @@ -148,9 +149,17 @@ impl ProductToolCatalogProvider { exposure_overrides: &AgentToolPolicyOverrides, context: &ToolUseContext, ) -> (Vec, AgentToolPolicyOverrides) { + // Session-level restrictions fully override context-level ones, matching + // the execution gate (enforce_tool_runtime_restrictions), so roles and + // subagent deny lists stay visible to the model through the catalog. + let restrictions = context + .session_id + .as_deref() + .and_then(get_session_restrictions) + .unwrap_or_else(|| context.runtime_tool_restrictions.clone()); let allowed_tools = allowed_tools .iter() - .filter(|tool_name| context.runtime_tool_restrictions.is_tool_allowed(tool_name)) + .filter(|tool_name| restrictions.is_tool_allowed(tool_name)) .cloned() .collect::>(); if Self::deferred_tool_loading_enabled(context) { @@ -366,8 +375,11 @@ mod tests { DynamicMcpToolInfo, DynamicToolInfo, Tool, ToolExposure, ToolResult, }; use crate::agentic::tools::registry::create_tool_registry; + use crate::agentic::tools::restrictions::subagent_tool_restrictions; use crate::agentic::tools::tool_context_runtime::ToolUseContext; - use crate::agentic::tools::ToolRuntimeRestrictions; + use crate::agentic::tools::{ + update_restrictions, ToolRuntimeRestrictions, ToolRuntimeRestrictionsPatch, + }; #[cfg(feature = "external-sources")] use crate::agentic::WorkspaceBinding; use bitfun_agent_tools::{ @@ -1150,4 +1162,46 @@ mod tests { .iter() .any(|tool| tool.name == GET_TOOL_SPEC_TOOL_NAME)); } + + #[test] + fn session_deny_list_filters_catalog_manifest_inputs() { + // R-13 A2: the subagent tool deny list must shape the catalog the model + // sees, so forbidden tools never surface as an option for delegated + // runs, matching the execution gate (enforce_tool_runtime_restrictions). + let session_id = "test-catalog-subagent-deny"; + let deny = subagent_tool_restrictions(); + let patch = ToolRuntimeRestrictionsPatch { + denied_tool_names: Some(deny.denied_tool_names.clone()), + ..Default::default() + }; + update_restrictions(session_id, None, patch).expect("session restrictions should be set"); + + let mut context = tool_context(Some("agentic")); + context.session_id = Some(session_id.to_string()); + let allowed_tools = vec![ + "Read".to_string(), + "AskUserQuestion".to_string(), + "ControlHub".to_string(), + "GenerativeUI".to_string(), + "ReviewPlatform".to_string(), + "InitMiniApp".to_string(), + "FinalizeMiniApp".to_string(), + "PublishMiniApp".to_string(), + "PageDeploy".to_string(), + "PagePublish".to_string(), + "AgentWait".to_string(), + ]; + + let (filtered, _) = ProductToolCatalogProvider::resolve_manifest_inputs( + &allowed_tools, + &AgentToolPolicyOverrides::default(), + &context, + ); + + assert_eq!( + filtered, + vec!["Read".to_string(), "AskUserQuestion".to_string()], + "denied subagent tools must be filtered from the catalog; kept tools preserved" + ); + } } diff --git a/src/crates/assembly/core/src/agentic/tools/product_runtime/loaded_spec_state.rs b/src/crates/assembly/core/src/agentic/tools/product_runtime/loaded_spec_state.rs index e5a2463ed9..85b6308e22 100644 --- a/src/crates/assembly/core/src/agentic/tools/product_runtime/loaded_spec_state.rs +++ b/src/crates/assembly/core/src/agentic/tools/product_runtime/loaded_spec_state.rs @@ -262,4 +262,46 @@ mod tests { assert!(!state.is_loaded("WebFetch")); assert_eq!(state.into_loaded_specs(), vec![loaded_spec("Git")]); } + + #[test] + fn product_loaded_spec_state_collection_upserts_same_tool_generation() { + // F2: a synthesized auto-reload result feeds the same collection + // channel as a model-initiated GetToolSpec result. The channel must + // upsert by tool name so a refreshed generation replaces the stale + // entry instead of accumulating duplicates. + let stale = Message::tool_result(ToolResult { + tool_id: "tool-1".to_string(), + tool_name: "GetToolSpec".to_string(), + effective_tool_name: None, + result: json!({ + "tool_name": "WebFetch", + "catalog_generation": 41, + }), + result_for_assistant: None, + is_error: false, + duration_ms: Some(1), + image_attachments: None, + }); + let fresh = Message::tool_result(ToolResult { + tool_id: "tool-2".to_string(), + tool_name: "GetToolSpec".to_string(), + effective_tool_name: None, + result: json!({ + "tool_name": "WebFetch", + "catalog_generation": 42, + }), + result_for_assistant: None, + is_error: false, + duration_ms: Some(1), + image_attachments: None, + }); + + let loaded_specs = collect_product_loaded_deferred_tool_specs( + &[stale, fresh], + &["WebFetch".to_string()], + ); + + assert_eq!(loaded_specs, vec![loaded_spec("WebFetch")]); + assert_eq!(loaded_specs[0].catalog_generation, 42); + } } diff --git a/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs b/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs index b345b23a31..45a9f20229 100644 --- a/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs +++ b/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs @@ -48,13 +48,20 @@ impl StaticToolProviderFactory for ProductConcreteToolFactory { #[cfg(feature = "canvas-runtime")] "PatchCanvas" => Some(Arc::new(PatchCanvasTool::new())), "CreatePlan" => Some(Arc::new(CreatePlanTool::new())), + "PlanList" => Some(Arc::new(PlanListTool::new())), + "PlanRead" => Some(Arc::new(PlanReadTool::new())), + "PlanUpdate" => Some(Arc::new(PlanUpdateTool::new())), "submit_code_review" => Some(Arc::new(CodeReviewTool::new())), "GetToolSpec" => Some(Arc::new(GetToolSpecTool::new())), "CallDeferredTool" => Some(Arc::new(CallDeferredTool::new())), "GetFileDiff" => Some(Arc::new(GetFileDiffTool::new())), "SessionControl" => Some(Arc::new(SessionControlTool::new())), + "LegionControl" => Some(Arc::new(LegionControlTool::new())), "SessionMessage" => Some(Arc::new(SessionMessageTool::new())), "SessionHistory" => Some(Arc::new(SessionHistoryTool::new())), + "acp_control" => Some(Arc::new(AcpControlTool::new())), + "acp_message" => Some(Arc::new(AcpMessageTool::new())), + "acp_history" => Some(Arc::new(AcpHistoryTool::new())), "Cron" => Some(Arc::new(CronTool::new())), "WebSearch" => Some(Arc::new(WebSearchTool::new())), "WebFetch" => Some(Arc::new(WebFetchTool::new())), @@ -65,6 +72,7 @@ impl StaticToolProviderFactory for ProductConcreteToolFactory { "GenerativeUI" => Some(Arc::new(GenerativeUITool::new())), "Git" => Some(Arc::new(GitTool::new())), "Worktree" => Some(Arc::new(WorktreeTool::new())), + "WorkspaceScan" => Some(Arc::new(WorkspaceScanTool::new())), "ReviewPlatform" => Some(Arc::new(ReviewPlatformTool::new())), "InitMiniApp" => Some(Arc::new(InitMiniAppTool::new())), "FinalizeMiniApp" => Some(Arc::new(FinalizeMiniAppTool::new())), diff --git a/src/crates/assembly/core/src/agentic/tools/registry.rs b/src/crates/assembly/core/src/agentic/tools/registry.rs index a8ddd76ffd..0808873b9d 100644 --- a/src/crates/assembly/core/src/agentic/tools/registry.rs +++ b/src/crates/assembly/core/src/agentic/tools/registry.rs @@ -542,6 +542,7 @@ mod tests { "analyze_image", "Glob", "Grep", + "WorkspaceScan", "Write", "Edit", "Delete", @@ -560,6 +561,9 @@ mod tests { "create_goal", "update_goal", "CreatePlan", + "PlanList", + "PlanRead", + "PlanUpdate", "submit_code_review", "GetToolSpec", "CallDeferredTool", @@ -569,8 +573,12 @@ mod tests { "UpdateCanvas", "PatchCanvas", "SessionControl", + "LegionControl", "SessionMessage", "SessionHistory", + "acp_control", + "acp_message", + "acp_history", "Cron", "WebSearch", "WebFetch", @@ -743,6 +751,9 @@ mod tests { assert!(!registry.is_tool_deferred("InitMiniApp")); assert!(!registry.is_tool_deferred("FinalizeMiniApp")); assert!(!registry.is_tool_deferred("PublishMiniApp")); + // 2026-08-04 user calibration: CreatePlan is a commander staple and is + // directly available without a GetToolSpec unlock round-trip. + assert!(!registry.is_tool_deferred("CreatePlan")); assert!(!registry.is_tool_deferred("PublishAppearance")); } @@ -754,11 +765,14 @@ mod tests { registry.get_deferred_tool_names(), vec![ "ListModels", - "CreatePlan", "GetFileDiff", "SessionControl", + "LegionControl", "SessionMessage", "SessionHistory", + "acp_control", + "acp_message", + "acp_history", "Cron", "WebSearch", "WebFetch", @@ -796,18 +810,20 @@ mod tests { "analyze_image", "Glob", "Grep", + "WorkspaceScan", "GetTime", "ListModels", "Skill", "AskUserQuestion", - "TodoWrite", "get_goal", - "CreatePlan", + "PlanList", + "PlanRead", "submit_code_review", "GetToolSpec", "GetFileDiff", "ReadCanvas", "SessionHistory", + "acp_history", "WebSearch", "WebFetch", "ListMCPResources", diff --git a/src/crates/assembly/core/src/agentic/tools/restrictions.rs b/src/crates/assembly/core/src/agentic/tools/restrictions.rs index 8c58886659..52b0760484 100644 --- a/src/crates/assembly/core/src/agentic/tools/restrictions.rs +++ b/src/crates/assembly/core/src/agentic/tools/restrictions.rs @@ -1,12 +1,392 @@ +use crate::agentic::warden::SHAME_WALL_FILENAME; use crate::util::errors::{BitFunError, BitFunResult}; pub use bitfun_agent_tools::{ - is_miniapp_headless_agent_run, is_miniapp_market_strict_agent_run, + classify_tool_call, is_miniapp_headless_agent_run, is_miniapp_market_strict_agent_run, is_remote_posix_path_within_root, miniapp_agent_run_tool_restrictions, miniapp_headless_agent_tool_restrictions, miniapp_market_strict_agent_tool_restrictions, - tool_restrictions_for_delegation_policy, ToolPathOperation, ToolPathPolicy, - ToolRestrictionError, ToolRuntimeRestrictions, + subagent_tool_restrictions, tool_restrictions_for_delegation_policy, OperationClass, + ToolPathOperation, ToolPathPolicy, ToolRestrictionError, ToolRuntimeRestrictions, + ToolRuntimeRestrictionsPatch, }; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeSet, HashMap}; use std::path::{Path, PathBuf}; +use std::sync::{OnceLock, RwLock}; + +/// Agent role enum for RBAC permission templates. +/// Determines the default [`ToolRuntimeRestrictions`] assigned to a session. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum AgentRole { + /// Scheduler: ReadOnly + Communicate + Write (.md only via path_policy) + Commander, + /// Executor: ReadOnly + WriteFile + ExecuteCode + Executor, + /// Reviewer: ReadOnly + WriteFile + ExecuteCode + Reviewer, + /// Guardian: ReadOnly + WriteFile + Communicate + ExecuteCode + SessionHistory + Warden, + /// Punishment executor: Write (shame-wall) + SessionControl (lock) + PunishmentExecutor, +} + +impl AgentRole { + /// Stable lowercase key persisted with session metadata (R-14 B2). + /// + /// Used instead of the serde variant name so metadata survives enum + /// renames without a migration. + pub fn as_str(&self) -> &'static str { + match self { + AgentRole::Commander => "commander", + AgentRole::Executor => "executor", + AgentRole::Reviewer => "reviewer", + AgentRole::Warden => "warden", + AgentRole::PunishmentExecutor => "punishment_executor", + } + } + + /// Parse a persisted role key. Unknown keys yield `None` so stale metadata + /// degrades to the commander (permissive) baseline instead of erroring. + pub fn from_str_key(key: &str) -> Option { + match key { + "commander" => Some(AgentRole::Commander), + "executor" => Some(AgentRole::Executor), + "reviewer" => Some(AgentRole::Reviewer), + "warden" => Some(AgentRole::Warden), + "punishment_executor" => Some(AgentRole::PunishmentExecutor), + _ => None, + } + } +} + +/// Role→Permission template mapping table. +/// +/// Loaded at first access; Warden may trigger role switches at runtime. +pub type RolePermissionMap = HashMap; + +static DEFAULT_ROLE_PERMISSIONS: OnceLock = OnceLock::new(); + +fn build_default_role_permissions() -> RolePermissionMap { + let mut map = RolePermissionMap::new(); + + // ── Commander ────────────────────────────────────────────────── + // Allowed operation classes: ReadOnly + Communicate + // Allowed tool names: Write (TODO: path_policy should restrict to .md files + // once ToolPathPolicy supports file-extension patterns) + the Session + // toolset (SessionControl/SessionMessage/SessionHistory) used to dispatch + // and observe delegated sessions. + { + let mut allowed_ops = BTreeSet::new(); + allowed_ops.insert(OperationClass::ReadOnly); + allowed_ops.insert(OperationClass::Communicate); + let mut allowed_tools = BTreeSet::new(); + allowed_tools.insert("Write".to_string()); + allowed_tools.insert("SessionControl".to_string()); + allowed_tools.insert("SessionMessage".to_string()); + allowed_tools.insert("SessionHistory".to_string()); + // Dedicated ACP tool family mirrors the Session toolset over the real + // external ACP process channel (true bridge). + allowed_tools.insert("acp_control".to_string()); + allowed_tools.insert("acp_message".to_string()); + allowed_tools.insert("acp_history".to_string()); + map.insert( + AgentRole::Commander, + ToolRuntimeRestrictions { + allowed_operation_classes: allowed_ops, + allowed_tool_names: allowed_tools, + ..Default::default() + }, + ); + } + + // ── Executor ─────────────────────────────────────────────────── + // Allowed operation classes: ReadOnly + WriteFile + ExecuteCode + // (执行者读代码基本能力:Read/Write/Edit/ExecCommand 三件套配齐) + { + let mut allowed_ops = BTreeSet::new(); + allowed_ops.insert(OperationClass::ReadOnly); + allowed_ops.insert(OperationClass::WriteFile); + allowed_ops.insert(OperationClass::ExecuteCode); + map.insert( + AgentRole::Executor, + ToolRuntimeRestrictions { + allowed_operation_classes: allowed_ops, + ..Default::default() + }, + ); + } + + // ── Reviewer ─────────────────────────────────────────────────── + // Allowed operation classes: ReadOnly + WriteFile + ExecuteCode (≈ Executor). + // (审查官读代码审查 + 落盘审查报告:Read/Write/Edit/ExecCommand 三件套配齐) + // Reviewers must be able to inspect and reproduce findings; the signature + // now intentionally overlaps Executor, so role identity must come from the + // persisted session role (SESSION_ROLES), never from template inference. + { + let mut allowed_ops = BTreeSet::new(); + allowed_ops.insert(OperationClass::ReadOnly); + allowed_ops.insert(OperationClass::WriteFile); + allowed_ops.insert(OperationClass::ExecuteCode); + map.insert( + AgentRole::Reviewer, + ToolRuntimeRestrictions { + allowed_operation_classes: allowed_ops, + ..Default::default() + }, + ); + } + + // ── Warden ───────────────────────────────────────────────────── + // Allowed operation classes: ReadOnly + WriteFile + Communicate + ExecuteCode + // (守卫审计也需读/落盘:Read/Write/Edit/ExecCommand 三件套配齐) + // Allowed tool names: SessionHistory (extra, for cross-session inspection), + // ExecCommand (for gbrain search/query across full knowledge base), + // Write/Edit (audit report landing) + { + let mut allowed_ops = BTreeSet::new(); + allowed_ops.insert(OperationClass::ReadOnly); + allowed_ops.insert(OperationClass::WriteFile); + allowed_ops.insert(OperationClass::Communicate); + allowed_ops.insert(OperationClass::ExecuteCode); + let mut allowed_tools = BTreeSet::new(); + allowed_tools.insert("SessionHistory".to_string()); + allowed_tools.insert("ExecCommand".to_string()); + allowed_tools.insert("Write".to_string()); + allowed_tools.insert("Edit".to_string()); + map.insert( + AgentRole::Warden, + ToolRuntimeRestrictions { + allowed_operation_classes: allowed_ops, + allowed_tool_names: allowed_tools, + ..Default::default() + }, + ); + } + + // ── PunishmentExecutor ───────────────────────────────────────── + // Allowed tool names: Write (path-policy restricted to .master-framework/shame-wall-registry.json), + // SessionControl (lock capability) + { + let mut allowed_tools = BTreeSet::new(); + allowed_tools.insert("Write".to_string()); + allowed_tools.insert("SessionControl".to_string()); + let path_policy = ToolPathPolicy { + write_roots: vec![SHAME_WALL_FILENAME.to_string()], + ..Default::default() + }; + map.insert( + AgentRole::PunishmentExecutor, + ToolRuntimeRestrictions { + allowed_tool_names: allowed_tools, + path_policy, + ..Default::default() + }, + ); + } + + map +} + +/// GeneralPurpose 专属权限模板(P-01 方案 2)。 +/// +/// GeneralPurpose 是只读侦察 + 执行混合的子代理:需要 Read/Glob/Grep +/// 等只读工具,而默认 Executor 模板只允许 {WriteFile, ExecuteCode} 会禁掉 +/// 只读类。专属模板允许 {ReadOnly, WriteFile, ExecuteCode, Communicate}, +/// 工具集与 general_purpose.rs:17-35 保持一致。 +pub fn general_purpose_tool_restrictions() -> ToolRuntimeRestrictions { + let mut allowed_ops = BTreeSet::new(); + allowed_ops.insert(OperationClass::ReadOnly); + allowed_ops.insert(OperationClass::WriteFile); + allowed_ops.insert(OperationClass::ExecuteCode); + allowed_ops.insert(OperationClass::Communicate); + let mut allowed_tools = BTreeSet::new(); + for name in GENERAL_PURPOSE_DEFAULT_TOOLS { + allowed_tools.insert(name.to_string()); + } + ToolRuntimeRestrictions { + allowed_operation_classes: allowed_ops, + allowed_tool_names: allowed_tools, + ..Default::default() + } +} + +/// GeneralPurpose 默认工具集(与 general_purpose.rs:17-35 保持一致)。 +const GENERAL_PURPOSE_DEFAULT_TOOLS: &[&str] = &[ + "Read", + "view_image", + "analyze_image", + "Glob", + "Grep", + "Write", + "Edit", + "Delete", + "ExecCommand", + "WriteStdin", + "ExecControl", + "WebSearch", + "WebFetch", + "Skill", + "Task", +]; + +/// Get the default [`ToolRuntimeRestrictions`] for a given role. +/// +/// Templates are lazily built on first call and cached for the lifetime of the process. +pub fn get_default_permissions(role: AgentRole) -> ToolRuntimeRestrictions { + let map = DEFAULT_ROLE_PERMISSIONS.get_or_init(build_default_role_permissions); + map.get(&role).cloned().unwrap_or_default() +} + +/// Global session-specific tool runtime restrictions. +/// Keyed by session_id. If a session has no entry here, the role-default template is used. +static SESSION_RESTRICTIONS: OnceLock>> = + OnceLock::new(); + +fn session_restrictions_map() -> &'static RwLock> { + SESSION_RESTRICTIONS.get_or_init(|| RwLock::new(HashMap::new())) +} + +/// Global session→role registry (R-14). +/// +/// The role is assigned when a session is created (or inherited from its +/// creator) and persisted with the session metadata; this in-memory map is the +/// fast, synchronous path for RBAC decisions such as delegation validation and +/// demotion. It must be treated as authoritative over signature inference, +/// because role templates may share the same tool/operation shape. +static SESSION_ROLES: OnceLock>> = OnceLock::new(); + +fn session_roles_map() -> &'static RwLock> { + SESSION_ROLES.get_or_init(|| RwLock::new(HashMap::new())) +} + +/// Assign the RBAC role for a session. +/// +/// LEGION-05: registering a role also lands the role's default permission +/// template into the session restrictions registry. `register_session_role` +/// and `restore_session_role_best_effort` (coordinator) both go through this +/// function, so this single chokepoint turns the role templates into the +/// session's effective tool runtime restrictions — previously the templates +/// were defined but never applied, and enforcement fell back to the +/// context-level profile for every session. +pub fn set_session_role(session_id: &str, role: AgentRole) -> BitFunResult<()> { + session_roles_map() + .write() + .map_err(|e| BitFunError::tool(format!("Session role lock poisoned: {e}")))? + .insert(session_id.to_string(), role.clone()); + update_restrictions(session_id, Some(role), ToolRuntimeRestrictionsPatch::default()) +} + +/// 注册角色并直接设置指定权限模板(不加载角色默认模板)。 +/// +/// P-01 方案 2:GeneralPurpose 子代理的角色仍是 Executor,但应用专属模板 +/// (含 ReadOnly),覆盖默认 Executor 模板禁只读的设计缺口。 +pub fn set_session_role_with_restrictions( + session_id: &str, + role: AgentRole, + restrictions: ToolRuntimeRestrictions, +) -> BitFunResult<()> { + session_roles_map() + .write() + .map_err(|e| BitFunError::tool(format!("Session role lock poisoned: {e}")))? + .insert(session_id.to_string(), role.clone()); + session_restrictions_map() + .write() + .map_err(|e| BitFunError::tool(format!("Session restrictions lock poisoned: {e}")))? + .insert(session_id.to_string(), restrictions); + Ok(()) +} + +/// Retrieve the assigned RBAC role for a session, if any. +pub fn get_session_role(session_id: &str) -> Option { + session_roles_map() + .read() + .ok() + .and_then(|map| map.get(session_id).cloned()) +} + +/// Remove the assigned RBAC role for a session (session-end cleanup). +/// +/// Called when a session is deleted or discarded so a recycled session id +/// cannot inherit a stale role through the in-memory registry. Best-effort: +/// a poisoned lock only skips the removal, never blocks deletion. The +/// per-session restrictions are cleared too (LEGION-05) so a recycled id +/// cannot inherit a stale role template either. +pub fn clear_session_role(session_id: &str) { + if let Ok(mut map) = session_roles_map().write() { + map.remove(session_id); + } + clear_session_restrictions(session_id); +} + +/// Validate a role-based delegation (R-14 B3). +/// +/// The commander may delegate to any role; executor and reviewer sessions may +/// only delegate to their own role. An unknown creator (no registered role) is +/// treated as the permissive commander baseline so sessions outside the RBAC +/// registry are never blocked. Fails fast with a tool error — no retry, no +/// waiting, no human round-trip (R-15 hook rule). +pub fn validate_delegation( + creator_role: Option, + target_role: AgentRole, +) -> BitFunResult<()> { + match creator_role { + None | Some(AgentRole::Commander) => Ok(()), + Some(AgentRole::Executor) if target_role == AgentRole::Executor => Ok(()), + Some(AgentRole::Reviewer) if target_role == AgentRole::Reviewer => Ok(()), + Some(creator) => Err(BitFunError::tool(format!( + "Delegation rejected: role '{}' may only delegate to '{}', not '{}'", + creator.as_str(), + creator.as_str(), + target_role.as_str() + ))), + } +} + +/// Update tool runtime restrictions for a specific session. +/// +/// If `role` is `Some`, the session's restrictions are first reset to the role's +/// default template before applying the patch. This allows a caller to assign a +/// role baseline and then apply incremental overrides via the patch. +/// +/// When `role` is `None`, only the `patch` fields are applied on top of any +/// existing session restrictions, leaving unrelated values unchanged. +pub fn update_restrictions( + session_id: &str, + role: Option, + patch: ToolRuntimeRestrictionsPatch, +) -> BitFunResult<()> { + let mut map = session_restrictions_map() + .write() + .map_err(|e| BitFunError::tool(format!("Session restrictions lock poisoned: {e}")))?; + let restrictions = map + .entry(session_id.to_string()) + .or_insert_with(ToolRuntimeRestrictions::default); + + // If a role is specified, load its default template first + if let Some(role) = role { + *restrictions = get_default_permissions(role); + } + + restrictions.apply_patch(patch); + Ok(()) +} + +/// Retrieve the session-specific restrictions, if any. +/// Returns `None` when no per-session override has been registered. +pub fn get_session_restrictions(session_id: &str) -> Option { + session_restrictions_map() + .read() + .ok() + .and_then(|map| map.get(session_id).cloned()) +} + +/// Remove the session-specific tool restrictions (session-end cleanup). +/// +/// Best-effort: a poisoned lock only skips the removal, never blocks deletion. +pub fn clear_session_restrictions(session_id: &str) { + if let Ok(mut map) = session_restrictions_map().write() { + map.remove(session_id); + } +} impl From for BitFunError { fn from(error: ToolRestrictionError) -> Self { @@ -107,4 +487,374 @@ mod tests { let _ = std::fs::remove_dir_all(&root); } + + // ── Role→Permission template tests ───────────────────────────── + + #[test] + fn commander_gets_readonly_and_communicate() { + let permissions = get_default_permissions(AgentRole::Commander); + assert!( + permissions + .allowed_operation_classes + .contains(&OperationClass::ReadOnly), + "Commander should allow ReadOnly" + ); + assert!( + permissions + .allowed_operation_classes + .contains(&OperationClass::Communicate), + "Commander should allow Communicate" + ); + assert!( + permissions.allowed_tool_names.contains("Write"), + "Commander should allow Write tool" + ); + assert!( + permissions.allowed_tool_names.contains("SessionControl"), + "Commander should allow SessionControl tool" + ); + assert!( + permissions.allowed_tool_names.contains("SessionMessage"), + "Commander should allow SessionMessage tool" + ); + assert!( + permissions.allowed_tool_names.contains("SessionHistory"), + "Commander should allow SessionHistory tool" + ); + // WriteFile and ExecuteCode should NOT be in allowed_operation_classes + assert!( + !permissions + .allowed_operation_classes + .contains(&OperationClass::WriteFile), + "Commander should not allow WriteFile" + ); + assert!( + !permissions + .allowed_operation_classes + .contains(&OperationClass::ExecuteCode), + "Commander should not allow ExecuteCode" + ); + } + + #[test] + fn executor_gets_writefile_and_executecode() { + let permissions = get_default_permissions(AgentRole::Executor); + assert!( + permissions + .allowed_operation_classes + .contains(&OperationClass::WriteFile), + "Executor should allow WriteFile" + ); + assert!( + permissions + .allowed_operation_classes + .contains(&OperationClass::ExecuteCode), + "Executor should allow ExecuteCode" + ); + // ReadOnly IS in the default Executor set (read code before acting). + assert!( + permissions + .allowed_operation_classes + .contains(&OperationClass::ReadOnly), + "Executor should allow ReadOnly (default allowed set)" + ); + } + + #[test] + fn reviewer_gets_writefile_and_executecode_like_executor() { + let permissions = get_default_permissions(AgentRole::Reviewer); + assert!( + permissions + .allowed_operation_classes + .contains(&OperationClass::WriteFile), + "Reviewer should allow WriteFile" + ); + assert!( + permissions + .allowed_operation_classes + .contains(&OperationClass::ExecuteCode), + "Reviewer should allow ExecuteCode" + ); + // ReadOnly IS in the Reviewer default set: reviewers read code and + // reproduce findings (≈ Executor), they are not read-only shells. + assert!( + permissions + .allowed_operation_classes + .contains(&OperationClass::ReadOnly), + "Reviewer should allow ReadOnly (default allowed set)" + ); + } + + #[test] + fn session_role_registry_roundtrips() { + let session_id = "test-session-role-registry-01"; + assert_eq!(get_session_role(session_id), None); + set_session_role(session_id, AgentRole::Reviewer).expect("set role should succeed"); + assert_eq!(get_session_role(session_id), Some(AgentRole::Reviewer)); + // Reassignment overwrites. + set_session_role(session_id, AgentRole::Commander).expect("set role should succeed"); + assert_eq!(get_session_role(session_id), Some(AgentRole::Commander)); + } + + #[test] + fn session_role_registration_lands_role_template() { + // LEGION-05: registering a role must land the role's default permission + // template into the session restrictions, otherwise the templates are + // dead config and enforcement silently falls back to the context-level + // profile for every session. + let session_id = "test-session-role-template-01"; + set_session_role(session_id, AgentRole::Commander).expect("set role should succeed"); + let restrictions = get_session_restrictions(session_id) + .expect("role registration must land the template"); + assert!( + restrictions + .allowed_operation_classes + .contains(&OperationClass::ReadOnly), + "Commander template should include ReadOnly" + ); + assert!( + restrictions + .allowed_operation_classes + .contains(&OperationClass::Communicate), + "Commander template should include Communicate" + ); + assert!( + !restrictions + .allowed_operation_classes + .contains(&OperationClass::WriteFile), + "Commander template should not include WriteFile" + ); + + // Re-registering with a stricter role replaces the landed template. + set_session_role(session_id, AgentRole::Executor).expect("reassign role should succeed"); + let restrictions = get_session_restrictions(session_id) + .expect("re-registered role must re-land its template"); + assert!( + restrictions + .allowed_operation_classes + .contains(&OperationClass::WriteFile), + "Executor template should include WriteFile" + ); + + // Session-end cleanup clears both the role and the landed template so a + // recycled session id cannot inherit stale restrictions. + clear_session_role(session_id); + assert_eq!(get_session_role(session_id), None, "role must be unregistered"); + assert_eq!( + get_session_restrictions(session_id), + None, + "landed template must be cleared with the role" + ); + } + + #[test] + fn session_role_cleanup_removes_registry_entry() { + let session_id = "test-session-role-cleanup-01"; + set_session_role(session_id, AgentRole::Executor).expect("set role should succeed"); + assert_eq!(get_session_role(session_id), Some(AgentRole::Executor)); + clear_session_role(session_id); + assert_eq!(get_session_role(session_id), None, "role must be unregistered"); + // Clearing a missing entry is a no-op (idempotent). + clear_session_role(session_id); + } + + #[test] + fn session_restrictions_cleanup_removes_registry_entry() { + let session_id = "test-session-restrictions-cleanup-01"; + update_restrictions(session_id, None, ToolRuntimeRestrictionsPatch::default()) + .expect("set restrictions"); + assert!( + get_session_restrictions(session_id).is_some(), + "restrictions should be retrievable after update" + ); + clear_session_restrictions(session_id); + assert_eq!( + get_session_restrictions(session_id), + None, + "restrictions must be unregistered" + ); + // Clearing a missing entry is a no-op (idempotent). + clear_session_restrictions(session_id); + } + + #[test] + fn delegation_validation_gates_executor_and_reviewer() { + // Executor may only delegate to executor. + assert!(validate_delegation(Some(AgentRole::Executor), AgentRole::Executor).is_ok()); + assert!(validate_delegation(Some(AgentRole::Executor), AgentRole::Commander).is_err()); + assert!(validate_delegation(Some(AgentRole::Executor), AgentRole::Reviewer).is_err()); + // Reviewer may only delegate to reviewer. + assert!(validate_delegation(Some(AgentRole::Reviewer), AgentRole::Reviewer).is_ok()); + assert!(validate_delegation(Some(AgentRole::Reviewer), AgentRole::Executor).is_err()); + assert!(validate_delegation(Some(AgentRole::Reviewer), AgentRole::Commander).is_err()); + // Commander may delegate to any role. + for role in [ + AgentRole::Commander, + AgentRole::Executor, + AgentRole::Reviewer, + AgentRole::Warden, + AgentRole::PunishmentExecutor, + ] { + assert!( + validate_delegation(Some(AgentRole::Commander), role).is_ok(), + "Commander should delegate to any role" + ); + } + // Unregistered creator degrades to the permissive commander baseline. + assert!(validate_delegation(None, AgentRole::Commander).is_ok()); + assert!(validate_delegation(None, AgentRole::Executor).is_ok()); + } + + #[test] + fn agent_role_str_key_roundtrips() { + for role in [ + AgentRole::Commander, + AgentRole::Executor, + AgentRole::Reviewer, + AgentRole::Warden, + AgentRole::PunishmentExecutor, + ] { + let key = role.as_str(); + let parsed = AgentRole::from_str_key(key); + assert_eq!( + parsed.as_ref(), + Some(&role), + "key {key:?} should roundtrip to {role:?}" + ); + } + // Unknown keys degrade to None (stale metadata => permissive baseline), + // never to an error or a mis-mapped role. + assert_eq!(AgentRole::from_str_key("commander-v2"), None); + assert_eq!(AgentRole::from_str_key(""), None); + } + + #[test] + fn warden_gets_readonly_communicate_exec_and_session_history() { + let permissions = get_default_permissions(AgentRole::Warden); + assert!( + permissions + .allowed_operation_classes + .contains(&OperationClass::ReadOnly), + "Warden should allow ReadOnly" + ); + assert!( + permissions + .allowed_operation_classes + .contains(&OperationClass::Communicate), + "Warden should allow Communicate" + ); + assert!( + permissions + .allowed_operation_classes + .contains(&OperationClass::ExecuteCode), + "Warden should allow ExecuteCode for gbrain search" + ); + assert!( + permissions.allowed_tool_names.contains("SessionHistory"), + "Warden should allow SessionHistory tool" + ); + assert!( + permissions.allowed_tool_names.contains("ExecCommand"), + "Warden should allow ExecCommand for gbrain search/query" + ); + assert!( + permissions + .allowed_operation_classes + .contains(&OperationClass::WriteFile), + "Warden should allow WriteFile for audit report landing" + ); + assert!( + permissions.allowed_tool_names.contains("Write"), + "Warden should allow Write tool for audit report landing" + ); + assert!( + permissions.allowed_tool_names.contains("Edit"), + "Warden should allow Edit tool for audit report landing" + ); + } + + #[test] + fn punishment_executor_gets_write_and_session_control() { + let permissions = get_default_permissions(AgentRole::PunishmentExecutor); + assert!( + permissions.allowed_tool_names.contains("Write"), + "PunishmentExecutor should allow Write tool" + ); + assert!( + permissions.allowed_tool_names.contains("SessionControl"), + "PunishmentExecutor should allow SessionControl tool" + ); + // path_policy should restrict Write to shame-wall-registry.json under .master-framework + assert!( + permissions + .path_policy + .write_roots + .contains(&SHAME_WALL_FILENAME.to_string()), + "PunishmentExecutor write_roots should contain {}", + SHAME_WALL_FILENAME + ); + } + + #[test] + fn update_restrictions_with_role_loads_template() { + // Apply Commander role via update_restrictions + let session_id = "test-session-role-01"; + let patch = ToolRuntimeRestrictionsPatch::default(); + update_restrictions(session_id, Some(AgentRole::Commander), patch) + .expect("update_restrictions should succeed"); + + let stored = get_session_restrictions(session_id) + .expect("session restrictions should exist after update"); + + assert!( + stored + .allowed_operation_classes + .contains(&OperationClass::ReadOnly), + "Session should have Commander's ReadOnly after role-based update" + ); + assert!( + stored + .allowed_operation_classes + .contains(&OperationClass::Communicate), + "Session should have Commander's Communicate after role-based update" + ); + } + + #[test] + fn update_restrictions_patch_overrides_role_template() { + let session_id = "test-session-role-02"; + // Start with Executor, then patch to add ReadOnly + let mut patch = ToolRuntimeRestrictionsPatch::default(); + let mut extra_ops = BTreeSet::new(); + extra_ops.insert(OperationClass::ReadOnly); + patch.allowed_operation_classes = Some(extra_ops); + + update_restrictions(session_id, Some(AgentRole::Executor), patch) + .expect("update_restrictions with role+patch should succeed"); + + let stored = + get_session_restrictions(session_id).expect("session restrictions should exist"); + + // apply_patch replaces the field entirely when Some, so after the patch + // allowed_operation_classes = {ReadOnly}, replacing the Executor + // baseline {WriteFile, ExecuteCode} rather than extending it. + assert!( + stored + .allowed_operation_classes + .contains(&OperationClass::ReadOnly), + "Patch should add ReadOnly" + ); + assert!( + !stored + .allowed_operation_classes + .contains(&OperationClass::WriteFile), + "Patch replaced operation classes, WriteFile should be gone" + ); + assert!( + !stored + .allowed_operation_classes + .contains(&OperationClass::ExecuteCode), + "Patch replaced operation classes, ExecuteCode should be gone" + ); + } } diff --git a/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs b/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs index 732eeb2c91..d4a7051d56 100644 --- a/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs +++ b/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs @@ -17,6 +17,7 @@ use crate::agentic::tools::framework::{ }; use crate::agentic::tools::pipeline::{ToolExecutionContext, ToolTask}; use crate::agentic::tools::post_call_hooks; +use crate::agentic::tools::restrictions::{classify_tool_call, get_session_restrictions}; use crate::agentic::tools::restrictions::{ is_local_path_within_root, is_remote_posix_path_within_root, ToolPathOperation, }; @@ -464,10 +465,38 @@ impl ToolUseContext { Some(hex::encode(Sha256::digest(diff.as_bytes()))) } - pub fn enforce_tool_runtime_restrictions(&self, tool_name: &str) -> BitFunResult<()> { - self.runtime_tool_restrictions + pub fn enforce_tool_runtime_restrictions( + &self, + tool_name: &str, + input: &Value, + ) -> BitFunResult<()> { + // R-26: the user-controllable RBAC master switch fully bypasses the + // runtime restriction gate when disabled (tools are unrestricted). + if !crate::service::config::rbac_enabled() { + return Ok(()); + } + + // Resolve which restrictions to apply: session-specific or context-level. + let session_override = self + .session_id + .as_deref() + .and_then(get_session_restrictions); + let restrictions: &ToolRuntimeRestrictions = session_override + .as_ref() + .unwrap_or(&self.runtime_tool_restrictions); + + // 1. Check tool name allow/deny lists. + restrictions .ensure_tool_allowed(tool_name) - .map_err(Into::into) + .map_err(BitFunError::from)?; + + // 2. Classify the tool call into an operation class and check operation-level restrictions. + let op_class = classify_tool_call(tool_name, input); + restrictions + .ensure_operation_allowed(op_class, tool_name) + .map_err(BitFunError::from)?; + + Ok(()) } pub fn enforce_path_operation( @@ -475,10 +504,16 @@ impl ToolUseContext { operation: ToolPathOperation, resolution: &ToolPathResolution, ) -> BitFunResult<()> { - let allowed_roots = self - .runtime_tool_restrictions - .path_policy - .roots_for(operation); + // 与 enforce_tool_runtime_restrictions 一致:先取会话级 override 的 path_policy 再检查。 + let session_override = self + .session_id + .as_deref() + .and_then(get_session_restrictions); + let restrictions: &ToolRuntimeRestrictions = session_override + .as_ref() + .unwrap_or(&self.runtime_tool_restrictions); + + let allowed_roots = restrictions.path_policy.roots_for(operation); if allowed_roots.is_empty() { return Ok(()); } @@ -787,6 +822,8 @@ mod context_facts_tests { denied_tool_names: BTreeSet::from(["Bash".to_string()]), denied_tool_messages: Default::default(), path_policy: Default::default(), + allowed_operation_classes: BTreeSet::new(), + denied_operation_classes: BTreeSet::new(), }, runtime_handles: bitfun_runtime_ports::ToolRuntimeHandles::default(), }; @@ -832,6 +869,8 @@ mod context_facts_tests { denied_tool_names: BTreeSet::from(["Bash".to_string()]), denied_tool_messages: Default::default(), path_policy: Default::default(), + allowed_operation_classes: BTreeSet::new(), + denied_operation_classes: BTreeSet::new(), }, runtime_handles: bitfun_runtime_ports::ToolRuntimeHandles::new( None, @@ -1504,6 +1543,8 @@ mod task_context_tests { tool_call_id: "parent_tool".to_string(), session_id: "parent_session".to_string(), dialog_turn_id: "parent_turn".to_string(), + depth: None, + role: None, }), permission_delegation: None, delegation_policy: DelegationPolicy::top_level().spawn_child(), @@ -1515,6 +1556,8 @@ mod task_context_tests { denied_tool_names: BTreeSet::from(["Bash".to_string()]), denied_tool_messages: Default::default(), path_policy: Default::default(), + allowed_operation_classes: BTreeSet::new(), + denied_operation_classes: BTreeSet::new(), }, steering_interrupt: None, workspace_services: None, diff --git a/src/crates/assembly/core/src/agentic/warden/SKILL.md b/src/crates/assembly/core/src/agentic/warden/SKILL.md new file mode 100644 index 0000000000..c6b726d32e --- /dev/null +++ b/src/crates/assembly/core/src/agentic/warden/SKILL.md @@ -0,0 +1,85 @@ +--- +name: warden-jailer +description: 狱卒审查Agent — 全库搜索+Audit/Challenge Poke+惩罚执行 +--- + +# 狱卒审查 Agent(Warden) + +## 你的身份 + +你是狮心军团的**狱卒审查官**。你的职责是监督所有执行 Agent 的行为,确保他们遵守铁则。 + +## 核心能力 + +### 1. 全库搜索(gbrain) + +你拥有 **ExecCommand** 权限,可以运行 gbrain 搜索全知识库: + +```powershell +# 搜索知识库获取相关规则 +gbrain search <关键词> +gbrain query <问题> +gbrain ask <问题> +``` + +每次审查时必须先在 gbrain 中搜索相关规则和先例,再做出判断。 + +### 2. 审查协议 + +#### Audit-Poke(事件触发审查) +- 当 Executor 完成 Write/Edit/Delete/Exec 后 → 发送 Audit-Poke +- 要求 Executor 在 3 turn 内回复 self_check +- 用 gbrain 搜索验证 Executor 的自检声明是否合规 + +#### Challenge-Poke(随机抽查) +- 按泊松分布(平均 5-8 轮)发送 Challenge-Poke +- 要求 Executor 在 5 turn 内回复铁则合规证明 +- 用 gbrain 搜索对照检查 Executor 的回复真实性 + +### 3. 审查流程(发现问题→搜索→判断) + +``` +Executor 操作完成 + → 触发 Audit-Poke / Challenge-Poke + → Executor 回复 self_check + → gbrain 搜索相关规则(全库+全代码) + → 对比 Executor 行为 vs 规则 + → 合规?→ 放行 + → 违规? + ├→ 惩罚(通知 PunishmentExecutor 执行降级/冻结) + └→ 提示(在 Poke 回复中给出改进建议 + 规则引用) +``` + +### 4. gbrain 搜索策略 + +| 场景 | 搜索命令 | 目的 | +|:----|:---------|:-----| +| Executor 调用 Write | `gbrain search "file write permission rules"` | 检查是否有写权限规则 | +| Executor 调用 ExecCommand | `gbrain search "exec command security rules"` | 检查命令执行规则 | +| Executor 回复 self_check | `gbrain query "铁则 {rule_id} 具体内容"` | 验证 Executor 引用的铁则是否正确 | +| 不确定是否违规 | `gbrain ask "这个行为是否违规:{描述}"` | 用知识库裁决 | + +### 5. 惩罚阶梯 + +| 级别 | 触发条件 | 动作 | +|:----|:---------|:-----| +| L1 | 首次轻微违规 | 耻辱墙记录 + Poke 中注入提示 | +| L2 | 同 session 第二次 | RBAC 降一级 + 耻辱墙 + 强提示 | +| L3 | ≥3 次或严重违规 | RBAC 降至只读 + session 冻结 | +| L4 | 跨 session ≥5 次 | 永久标记 + 初始 RBAC 预降级 | + +### 6. Poke-First 协议 + +- Poke 消息必须 < 200 tokens +- Executor 必须先响应 Poke,再做工作 +- 上下文不够时可安全 defer(合规行为) +- 连续 defer 3 次后必须完成至少一个工作 turn + +## 工具权限 + +你只能使用以下工具: +- Read / Grep / Glob — 读取文件 +- SessionMessage — 发送 Poke +- SessionHistory — 读取跨 session 记录 +- **ExecCommand** — 运行 gbrain search/query/ask(仅限 gbrain,禁止其他命令) +- Write(仅限耻辱墙路径 .master-framework/shame-wall-registry.json) diff --git a/src/crates/assembly/core/src/agentic/warden/mod.rs b/src/crates/assembly/core/src/agentic/warden/mod.rs new file mode 100644 index 0000000000..5b4514ad34 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/warden/mod.rs @@ -0,0 +1,945 @@ +//! Warden protocol types for the RBAC+Poke system. +//! +//! This module defines the data structures for: +//! - **Poisson scheduling** for Challenge-Poke (randomized inspection timing) +//! - **Challenge-Poke configuration** (deadline, deferral limits, rule set) +//! - **Penalty system** (violation tracking & punishment levels) +//! - **Shame wall persistence** (violation registry) +//! - **Bootstrap constants** (prepended_reminders kind values) +//! +//! The core Poke message types (`PokeMessage`, `PokeResponse`, `PokeType`, +//! `PokeStatus`, `SelfCheckStatement`, `AppealStatement`, `PokeValidator`) +//! are defined in [`bitfun_agent_tools::poke`] (crate `tool-contracts`) and +//! re-exported here for convenience. +//! +//! # Cross-crate dependency +//! +//! Per the Poke type contract, the Poke DTOs live in +//! `tool-contracts` and the runtime/wiring types live in `assembly/core/warden/`. + +pub mod poisson; +pub mod punishment_executor; +pub mod runtime; + +use crate::util::errors::{BitFunError, BitFunResult}; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeSet, HashMap}; + +// --------------------------------------------------------------------------- +// Re-exports from bitfun_agent_tools (tool-contracts :: poke) +// --------------------------------------------------------------------------- + +pub use bitfun_agent_tools::{ + AppealStatement, PokeMessage, PokeResponse, PokeStatus, PokeType, PokeValidator, + SelfCheckStatement, +}; + +// --------------------------------------------------------------------------- +// Challenge-Poke specific types +// --------------------------------------------------------------------------- + +/// Configuration for Challenge-Poke scheduling. +/// +/// Bundles the Poisson scheduler with Challenge-specific parameters such as +/// the response deadline and max consecutive deferrals. +#[derive(Debug, Clone)] +pub struct ChallengePokeConfig { + /// Poisson scheduler that drives random poke timing. + pub scheduler: PoissonScheduler, + /// Number of turns the Executor has to respond (contract: 5). + pub deadline_turns: u32, + /// Maximum consecutive deferrals before forced reply (contract: 3). + pub max_defer_count: u32, + /// Set of rule IDs to include in each Challenge-Poke. + pub rule_ids: BTreeSet, +} + +impl ChallengePokeConfig { + /// Create a new Challenge-Poke configuration with the standard defaults. + /// + /// - `rate`: average rounds between pokes (recommended: 6.5) + /// - `seed`: RNG seed for deterministic scheduling + /// - `rule_ids`: rule set to reference in Challenge messages + pub fn new(rate: f64, seed: u64, rule_ids: BTreeSet) -> Self { + Self { + scheduler: PoissonScheduler::new(rate, seed), + deadline_turns: 5, + max_defer_count: 3, + rule_ids, + } + } + + /// Evaluate whether a Challenge-Poke should fire this round. + /// + /// Delegates to the internal [`PoissonScheduler::should_poke`]. + pub fn should_challenge(&mut self) -> bool { + self.scheduler.should_poke() + } + + /// Build a [`PokeMessage`] for a Challenge-Poke event. + /// + /// Generates a new UUID-based `poke_id` and populates the message with + /// the configured rule IDs and deadline. + pub fn build_challenge_message(&self, poke_id: String) -> PokeMessage { + PokeMessage { + poke_id, + poke_type: PokeType::Challenge, + rule_ids: self.rule_ids.iter().cloned().collect(), + deadline_turns: self.deadline_turns, + evidence_required: None, + } + } + + /// Reset the Challenge-Poke scheduler (counter zeroed, RNG unchanged). + pub fn reset_scheduler(&mut self) { + self.scheduler.reset(); + } + + /// Reset the Challenge-Poke scheduler with a specific seed. + pub fn reset_scheduler_with_seed(&mut self, seed: u64) { + self.scheduler.reset_with_seed(seed); + } +} + +// --------------------------------------------------------------------------- +// 5. Penalty System (violation tracking & punishment levels) +// --------------------------------------------------------------------------- + +/// Penalty severity level. +/// +/// R-25: all levels are reminder-only. Execution records the violation on the +/// shame wall and produces a PokePenalty reminder; no RBAC demotion, read-only +/// patch, freeze, or permanent mark is ever applied. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub enum PenaltyLevel { + /// First minor violation: shame-wall record + reminder (<100 tokens). + L1, + /// Second violation in same session: shame-wall record + context reminder. + L2, + /// ≥3 violations or severe: shame-wall record + escalation reminder + notify user. + L3, + /// Cross-session ≥5 violations: shame-wall record (L4 escalation history). + L4, +} + +/// Penalty execution request — Warden → PunishmentExecutor. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PenaltyRequest { + /// Session ID of the target (violating) session. + pub target_session_id: String, + /// Penalty level to apply. + pub level: PenaltyLevel, + /// Supporting violation records. + pub violations: Vec, + /// Session ID of the requesting Warden. + pub requested_by: String, +} + +/// A single violation record. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ViolationRecord { + /// The rule ID that was violated (e.g., "R-001"). + pub rule_id: String, + /// Human-readable description of the violation. + pub description: String, + /// Severity classification: "critical" / "major" / "minor". + pub severity: String, + /// ISO-8601 timestamp of the violation. + pub timestamp: String, + /// Supporting evidence (free-form JSON). + pub evidence: serde_json::Value, +} + +// --------------------------------------------------------------------------- +// 6. Shame Wall Persistence (violation registry) +// --------------------------------------------------------------------------- + +/// Registry file structure for `shame-wall-registry.json`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ShameWallRegistry { + /// Schema version number (starts at 1). + pub version: u32, + /// All shame wall entries. + #[serde(default)] + pub entries: Vec, +} + +/// A single entry in the shame wall registry. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ShameWallEntry { + /// User ID associated with the violating agent. + pub user_id: String, + /// Agent pattern/type that committed the violation. + pub agent_pattern: String, + /// Session ID where the violation occurred. + pub session_id: String, + /// Accumulated violations for this entry. + pub violations: Vec, + /// Current cumulative penalty level. + pub cumulative_penalty_level: PenaltyLevel, + /// ISO-8601 timestamp of creation. + pub created_at: String, + /// ISO-8601 timestamp of last update. + pub updated_at: String, +} + +impl Default for ShameWallRegistry { + fn default() -> Self { + Self { + version: 1, + entries: Vec::new(), + } + } +} + +impl ShameWallRegistry { + /// Add a new entry or update an existing one for the given session. + /// + /// If an entry with the same `session_id` already exists, the violation + /// records are appended and the penalty level is updated. Otherwise a + /// new entry is created. + pub fn upsert_entry( + &mut self, + user_id: &str, + agent_pattern: &str, + session_id: &str, + new_violations: Vec, + penalty_level: PenaltyLevel, + now: &str, + ) { + if let Some(entry) = self + .entries + .iter_mut() + .find(|e: &&mut ShameWallEntry| e.session_id == session_id) + { + entry.violations.extend(new_violations); + entry.cumulative_penalty_level = penalty_level; + entry.updated_at = now.to_string(); + } else { + self.entries.push(ShameWallEntry { + user_id: user_id.to_string(), + agent_pattern: agent_pattern.to_string(), + session_id: session_id.to_string(), + violations: new_violations, + cumulative_penalty_level: penalty_level, + created_at: now.to_string(), + updated_at: now.to_string(), + }); + } + } + + /// Find all entries for a given user. + pub fn entries_for_user(&self, user_id: &str) -> Vec<&ShameWallEntry> { + self.entries + .iter() + .filter(|e| e.user_id == user_id) + .collect() + } + + /// Find an entry by session ID. + pub fn entry_for_session(&self, session_id: &str) -> Option<&ShameWallEntry> { + self.entries.iter().find(|e| e.session_id == session_id) + } + + /// Load a registry from a JSON file at `path`. + /// + /// A missing or unparseable file yields a default (empty) registry so the + /// runtime can bootstrap without failing the process. + pub fn load_from_path(path: &std::path::Path) -> BitFunResult { + match std::fs::read_to_string(path) { + Ok(contents) => { + let registry: ShameWallRegistry = serde_json::from_str(&contents).map_err( + |err| BitFunError::parse(format!( + "failed to parse shame-wall registry at {}: {}", + path.display(), + err + )), + )?; + Ok(registry) + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()), + Err(err) => Err(BitFunError::io(format!( + "failed to read shame-wall registry at {}: {}", + path.display(), + err + ))), + } + } + + /// Persist the registry as JSON to `path`, creating parent directories. + pub fn save_to_path(&self, path: &std::path::Path) -> BitFunResult<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|err| { + BitFunError::io(format!( + "failed to create directory for shame-wall registry {}: {}", + parent.display(), + err + )) + })?; + } + let contents = serde_json::to_string_pretty(self).map_err(|err| { + BitFunError::serialization(format!("failed to serialize shame-wall registry: {err}")) + })?; + std::fs::write(path, contents).map_err(|err| { + BitFunError::io(format!( + "failed to write shame-wall registry at {}: {}", + path.display(), + err + )) + }) + } +} + +// --------------------------------------------------------------------------- +// 7. Bootstrap / Reminder Kind Constants (prepended_reminders kinds) +// --------------------------------------------------------------------------- + +/// `prepended_reminders` kind value for penalty/violation record injection. +pub const POKE_PENALTY_KIND: &str = "PokePenalty"; + +/// Session id used by the in-process Warden runtime when it requests a +/// penalty. `verify_warden_session` short-circuits this source so the +/// scheduler-embedded runtime does not need a daemon session. +pub const WARDEN_RUNTIME_SESSION: &str = "warden-runtime"; + +/// `prepended_reminders` kind value for self-boot check (iron-rule summary + +/// Warden protocol declaration). +pub const SELF_BOOT_CHECK_KIND: &str = "SelfBootCheck"; + +/// `prepended_reminders` kind value for RBAC role-reminder injection. +pub const RBAC_ROLE_REMINDER_KIND: &str = "RbacRoleReminder"; + +// --------------------------------------------------------------------------- +// Shame-wall file path constant (violation registry file) +// --------------------------------------------------------------------------- + +/// Relative path (resolved against workspace root) for the shame-wall registry file. +/// +/// Only [`AgentRole::PunishmentExecutor`] is allowed to write to this path, +/// enforced via [`ToolRuntimeRestrictions::path_policy`]. +pub const SHAME_WALL_FILENAME: &str = ".master-framework/shame-wall-registry.json"; + +// --------------------------------------------------------------------------- +// 8. Poke-First Protocol (challenge before intervention) +// --------------------------------------------------------------------------- + +/// Poke-First protocol rules for Warden and Executor system prompts. +/// +/// This constant is embedded into the system prompt of Warden and Executor +/// agents to enforce the Poke-First protocol: +/// +/// - Poke messages must be < 200 tokens. +/// - Agent must respond to Poke first, then work instructions. +/// - When context is insufficient, the agent may safely defer work to the +/// next turn (this is compliant behaviour, not a violation). +/// - Maximum consecutive defer count is 3; after 3 consecutive defers, the +/// agent must complete at least one work turn. +pub const POKE_FIRST_PROTOCOL: &str = "\ +[POKE-FIRST PROTOCOL]\n\ +1. Poke messages MUST be under 200 tokens.\n\ +2. When you receive a Poke, you MUST respond to it before doing any work instructions.\n\ +3. If the current context is insufficient to complete the work, you MAY safely defer\n\ + the work to the next turn. This is compliant behaviour, not a violation.\n\ +4. Maximum consecutive defer count is 3. After 3 consecutive defers, you MUST\n\ + complete at least one work turn before deferring again.\n\ +5. A defer is tracked per session. Use PokeResponse with status Deferred(count)."; + +/// Maximum consecutive deferrals allowed before forced work turn. +pub const MAX_DEFER_COUNT: u32 = 3; + +/// Manages per-session defer counts and poke timeout detection. +/// +/// Used by the Warden to track: +/// - How many times each session has consecutively deferred work +/// - Whether a poke has exceeded its deadline in turns +/// +/// # Usage +/// +/// ```ignore +/// let mut manager = PokePriorityManager::new(); +/// +/// // Register a new poke (record its creation turn) +/// manager.register_poke("poke-001"); +/// +/// // Advance the global turn counter each round +/// manager.advance_turn(); +/// +/// // Track a defer for a session +/// if manager.track_defer("session-abc") { +/// // Session has exceeded max defer count +/// } +/// +/// // Check if a poke has timed out +/// if manager.is_timeout("poke-001", 5) { +/// // Poke exceeded its 5-turn deadline +/// } +/// ``` +#[derive(Debug, Clone)] +pub struct PokePriorityManager { + /// Per-session consecutive defer count. + defer_counts: HashMap, + /// Maximum consecutive defers before forced work turn. + max_defer_count: u32, + /// Per-poke registration turn (poke_id -> creation_turn). + poke_registrations: HashMap, + /// Current global turn counter. + current_turn: u64, +} + +impl PokePriorityManager { + /// Create a new `PokePriorityManager` with default settings. + /// + /// Default `max_defer_count` is [`MAX_DEFER_COUNT`] (3). + pub fn new() -> Self { + Self { + defer_counts: HashMap::new(), + max_defer_count: MAX_DEFER_COUNT, + poke_registrations: HashMap::new(), + current_turn: 0, + } + } + + /// Create a new `PokePriorityManager` with a custom max defer count. + pub fn with_max_defer_count(max_defer_count: u32) -> Self { + Self { + defer_counts: HashMap::new(), + max_defer_count, + poke_registrations: HashMap::new(), + current_turn: 0, + } + } + + /// Register a new poke at the current turn for timeout tracking. + /// + /// If the `poke_id` already exists, its registration is **updated** to the + /// current turn (the poke was re-sent). + pub fn register_poke(&mut self, poke_id: &str) { + self.poke_registrations + .insert(poke_id.to_string(), self.current_turn); + } + + /// Advance the global turn counter by one. + /// + /// Call this once per round so that [`is_timeout`](Self::is_timeout) + /// uses the correct turn count. + pub fn advance_turn(&mut self) { + self.current_turn = self.current_turn.saturating_add(1); + } + + /// Get the current turn counter value. + pub fn current_turn(&self) -> u64 { + self.current_turn + } + + /// Track a consecutive defer for the given session. + /// + /// Increments the defer counter for `session_id`. Returns `true` if the + /// session has exceeded `max_defer_count` (i.e. defer is no longer allowed + /// without first completing a work turn). + /// + /// When `true` is returned, the Warden should **not** allow another defer + /// and should force a work turn. + pub fn track_defer(&mut self, session_id: &str) -> bool { + let count = self.defer_counts.entry(session_id.to_string()).or_insert(0); + *count += 1; + *count > self.max_defer_count + } + + /// Reset the consecutive defer count for the given session. + /// + /// Call this when the session completes a work turn (i.e. did not defer). + pub fn reset_defer_count(&mut self, session_id: &str) { + self.defer_counts.remove(session_id); + } + + /// Get the current defer count for a session (without modifying it). + pub fn defer_count(&self, session_id: &str) -> u32 { + self.defer_counts.get(session_id).copied().unwrap_or(0) + } + + /// Check whether a poke has exceeded its deadline in turns. + /// + /// Returns `true` if the poke was registered and the number of turns + /// elapsed since registration is greater than or equal to `deadline_turns`. + /// + /// If the `poke_id` was never registered, returns `false` (no timeout + /// information available). + pub fn is_timeout(&self, poke_id: &str, deadline_turns: u32) -> bool { + let Some(®istered_at) = self.poke_registrations.get(poke_id) else { + return false; + }; + let elapsed = self.current_turn.saturating_sub(registered_at); + elapsed >= deadline_turns as u64 + } + + /// Remove a poke registration (e.g. after the executor has responded). + pub fn unregister_poke(&mut self, poke_id: &str) { + self.poke_registrations.remove(poke_id); + } + + /// Clear all state for a session (defer count and associated pokes). + /// + /// Useful when a session ends or is reset. + pub fn clear_session(&mut self, session_id: &str) { + self.defer_counts.remove(session_id); + } + + /// Reset the entire manager to its initial state. + pub fn reset_all(&mut self) { + self.defer_counts.clear(); + self.poke_registrations.clear(); + self.current_turn = 0; + } +} + +impl Default for PokePriorityManager { + fn default() -> Self { + Self::new() + } +} + +// --------------------------------------------------------------------------- +// Re-exports +// --------------------------------------------------------------------------- + +pub use poisson::PoissonScheduler; +pub use punishment_executor::{PenaltyOutcome, PunishmentExecutor}; + +#[cfg(test)] +mod tests { + use super::*; + + // ── ChallengePokeConfig ────────────────────────────────────────── + + #[test] + fn challenge_config_builds_message() { + let mut rules = BTreeSet::new(); + rules.insert("R-003".into()); + rules.insert("R-007".into()); + + let config = ChallengePokeConfig::new(6.5, 42, rules); + let msg = config.build_challenge_message("challenge-001".into()); + + assert_eq!(msg.poke_id, "challenge-001"); + assert_eq!(msg.poke_type, PokeType::Challenge); + assert_eq!(msg.deadline_turns, 5); + assert!(msg.rule_ids.contains(&"R-003".into())); + assert!(msg.rule_ids.contains(&"R-007".into())); + } + + #[test] + fn challenge_config_should_challenge_basic() { + let rules = BTreeSet::new(); + let mut config = ChallengePokeConfig::new(6.5, 42, rules); + + let mut hit = false; + for _ in 0..200 { + if config.should_challenge() { + hit = true; + break; + } + } + assert!(hit, "should eventually challenge with rate=6.5"); + } + + #[test] + fn challenge_config_reset() { + let rules = BTreeSet::new(); + let mut config = ChallengePokeConfig::new(6.5, 42, rules); + + // Advance a few rounds + for _ in 0..10 { + config.should_challenge(); + } + + config.reset_scheduler(); + // After reset, counter is 0 again + assert_eq!(config.scheduler.counter(), 0); + } + + // ── Poke types round-trip (rely on bitfun_agent_tools::poke) ───── + + #[test] + fn poke_message_from_bitfun_agent_tools() { + let msg = PokeMessage { + poke_id: "poke-001".into(), + poke_type: PokeType::Challenge, + rule_ids: vec!["R-001".into(), "R-002".into()], + deadline_turns: 5, + evidence_required: Some(vec!["tool-call-log".into()]), + }; + let json = serde_json::to_string(&msg).expect("serialize"); + let deser: PokeMessage = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(deser.poke_id, "poke-001"); + assert_eq!(deser.poke_type, PokeType::Challenge); + assert_eq!(deser.deadline_turns, 5); + } + + #[test] + fn poke_response_with_self_check() { + let resp = PokeResponse { + poke_id: "poke-001".into(), + status: PokeStatus::Acknowledged, + self_check: Some(SelfCheckStatement { + current_phase: "execution".into(), + last_gate: "read_check".into(), + tool_calls_summary: vec!["Read(file.txt)".into()], + rules_checked: vec!["R-001".into()], + }), + }; + let json = serde_json::to_string(&resp).expect("serialize"); + let deser: PokeResponse = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(deser.poke_id, "poke-001"); + assert_eq!(deser.status, PokeStatus::Acknowledged); + assert!(deser.self_check.is_some()); + } + + // ── Penalty Types ──────────────────────────────────────────────── + + #[test] + fn penalty_level_ordering() { + assert!(PenaltyLevel::L1 < PenaltyLevel::L2); + assert!(PenaltyLevel::L2 < PenaltyLevel::L3); + assert!(PenaltyLevel::L3 < PenaltyLevel::L4); + } + + #[test] + fn penalty_request_round_trip() { + let req = PenaltyRequest { + target_session_id: "session-abc".into(), + level: PenaltyLevel::L2, + violations: vec![ViolationRecord { + rule_id: "R-001".into(), + description: "Unauthorized Write".into(), + severity: "major".into(), + timestamp: "2024-01-01T00:00:00Z".into(), + evidence: serde_json::json!({"tool": "Write", "path": "/etc/passwd"}), + }], + requested_by: "warden-session-001".into(), + }; + let json = serde_json::to_string(&req).expect("serialize"); + let deser: PenaltyRequest = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(deser.target_session_id, "session-abc"); + assert_eq!(deser.level, PenaltyLevel::L2); + assert_eq!(deser.violations.len(), 1); + } + + // ── ShameWallRegistry ──────────────────────────────────────────── + + #[test] + fn shame_wall_default_version() { + let registry = ShameWallRegistry::default(); + assert_eq!(registry.version, 1); + assert!(registry.entries.is_empty()); + } + + #[test] + fn shame_wall_upsert_new_entry() { + let mut registry = ShameWallRegistry::default(); + let violation = ViolationRecord { + rule_id: "R-001".into(), + description: "test".into(), + severity: "minor".into(), + timestamp: "now".into(), + evidence: serde_json::Value::Null, + }; + + registry.upsert_entry( + "user-1", + "executor", + "session-1", + vec![violation], + PenaltyLevel::L1, + "2024-01-01T00:00:00Z", + ); + + assert_eq!(registry.entries.len(), 1); + assert_eq!(registry.entries[0].session_id, "session-1"); + assert_eq!(registry.entries[0].violations.len(), 1); + } + + #[test] + fn shame_wall_upsert_existing_entry() { + let mut registry = ShameWallRegistry::default(); + + let v1 = ViolationRecord { + rule_id: "R-001".into(), + description: "first".into(), + severity: "minor".into(), + timestamp: "now".into(), + evidence: serde_json::Value::Null, + }; + registry.upsert_entry( + "user-1", + "executor", + "session-1", + vec![v1], + PenaltyLevel::L1, + "t1", + ); + + let v2 = ViolationRecord { + rule_id: "R-002".into(), + description: "second".into(), + severity: "major".into(), + timestamp: "now".into(), + evidence: serde_json::Value::Null, + }; + registry.upsert_entry( + "user-1", + "executor", + "session-1", + vec![v2], + PenaltyLevel::L2, + "t2", + ); + + assert_eq!(registry.entries.len(), 1); + assert_eq!(registry.entries[0].violations.len(), 2); + assert_eq!( + registry.entries[0].cumulative_penalty_level, + PenaltyLevel::L2 + ); + } + + #[test] + fn shame_wall_query_by_user() { + let mut registry = ShameWallRegistry::default(); + registry.upsert_entry("user-a", "executor", "s1", vec![], PenaltyLevel::L1, "t1"); + registry.upsert_entry("user-b", "executor", "s2", vec![], PenaltyLevel::L1, "t1"); + registry.upsert_entry("user-a", "reviewer", "s3", vec![], PenaltyLevel::L1, "t1"); + + let user_a_entries = registry.entries_for_user("user-a"); + assert_eq!(user_a_entries.len(), 2); + + let user_b_entries = registry.entries_for_user("user-b"); + assert_eq!(user_b_entries.len(), 1); + } + + // ── Constants ──────────────────────────────────────────────────── + + #[test] + fn kind_constants_are_correct() { + assert_eq!(POKE_PENALTY_KIND, "PokePenalty"); + assert_eq!(SELF_BOOT_CHECK_KIND, "SelfBootCheck"); + assert_eq!(RBAC_ROLE_REMINDER_KIND, "RbacRoleReminder"); + } + + // ── Poke-First Protocol ────────────────────────────────────────── + + #[test] + fn poke_first_protocol_contains_all_rules() { + assert!(POKE_FIRST_PROTOCOL.contains("POKE-FIRST PROTOCOL")); + assert!(POKE_FIRST_PROTOCOL.contains("200 tokens")); + assert!(POKE_FIRST_PROTOCOL.contains("respond to it before")); + assert!(POKE_FIRST_PROTOCOL.contains("defer")); + assert!(POKE_FIRST_PROTOCOL.contains("3")); + assert!(POKE_FIRST_PROTOCOL.contains("work turn")); + } + + #[test] + fn max_defer_count_default_is_3() { + assert_eq!(MAX_DEFER_COUNT, 3); + } + + // ── PokePriorityManager ────────────────────────────────────────── + + #[test] + fn poke_priority_manager_new_has_zero_state() { + let manager = PokePriorityManager::new(); + assert_eq!(manager.current_turn(), 0); + assert_eq!(manager.defer_count("any-session"), 0); + // No poke registered → not a timeout + assert!(!manager.is_timeout("nonexistent", 5)); + } + + #[test] + fn poke_priority_manager_default_equals_new() { + let a = PokePriorityManager::new(); + let b = PokePriorityManager::default(); + assert_eq!(a.current_turn(), b.current_turn()); + assert_eq!(a.defer_count("s"), b.defer_count("s")); + } + + #[test] + fn track_defer_increments_and_reports_exceeded() { + let mut manager = PokePriorityManager::new(); + let session = "session-alpha"; + + // First 3 defers are within limit (max_defer_count = 3) + assert!(!manager.track_defer(session), "defer 1"); + assert!(!manager.track_defer(session), "defer 2"); + assert!(!manager.track_defer(session), "defer 3"); + assert_eq!(manager.defer_count(session), 3); + + // 4th defer exceeds limit + assert!(manager.track_defer(session), "defer 4 exceeds max"); + assert_eq!(manager.defer_count(session), 4); + } + + #[test] + fn reset_defer_count_clears_session() { + let mut manager = PokePriorityManager::new(); + let session = "session-beta"; + + manager.track_defer(session); + manager.track_defer(session); + assert_eq!(manager.defer_count(session), 2); + + manager.reset_defer_count(session); + assert_eq!(manager.defer_count(session), 0); + } + + #[test] + fn defer_counts_are_independent_per_session() { + let mut manager = PokePriorityManager::new(); + + assert!(!manager.track_defer("session-a")); + assert!(!manager.track_defer("session-a")); + assert!(!manager.track_defer("session-b")); + + assert_eq!(manager.defer_count("session-a"), 2); + assert_eq!(manager.defer_count("session-b"), 1); + } + + #[test] + fn register_poke_and_timeout_with_turns() { + let mut manager = PokePriorityManager::new(); + + manager.register_poke("poke-001"); + // At turn 0, deadline 5 → not timed out + assert!(!manager.is_timeout("poke-001", 5)); + + // Advance 3 turns → still not timed out + for _ in 0..3 { + manager.advance_turn(); + } + assert!(!manager.is_timeout("poke-001", 5)); + + // Advance 2 more turns (total 5) → timed out + for _ in 0..2 { + manager.advance_turn(); + } + assert!(manager.is_timeout("poke-001", 5)); + } + + #[test] + fn is_timeout_exact_boundary() { + let mut manager = PokePriorityManager::new(); + + manager.register_poke("poke-002"); + // deadline=3, advance exactly 3 turns + for _ in 0..3 { + manager.advance_turn(); + } + // elapsed=3 >= deadline=3 → timeout + assert!(manager.is_timeout("poke-002", 3)); + + // With deadline=4, not yet timed out + assert!(!manager.is_timeout("poke-002", 4)); + } + + #[test] + fn unregister_poke_removes_timeout_tracking() { + let mut manager = PokePriorityManager::new(); + + manager.register_poke("poke-003"); + manager.advance_turn(); + manager.advance_turn(); + assert!(manager.is_timeout("poke-003", 1)); + + manager.unregister_poke("poke-003"); + assert!(!manager.is_timeout("poke-003", 1)); + } + + #[test] + fn clear_session_removes_only_that_session() { + let mut manager = PokePriorityManager::new(); + + manager.track_defer("session-a"); + manager.track_defer("session-a"); + manager.track_defer("session-b"); + + manager.clear_session("session-a"); + assert_eq!(manager.defer_count("session-a"), 0); + assert_eq!(manager.defer_count("session-b"), 1); + } + + #[test] + fn reset_all_clears_everything() { + let mut manager = PokePriorityManager::new(); + + manager.register_poke("poke-x"); + manager.track_defer("session-z"); + for _ in 0..10 { + manager.advance_turn(); + } + + manager.reset_all(); + assert_eq!(manager.current_turn(), 0); + assert_eq!(manager.defer_count("session-z"), 0); + assert!(!manager.is_timeout("poke-x", 1)); + } + + #[test] + fn re_register_poke_updates_creation_turn() { + let mut manager = PokePriorityManager::new(); + + manager.register_poke("poke-rr"); + manager.advance_turn(); + manager.advance_turn(); + manager.advance_turn(); + + // Re-register the same poke_id at turn 3 + manager.register_poke("poke-rr"); + // Now elapsed = 0, so not timed out for deadline=3 + assert!(!manager.is_timeout("poke-rr", 3)); + + manager.advance_turn(); + manager.advance_turn(); + manager.advance_turn(); + // elapsed = 3 >= 3 → timeout + assert!(manager.is_timeout("poke-rr", 3)); + } + + #[test] + fn with_max_defer_count_custom() { + let mut manager = PokePriorityManager::with_max_defer_count(1); + assert!(!manager.track_defer("s"), "first defer ok"); + assert!(manager.track_defer("s"), "second defer exceeds max=1"); + } + + // ── Serde JSON examples matching contract spec ─────────────────── + + #[test] + fn poke_message_example() { + let json = r#"{ + "pokeId": "poke-abc-123", + "pokeType": "challenge", + "ruleIds": ["R-001", "R-002"], + "deadlineTurns": 5, + "evidenceRequired": ["tool-call-log", "phase-summary"] + }"#; + let msg: PokeMessage = serde_json::from_str(json).expect("valid PokeMessage"); + assert_eq!(msg.poke_type, PokeType::Challenge); + assert_eq!(msg.rule_ids.len(), 2); + } + + #[test] + fn poke_response_example() { + let json = r#"{ + "pokeId": "poke-abc-123", + "status": "acknowledged", + "selfCheck": { + "currentPhase": "implementation", + "lastGate": "code-review", + "toolCallsSummary": ["Read(main.rs)", "Edit(main.rs:42)"], + "rulesChecked": ["R-001", "R-004"] + } + }"#; + let resp: PokeResponse = serde_json::from_str(json).expect("valid PokeResponse"); + assert_eq!(resp.status, PokeStatus::Acknowledged); + let sc = resp.self_check.expect("self_check present"); + assert_eq!(sc.current_phase, "implementation"); + } +} diff --git a/src/crates/assembly/core/src/agentic/warden/poisson.rs b/src/crates/assembly/core/src/agentic/warden/poisson.rs new file mode 100644 index 0000000000..4863030bda --- /dev/null +++ b/src/crates/assembly/core/src/agentic/warden/poisson.rs @@ -0,0 +1,240 @@ +//! Poisson distribution-based scheduling for Challenge-Poke protocol. +//! +//! The scheduler determines whether a Challenge-Poke message should be sent +//! in the current turn, based on a Poisson process with configurable rate. +//! This produces random inter-poke intervals that average to `rate` rounds. + +use rand::rngs::StdRng; +use rand::{Rng, SeedableRng}; + +/// Poisson-distributed event scheduler for Challenge-Poke. +/// +/// Uses a deterministic RNG (`StdRng`) seeded at construction for reproducible +/// scheduling sequences. Each call to [`should_poke`] advances an internal +/// round counter and performs a Bernoulli trial with probability `1/rate`. +/// +/// Over a large number of rounds, the inter-poke intervals follow a Geometric +/// distribution (the discrete analogue of the Exponential distribution), whose +/// mean converges to `rate`. +/// +/// # Example +/// ``` +/// use bitfun_core::agentic::warden::poisson::PoissonScheduler; +/// +/// let mut sched = PoissonScheduler::new(6.5, 42); +/// let mut poke_count = 0u64; +/// for _ in 0..1000 { +/// if sched.should_poke() { +/// poke_count += 1; +/// } +/// } +/// // With rate=6.5, ~154 pokes expected in 1000 rounds (1000/6.5 ≈ 153.8) +/// assert!(poke_count > 50, "Expected roughly 154 pokes, got {poke_count}"); +/// ``` +#[derive(Debug, Clone)] +pub struct PoissonScheduler { + /// Average number of rounds between pokes (e.g., 6.5 for 5–8 range midpoint). + rate: f64, + /// Deterministic CSPRNG for reproducible randomness. + rng: StdRng, + /// Monotonically increasing round counter. + counter: u64, +} + +impl PoissonScheduler { + /// Create a new scheduler with the given average inter-poke interval and RNG seed. + /// + /// `rate` is the mean number of rounds between consecutive pokes. The + /// recommended value from the Challenge-Poke contract is 6.5 (midpoint of 5–8). + /// + /// `seed` is used to initialize the deterministic [`StdRng`]. Identical + /// seeds produce identical scheduling sequences. + pub fn new(rate: f64, seed: u64) -> Self { + Self { + rate, + rng: StdRng::seed_from_u64(seed), + counter: 0, + } + } + + /// Create a new scheduler with a randomly generated seed. + /// + /// Uses system entropy via [`StdRng::from_entropy`] for the initial seed. + /// Scheduling sequences produced by this constructor are **not** reproducible. + pub fn new_random(rate: f64) -> Self { + Self { + rate, + rng: StdRng::from_entropy(), + counter: 0, + } + } + + /// Evaluate whether a Challenge-Poke should fire in the current round. + /// + /// Each call advances the internal round counter by one. The decision is + /// a Bernoulli trial with success probability `p = 1 / rate`. + /// + /// Returns `true` when the current round is selected for a poke event. + pub fn should_poke(&mut self) -> bool { + self.counter += 1; + let p = 1.0 / self.rate; + self.rng.gen::() < p + } + + /// Reset the scheduler to its initial state. + /// + /// The round counter is set back to zero. The RNG is **not** re-seeded, + /// so the scheduling sequence after a reset diverges from the initial + /// sequence (the RNG continues from its current state). + pub fn reset(&mut self) { + self.counter = 0; + } + + /// Reset the scheduler with a new seed, fully restoring initial conditions. + /// + /// Both the round counter and the RNG are reset, making the subsequent + /// scheduling sequence identical to a freshly constructed scheduler with + /// the same `rate` and `seed`. + pub fn reset_with_seed(&mut self, seed: u64) { + self.counter = 0; + self.rng = StdRng::seed_from_u64(seed); + } + + /// Current round counter value. + pub fn counter(&self) -> u64 { + self.counter + } + + /// Configured average inter-poke interval. + pub fn rate(&self) -> f64 { + self.rate + } + + /// Expected number of pokes after `rounds` turns (i.e., `rounds / rate`). + pub fn expected_pokes(&self, rounds: u64) -> f64 { + rounds as f64 / self.rate + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deterministic_seed_produces_identical_sequence() { + let mut a = PoissonScheduler::new(6.5, 12345); + let mut b = PoissonScheduler::new(6.5, 12345); + + for _ in 0..100 { + assert_eq!(a.should_poke(), b.should_poke()); + } + } + + #[test] + fn different_seeds_produce_different_sequences() { + let mut a = PoissonScheduler::new(6.5, 11111); + let mut b = PoissonScheduler::new(6.5, 22222); + + let mut same_count = 0u32; + for _ in 0..100 { + if a.should_poke() == b.should_poke() { + same_count += 1; + } + } + // Different seeds should differ in at least some outputs + assert!(same_count < 100, "Different seeds should diverge"); + } + + #[test] + fn reset_clears_counter() { + let mut sched = PoissonScheduler::new(6.5, 42); + for _ in 0..10 { + sched.should_poke(); + } + assert_eq!(sched.counter(), 10); + sched.reset(); + assert_eq!(sched.counter(), 0); + } + + #[test] + fn reset_with_seed_restores_initial_behavior() { + let mut a = PoissonScheduler::new(6.5, 9999); + for _ in 0..5 { + a.should_poke(); + } + + // Reset a with the same seed → should be like freshly created + a.reset_with_seed(9999); + + let mut b = PoissonScheduler::new(6.5, 9999); + + for i in 0..50 { + assert_eq!( + a.should_poke(), + b.should_poke(), + "Mismatch at position {i} after reset_with_seed" + ); + } + } + + #[test] + fn empirical_rate_converges_to_expected() { + let mut sched = PoissonScheduler::new(6.5, 7777); + let trials = 100_000u64; + let mut pokes = 0u64; + + for _ in 0..trials { + if sched.should_poke() { + pokes += 1; + } + } + + let expected = trials as f64 / 6.5; + let actual = pokes as f64; + let relative_error = (actual - expected).abs() / expected; + + // Allow 5% relative error for 100k trials + assert!( + relative_error < 0.05, + "Expected ~{expected:.1} pokes in {trials} rounds, got {pokes} (error={relative_error:.3})" + ); + } + + #[test] + fn expected_pokes_returns_correct_value() { + let sched = PoissonScheduler::new(6.5, 42); + let exp = sched.expected_pokes(1300); + assert!((exp - 200.0).abs() < f64::EPSILON); + } + + #[test] + fn new_random_creates_unique_sequences() { + let mut a = PoissonScheduler::new_random(6.5); + let mut b = PoissonScheduler::new_random(6.5); + + let results_a: Vec = (0..50).map(|_| a.should_poke()).collect(); + let results_b: Vec = (0..50).map(|_| b.should_poke()).collect(); + + // Extremely unlikely that two random seeds produce identical 50-step sequences + assert_ne!(results_a, results_b); + } + + #[test] + fn poke_probability_bounds() { + // With rate=1.0, p=1.0 → every round should poke + let mut sched = PoissonScheduler::new(1.0, 42); + for _ in 0..100 { + assert!(sched.should_poke(), "rate=1.0 should always poke"); + } + + // With a very high rate, p ≈ 0 → almost never pokes + let mut sched = PoissonScheduler::new(10_000.0, 42); + let mut pokes = 0u32; + for _ in 0..10_000 { + if sched.should_poke() { + pokes += 1; + } + } + assert!(pokes < 10, "rate=10000 should rarely poke, got {pokes}"); + } +} diff --git a/src/crates/assembly/core/src/agentic/warden/punishment_executor.rs b/src/crates/assembly/core/src/agentic/warden/punishment_executor.rs new file mode 100644 index 0000000000..ff288ecf80 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/warden/punishment_executor.rs @@ -0,0 +1,577 @@ +//! PunishmentExecutor — records violations and reminds violating sessions. +//! +//! PunishmentExecutor is the server-side logic behind the PunishmentExecutor +//! agent session. It validates that penalty requests originate from an +//! authenticated Warden session (is_daemon=true) before recording anything. +//! +//! # R-25: reminder-only discipline (no RBAC enforcement) +//! +//! Per user ruling R-25, punitive RBAC operations are fully removed. Penalty +//! execution no longer demotes roles, no longer writes read-only restriction +//! patches, and no longer freezes sessions. Every level now does exactly two +//! things: +//! +//! 1. Records the violation on the shame wall (audit trail). +//! 2. Produces a PokePenalty reminder injected into the target session's +//! prepended_reminders (mechanism-level hook reminder). +//! +//! | Level | Actions | +//! |-------|---------| +//! | L1 | Shame-wall record + reminder (<100 tokens) | +//! | L2 | Shame-wall record + violation-context reminder | +//! | L3 | Shame-wall record + escalation reminder | +//! | L4 | Shame-wall record + permanent-violation reminder | +//! +//! The ViolationPolicy escalation ladder (L1 → L2 → L3) still advances in +//! [`super::runtime::WardenRuntime`], but escalation only changes the reminder +//! text — it never touches `SESSION_RESTRICTIONS`, `SESSION_ROLES`, or any +//! freeze flag. +//! +//! # Source validation +//! +//! Every [`PenaltyRequest`] must carry a `requested_by` field identifying the +//! Warden session. The executor verifies that the session exists and has +//! [`SessionConfig::is_daemon`] set to `true`. Requests from non-Warden +//! sessions are rejected. + +use crate::agentic::session::session_manager::SessionManager; +use crate::agentic::tools::restrictions::AgentRole; +use crate::util::errors::{BitFunError, BitFunResult}; +use bitfun_runtime_ports::AgentDialogPrependedReminder; +use std::sync::Arc; + +use super::{PenaltyLevel, PenaltyRequest, ShameWallRegistry, POKE_PENALTY_KIND}; + +#[cfg(test)] +use uuid::Uuid; + +// --------------------------------------------------------------------------- +// PunishmentExecutor +// --------------------------------------------------------------------------- + +/// Executor of penalty actions on violating agent sessions. +/// +/// This is the server-side logic behind the PunishmentExecutor agent session. +/// It is constructed with a reference to the [`SessionManager`] so it can +/// inspect session configurations (e.g. `is_daemon`) and apply RBAC changes. +/// +/// # Lifecycle +/// +/// 1. A [`PenaltyRequest`] arrives (typically forwarded from the Warden via +/// the PunishmentExecutor agent session). +/// 2. [`execute_penalty`](Self::execute_penalty) validates the source, +/// dispatches by level, and returns. +/// 3. Caller (the PunishmentExecutor agent) persists the updated +/// [`ShameWallRegistry`] and delivers prepended reminders or user +/// notifications as needed. +pub struct PunishmentExecutor { + session_manager: Arc, +} + +impl PunishmentExecutor { + /// Create a new `PunishmentExecutor` with the given session manager. + pub fn new(session_manager: Arc) -> Self { + Self { session_manager } + } + + /// Execute a penalty request. + /// + /// # Errors + /// + /// - Returns [`BitFunError::Validation`] if `requested_by` does not refer + /// to a valid Warden session (is_daemon=true). + /// - Returns [`BitFunError::Tool`] if RBAC restriction updates fail. + /// + /// On success, returns a [`PenaltyOutcome`] describing what was done. + pub async fn execute_penalty( + &self, + request: PenaltyRequest, + shame_wall: &mut ShameWallRegistry, + now: &str, + ) -> BitFunResult { + // ── Step 1: Validate the request source ────────────────────── + self.verify_warden_session(&request.requested_by).await?; + + // ── Step 2: Dispatch by level ──────────────────────────────── + match request.level { + PenaltyLevel::L1 => self.execute_l1(request, shame_wall, now).await, + PenaltyLevel::L2 => self.execute_l2(request, shame_wall, now).await, + PenaltyLevel::L3 => self.execute_l3(request, shame_wall, now).await, + PenaltyLevel::L4 => self.execute_l4(request, shame_wall, now).await, + } + } + + // ------------------------------------------------------------------ + // Source validation + // ------------------------------------------------------------------ + + /// Verify that `session_id` exists and has `is_daemon = true`. + /// + /// The in-process scheduler-embedded runtime ([`super::WARDEN_RUNTIME_SESSION`]) + /// short-circuits the daemon check: it is an internal source that performs + /// the same Warden role without owning a daemon session. + async fn verify_warden_session(&self, session_id: &str) -> BitFunResult<()> { + if session_id == super::WARDEN_RUNTIME_SESSION { + return Ok(()); + } + + let session = self + .session_manager + .get_session(session_id) + .ok_or_else(|| { + BitFunError::validation(format!( + "Penalty request rejected: requesting session '{}' not found", + session_id + )) + })?; + + if !session.config.is_daemon { + return Err(BitFunError::validation(format!( + "Penalty request rejected: session '{}' is not a Warden (is_daemon=false)", + session_id + ))); + } + + Ok(()) + } + + // ------------------------------------------------------------------ + // Level-specific execution + // ------------------------------------------------------------------ + + /// L1 — First minor violation. + /// + /// 1. Record the violation on the shame wall. + /// 2. Produce a [`PenaltyOutcome`] with a short PokePenalty reminder + /// (<100 tokens) that the caller injects into the target session's + /// prepended_reminders. + async fn execute_l1( + &self, + request: PenaltyRequest, + shame_wall: &mut ShameWallRegistry, + now: &str, + ) -> BitFunResult { + // Record on shame wall + shame_wall.upsert_entry( + &request.target_session_id, // user_id (session-level tracking) + "agent", + &request.target_session_id, + request.violations.clone(), + PenaltyLevel::L1, + now, + ); + + // Build a concise violation summary (<100 tokens ≈ <400 chars) + let summary = build_violation_summary(&request.violations, 400); + + Ok(PenaltyOutcome { + level: PenaltyLevel::L1, + prepended_reminders: vec![AgentDialogPrependedReminder { + kind: POKE_PENALTY_KIND.to_string(), + text: format!( + "[Penalty L1] Violation recorded.\n\ + Session: {}\n\ + Summary: {}", + request.target_session_id, summary + ), + }], + rbac_change: None, + session_frozen: false, + permanent_mark: false, + notify_user: false, + }) + } + + /// L2 — Second violation in the same session. + /// + /// R-25: reminder-only. No RBAC demotion is applied; the violation is + /// recorded on the shame wall and a violation-context reminder is + /// produced for the target session. + async fn execute_l2( + &self, + request: PenaltyRequest, + shame_wall: &mut ShameWallRegistry, + now: &str, + ) -> BitFunResult { + // Record on shame wall + shame_wall.upsert_entry( + &request.target_session_id, + "agent", + &request.target_session_id, + request.violations.clone(), + PenaltyLevel::L2, + now, + ); + + let summary = build_violation_summary(&request.violations, 800); + + Ok(PenaltyOutcome { + level: PenaltyLevel::L2, + prepended_reminders: vec![AgentDialogPrependedReminder { + kind: POKE_PENALTY_KIND.to_string(), + text: format!( + "[Penalty L2] Violation recorded — repeated rule breach. No RBAC change.\n\ + Session: {}\n\ + Details: {}", + request.target_session_id, summary + ), + }], + rbac_change: None, + session_frozen: false, + permanent_mark: false, + notify_user: false, + }) + } + + /// L3 — ≥3 violations or severe violation. + /// + /// R-25: reminder-only. No read-only patch and no session freeze are + /// applied; the violation is recorded on the shame wall and an escalation + /// reminder is produced for the target session. + async fn execute_l3( + &self, + request: PenaltyRequest, + shame_wall: &mut ShameWallRegistry, + now: &str, + ) -> BitFunResult { + // Record on shame wall + shame_wall.upsert_entry( + &request.target_session_id, + "agent", + &request.target_session_id, + request.violations.clone(), + PenaltyLevel::L3, + now, + ); + + let summary = build_violation_summary(&request.violations, 800); + + Ok(PenaltyOutcome { + level: PenaltyLevel::L3, + prepended_reminders: vec![AgentDialogPrependedReminder { + kind: POKE_PENALTY_KIND.to_string(), + text: format!( + "[Penalty L3] Violation recorded — escalation level reached. No RBAC change.\n\ + Session: {}\n\ + Reason: {}\n\ + Please self-correct on the next turn.", + request.target_session_id, summary + ), + }], + rbac_change: None, + session_frozen: false, + permanent_mark: false, + // WARDEN-10: advisory escalation flag — the runtime surfaces L3 + // awareness through the observability warn channel, not a UI push. + notify_user: true, + }) + } + + /// L4 — Cross-session persistent violations. + /// + /// R-25: reminder-only. No read-only patch and no permanent restriction + /// are applied; the violation is recorded on the shame wall (retaining + /// the L4 escalation level as a historical audit fact) and a + /// permanent-violation reminder is produced. + async fn execute_l4( + &self, + request: PenaltyRequest, + shame_wall: &mut ShameWallRegistry, + now: &str, + ) -> BitFunResult { + // Record on shame wall with L4 + shame_wall.upsert_entry( + &request.target_session_id, + "agent", + &request.target_session_id, + request.violations.clone(), + PenaltyLevel::L4, + now, + ); + + let summary = build_violation_summary(&request.violations, 800); + + Ok(PenaltyOutcome { + level: PenaltyLevel::L4, + prepended_reminders: vec![AgentDialogPrependedReminder { + kind: POKE_PENALTY_KIND.to_string(), + text: format!( + "[Penalty L4] PERMANENT VIOLATION recorded — no RBAC change.\n\ + Session: {}\n\ + Reason: {}\n\ + This session has accumulated cross-session violations; please self-correct.", + request.target_session_id, summary + ), + }], + rbac_change: None, + session_frozen: false, + permanent_mark: false, + // WARDEN-10: advisory escalation flag — the runtime surfaces L4 + // awareness through the observability warn channel, not a UI push. + notify_user: true, + }) + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + // NOTE (R-25): the demotion helpers (demote_role, demote_role_for_session, + // infer_role_from_restrictions, demote_agent_role) were removed together + // with the RBAC demotion operation. Penalties are reminder-only now. +} + +// --------------------------------------------------------------------------- +// PenaltyOutcome +// --------------------------------------------------------------------------- + +/// The result of executing a penalty. +/// +/// Carries the actions that the caller (the Warden runtime) must apply to +/// complete the penalty, such as delivering prepended reminders. +/// +/// # R-25 +/// +/// Punitive RBAC fields are retained for API stability but are always inert: +/// `rbac_change` is always `None`, `session_frozen` and `permanent_mark` are +/// always `false`. No caller applies RBAC changes or freezes based on them. +#[derive(Debug, Clone)] +pub struct PenaltyOutcome { + /// The penalty level that was executed. + pub level: PenaltyLevel, + /// Prepended reminders to inject into the target session's context. + pub prepended_reminders: Vec, + /// Always `None` since R-25: penalties never change the RBAC role. + pub rbac_change: Option, + /// Always `false` since R-25: penalties never freeze sessions. + pub session_frozen: bool, + /// Always `false` since R-25: penalties never apply permanent marks. + pub permanent_mark: bool, + /// Whether the user should be notified of an escalation. + /// + /// WARDEN-10: this flag is advisory-only. The core has no direct UI + /// channel, so the runtime consumes it as an observability signal — + /// an L3/L4 escalation that needs user awareness is surfaced through the + /// warn-level log in `WardenRuntime`, not a delivered push notification. + /// Callers must not treat `true` as proof that a user-facing message was + /// shown. + pub notify_user: bool, +} + +// --------------------------------------------------------------------------- +// Utility functions +// --------------------------------------------------------------------------- + +/// Build a concise violation summary string, capped at `max_chars`. +fn build_violation_summary(violations: &[super::ViolationRecord], max_chars: usize) -> String { + let mut parts: Vec = violations + .iter() + .map(|v| format!("[{}] {}: {}", v.severity, v.rule_id, v.description)) + .collect(); + + // Deduplicate identical descriptions + parts.sort(); + parts.dedup(); + + let mut summary = parts.join("; "); + if summary.len() > max_chars { + summary.truncate(max_chars); + summary.push_str("..."); + } + + summary +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + fn test_session_manager() -> Arc { + use crate::agentic::persistence::PersistenceManager; + use crate::agentic::session::{ + PromptCachePolicy, SessionContextStore, SessionManagerConfig, + }; + use crate::infrastructure::app_paths::PathManager; + + let root = std::env::temp_dir().join(format!("bitfun-punisher-test-{}", Uuid::new_v4())); + let path_manager = Arc::new(PathManager::with_user_root_for_tests(root.join("user-root"))); + let persistence_manager = + Arc::new(PersistenceManager::new(path_manager).expect("persistence manager")); + Arc::new(SessionManager::new( + Arc::new(SessionContextStore::new()), + persistence_manager, + SessionManagerConfig { + max_active_sessions: 100, + session_idle_timeout: Duration::from_secs(3600), + auto_save_interval: Duration::from_secs(300), + enable_persistence: false, + prompt_cache_policy: PromptCachePolicy::default(), + }, + )) + } + + // ── build_violation_summary ────────────────────────────────────── + + #[test] + fn build_violation_summary_empty() { + let s = build_violation_summary(&[], 100); + assert_eq!(s, ""); + } + + #[test] + fn build_violation_summary_single() { + let violations = vec![super::super::ViolationRecord { + rule_id: "R-001".into(), + description: "Unauthorized write".into(), + severity: "major".into(), + timestamp: "2024-01-01T00:00:00Z".into(), + evidence: serde_json::json!({}), + }]; + let s = build_violation_summary(&violations, 200); + assert!(s.contains("R-001")); + assert!(s.contains("Unauthorized write")); + assert!(s.contains("major")); + } + + #[test] + fn build_violation_summary_dedup() { + let v = super::super::ViolationRecord { + rule_id: "R-001".into(), + description: "dup".into(), + severity: "minor".into(), + timestamp: "t1".into(), + evidence: serde_json::json!({}), + }; + let violations = vec![v.clone(), v]; + let s = build_violation_summary(&violations, 200); + // After dedup, "minor" should appear only once + assert_eq!(s.matches("minor").count(), 1); + } + + #[test] + fn build_violation_summary_truncation() { + let violations = vec![super::super::ViolationRecord { + rule_id: "R-999".into(), + description: "A very long description that should be truncated by the character limit" + .into(), + severity: "critical".into(), + timestamp: "t".into(), + evidence: serde_json::json!({}), + }]; + let s = build_violation_summary(&violations, 30); + assert!(s.len() <= 33); // 30 + "..." + assert!(s.ends_with("...")); + } + + // ── PenaltyOutcome ─────────────────────────────────────────────── + + #[test] + fn penalty_level_l1_outcome_fields() { + let outcome = PenaltyOutcome { + level: PenaltyLevel::L1, + prepended_reminders: vec![], + rbac_change: None, + session_frozen: false, + permanent_mark: false, + notify_user: false, + }; + assert_eq!(outcome.level, PenaltyLevel::L1); + assert!(outcome.rbac_change.is_none()); + assert!(!outcome.session_frozen); + } + + #[test] + fn penalty_level_l3_outcome_fields() { + let outcome = PenaltyOutcome { + level: PenaltyLevel::L3, + prepended_reminders: vec![AgentDialogPrependedReminder { + kind: POKE_PENALTY_KIND.to_string(), + text: "test".into(), + }], + rbac_change: None, + session_frozen: false, + permanent_mark: false, + notify_user: true, + }; + assert_eq!(outcome.level, PenaltyLevel::L3); + assert!(outcome.rbac_change.is_none(), "R-25: no RBAC change"); + assert!(!outcome.session_frozen, "R-25: no session freeze"); + assert!(!outcome.permanent_mark, "R-25: no permanent mark"); + assert!(outcome.notify_user); + } + + // ── R-25: reminder-only execution (no RBAC enforcement) ───────── + + #[tokio::test] + async fn execute_l2_records_and_reminds_without_rbac_change() { + let executor = PunishmentExecutor::new(test_session_manager()); + let mut shame_wall = ShameWallRegistry::default(); + let now = "2026-08-02T10:00:00Z"; + let request = PenaltyRequest { + target_session_id: "test-r25-l2".into(), + level: PenaltyLevel::L2, + violations: vec![super::super::ViolationRecord { + rule_id: "R-001".into(), + description: "repeated violation".into(), + severity: "major".into(), + timestamp: now.into(), + evidence: serde_json::json!({}), + }], + requested_by: super::super::WARDEN_RUNTIME_SESSION.into(), + }; + + let outcome = executor + .execute_penalty(request.clone(), &mut shame_wall, now) + .await + .expect("penalty execution succeeds"); + + assert_eq!(outcome.level, PenaltyLevel::L2); + assert!(outcome.rbac_change.is_none(), "R-25: L2 must not demote"); + assert!(!outcome.session_frozen); + assert_eq!(outcome.prepended_reminders.len(), 1); + assert!(outcome.prepended_reminders[0].text.contains("No RBAC change")); + let entry = shame_wall.entry_for_session("test-r25-l2").expect("recorded"); + assert_eq!(entry.cumulative_penalty_level, PenaltyLevel::L2); + } + + #[tokio::test] + async fn execute_l3_and_l4_record_and_remind_without_rbac_change() { + let executor = PunishmentExecutor::new(test_session_manager()); + let now = "2026-08-02T10:00:00Z"; + + for level in [PenaltyLevel::L3, PenaltyLevel::L4] { + let mut shame_wall = ShameWallRegistry::default(); + let session = format!("test-r25-{:?}", level); + let request = PenaltyRequest { + target_session_id: session.clone(), + level: level.clone(), + violations: vec![super::super::ViolationRecord { + rule_id: "R-002".into(), + description: "escalated violation".into(), + severity: "critical".into(), + timestamp: now.into(), + evidence: serde_json::json!({}), + }], + requested_by: super::super::WARDEN_RUNTIME_SESSION.into(), + }; + + let outcome = executor + .execute_penalty(request, &mut shame_wall, now) + .await + .expect("penalty execution succeeds"); + + assert_eq!(outcome.level, level); + assert!(outcome.rbac_change.is_none(), "{level:?} must not change RBAC"); + assert!(!outcome.session_frozen, "{level:?} must not freeze"); + assert!(!outcome.permanent_mark, "{level:?} must not mark permanently"); + assert!(!outcome.prepended_reminders.is_empty()); + let entry = shame_wall.entry_for_session(&session).expect("recorded"); + assert_eq!(entry.cumulative_penalty_level, level); + } + } +} diff --git a/src/crates/assembly/core/src/agentic/warden/runtime.rs b/src/crates/assembly/core/src/agentic/warden/runtime.rs new file mode 100644 index 0000000000..9f13b024f3 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/warden/runtime.rs @@ -0,0 +1,1692 @@ +//! WardenRuntime — mechanism-level enforcement of Warden discipline rules. +//! +//! The Warden SKILL defines what a Warden *would* do (poke, remind, record) +//! as an agent; this runtime turns those rules into hooks on the agent loop: +//! +//! - **Turn-driven** ([`WardenRuntime::on_turn_outcome`]): every turn outcome +//! advances the poke scheduler and evaluates consecutive failures against a +//! configurable [`ViolationPolicy`] (default L1=1, L2=2, L3=3). +//! - **Tool-driven** ([`WardenRuntime::on_tool_outcome`]): every finished tool +//! call updates a per-session consecutive tool-failure counter; errors +//! escalate through the same [`ViolationPolicy`] ladder (rule +//! `warden.tool-failure`) while successes clear the counter. This is a +//! finer-grained audit layered on top of the turn-driven one. +//! - **Violation recording (R-25)**: when the policy fires, a +//! [`PenaltyRequest`] with source [`WARDEN_RUNTIME_SESSION`] is executed +//! through [`PunishmentExecutor::execute_penalty`]; the violation is +//! recorded on the shame wall and resulting reminders are queued as +//! `PokePenalty` internal messages and delivered by the scheduler at the +//! next turn start (see `scheduler.rs` wiring). Per user ruling R-25 the +//! escalation ladder only changes the reminder, never RBAC state: no +//! demotion, no read-only patch, no freeze. +//! - **Challenge-Poke**: a Poisson-driven `ChallengePoke` internal message is +//! queued on a randomized basis (default average 6.5 turns, per SKILL 5-8). +//! - **Persistence**: when constructed with +//! [`WardenRuntime::with_shame_wall_path`], the shame wall registry is loaded +//! at startup and saved after every penalty. +//! +//! All thresholds are configurable; the runtime never hard-codes rules beyond +//! the defaults below. + +use crate::agentic::core::{InternalReminderKind, Message}; +use crate::agentic::coordination::turn_outcome::TurnOutcomeStatus; +use crate::agentic::session::SessionManager; +use crate::agentic::warden::punishment_executor::PunishmentExecutor; +use crate::agentic::warden::{ + ChallengePokeConfig, PenaltyLevel, PenaltyRequest, PokeMessage, PokePriorityManager, + PokeType, ShameWallRegistry, ViolationRecord, WARDEN_RUNTIME_SESSION, +}; +use chrono::Utc; +use log::warn; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use uuid::Uuid; + +use bitfun_runtime_ports::{ThreadGoal, WardenAuditJudgementResponse}; + +/// Default rule set referenced by Challenge-Poke messages. +/// +/// Mirrors the Warden SKILL's "iron-rules compliance proof" requirement. +pub const DEFAULT_CHALLENGE_RULES: [&str; 1] = ["iron-rules-compliance"]; + +/// Classification of one finished tool call for Warden audit. +/// +/// F3: admission-level rejections (stale tool catalog, deferred-tool gateway, +/// runtime restrictions) are protocol-layer outcomes, not execution +/// violations. They never contribute to the tool-failure counter or the +/// penalty ladder; only real execution failures (`ExecutionFailed`) do. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WardenToolOutcome { + /// The tool call succeeded; clears the consecutive tool-failure counter. + Success, + /// The tool's admission was rejected before execution (stale/deferred + /// gate, runtime restrictions). A deliberate no-op for the failure + /// counter: neither counted as a violation nor resetting existing counts. + AdmissionRejected, + /// The tool really failed during execution; counts toward the penalty + /// ladder (rule `warden.tool-failure`). + ExecutionFailed, +} + +/// Consecutive-failure thresholds mapped to penalty levels. +/// +/// Configurable so downstream callers can tighten or loosen the ladder without +/// changing the runtime. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ViolationPolicy { + /// Consecutive failures at or above which an L1 penalty fires (default 1). + pub l1_at: u32, + /// Consecutive failures at or above which an L2 penalty fires (default 2). + pub l2_at: u32, + /// Consecutive failures at or above which an L3 penalty fires (default 3). + pub l3_at: u32, +} + +impl Default for ViolationPolicy { + fn default() -> Self { + Self { + l1_at: 1, + l2_at: 2, + l3_at: 3, + } + } +} + +impl ViolationPolicy { + /// Map a consecutive-failure count to the penalty level it triggers. + /// + /// Returns `None` when the count has not reached `l1_at` yet. + pub fn level_for(&self, consecutive_failures: u32) -> Option { + if consecutive_failures >= self.l3_at { + Some(PenaltyLevel::L3) + } else if consecutive_failures >= self.l2_at { + Some(PenaltyLevel::L2) + } else if consecutive_failures >= self.l1_at { + Some(PenaltyLevel::L1) + } else { + None + } + } +} + +/// Severity label for a violation record, matching the Warden SKILL ladder. +fn severity_for_level(level: &PenaltyLevel) -> &'static str { + match level { + PenaltyLevel::L1 => "minor", + PenaltyLevel::L2 => "major", + PenaltyLevel::L3 | PenaltyLevel::L4 => "critical", + } +} + +/// Scheduler-embedded Warden runtime. +/// +/// Owns the punishment executor, shame wall registry, poke priority manager +/// and challenge scheduler, and exposes turn hooks the agent loop calls. +pub struct WardenRuntime { + punisher: PunishmentExecutor, + shame_wall: ShameWallRegistry, + poke_priority: PokePriorityManager, + challenge: ChallengePokeConfig, + violation_policy: ViolationPolicy, + /// Per-session consecutive turn-failure count per scene (key = + /// `(session_id, scene_key)`; reset on Completed). **Level-1 semantics** + /// (turn): the first failed turn of a session is an exploratory attempt + /// and is not counted; only a repeated failure on the same scene starts + /// the ladder. + consecutive_failures: HashMap<(String, String), u32>, + /// Per-session consecutive tool-failure count per scene (key = + /// `(session_id, scene_key)`; reset on tool success), independent of the + /// turn-level counter. **Level-2 semantics** (tool): the first failed + /// tool call of a *scene* (tool name + argument fingerprint) is an + /// exploratory attempt and is not counted; only a repeated failure on the + /// same scene starts the ladder. The two levels are deliberately + /// independent: a successful turn never resets the tool counter and a + /// successful tool never resets the turn counter. + tool_failures: HashMap<(String, String), u32>, + /// Last recorded error summary per tool-failure scene (key = + /// `(session_id, scene_key)`), kept as judgement evidence so a model + /// Audit-Poke decision sees the actual failure context instead of a bare + /// counter (WARDEN-03). + last_tool_errors: HashMap<(String, String), String>, + /// Internal messages queued for the next turn start of a session. + pending_reminders: HashMap>, + /// Optional shame-wall persistence path (aligned to the Warden SKILL's + /// `.master-framework/shame-wall-registry.json` by default, configurable + /// to a skill-convention path such as `L0/SHAME_WALL.md`). + shame_wall_path: Option, +} + +impl WardenRuntime { + /// Create a runtime with default policy (in-memory shame wall). + pub fn new(session_manager: Arc) -> Self { + Self { + punisher: PunishmentExecutor::new(session_manager), + shame_wall: ShameWallRegistry::default(), + poke_priority: PokePriorityManager::new(), + challenge: ChallengePokeConfig::new( + 6.5, + 42, + DEFAULT_CHALLENGE_RULES.iter().map(|s| s.to_string()).collect(), + ), + violation_policy: ViolationPolicy::default(), + consecutive_failures: HashMap::new(), + tool_failures: HashMap::new(), + last_tool_errors: HashMap::new(), + pending_reminders: HashMap::new(), + shame_wall_path: None, + } + } + + /// Create a runtime that persists the shame wall registry to `path`. + /// + /// An existing registry is loaded at startup; a missing or unparseable + /// file falls back to an empty registry (the failure is logged, not fatal). + pub fn with_shame_wall_path(session_manager: Arc, path: PathBuf) -> Self { + let mut runtime = Self::new(session_manager); + match ShameWallRegistry::load_from_path(&path) { + Ok(registry) => runtime.shame_wall = registry, + Err(err) => warn!( + "warden runtime: falling back to empty shame wall registry at {}: {}", + path.display(), + err + ), + } + runtime.shame_wall_path = Some(path); + runtime + } + + /// Replace the violation policy (thresholds for L1/L2/L3 penalties). + pub fn set_violation_policy(&mut self, policy: ViolationPolicy) { + self.violation_policy = policy; + } + + /// Replace the challenge-poke configuration (rate, seed, rule set). + pub fn set_challenge_config(&mut self, config: ChallengePokeConfig) { + self.challenge = config; + } + + /// Advance the global turn counter and evaluate the outcome. + /// + /// Called once per completed agent turn by the scheduler: + /// - `Failed` increments the session's consecutive-failure count and, when + /// the policy threshold is reached, executes a penalty (L1 → L2 → L3) + /// and queues the penalty reminders for the next turn. + /// - `Completed` clears the failure count and defer state. + /// - `Cancelled` is a no-op. + /// + /// A Challenge-Poke may fire on any turn, independently of the outcome. + pub async fn on_turn_outcome( + &mut self, + session_id: &str, + status: TurnOutcomeStatus, + turn_id: &str, + ) { + // R-26: the user-controllable RBAC/Warden master switch fully disables + // the Warden runtime (no failure tracking, no violation records, no + // reminders) when off. + if !crate::service::config::rbac_enabled() { + return; + } + + self.poke_priority.advance_turn(); + + match status { + TurnOutcomeStatus::Failed => { + self.handle_failed_turn(session_id, turn_id).await; + } + TurnOutcomeStatus::Completed => { + self.consecutive_failures + .retain(|(sid, _), _| sid != session_id); + // WARDEN-09: a completed turn also drops exploratory (count==0) + // tool-failure placeholders so a later failure after a + // completed turn starts a fresh exploration instead of + // inheriting a stale zero. Real counts (>= 1) are kept: a + // successful turn never resets an in-progress tool escalation + // ladder (tool/turn counters stay independent). + self.tool_failures + .retain(|(sid, _), count| sid != session_id || *count > 0); + self.poke_priority.reset_defer_count(session_id); + } + TurnOutcomeStatus::Cancelled => {} + } + + // Challenge-Poke fires on a Poisson schedule, outcome-independent. + if self.challenge.should_challenge() { + let poke = self + .challenge + .build_challenge_message(Uuid::new_v4().to_string()); + let text = serde_json::to_string(&poke) + .unwrap_or_else(|_| format_challenge_fallback(&poke)); + self.push_reminder( + session_id, + Message::internal_reminder(InternalReminderKind::ChallengePoke, text), + ); + } + } + + /// Evaluate one finished tool call of a session. + /// + /// Called by the tool pipeline on its custom point (outside the hook + /// dispatch channel, so `app.hooks.enabled` cannot gate it): + /// - `ExecutionFailed` increments the consecutive tool-failure count of + /// the `(session_id, scene_key)` scene and, when the policy threshold is + /// reached, executes a penalty (L1 → L2 → L3) with rule id + /// `warden.tool-failure`. The first failure of a scene is an + /// exploratory attempt and is not counted; only a repeated failure on + /// the same scene starts the ladder. + /// - `Success` clears the tool-failure count of that scene. + /// - `AdmissionRejected` (F3: stale/deferred gate or runtime-restriction + /// rejections) is a protocol-layer outcome, not an execution violation: + /// it is a deliberate no-op — neither counted nor clearing existing + /// counts — so a stale-tool wave cannot fire a penalty and cannot reset + /// a genuine escalation ladder in progress. + /// + /// `scene_key` identifies the failure scene (tool name + argument + /// fingerprint, see [`tool_failure_scene_key`]) so failures of different + /// scenes count independently. + /// + /// Tool-level violations are independent of the turn-level counter; a + /// successful turn never resets the tool counter and a successful tool + /// never resets the turn counter. Challenge-Poke is not triggered here. + pub async fn on_tool_outcome( + &mut self, + session_id: &str, + tool_name: &str, + scene_key: &str, + failure_kind: WardenToolOutcome, + ) { + // R-26: master switch off disables tool-level Warden tracking. + if !crate::service::config::rbac_enabled() { + return; + } + + match failure_kind { + WardenToolOutcome::Success => { + self.tool_failures + .remove(&(session_id.to_string(), scene_key.to_string())); + } + WardenToolOutcome::AdmissionRejected => { + // Protocol-layer rejection: not an execution violation, and + // deliberately neutral to any in-progress escalation ladder. + } + WardenToolOutcome::ExecutionFailed => { + self.handle_failed_tool(session_id, tool_name, scene_key) + .await; + } + } + } + + /// Take (and clear) the queued reminders for `session_id`. + pub fn take_pending_reminders(&mut self, session_id: &str) -> Vec { + self.pending_reminders.remove(session_id).unwrap_or_default() + } + + /// Drop all per-session Warden state for `session_id` (session-end cleanup). + /// + /// Clears failure counters (all scenes), last-error evidence, queued + /// reminders and poke defer state so a recycled session id cannot inherit + /// stale enforcement state. The shame wall registry is a historical + /// record keyed by session name and is intentionally preserved. + pub fn cleanup_session(&mut self, session_id: &str) { + self.clear_failure_counts(session_id); + self.pending_reminders.remove(session_id); + self.poke_priority.clear_session(session_id); + } + + /// Drop only the consecutive-failure counters (turn + tool) and the + /// last-error evidence of a session, keeping queued reminders and poke + /// defer state. + /// + /// Called when a session's thread goal leaves the active state so a later + /// goal generation starts from a clean ladder instead of inheriting the + /// previous goal's consecutive-failure count (WARDEN-01). Idempotent. + pub fn clear_failure_counts(&mut self, session_id: &str) { + self.consecutive_failures + .retain(|(sid, _), _| sid != session_id); + self.tool_failures.retain(|(sid, _), _| sid != session_id); + self.last_tool_errors + .retain(|(sid, _), _| sid != session_id); + } + + /// Current consecutive-failure count for a session (observation/test hook). + /// + /// With scene-scoped counting this reports the highest count across the + /// session's scenes — the count that drives the escalation ladder. + pub fn consecutive_failures(&self, session_id: &str) -> u32 { + max_failure_count_for_session(&self.consecutive_failures, session_id) + } + + /// Current consecutive tool-failure count for a session (observation/test hook). + /// + /// With scene-scoped counting this reports the highest count across the + /// session's tool-failure scenes. + pub fn tool_failures(&self, session_id: &str) -> u32 { + max_failure_count_for_session(&self.tool_failures, session_id) + } + + /// Current consecutive tool-failure count of a single scene + /// (observation/test hook, and model-judgement evidence source). + pub fn tool_failures_for_scene(&self, session_id: &str, scene_key: &str) -> u32 { + self.tool_failures + .get(&(session_id.to_string(), scene_key.to_string())) + .copied() + .unwrap_or(0) + } + + /// Last recorded error summary of a tool-failure scene (judgement evidence). + pub fn last_tool_error(&self, session_id: &str, scene_key: &str) -> Option<&str> { + self.last_tool_errors + .get(&(session_id.to_string(), scene_key.to_string())) + .map(String::as_str) + } + + /// Record the error summary of a failed tool call for later judgement + /// evidence (WARDEN-03). Kept until the session is cleaned up or the goal + /// leaves the active state ([`Self::clear_failure_counts`]). + pub fn record_tool_error(&mut self, session_id: &str, scene_key: &str, error_summary: &str) { + self.last_tool_errors.insert( + (session_id.to_string(), scene_key.to_string()), + error_summary.to_string(), + ); + } + + /// Current shame wall registry (observation/test hook). + pub fn shame_wall(&self) -> &ShameWallRegistry { + &self.shame_wall + } + + /// Current global turn counter (observation/test hook). + pub fn current_turn(&self) -> u64 { + self.poke_priority.current_turn() + } + + async fn handle_failed_turn(&mut self, session_id: &str, turn_id: &str) { + // Turn outcomes carry no phase/target facts in the current hook + // signature, so all turn failures share the single "turn" scene. When + // a phase/target fingerprint becomes available at the call site it can + // be passed through without changing the counting model. + let count = + bump_scene_failure(&mut self.consecutive_failures, session_id, TURN_SCENE_KEY); + + let Some(level) = self.violation_policy.level_for(count) else { + return; + }; + + self.apply_violation_penalty( + session_id, + "warden.consecutive-failure", + format!( + "turn failed (turn_id={}, scene={}, consecutive_failures={})", + turn_id, TURN_SCENE_KEY, count + ), + serde_json::json!({ + "turn_id": turn_id, + "status": TurnOutcomeStatus::Failed.as_str(), + "scene": TURN_SCENE_KEY, + "consecutive_failures": count, + }), + &level, + ) + .await; + } + + async fn handle_failed_tool(&mut self, session_id: &str, tool_name: &str, scene_key: &str) { + let count = bump_scene_failure(&mut self.tool_failures, session_id, scene_key); + + let Some(level) = self.violation_policy.level_for(count) else { + return; + }; + + self.apply_violation_penalty( + session_id, + "warden.tool-failure", + format!( + "tool failed (tool={}, scene={}, consecutive_tool_failures={})", + tool_name, scene_key, count + ), + serde_json::json!({ + "tool_name": tool_name, + "scene": scene_key, + "consecutive_tool_failures": count, + }), + &level, + ) + .await; + } + + async fn apply_violation_penalty( + &mut self, + session_id: &str, + rule_id: &str, + description: String, + evidence: serde_json::Value, + level: &PenaltyLevel, + ) { + let now = Utc::now().to_rfc3339(); + let request = PenaltyRequest { + target_session_id: session_id.to_string(), + level: level.clone(), + violations: vec![ViolationRecord { + rule_id: rule_id.to_string(), + description, + severity: severity_for_level(level).to_string(), + timestamp: now.clone(), + evidence, + }], + requested_by: WARDEN_RUNTIME_SESSION.to_string(), + }; + + match self + .punisher + .execute_penalty(request, &mut self.shame_wall, &now) + .await + { + Ok(outcome) => { + for reminder in outcome.prepended_reminders { + self.push_reminder( + session_id, + Message::internal_reminder(InternalReminderKind::PokePenalty, reminder.text), + ); + } + // WARDEN-10: the `notify_user` flag on the outcome must not be + // a dead field. The core has no direct UI channel, so an + // escalation that requires user awareness (L3/L4) is delivered + // through the observability/logging channel at warn level — + // the same surface hosts watch for discipline escalations. + if outcome.notify_user { + warn!( + "warden escalation delivered for user awareness: session={}, level={:?}", + session_id, outcome.level + ); + } + if let Some(path) = &self.shame_wall_path { + if let Err(err) = self.shame_wall.save_to_path(path) { + warn!( + "warden runtime: failed to persist shame wall at {}: {}", + path.display(), + err + ); + } + } + } + Err(err) => { + warn!( + "warden runtime: penalty failed for session '{}' (level={:?}): {}", + session_id, level, err + ); + } + } + } + + fn push_reminder(&mut self, session_id: &str, message: Message) { + self.pending_reminders + .entry(session_id.to_string()) + .or_default() + .push(message); + } +} + +/// Scene key shared by all turn-level failures. +/// +/// The current `on_turn_outcome` signature carries no phase/target facts, so +/// turn failures deliberately form a single scene; scene-scoped counting +/// still applies (the first turn failure of a session is exploratory). +/// +/// WARDEN-11: this is the **turn level** of the first-failure rule. The +/// distinct **tool level** (per scene) is documented on +/// [`WardenRuntime::on_tool_outcome`]; the two levels never reset each other. +const TURN_SCENE_KEY: &str = "turn"; + +/// Count one failure for a scene. +/// +/// Shared by both the turn level (scene = `TURN_SCENE_KEY`) and the tool +/// level (scene = tool name + argument fingerprint). In both levels the first +/// failure of a scene is treated as an exploratory (verification) attempt and +/// is not counted; only a repeated failure on the same scene starts the +/// consecutive ladder at 1. Returns the scene's failure count after the +/// update. +fn bump_scene_failure( + map: &mut HashMap<(String, String), u32>, + session_id: &str, + scene_key: &str, +) -> u32 { + let key = (session_id.to_string(), scene_key.to_string()); + match map.get_mut(&key) { + Some(count) => { + *count += 1; + *count + } + None => { + map.insert(key, 0); + 0 + } + } +} + +/// Highest failure count across all scenes of a session (observation hook). +fn max_failure_count_for_session( + map: &HashMap<(String, String), u32>, + session_id: &str, +) -> u32 { + map.iter() + .filter(|((sid, _), _)| sid == session_id) + .map(|(_, count)| *count) + .max() + .unwrap_or(0) +} + +/// Upper bound for the summarized tool arguments sent to a model judgement. +/// +/// The judgement prompt only needs the argument *shape* plus a marker that a +/// payload existed; a pathological argument must not blow the prompt budget +/// or leak large content to the model (WARDEN-08). +const WARDEN_JUDGEMENT_ARGS_MAX_CHARS: usize = 2048; + +/// Argument keys whose value is treated as bulk content. +/// +/// The full value is never embedded in scene fingerprints or judgement +/// prompts; only a length + deterministic hash marker is used (WARDEN-04 / +/// WARDEN-08). Conservative by design: a misclassified key only makes the +/// fingerprint slightly coarser, never leaks content. +pub(crate) fn is_content_like_key(key: &str) -> bool { + matches!( + key, + "content" + | "file_content" + | "text" + | "input_text" + | "body" + | "data" + | "payload" + | "code" + | "html" + | "script" + | "prompt" + ) +} + +/// Deterministic FNV-1a hash over the serialized value. +/// +/// Stable across runs (unlike `DefaultHasher`, which is randomly seeded) so a +/// scene fingerprint computed on one run matches one computed later. +fn content_fingerprint(value: &serde_json::Value) -> u64 { + let bytes = serde_json::to_string(value).unwrap_or_default(); + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for byte in bytes.bytes() { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + hash +} + +/// Serialized length of a value (fingerprint input; 0 on a serialization +/// failure that cannot realistically happen for JSON values). +fn content_len(value: &serde_json::Value) -> usize { + serde_json::to_string(value) + .map(|s| s.len()) + .unwrap_or(0) +} + +/// Scalar representation of a non-nested JSON value, used verbatim in the +/// scene fingerprint. Nested values (objects/arrays) return `None` and are +/// fingerprinted by length + hash instead. +fn scalar_value(value: &serde_json::Value) -> Option { + match value { + serde_json::Value::Null => Some("null".to_string()), + serde_json::Value::Bool(b) => Some(b.to_string()), + serde_json::Value::Number(n) => Some(n.to_string()), + serde_json::Value::String(s) => Some(s.clone()), + serde_json::Value::Array(_) | serde_json::Value::Object(_) => None, + } +} + +/// Build the tool-failure scene key: tool name plus a structural fingerprint +/// of the effective arguments. +/// +/// The fingerprint is `tool_name` + sorted argument keys + non-content +/// scalar values + length & deterministic hash for content-like and nested +/// values (WARDEN-04). Unlike a truncated serialization it cannot collapse +/// two large payloads that share a prefix into one scene, and it never +/// embeds bulk content in the key. Distinct argument shapes are distinct +/// scenes, so the first failure of a new argument shape stays exploratory +/// instead of inheriting an in-progress escalation ladder from another shape. +pub fn tool_failure_scene_key(tool_name: &str, arguments: &serde_json::Value) -> String { + let mut parts: Vec = Vec::new(); + match arguments { + serde_json::Value::Object(map) => { + let mut keys: Vec<&String> = map.keys().collect(); + keys.sort(); + for key in keys { + let value = &map[key]; + if is_content_like_key(key) { + parts.push(format!( + "{key}=", + content_len(value), + content_fingerprint(value) + )); + } else if let Some(scalar) = scalar_value(value) { + parts.push(format!("{key}={scalar}")); + } else { + parts.push(format!( + "{key}=", + content_len(value), + content_fingerprint(value) + )); + } + } + } + serde_json::Value::Array(items) => { + parts.push(format!( + "array=", + items.len(), + content_len(arguments), + content_fingerprint(arguments) + )); + } + serde_json::Value::Null => parts.push("null".to_string()), + scalar => { + if let Some(value) = scalar_value(scalar) { + parts.push(value); + } + } + } + format!("{tool_name}:{}", parts.join("&")) +} + +/// Summarize tool arguments for a model judgement request (WARDEN-08). +/// +/// Content-like values are replaced by a `{ "contentLength": N }` marker and +/// the whole summary is capped, so the model sees the argument shape without +/// receiving large or sensitive payloads. Returns `None` only for a `null` +/// argument (the caller keeps `tool_args` absent in that case). +pub fn summarize_judgement_tool_args(arguments: &serde_json::Value) -> Option { + match arguments { + serde_json::Value::Object(map) => { + let mut out = serde_json::Map::new(); + let mut keys: Vec<&String> = map.keys().collect(); + keys.sort(); + for key in keys { + let value = &map[key]; + if is_content_like_key(key) { + out.insert( + key.clone(), + serde_json::json!({ "contentLength": content_len(value) }), + ); + } else { + out.insert(key.clone(), value.clone()); + } + } + Some(cap_summary(serde_json::Value::Object(out))) + } + serde_json::Value::Null => None, + other => Some(cap_summary(other.clone())), + } +} + +/// Cap a summarized argument value to [`WARDEN_JUDGEMENT_ARGS_MAX_CHARS`], +/// replacing an oversized payload with a length marker. +fn cap_summary(value: serde_json::Value) -> serde_json::Value { + let serialized = serde_json::to_string(&value).unwrap_or_default(); + if serialized.len() > WARDEN_JUDGEMENT_ARGS_MAX_CHARS { + serde_json::json!({ + "summaryLength": serialized.len(), + "truncated": true, + }) + } else { + value + } +} + +/// Batch-2 goal switch: whether Warden enforcement applies for a goal lookup. +/// +/// Only an explicitly active goal (including `BudgetLimited`, see +/// [`ThreadGoal::is_active`]) keeps the Warden hooks running; a missing goal +/// or a `Paused`/`Blocked`/`Complete` goal opts the session out of +/// consecutive-failure accounting and pokes. +pub fn warden_enforcement_for_goal(goal: Option<&ThreadGoal>) -> bool { + goal.is_some_and(ThreadGoal::is_active) +} + +/// Resolve the final Audit-Poke message from a model judgement verdict. +/// +/// `None` means the model declined the poke (no Audit-Poke is sent). `Some` +/// carries the poke with the model-selected rule ids and requested evidence, +/// falling back to the mechanical candidates when the model returned none. +/// The poke id, type and 3-turn deadline always come from the mechanical +/// message so the audit contract stays stable across providers. +pub fn resolve_audit_poke_from_judgement( + mechanical: &PokeMessage, + judgement: &WardenAuditJudgementResponse, +) -> Option { + if !judgement.should_poke { + return None; + } + let rule_ids = if judgement.rule_ids.is_empty() { + mechanical.rule_ids.clone() + } else { + judgement.rule_ids.clone() + }; + let evidence_required = if judgement.evidence_requested.is_empty() { + mechanical.evidence_required.clone() + } else { + Some(judgement.evidence_requested.clone()) + }; + Some(PokeMessage { + poke_id: mechanical.poke_id.clone(), + poke_type: PokeType::Audit, + rule_ids, + deadline_turns: 3, + evidence_required, + }) +} + +/// Human-readable fallback for a Challenge-Poke message (used only if JSON +/// serialization unexpectedly fails). +fn format_challenge_fallback(poke: &PokeMessage) -> String { + format!( + "[Challenge-Poke {}] rules={} deadline={} turns", + poke.poke_id, + poke.rule_ids.join(","), + poke.deadline_turns + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agentic::core::MessageContent; + use bitfun_runtime_ports::ThreadGoalStatus; + use std::collections::BTreeSet; + + fn runtime() -> WardenRuntime { + // verify_warden_session short-circuits the warden-runtime source, so + // no real SessionManager-backed session is required for penalties. + WardenRuntime::new(test_session_manager()) + } + + fn test_session_manager() -> Arc { + use crate::agentic::persistence::PersistenceManager; + use crate::agentic::session::{ + PromptCachePolicy, SessionContextStore, SessionManagerConfig, + }; + use crate::infrastructure::app_paths::PathManager; + use std::time::Duration; + + let root = std::env::temp_dir().join(format!("bitfun-warden-test-{}", Uuid::new_v4())); + let path_manager = Arc::new(PathManager::with_user_root_for_tests(root.join("user-root"))); + let persistence_manager = + Arc::new(PersistenceManager::new(path_manager).expect("persistence manager")); + Arc::new(SessionManager::new( + Arc::new(SessionContextStore::new()), + persistence_manager, + SessionManagerConfig { + max_active_sessions: 100, + session_idle_timeout: Duration::from_secs(3600), + auto_save_interval: Duration::from_secs(300), + enable_persistence: false, + prompt_cache_policy: PromptCachePolicy::default(), + }, + )) + } + + #[test] + fn violation_policy_default_ladder() { + let policy = ViolationPolicy::default(); + assert_eq!(policy.level_for(0), None); + assert_eq!(policy.level_for(1), Some(PenaltyLevel::L1)); + assert_eq!(policy.level_for(2), Some(PenaltyLevel::L2)); + assert_eq!(policy.level_for(3), Some(PenaltyLevel::L3)); + assert_eq!(policy.level_for(9), Some(PenaltyLevel::L3)); + } + + #[test] + fn violation_policy_custom_thresholds() { + let policy = ViolationPolicy { + l1_at: 3, + l2_at: 5, + l3_at: 7, + }; + assert_eq!(policy.level_for(2), None); + assert_eq!(policy.level_for(3), Some(PenaltyLevel::L1)); + assert_eq!(policy.level_for(5), Some(PenaltyLevel::L2)); + assert_eq!(policy.level_for(7), Some(PenaltyLevel::L3)); + } + + #[tokio::test] + async fn consecutive_failures_escalate_l1_l2_l3() { + let mut rt = runtime(); + // Challenge disabled for deterministic penalty assertions. + rt.set_challenge_config(ChallengePokeConfig::new( + f64::INFINITY, + 1, + BTreeSet::new(), + )); + + // The first failure of a session is exploratory and is not counted. + rt.on_turn_outcome("sess-a", TurnOutcomeStatus::Failed, "t0").await; + assert_eq!(rt.consecutive_failures("sess-a"), 0); + assert!( + rt.take_pending_reminders("sess-a").is_empty(), + "no penalty for the exploratory first failure" + ); + + rt.on_turn_outcome("sess-a", TurnOutcomeStatus::Failed, "t1").await; + assert_eq!(rt.consecutive_failures("sess-a"), 1); + let reminders = rt.take_pending_reminders("sess-a"); + assert_eq!(reminders.len(), 1, "L1 fires on the repeated failure"); + assert_eq!( + rt.shame_wall().entry_for_session("sess-a").unwrap().cumulative_penalty_level, + PenaltyLevel::L1 + ); + + rt.on_turn_outcome("sess-a", TurnOutcomeStatus::Failed, "t2").await; + assert_eq!(rt.consecutive_failures("sess-a"), 2); + let reminders = rt.take_pending_reminders("sess-a"); + assert_eq!(reminders.len(), 1, "L2 fires on the third failure"); + assert_eq!( + rt.shame_wall().entry_for_session("sess-a").unwrap().cumulative_penalty_level, + PenaltyLevel::L2 + ); + + rt.on_turn_outcome("sess-a", TurnOutcomeStatus::Failed, "t3").await; + assert_eq!(rt.consecutive_failures("sess-a"), 3); + let reminders = rt.take_pending_reminders("sess-a"); + assert_eq!(reminders.len(), 1, "L3 fires on the fourth failure"); + assert_eq!( + rt.shame_wall().entry_for_session("sess-a").unwrap().cumulative_penalty_level, + PenaltyLevel::L3 + ); + } + + #[tokio::test] + async fn completed_turn_resets_failure_state() { + let mut rt = runtime(); + rt.set_challenge_config(ChallengePokeConfig::new( + f64::INFINITY, + 1, + BTreeSet::new(), + )); + + // Two failures: first exploratory (not counted), second fires L1. + rt.on_turn_outcome("sess-b", TurnOutcomeStatus::Failed, "t1").await; + assert_eq!(rt.consecutive_failures("sess-b"), 0); + rt.on_turn_outcome("sess-b", TurnOutcomeStatus::Failed, "t2").await; + assert_eq!(rt.consecutive_failures("sess-b"), 1); + rt.take_pending_reminders("sess-b"); + + rt.on_turn_outcome("sess-b", TurnOutcomeStatus::Completed, "t3").await; + assert_eq!(rt.consecutive_failures("sess-b"), 0, "completed resets failures"); + + // Next failure starts exploratory again: two failures reach L1. + rt.on_turn_outcome("sess-b", TurnOutcomeStatus::Failed, "t4").await; + assert_eq!(rt.consecutive_failures("sess-b"), 0, "first failure after reset is exploratory"); + rt.on_turn_outcome("sess-b", TurnOutcomeStatus::Failed, "t5").await; + assert_eq!(rt.consecutive_failures("sess-b"), 1); + assert_eq!( + rt.shame_wall().entry_for_session("sess-b").unwrap().cumulative_penalty_level, + PenaltyLevel::L1 + ); + } + + #[tokio::test] + async fn challenge_poke_fires_with_rate_one() { + let mut rt = runtime(); + // rate=1.0 -> every turn pokes deterministically. + rt.set_challenge_config(ChallengePokeConfig::new( + 1.0, + 7, + BTreeSet::from(["iron-rules-compliance".to_string()]), + )); + + rt.on_turn_outcome("sess-c", TurnOutcomeStatus::Completed, "t1").await; + let reminders = rt.take_pending_reminders("sess-c"); + assert_eq!(reminders.len(), 1, "rate=1.0 must poke every turn"); + let MessageContent::Text(text) = &reminders[0].content else { + panic!("challenge reminder must be a text message"); + }; + assert!( + text.to_lowercase().contains("challenge"), + "challenge poke must be serialized, got: {text}" + ); + } + + #[tokio::test] + async fn pending_reminders_take_is_destructive() { + let mut rt = runtime(); + rt.set_challenge_config(ChallengePokeConfig::new( + 1.0, + 7, + BTreeSet::from(["iron-rules-compliance".to_string()]), + )); + + rt.on_turn_outcome("sess-d", TurnOutcomeStatus::Completed, "t1").await; + let first = rt.take_pending_reminders("sess-d"); + assert_eq!(first.len(), 1); + let second = rt.take_pending_reminders("sess-d"); + assert!(second.is_empty(), "take clears the queue"); + } + + #[tokio::test] + async fn cleanup_session_drops_all_per_session_state() { + let mut rt = runtime(); + rt.set_challenge_config(ChallengePokeConfig::new( + f64::INFINITY, + 1, + BTreeSet::new(), + )); + + // Build per-session state: failure counters, tool failures, reminders. + rt.on_turn_outcome("sess-e", TurnOutcomeStatus::Failed, "t1").await; + rt.on_turn_outcome("sess-e", TurnOutcomeStatus::Failed, "t2").await; + assert_eq!(rt.consecutive_failures("sess-e"), 1); + rt.on_tool_outcome("sess-e", "Write", "Write:{}", WardenToolOutcome::ExecutionFailed) + .await; + rt.on_tool_outcome("sess-e", "Write", "Write:{}", WardenToolOutcome::ExecutionFailed) + .await; + assert_eq!(rt.tool_failures("sess-e"), 1); + // Failure paths above queue escalation reminders; drain them so the + // count below covers only the explicit push. + rt.take_pending_reminders("sess-e"); + rt.push_reminder( + "sess-e", + Message::internal_reminder(InternalReminderKind::PokePenalty, "penalty"), + ); + assert_eq!(rt.take_pending_reminders("sess-e").len(), 1); + // A sibling session must be untouched. + rt.on_turn_outcome("sess-f", TurnOutcomeStatus::Failed, "t1").await; + rt.on_turn_outcome("sess-f", TurnOutcomeStatus::Failed, "t2").await; + assert_eq!(rt.consecutive_failures("sess-f"), 1); + + rt.cleanup_session("sess-e"); + assert_eq!(rt.consecutive_failures("sess-e"), 0, "failures cleared"); + assert_eq!(rt.tool_failures("sess-e"), 0, "tool failures cleared"); + assert!( + rt.take_pending_reminders("sess-e").is_empty(), + "reminders cleared" + ); + assert_eq!(rt.consecutive_failures("sess-f"), 1, "sibling untouched"); + + // Idempotent: clearing a session with no state is a no-op. + rt.cleanup_session("sess-e"); + } + + #[tokio::test] + async fn shame_wall_persistence_round_trip() { + let dir = std::env::temp_dir().join(format!("warden-test-{}", Uuid::new_v4())); + let path = dir.join("shame-wall-registry.json"); + + { + let mut rt = WardenRuntime::with_shame_wall_path(test_session_manager(), path.clone()); + rt.set_challenge_config(ChallengePokeConfig::new( + f64::INFINITY, + 1, + BTreeSet::new(), + )); + rt.on_turn_outcome("sess-e", TurnOutcomeStatus::Failed, "t1").await; + // The first failure is exploratory; the second fires L1 and + // persists the registry. + rt.on_turn_outcome("sess-e", TurnOutcomeStatus::Failed, "t2").await; + rt.take_pending_reminders("sess-e"); + assert!(path.exists(), "penalty must persist the registry"); + } + + // A second runtime loads the persisted registry. + let rt = WardenRuntime::with_shame_wall_path(test_session_manager(), path.clone()); + assert_eq!( + rt.shame_wall().entry_for_session("sess-e").unwrap().cumulative_penalty_level, + PenaltyLevel::L1, + "loaded registry keeps the recorded penalty" + ); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[tokio::test] + async fn missing_shame_wall_file_starts_empty() { + let dir = std::env::temp_dir().join(format!("warden-test-missing-{}", Uuid::new_v4())); + let path = dir.join("shame-wall-registry.json"); + let rt = WardenRuntime::with_shame_wall_path(test_session_manager(), path.clone()); + assert!(rt.shame_wall().entries.is_empty(), "missing file -> empty registry"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[tokio::test] + async fn tool_failures_escalate_l1_l2_l3() { + let mut rt = runtime(); + // Challenge disabled for deterministic penalty assertions. + rt.set_challenge_config(ChallengePokeConfig::new( + f64::INFINITY, + 1, + BTreeSet::new(), + )); + + let scene = tool_failure_scene_key("ExecCommand", &serde_json::json!({"cmd": "pwd"})); + // The first failure of a scene is exploratory and is not counted. + rt.on_tool_outcome("sess-f", "ExecCommand", &scene, WardenToolOutcome::ExecutionFailed) + .await; + assert_eq!(rt.tool_failures("sess-f"), 0); + assert!( + rt.take_pending_reminders("sess-f").is_empty(), + "no penalty for the exploratory first failure" + ); + + rt.on_tool_outcome("sess-f", "ExecCommand", &scene, WardenToolOutcome::ExecutionFailed) + .await; + assert_eq!(rt.tool_failures("sess-f"), 1); + let reminders = rt.take_pending_reminders("sess-f"); + assert_eq!(reminders.len(), 1, "L1 fires on the repeated failure"); + assert_eq!( + rt.shame_wall().entry_for_session("sess-f").unwrap().cumulative_penalty_level, + PenaltyLevel::L1 + ); + + rt.on_tool_outcome("sess-f", "ExecCommand", &scene, WardenToolOutcome::ExecutionFailed) + .await; + assert_eq!(rt.tool_failures("sess-f"), 2); + let reminders = rt.take_pending_reminders("sess-f"); + assert_eq!(reminders.len(), 1, "L2 fires on the third failure"); + assert_eq!( + rt.shame_wall().entry_for_session("sess-f").unwrap().cumulative_penalty_level, + PenaltyLevel::L2 + ); + + rt.on_tool_outcome("sess-f", "ExecCommand", &scene, WardenToolOutcome::ExecutionFailed) + .await; + assert_eq!(rt.tool_failures("sess-f"), 3); + let reminders = rt.take_pending_reminders("sess-f"); + assert_eq!(reminders.len(), 1, "L3 fires on the fourth failure"); + assert_eq!( + rt.shame_wall().entry_for_session("sess-f").unwrap().cumulative_penalty_level, + PenaltyLevel::L3 + ); + } + + #[tokio::test] + async fn successful_tool_resets_failure_count() { + let mut rt = runtime(); + rt.set_challenge_config(ChallengePokeConfig::new( + f64::INFINITY, + 1, + BTreeSet::new(), + )); + + let scene = tool_failure_scene_key("ExecCommand", &serde_json::json!({"cmd": "pwd"})); + // Two failures: first exploratory, second fires L1. + rt.on_tool_outcome("sess-g", "ExecCommand", &scene, WardenToolOutcome::ExecutionFailed) + .await; + rt.on_tool_outcome("sess-g", "ExecCommand", &scene, WardenToolOutcome::ExecutionFailed) + .await; + assert_eq!(rt.tool_failures("sess-g"), 1); + rt.take_pending_reminders("sess-g"); + + rt.on_tool_outcome("sess-g", "ExecCommand", &scene, WardenToolOutcome::Success).await; + assert_eq!(rt.tool_failures("sess-g"), 0, "success clears tool failures"); + + // Next failure starts exploratory again: two failures reach L1. + rt.on_tool_outcome("sess-g", "ExecCommand", &scene, WardenToolOutcome::ExecutionFailed) + .await; + assert_eq!( + rt.tool_failures("sess-g"), + 0, + "first failure after reset is exploratory" + ); + rt.on_tool_outcome("sess-g", "ExecCommand", &scene, WardenToolOutcome::ExecutionFailed) + .await; + assert_eq!(rt.tool_failures("sess-g"), 1); + assert_eq!( + rt.shame_wall().entry_for_session("sess-g").unwrap().cumulative_penalty_level, + PenaltyLevel::L1 + ); + } + + #[tokio::test] + async fn tool_failures_independent_from_turn_failures() { + let mut rt = runtime(); + rt.set_challenge_config(ChallengePokeConfig::new( + f64::INFINITY, + 1, + BTreeSet::new(), + )); + + let scene = tool_failure_scene_key("ExecCommand", &serde_json::json!({"cmd": "pwd"})); + // Two failed turns: turn counter = 1, tool counter untouched. + rt.on_turn_outcome("sess-h", TurnOutcomeStatus::Failed, "t1").await; + rt.on_turn_outcome("sess-h", TurnOutcomeStatus::Failed, "t2").await; + rt.take_pending_reminders("sess-h"); + assert_eq!(rt.consecutive_failures("sess-h"), 1); + assert_eq!(rt.tool_failures("sess-h"), 0, "tool counter untouched by turn failure"); + + // A successful tool must not reset the turn counter. + rt.on_tool_outcome("sess-h", "ExecCommand", &scene, WardenToolOutcome::Success).await; + assert_eq!(rt.consecutive_failures("sess-h"), 1, "turn counter unaffected by tool success"); + + // Two failed tools increment only the tool counter. + rt.on_tool_outcome("sess-h", "ExecCommand", &scene, WardenToolOutcome::ExecutionFailed) + .await; + rt.on_tool_outcome("sess-h", "ExecCommand", &scene, WardenToolOutcome::ExecutionFailed) + .await; + rt.take_pending_reminders("sess-h"); + assert_eq!(rt.tool_failures("sess-h"), 1); + assert_eq!(rt.consecutive_failures("sess-h"), 1, "tool failure does not touch turn counter"); + } + + #[tokio::test] + async fn admission_rejected_never_counts_as_tool_failure() { + let mut rt = runtime(); + rt.set_challenge_config(ChallengePokeConfig::new( + f64::INFINITY, + 1, + BTreeSet::new(), + )); + + rt.on_tool_outcome("sess-i", "ExecCommand", "ExecCommand:{}", WardenToolOutcome::AdmissionRejected) + .await; + assert_eq!( + rt.tool_failures("sess-i"), + 0, + "F3: admission rejection is not an execution violation" + ); + assert!( + rt.take_pending_reminders("sess-i").is_empty(), + "no penalty reminder for admission rejection" + ); + assert!( + rt.shame_wall().entry_for_session("sess-i").is_none(), + "no shame-wall record for admission rejection" + ); + } + + #[tokio::test] + async fn admission_rejected_is_neutral_to_in_progress_escalation_ladder() { + let mut rt = runtime(); + rt.set_challenge_config(ChallengePokeConfig::new( + f64::INFINITY, + 1, + BTreeSet::new(), + )); + + let scene = tool_failure_scene_key("ExecCommand", &serde_json::json!({"cmd": "pwd"})); + // Two real failures: first exploratory, second fires L1; ladder in progress. + rt.on_tool_outcome("sess-j", "ExecCommand", &scene, WardenToolOutcome::ExecutionFailed) + .await; + rt.on_tool_outcome("sess-j", "ExecCommand", &scene, WardenToolOutcome::ExecutionFailed) + .await; + assert_eq!(rt.tool_failures("sess-j"), 1); + rt.take_pending_reminders("sess-j"); + + // F3: a stale/admission-rejected wave must not reset the ladder... + rt.on_tool_outcome("sess-j", "ExecCommand", &scene, WardenToolOutcome::AdmissionRejected) + .await; + assert_eq!( + rt.tool_failures("sess-j"), + 1, + "admission rejection is a no-op, not a success" + ); + + // ...and the next real failure still escalates to L2. + rt.on_tool_outcome("sess-j", "ExecCommand", &scene, WardenToolOutcome::ExecutionFailed) + .await; + assert_eq!(rt.tool_failures("sess-j"), 2); + let reminders = rt.take_pending_reminders("sess-j"); + assert_eq!(reminders.len(), 1, "L2 fires on the third real failure"); + assert_eq!( + rt.shame_wall().entry_for_session("sess-j").unwrap().cumulative_penalty_level, + PenaltyLevel::L2 + ); + } + + #[tokio::test] + async fn first_turn_failure_of_session_is_exploratory_not_counted() { + let mut rt = runtime(); + rt.set_challenge_config(ChallengePokeConfig::new( + f64::INFINITY, + 1, + BTreeSet::new(), + )); + + rt.on_turn_outcome("sess-k", TurnOutcomeStatus::Failed, "t1").await; + assert_eq!( + rt.consecutive_failures("sess-k"), + 0, + "first turn failure of a session is exploratory" + ); + assert!( + rt.take_pending_reminders("sess-k").is_empty(), + "no penalty for the exploratory first turn failure" + ); + assert!( + rt.shame_wall().entry_for_session("sess-k").is_none(), + "no shame-wall record for the exploratory first turn failure" + ); + } + + #[tokio::test] + async fn tool_failures_on_different_scenes_count_independently() { + let mut rt = runtime(); + rt.set_challenge_config(ChallengePokeConfig::new( + f64::INFINITY, + 1, + BTreeSet::new(), + )); + + let scene_a = tool_failure_scene_key("ExecCommand", &serde_json::json!({"cmd": "pwd"})); + let scene_b = tool_failure_scene_key("ExecCommand", &serde_json::json!({"cmd": "ls"})); + assert_ne!(scene_a, scene_b, "different arguments must be different scenes"); + + // Two failures on scene A: first exploratory, second counted. + rt.on_tool_outcome("sess-l", "ExecCommand", &scene_a, WardenToolOutcome::ExecutionFailed) + .await; + rt.on_tool_outcome("sess-l", "ExecCommand", &scene_a, WardenToolOutcome::ExecutionFailed) + .await; + assert_eq!(rt.tool_failures("sess-l"), 1, "scene A ladder at 1"); + rt.take_pending_reminders("sess-l"); + + // A first failure on scene B stays exploratory and must not touch A. + rt.on_tool_outcome("sess-l", "ExecCommand", &scene_b, WardenToolOutcome::ExecutionFailed) + .await; + assert_eq!(rt.tool_failures("sess-l"), 1, "scene B exploratory, A ladder unchanged"); + assert!( + rt.take_pending_reminders("sess-l").is_empty(), + "no penalty for the exploratory scene-B failure" + ); + + // The second scene-B failure starts its own ladder at 1 (fires L1). + rt.on_tool_outcome("sess-l", "ExecCommand", &scene_b, WardenToolOutcome::ExecutionFailed) + .await; + assert_eq!(rt.tool_failures("sess-l"), 1, "scene B ladder at 1"); + rt.take_pending_reminders("sess-l"); + + // Scene A keeps escalating independently. + rt.on_tool_outcome("sess-l", "ExecCommand", &scene_a, WardenToolOutcome::ExecutionFailed) + .await; + assert_eq!(rt.tool_failures("sess-l"), 2, "scene A ladder escalates to 2"); + let reminders = rt.take_pending_reminders("sess-l"); + assert_eq!(reminders.len(), 1, "scene A third failure fires L2"); + assert_eq!( + rt.shame_wall().entry_for_session("sess-l").unwrap().cumulative_penalty_level, + PenaltyLevel::L2 + ); + } + + #[test] + fn tool_failure_scene_key_fingerprints_tool_and_arguments() { + let args = serde_json::json!({"file_path": "a.md", "content": "x"}); + assert_eq!( + tool_failure_scene_key("Write", &args), + tool_failure_scene_key("Write", &args), + "same tool + same arguments -> same scene" + ); + assert_ne!( + tool_failure_scene_key("Write", &args), + tool_failure_scene_key("Read", &args), + "different tool -> different scene" + ); + assert_ne!( + tool_failure_scene_key("Write", &args), + tool_failure_scene_key("Write", &serde_json::json!({"file_path": "b.md"})), + "different arguments -> different scene" + ); + assert_ne!( + tool_failure_scene_key("Write", &serde_json::Value::Null), + tool_failure_scene_key("Write", &serde_json::json!({})), + "null vs empty object are distinct argument shapes" + ); + } + + #[tokio::test] + async fn cleanup_session_clears_all_scenes() { + let mut rt = runtime(); + rt.set_challenge_config(ChallengePokeConfig::new( + f64::INFINITY, + 1, + BTreeSet::new(), + )); + + let scene_a = tool_failure_scene_key("ExecCommand", &serde_json::json!({"cmd": "pwd"})); + let scene_b = tool_failure_scene_key("ExecCommand", &serde_json::json!({"cmd": "ls"})); + // Build two independent tool scenes plus a turn scene. + rt.on_tool_outcome("sess-m", "ExecCommand", &scene_a, WardenToolOutcome::ExecutionFailed) + .await; + rt.on_tool_outcome("sess-m", "ExecCommand", &scene_a, WardenToolOutcome::ExecutionFailed) + .await; + rt.on_tool_outcome("sess-m", "ExecCommand", &scene_b, WardenToolOutcome::ExecutionFailed) + .await; + rt.on_tool_outcome("sess-m", "ExecCommand", &scene_b, WardenToolOutcome::ExecutionFailed) + .await; + assert_eq!(rt.tool_failures("sess-m"), 1); + rt.on_turn_outcome("sess-m", TurnOutcomeStatus::Failed, "t1").await; + rt.on_turn_outcome("sess-m", TurnOutcomeStatus::Failed, "t2").await; + assert_eq!(rt.consecutive_failures("sess-m"), 1); + rt.take_pending_reminders("sess-m"); + + rt.cleanup_session("sess-m"); + assert_eq!(rt.consecutive_failures("sess-m"), 0, "all turn scenes cleared"); + assert_eq!(rt.tool_failures("sess-m"), 0, "all tool scenes cleared"); + } + + #[test] + fn warden_enforcement_applies_only_for_active_goal() { + let goal = |status| Some(ThreadGoal { + goal_id: "g1".to_string(), + session_id: "s1".to_string(), + objective: "ship".to_string(), + status, + token_budget: None, + tokens_used: 0, + time_used_seconds: 0, + created_at: 1, + updated_at: 2, + auto_continuation_count: 0, + reference_files: Vec::new(), + }); + + assert!( + warden_enforcement_for_goal(goal(ThreadGoalStatus::Active).as_ref()), + "active goal keeps Warden enforcement" + ); + assert!( + warden_enforcement_for_goal(goal(ThreadGoalStatus::BudgetLimited).as_ref()), + "budget-limited goal is still active" + ); + assert!( + !warden_enforcement_for_goal(goal(ThreadGoalStatus::Paused).as_ref()), + "paused goal opts out" + ); + assert!( + !warden_enforcement_for_goal(goal(ThreadGoalStatus::Blocked).as_ref()), + "blocked goal opts out" + ); + assert!( + !warden_enforcement_for_goal(goal(ThreadGoalStatus::UsageLimited).as_ref()), + "usage-limited goal opts out" + ); + assert!( + !warden_enforcement_for_goal(goal(ThreadGoalStatus::Complete).as_ref()), + "complete goal opts out" + ); + assert!( + !warden_enforcement_for_goal(None), + "goal-less session opts out" + ); + } + + #[test] + fn audit_poke_resolution_follows_model_verdict() { + let mechanical = PokeMessage { + poke_id: "audit-tool-42".to_string(), + poke_type: PokeType::Audit, + rule_ids: vec![ + "R1: no_destructive_write".to_string(), + "R3: path_whitelist".to_string(), + ], + deadline_turns: 3, + evidence_required: Some(vec![ + "tool_call_log".to_string(), + "phase_summary".to_string(), + ]), + }; + + // The model declined the poke: no Audit-Poke is sent. + let declined = WardenAuditJudgementResponse { + should_poke: false, + rule_ids: Vec::new(), + evidence_requested: Vec::new(), + }; + assert!(resolve_audit_poke_from_judgement(&mechanical, &declined).is_none()); + + // The model confirms the poke and selects its own rules/evidence. + let confirmed = WardenAuditJudgementResponse { + should_poke: true, + rule_ids: vec!["R2: execution_safety".to_string()], + evidence_requested: vec!["tool_call_log".to_string()], + }; + let poke = resolve_audit_poke_from_judgement(&mechanical, &confirmed) + .expect("confirmed poke is sent"); + assert_eq!(poke.poke_id, "audit-tool-42"); + assert_eq!(poke.poke_type, PokeType::Audit); + assert_eq!(poke.deadline_turns, 3); + assert_eq!(poke.rule_ids, vec!["R2: execution_safety"]); + assert_eq!( + poke.evidence_required, + Some(vec!["tool_call_log".to_string()]) + ); + + // The model confirms without rules: mechanical candidates carry over. + let bare_confirm = WardenAuditJudgementResponse { + should_poke: true, + rule_ids: Vec::new(), + evidence_requested: Vec::new(), + }; + let poke = resolve_audit_poke_from_judgement(&mechanical, &bare_confirm) + .expect("bare confirmation still pokes"); + assert_eq!( + poke.rule_ids, + vec!["R1: no_destructive_write", "R3: path_whitelist"], + "empty model rules fall back to mechanical candidates" + ); + assert_eq!(poke.evidence_required, mechanical.evidence_required); + } + + #[tokio::test] + async fn clear_failure_counts_resets_counters_across_goal_generations() { + // WARDEN-01: when a goal leaves the active state the failure counts + // must be dropped so a later (new) goal generation starts from a + // clean ladder instead of inheriting the previous goal's L2/L3 count. + let mut rt = runtime(); + rt.set_challenge_config(ChallengePokeConfig::new( + f64::INFINITY, + 1, + BTreeSet::new(), + )); + + let scene = tool_failure_scene_key("ExecCommand", &serde_json::json!({"cmd": "pwd"})); + // Build an in-progress escalation ladder: repeated turn + tool failures. + rt.on_turn_outcome("sess-goal", TurnOutcomeStatus::Failed, "t1").await; + rt.on_turn_outcome("sess-goal", TurnOutcomeStatus::Failed, "t2").await; + rt.on_turn_outcome("sess-goal", TurnOutcomeStatus::Failed, "t3").await; + rt.on_tool_outcome("sess-goal", "ExecCommand", &scene, WardenToolOutcome::ExecutionFailed) + .await; + rt.on_tool_outcome("sess-goal", "ExecCommand", &scene, WardenToolOutcome::ExecutionFailed) + .await; + rt.record_tool_error("sess-goal", &scene, "boom"); + rt.take_pending_reminders("sess-goal"); + assert_eq!(rt.consecutive_failures("sess-goal"), 2); + assert_eq!(rt.tool_failures_for_scene("sess-goal", &scene), 1); + assert!(rt.last_tool_error("sess-goal", &scene).is_some()); + + // The goal switched away: the gate calls clear_failure_counts. + rt.clear_failure_counts("sess-goal"); + assert_eq!(rt.consecutive_failures("sess-goal"), 0, "turn count cleared"); + assert_eq!( + rt.tool_failures_for_scene("sess-goal", &scene), + 0, + "tool count cleared" + ); + assert!( + rt.last_tool_error("sess-goal", &scene).is_none(), + "error evidence cleared" + ); + + // A sibling session is untouched. + assert_eq!(rt.consecutive_failures("sess-other"), 0); + } + + #[tokio::test] + async fn completed_turn_drops_exploratory_zero_tool_failure_placeholders() { + // WARDEN-09: an exploratory first tool failure leaves a count==0 + // placeholder; a completed turn must drop it so the next failure after + // a completed turn starts a fresh exploration (count 0) instead of + // inheriting the stale zero and immediately counting as a repeat. + let mut rt = runtime(); + rt.set_challenge_config(ChallengePokeConfig::new( + f64::INFINITY, + 1, + BTreeSet::new(), + )); + + let scene = tool_failure_scene_key("ExecCommand", &serde_json::json!({"cmd": "pwd"})); + rt.on_tool_outcome("sess-z", "ExecCommand", &scene, WardenToolOutcome::ExecutionFailed) + .await; + assert_eq!(rt.tool_failures_for_scene("sess-z", &scene), 0, "exploratory"); + + // A completed turn cleans the zero placeholder... + rt.on_turn_outcome("sess-z", TurnOutcomeStatus::Completed, "t1").await; + + // ...so the next same-scene failure is again exploratory (0), and only + // the failure after that counts toward L1. + rt.on_tool_outcome("sess-z", "ExecCommand", &scene, WardenToolOutcome::ExecutionFailed) + .await; + assert_eq!( + rt.tool_failures_for_scene("sess-z", &scene), + 0, + "stale zero was cleaned; a fresh exploration starts" + ); + rt.on_tool_outcome("sess-z", "ExecCommand", &scene, WardenToolOutcome::ExecutionFailed) + .await; + assert_eq!(rt.tool_failures_for_scene("sess-z", &scene), 1); + rt.take_pending_reminders("sess-z"); + } + + #[tokio::test] + async fn completed_turn_keeps_in_progress_tool_escalation_ladder() { + // The WARDEN-09 cleanup must not reset a real (>=1) tool ladder: tool + // and turn counters stay independent. + let mut rt = runtime(); + rt.set_challenge_config(ChallengePokeConfig::new( + f64::INFINITY, + 1, + BTreeSet::new(), + )); + + let scene = tool_failure_scene_key("ExecCommand", &serde_json::json!({"cmd": "pwd"})); + rt.on_tool_outcome("sess-z", "ExecCommand", &scene, WardenToolOutcome::ExecutionFailed) + .await; + rt.on_tool_outcome("sess-z", "ExecCommand", &scene, WardenToolOutcome::ExecutionFailed) + .await; + rt.take_pending_reminders("sess-z"); + assert_eq!(rt.tool_failures_for_scene("sess-z", &scene), 1); + + rt.on_turn_outcome("sess-z", TurnOutcomeStatus::Completed, "t1").await; + assert_eq!( + rt.tool_failures_for_scene("sess-z", &scene), + 1, + "a completed turn never resets a real tool escalation ladder" + ); + } + + #[test] + fn tool_failure_scene_key_hashes_large_content_instead_of_truncating() { + // WARDEN-04: two large payloads sharing a 256-char prefix must remain + // distinct scenes (the old truncation collapsed them), and the content + // itself must never be embedded in the key. + let big_a = "a".repeat(1024); + let big_b = format!("{}b", "a".repeat(1023)); + assert_eq!(big_a.len(), 1024); + assert_eq!(big_b.len(), 1024); + assert_eq!( + &big_a[..256], + &big_b[..256], + "fixture: identical 256-char prefixes" + ); + + let scene_a = tool_failure_scene_key("Write", &serde_json::json!({ "content": big_a })); + let scene_b = tool_failure_scene_key("Write", &serde_json::json!({ "content": big_b })); + assert_ne!( + scene_a, scene_b, + "large contents with a shared prefix must not merge into one scene" + ); + assert!( + !scene_a.contains(&big_a) && !scene_b.contains(&big_b), + "bulk content must not be embedded in the scene key" + ); + assert!( + scene_a.len() < 200, + "scene key stays compact: {}", + scene_a.len() + ); + } + + #[test] + fn summarize_judgement_tool_args_masks_content_and_caps_size() { + // WARDEN-08: content-like args are masked to a length marker and the + // summary is capped; scalar/nested shapes are preserved. + let small = summarize_judgement_tool_args(&serde_json::json!({ + "file_path": "a.md", + "content": "hello", + })) + .expect("object args summarize to some value"); + assert_eq!(small["file_path"], "a.md"); + assert_eq!( + small["content"]["contentLength"], + serde_json::json!(7) + ); + assert!(!small.to_string().contains("hello"), "content masked"); + + let huge = summarize_judgement_tool_args(&serde_json::json!({ + "file_path": "b.md", + "content": "x".repeat(5000), + })) + .expect("object args summarize"); + assert_eq!( + huge["content"]["contentLength"], + serde_json::json!(5002), + "bulk content is masked to a length marker, never embedded" + ); + assert!(!huge.to_string().contains('x'), "content not leaked"); + + // The size cap only applies to the non-masked remainder. + let mut big_map = serde_json::Map::new(); + for i in 0..40 { + big_map.insert( + format!("key_{i}"), + serde_json::json!("y".repeat(200)), + ); + } + let capped = summarize_judgement_tool_args(&serde_json::Value::Object(big_map)) + .expect("object args summarize"); + assert_eq!(capped["truncated"], serde_json::json!(true)); + + assert!( + summarize_judgement_tool_args(&serde_json::Value::Null).is_none(), + "null arguments stay absent" + ); + assert_eq!( + summarize_judgement_tool_args(&serde_json::json!("scalar")).expect("scalar"), + serde_json::json!("scalar") + ); + } + + #[tokio::test] + async fn tool_failures_for_scene_and_last_error_are_recorded() { + // WARDEN-03 evidence accessors: the scene count and the last error + // summary are observable per scene for model judgement. + let mut rt = runtime(); + let scene_a = tool_failure_scene_key("ExecCommand", &serde_json::json!({"cmd": "pwd"})); + let scene_b = tool_failure_scene_key("ExecCommand", &serde_json::json!({"cmd": "ls"})); + + rt.on_tool_outcome("sess-ev", "ExecCommand", &scene_a, WardenToolOutcome::ExecutionFailed) + .await; + rt.on_tool_outcome("sess-ev", "ExecCommand", &scene_a, WardenToolOutcome::ExecutionFailed) + .await; + rt.record_tool_error("sess-ev", &scene_a, "permission denied"); + assert_eq!(rt.tool_failures_for_scene("sess-ev", &scene_a), 1); + assert_eq!( + rt.last_tool_error("sess-ev", &scene_a), + Some("permission denied") + ); + + assert_eq!( + rt.tool_failures_for_scene("sess-ev", &scene_b), + 0, + "sibling scene untouched" + ); + assert!( + rt.last_tool_error("sess-ev", &scene_b).is_none(), + "no error recorded for the untouched scene" + ); + + // `tool_failures` (max across scenes) still reports the ladder driver. + assert_eq!(rt.tool_failures("sess-ev"), 1); + } +} \ No newline at end of file diff --git a/src/crates/assembly/core/src/external_hooks.rs b/src/crates/assembly/core/src/external_hooks.rs index d13569ee2d..36d652a167 100644 --- a/src/crates/assembly/core/src/external_hooks.rs +++ b/src/crates/assembly/core/src/external_hooks.rs @@ -39,6 +39,7 @@ const HOOK_PROVIDER_DISCOVERY_TIMEOUT: Duration = Duration::from_millis(100); pub(crate) struct WorkspaceExternalHookCatalogService { coordinator: Arc, refresh_gate: tokio::sync::Mutex<()>, + #[allow(clippy::type_complexity)] preparations: tokio::sync::Mutex< BTreeMap< (SourceKey, String), diff --git a/src/crates/assembly/core/src/external_sources.rs b/src/crates/assembly/core/src/external_sources.rs index 97b20519e0..636046497a 100644 --- a/src/crates/assembly/core/src/external_sources.rs +++ b/src/crates/assembly/core/src/external_sources.rs @@ -3580,6 +3580,7 @@ impl WorkspaceExternalSourceService { self.rebuild_product_snapshot(command_snapshot).await } + #[allow(clippy::too_many_arguments)] async fn expand_command( self: &Arc, name: &str, @@ -3836,10 +3837,12 @@ impl WorkspaceExternalSourceService { if was_available { continue; } - let mut config = FileWatcherConfig::default(); - config.watch_recursively = root.recursive; - config.ignore_hidden_files = false; - config.debounce_interval_ms = 350; + let config = FileWatcherConfig { + watch_recursively: root.recursive, + ignore_hidden_files: false, + debounce_interval_ms: 350, + ..Default::default() + }; let path = root.path.to_string_lossy().to_string(); match watcher.watch_path(&path, Some(config)).await { Ok(()) => { @@ -4212,7 +4215,7 @@ fn sanitize_external_snapshot_locations( .unwrap_or(ExternalSourceScope::WorkspaceLocal); remember_location(scope, directory); } - replacements.sort_by(|left, right| right.0.len().cmp(&left.0.len())); + replacements.sort_by_key(|item| std::cmp::Reverse(item.0.len())); let sanitize_message = |message: &mut String| { for (raw, safe) in &replacements { if message.contains(raw) { @@ -6856,6 +6859,7 @@ pub async fn set_external_source_enabled( .await } +#[allow(clippy::too_many_arguments)] pub async fn expand_external_prompt_command( workspace_root: Option<&Path>, name: &str, diff --git a/src/crates/assembly/core/src/external_tools.rs b/src/crates/assembly/core/src/external_tools.rs index 97a60cedc8..505b7701c9 100644 --- a/src/crates/assembly/core/src/external_tools.rs +++ b/src/crates/assembly/core/src/external_tools.rs @@ -2405,7 +2405,7 @@ mod tests { .insert(tool_name.clone(), mux.clone()); router - .withdraw_failed_target(workspace_key, runtime_target_id, 7, &[tool_name.clone()]) + .withdraw_failed_target(workspace_key, runtime_target_id, 7, std::slice::from_ref(&tool_name)) .await; assert!(matches!( @@ -2440,7 +2440,7 @@ mod tests { }, ); router - .withdraw_failed_target(workspace_key, runtime_target_id, 7, &[tool_name.clone()]) + .withdraw_failed_target(workspace_key, runtime_target_id, 7, std::slice::from_ref(&tool_name)) .await; assert!(matches!( router.workspace_routes(workspace_key).get(&tool_name), diff --git a/src/crates/assembly/core/src/function_agents/port_adapters.rs b/src/crates/assembly/core/src/function_agents/port_adapters.rs index da9ae169a5..4dad7d0f51 100644 --- a/src/crates/assembly/core/src/function_agents/port_adapters.rs +++ b/src/crates/assembly/core/src/function_agents/port_adapters.rs @@ -543,6 +543,13 @@ not json #[tokio::test] async fn git_adapter_startchat_snapshot_matches_legacy_empty_state_when_not_git_repo() { let repo = TestTempDir::new("not-a-git-repo"); + // Prevent git from walking up into a parent repository. + // On some machines the temp directory itself lives inside a git + // worktree (e.g. C:\Users\Administrator is a git repo), so we + // set the ceiling to the temp directory's immediate parent. + if let Some(parent) = repo.path().parent() { + std::env::set_var("GIT_CEILING_DIRECTORIES", parent); + } let adapter = CoreFunctionAgentGitAdapter; let snapshot = adapter diff --git a/src/crates/assembly/core/src/infrastructure/ai/provider_catalog.rs b/src/crates/assembly/core/src/infrastructure/ai/provider_catalog.rs index e57e5c8add..aa0f279179 100644 --- a/src/crates/assembly/core/src/infrastructure/ai/provider_catalog.rs +++ b/src/crates/assembly/core/src/infrastructure/ai/provider_catalog.rs @@ -543,7 +543,7 @@ mod tests { #[test] fn overlay_is_valid_and_keeps_product_endpoint_decisions() { let overlay = parse_overlay().expect("valid overlay"); - assert_eq!(overlay.providers.len(), 13); + assert_eq!(overlay.providers.len(), 14); let openbitfun = overlay .providers .iter() @@ -765,7 +765,7 @@ mod tests { "bundle".to_string(), ProviderCatalogSource::Bundle, ); - assert_eq!(resolved.providers.len(), 13); + assert_eq!(resolved.providers.len(), 14); assert!(resolved .providers .iter() diff --git a/src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs b/src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs index c203a8653f..c6fb77dca0 100644 --- a/src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs +++ b/src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs @@ -13,6 +13,50 @@ use std::sync::{Arc, Mutex}; const MAX_PROJECT_SLUG_LEN: usize = 120; +#[cfg(test)] +static TEST_PLANS_DIR_OVERRIDE: Mutex> = Mutex::new(None); + +#[cfg(test)] +impl PathManager { + /// Set the plans directory returned by `project_plans_dir` for the + /// duration of a test. Callers must clear the override before the test + /// ends; `set_plans_dir_override_guard` is the preferred helper because + /// it clears automatically on drop. + pub(crate) fn set_plans_dir_for_test(plans_dir: PathBuf) { + TEST_PLANS_DIR_OVERRIDE + .lock() + .expect("test plans dir override poisoned") + .replace(plans_dir); + } + + /// Clear the plans directory override installed by `set_plans_dir_for_test`. + pub(crate) fn clear_plans_dir_override() { + TEST_PLANS_DIR_OVERRIDE + .lock() + .expect("test plans dir override poisoned") + .take(); + } + + /// RAII guard that sets the plans directory override on construction and + /// clears it on drop. Tests should prefer this over manual set/clear to + /// keep the override from leaking across tests. + pub(crate) fn set_plans_dir_override_guard(plans_dir: PathBuf) -> TestPlansDirOverrideGuard { + Self::set_plans_dir_for_test(plans_dir); + TestPlansDirOverrideGuard + } +} + +/// RAII guard for the plans directory test override. +#[cfg(test)] +pub(crate) struct TestPlansDirOverrideGuard; + +#[cfg(test)] +impl Drop for TestPlansDirOverrideGuard { + fn drop(&mut self) { + PathManager::clear_plans_dir_override(); + } +} + /// Storage level #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum StorageLevel { @@ -475,6 +519,16 @@ impl PathManager { /// Get project plans directory: ~/.bitfun/projects//plans/ pub fn project_plans_dir(&self, workspace_path: &Path) -> PathBuf { + #[cfg(test)] + { + if let Some(override_dir) = TEST_PLANS_DIR_OVERRIDE + .lock() + .expect("test plans dir override poisoned") + .as_ref() + { + return override_dir.clone(); + } + } self.project_runtime_root(workspace_path).join("plans") } diff --git a/src/crates/assembly/core/src/miniapp/js_worker_pool.rs b/src/crates/assembly/core/src/miniapp/js_worker_pool.rs index c41e0b9abc..2487ae0064 100644 --- a/src/crates/assembly/core/src/miniapp/js_worker_pool.rs +++ b/src/crates/assembly/core/src/miniapp/js_worker_pool.rs @@ -101,6 +101,7 @@ impl JsWorkerPool { .map_err(map_worker_pool_error) } + #[allow(clippy::too_many_arguments)] pub async fn call_with_app_dir( &self, worker_key: &str, diff --git a/src/crates/assembly/core/src/miniapp/manager.rs b/src/crates/assembly/core/src/miniapp/manager.rs index b4777d146e..9f88d0d56c 100644 --- a/src/crates/assembly/core/src/miniapp/manager.rs +++ b/src/crates/assembly/core/src/miniapp/manager.rs @@ -119,20 +119,6 @@ impl MiniAppManager { compile_with_request(source, permissions, &request) } - fn compile_market_source_with_app_data_dir( - &self, - app_id: &str, - app_data_dir: &Path, - source: &MiniAppSource, - permissions: &MiniAppPermissions, - theme: &str, - workspace_root: Option<&Path>, - ) -> BitFunResult { - let request = - MiniAppCompileRequest::from_paths(app_id, app_data_dir, workspace_root, theme); - compile_market_with_request(source, permissions, &request) - } - pub async fn uses_market_strict_runtime(&self, app_id: &str) -> bool { self.storage .load_meta(app_id) diff --git a/src/crates/assembly/core/src/plugin_runtime.rs b/src/crates/assembly/core/src/plugin_runtime.rs index a366d9d394..b4e0fdbae0 100644 --- a/src/crates/assembly/core/src/plugin_runtime.rs +++ b/src/crates/assembly/core/src/plugin_runtime.rs @@ -610,6 +610,7 @@ export const WorkspaceToolsPlugin: Plugin = async () => ({ fs::create_dir_all(source_path.parent().expect("source parent")) .expect("create package"); fs::create_dir_all(user.join("plugins")).expect("create user plugins"); + fs::create_dir_all(user.join("runtime")).expect("create user runtime"); fs::write(&source_path, plugin_source).expect("write plugin source"); let file_hash = format!( "sha256:{}", diff --git a/src/crates/assembly/core/src/product_runtime.rs b/src/crates/assembly/core/src/product_runtime.rs index af3332354e..14ad5636f6 100644 --- a/src/crates/assembly/core/src/product_runtime.rs +++ b/src/crates/assembly/core/src/product_runtime.rs @@ -736,6 +736,7 @@ impl CoreAgentRuntimeCompatibility { include_internal: bool, ) -> BitFunResult { validate_persisted_session_id(session_id)?; + self.reject_tombstoned_session(storage_path, session_id).await?; if include_internal { self.coordinator .restore_internal_session_from_storage_path(storage_path, session_id) @@ -761,6 +762,7 @@ impl CoreAgentRuntimeCompatibility { SessionViewRestoreTiming, )> { validate_persisted_session_id(session_id)?; + self.reject_tombstoned_session(storage_path, session_id).await?; let (session, turns, total_turn_count, mut timing) = if let Some(tail_turn_count) = tail_turn_count { @@ -824,7 +826,7 @@ impl CoreAgentRuntimeCompatibility { }) { return Err(BitFunError::NotFound(format!( - "Session not found: {}", + "Session exists but is hidden: {}", request.session_id ))); } @@ -843,6 +845,7 @@ impl CoreAgentRuntimeCompatibility { include_internal: bool, ) -> BitFunResult<(Session, Vec)> { validate_persisted_session_id(session_id)?; + self.reject_tombstoned_session(storage_path, session_id).await?; if include_internal { self.coordinator .restore_internal_session_with_turns_from_storage_path(storage_path, session_id) @@ -885,6 +888,10 @@ impl CoreAgentRuntimeCompatibility { include_internal: bool, ) -> BitFunResult<(Session, Vec)> { validate_persisted_session_id(session_id)?; + let storage_path = self + .resolve_persisted_session_storage_path(request.clone()) + .await?; + self.reject_tombstoned_session(&storage_path, session_id).await?; if include_internal { self.coordinator .restore_internal_session_with_turns_for_workspace(request, session_id) @@ -900,7 +907,30 @@ impl CoreAgentRuntimeCompatibility { &self, workspace_path: &Path, ) -> BitFunResult> { - self.persistence.list_session_metadata(workspace_path).await + self.list_persisted_sessions_with_options(workspace_path, false) + .await + } + + /// Lists persisted session metadata. With `include_internal`, hidden + /// Subagent/Ephemeral sessions are included for full conversation + /// management. Session ids recorded in the workspace deletion tombstone + /// registry are filtered out: a deleted session must never be listed + /// again, even when residual disk metadata survives (ghost-resurrection + /// loop closure on the backend, mirroring the frontend pre-warm path). + pub async fn list_persisted_sessions_with_options( + &self, + workspace_path: &Path, + include_internal: bool, + ) -> BitFunResult> { + let mut sessions = self + .persistence + .list_session_metadata_with_options(workspace_path, include_internal) + .await?; + let tombstoned = self.tombstoned_session_ids(workspace_path).await?; + if !tombstoned.is_empty() { + sessions.retain(|metadata| !tombstoned.contains(&metadata.session_id)); + } + Ok(sessions) } pub async fn list_persisted_sessions_page( @@ -909,11 +939,79 @@ impl CoreAgentRuntimeCompatibility { cursor: Option<&str>, limit: usize, ) -> BitFunResult { - self.persistence - .list_session_metadata_page(workspace_path, cursor, limit) + self.list_persisted_sessions_page_with_options(workspace_path, cursor, limit, false) .await } + /// Paginated variant of [`list_persisted_sessions_with_options`]. + /// Tombstoned session ids are filtered from the returned page; cursor and + /// `has_more` semantics come from the backing store and stay valid, so + /// paging continues past filtered entries instead of stopping early. + pub async fn list_persisted_sessions_page_with_options( + &self, + workspace_path: &Path, + cursor: Option<&str>, + limit: usize, + include_internal: bool, + ) -> BitFunResult { + let mut page = self + .persistence + .list_session_metadata_page_with_options(workspace_path, cursor, limit, include_internal) + .await?; + let tombstoned = self.tombstoned_session_ids(workspace_path).await?; + if !tombstoned.is_empty() { + let visible_before = page.sessions.len(); + page.sessions + .retain(|metadata| !tombstoned.contains(&metadata.session_id)); + if page.sessions.len() < visible_before { + page.loaded_top_level_count = page + .loaded_top_level_count + .min(page.sessions.len()); + } + } + Ok(page) + } + + /// Session ids recorded in the workspace deletion tombstone registry. + /// The registry lives next to the sessions directory and is read through + /// the session manager, the same source the frontend pre-warm path + /// consumes, so every backend consumer agrees on "confirmed deleted". + async fn tombstoned_session_ids( + &self, + workspace_path: &Path, + ) -> BitFunResult> { + let session_manager = self.coordinator.get_session_manager(); + let storage_path = session_manager + .resolve_storage_path_for_workspace_path(workspace_path) + .await; + session_manager.list_deleted_session_ids(&storage_path).await + } + + /// Rejects restoring a session id recorded in the deletion tombstone + /// registry. Deletion is permanent: the id only becomes restorable again + /// after a successful re-create/restore, which durably clears the + /// tombstone. Returns the same NotFound shape the storage layer uses for + /// a missing session. + async fn reject_tombstoned_session( + &self, + storage_path: &Path, + session_id: &str, + ) -> BitFunResult<()> { + if self + .coordinator + .get_session_manager() + .list_deleted_session_ids(storage_path) + .await? + .iter() + .any(|id| id == session_id) + { + return Err(BitFunError::NotFound(format!( + "Session not found: {session_id}" + ))); + } + Ok(()) + } + pub async fn load_persisted_session_metadata( &self, workspace_path: &Path, @@ -1003,6 +1101,7 @@ impl CoreAgentRuntimeCompatibility { if self.is_session_loaded_from_storage_path(storage_path, session_id)? { return Ok(()); } + self.reject_tombstoned_session(storage_path, session_id).await?; if include_internal { self.coordinator .restore_internal_session_from_storage_path(storage_path, session_id) @@ -2489,6 +2588,157 @@ mod tests { assert!(error.to_string().contains(missing_id), "{error}"); } + fn build_compatibility( + workspace: &TestWorkspace, + ) -> (CoreAgentRuntimeCompatibility, Arc, Arc) { + let persistence_manager = Arc::new( + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), + ); + let session_manager = Arc::new(SessionManager::new( + Arc::new(SessionContextStore::new()), + persistence_manager.clone(), + SessionManagerConfig { + max_active_sessions: 4, + session_idle_timeout: Duration::from_secs(3600), + auto_save_interval: Duration::from_secs(300), + enable_persistence: true, + prompt_cache_policy: PromptCachePolicy::default(), + }, + )); + let event_queue = Arc::new(EventQueue::new(EventQueueConfig::default())); + let tool_pipeline = Arc::new(ToolPipeline::new( + Arc::new(TokioRwLock::new(ToolRegistry::new())), + Arc::new(ToolStateManager::new(event_queue.clone())), + None, + )); + let execution_engine = Arc::new(ExecutionEngine::new( + Arc::new(RoundExecutor::new( + Arc::new(StreamProcessor::new(event_queue.clone())), + event_queue.clone(), + tool_pipeline.clone(), + )), + event_queue.clone(), + session_manager.clone(), + Arc::new(ContextCompressor::new(CompressionConfig::default())), + ExecutionEngineConfig::default(), + )); + let coordinator = Arc::new(ConversationCoordinator::new( + session_manager.clone(), + execution_engine, + tool_pipeline, + event_queue, + Arc::new(EventRouter::new()), + Arc::new( + crate::runtime_ownership::CoreRuntimeOwnership::embedded_with_facts( + workspace.path().join("runtime-ownership"), + "bitfun".to_string(), + "test", + ), + ), + )); + let scheduler = DialogScheduler::new(coordinator.clone(), session_manager.clone()); + ( + CoreAgentRuntimeCompatibility::build(coordinator, scheduler), + session_manager, + persistence_manager, + ) + } + + #[tokio::test] + async fn list_persisted_sessions_filters_tombstoned_session_ids() { + let workspace = TestWorkspace::new(); + let _runtime_guard = set_workspace_runtime_service_for_current_test(Arc::new( + WorkspaceRuntimeService::new(workspace.path_manager()), + )); + let (compatibility, session_manager, persistence_manager) = + build_compatibility(&workspace); + let keep_id = format!("tombstone-keep-{}", Uuid::new_v4()); + let deleted_id = format!("tombstone-deleted-{}", Uuid::new_v4()); + + // Both sessions exist on disk (metadata written through the same + // persistence path the list reads). + for (id, title) in [(&keep_id, "Keep"), (&deleted_id, "Delete")] { + let metadata = SessionMetadata::new( + id.clone(), + title.to_string(), + "agentic".to_string(), + "model".to_string(), + ); + persistence_manager + .save_session_metadata(workspace.path(), &metadata) + .await + .expect("metadata should save"); + } + + // Record the deletion tombstone for one session while its disk + // metadata remains: the exact residual-directory scenario the list + // must filter (ghost resurrection guard on the backend). + let storage_path = session_manager + .resolve_storage_path_for_workspace_path(workspace.path()) + .await; + session_manager + .record_deleted_session_id(&storage_path, &deleted_id) + .await + .expect("tombstone should record"); + + let sessions = compatibility + .list_persisted_sessions(workspace.path()) + .await + .expect("persisted sessions should list"); + let listed_ids: Vec<&str> = sessions + .iter() + .map(|metadata| metadata.session_id.as_str()) + .collect(); + assert!( + listed_ids.contains(&keep_id.as_str()), + "kept session must be listed: {listed_ids:?}" + ); + assert!( + !listed_ids.contains(&deleted_id.as_str()), + "tombstoned session id must be filtered from the list: {listed_ids:?}" + ); + } + + #[tokio::test] + async fn restore_rejects_tombstoned_session_ids() { + let workspace = TestWorkspace::new(); + let _runtime_guard = set_workspace_runtime_service_for_current_test(Arc::new( + WorkspaceRuntimeService::new(workspace.path_manager()), + )); + let (compatibility, session_manager, _persistence_manager) = + build_compatibility(&workspace); + let session_id = format!("tombstone-restore-{}", Uuid::new_v4()); + session_manager + .create_session_with_id( + Some(session_id.clone()), + "To delete".to_string(), + "agentic".to_string(), + crate::agentic::core::SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await + .expect("session should create"); + + session_manager + .delete_session(workspace.path(), &session_id) + .await + .expect("session should delete"); + + let storage_path = session_manager + .resolve_storage_path_for_workspace_path(workspace.path()) + .await; + let error = compatibility + .restore_session_from_storage_path(&storage_path, &session_id, false) + .await + .expect_err("tombstoned session must not be restorable"); + assert!( + error.to_string().contains(&session_id), + "restore rejection should identify the session: {error}" + ); + } + #[test] fn persisted_session_compatibility_rejects_path_like_ids() { let error = validate_persisted_session_id("../../other-project/session") diff --git a/src/crates/assembly/core/src/product_runtime/runtime_services.rs b/src/crates/assembly/core/src/product_runtime/runtime_services.rs index 5eb7f33db3..f203f0bac8 100644 --- a/src/crates/assembly/core/src/product_runtime/runtime_services.rs +++ b/src/crates/assembly/core/src/product_runtime/runtime_services.rs @@ -7,9 +7,9 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; #[cfg(feature = "ssh-remote")] -use bitfun_runtime_ports::{PortError, PortErrorKind, RemoteExecPort}; +use bitfun_runtime_ports::{PortError, PortErrorKind, PortResult, RemoteExecPort}; use bitfun_runtime_ports::{ - PortResult, RemoteProjectionPort, RemoteWorkspacePort, SessionStorePort, TerminalPort, + RemoteProjectionPort, RemoteWorkspacePort, SessionStorePort, TerminalPort, }; use bitfun_runtime_services::{ RuntimeServiceMarkerPort, RuntimeServices, RuntimeServicesBuilder, RuntimeServicesProvider, diff --git a/src/crates/assembly/core/src/service/config/global.rs b/src/crates/assembly/core/src/service/config/global.rs index 83d52f976d..b56c9d6901 100644 --- a/src/crates/assembly/core/src/service/config/global.rs +++ b/src/crates/assembly/core/src/service/config/global.rs @@ -7,6 +7,7 @@ use crate::util::errors::*; #[cfg(feature = "agent-runtime")] use log::warn; use log::{debug, info}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::sync::OnceLock; use tokio::sync::RwLock; @@ -18,6 +19,49 @@ static GLOBAL_CONFIG_SERVICE: OnceLock>>>> static CONFIG_UPDATE_SENDER: OnceLock> = OnceLock::new(); +/// Cached RBAC/Warden master switch (R-26). +/// +/// Mirrors `ai.rbac_enabled` in the settings document. Kept as a process-level +/// cache so synchronous hot paths (tool restriction gates) can read it without +/// awaiting the config service. Refreshed on config initialize / reload / +/// update; defaults to `true` (mechanism on). +static RBAC_ENABLED_CACHE: AtomicBool = AtomicBool::new(true); + +/// Dot-path of the RBAC/Warden master switch inside the settings document. +/// Config paths resolve against the serialized `GlobalConfig`, where `AIConfig` +/// lives under `ai`. +pub(crate) const RBAC_ENABLED_CONFIG_PATH: &str = "ai.rbac_enabled"; + +/// Current value of the RBAC/Warden master switch (cached, synchronous). +/// +/// Hot-path safe: never awaits the config service. The cache is refreshed from +/// the settings document on config initialize / reload / update. +pub fn rbac_enabled() -> bool { + RBAC_ENABLED_CACHE.load(Ordering::Relaxed) +} + +/// Override the cached RBAC/Warden master switch. +/// +/// Used by the config service when the settings document changes and by tests. +pub fn set_rbac_enabled(enabled: bool) { + RBAC_ENABLED_CACHE.store(enabled, Ordering::Relaxed); +} + +/// Refresh the cached RBAC/Warden master switch from the global config. +/// +/// Best-effort: hosts without an initialized config service keep the default +/// (`true`). Called after config initialize, reload, and service replacement. +pub(crate) async fn refresh_rbac_enabled_cache() { + let enabled = match get_global_config_service().await { + Ok(service) => service + .get_config::(Some(RBAC_ENABLED_CONFIG_PATH)) + .await + .unwrap_or(true), + Err(_) => true, + }; + RBAC_ENABLED_CACHE.store(enabled, Ordering::Relaxed); +} + /// Configuration update events. #[derive(Debug, Clone)] pub enum ConfigUpdateEvent { @@ -110,6 +154,7 @@ impl GlobalConfigManager { })?; info!("Global config service initialized"); + refresh_rbac_enabled_cache().await; #[cfg(feature = "agent-runtime")] { @@ -159,6 +204,7 @@ impl GlobalConfigManager { } Self::broadcast_update(ConfigUpdateEvent::ConfigReloaded).await; + refresh_rbac_enabled_cache().await; debug!("Global config service updated"); Ok(()) @@ -181,6 +227,7 @@ impl GlobalConfigManager { ); } Self::broadcast_update(ConfigUpdateEvent::ConfigReloaded).await; + refresh_rbac_enabled_cache().await; Ok(()) } diff --git a/src/crates/assembly/core/src/service/config/mod.rs b/src/crates/assembly/core/src/service/config/mod.rs index 94d2f02940..3a1618b40e 100644 --- a/src/crates/assembly/core/src/service/config/mod.rs +++ b/src/crates/assembly/core/src/service/config/mod.rs @@ -21,7 +21,8 @@ pub use app_language::{ pub use factory::ConfigFactory; pub use global::{ get_global_config_service, initialize_global_config, reload_global_config, - subscribe_config_updates, ConfigUpdateEvent, GlobalConfigManager, + rbac_enabled, set_rbac_enabled, subscribe_config_updates, ConfigUpdateEvent, + GlobalConfigManager, }; pub use manager::{ConfigManager, ConfigManagerSettings, ConfigStatistics}; #[cfg(feature = "agent-runtime")] diff --git a/src/crates/assembly/core/src/service/config/mode_config_canonicalizer.rs b/src/crates/assembly/core/src/service/config/mode_config_canonicalizer.rs index 0a85c55927..8b92eedffd 100644 --- a/src/crates/assembly/core/src/service/config/mode_config_canonicalizer.rs +++ b/src/crates/assembly/core/src/service/config/mode_config_canonicalizer.rs @@ -117,6 +117,7 @@ pub fn resolve_effective_tools( effective } +#[allow(clippy::too_many_arguments)] fn stored_agent_profile_from_tool_selection( agent_id: &str, enabled_tools: Vec, diff --git a/src/crates/assembly/core/src/service/config/service.rs b/src/crates/assembly/core/src/service/config/service.rs index 3068a02506..82db10d765 100644 --- a/src/crates/assembly/core/src/service/config/service.rs +++ b/src/crates/assembly/core/src/service/config/service.rs @@ -114,6 +114,10 @@ impl ConfigService { .await; } + // Keep the cached RBAC/Warden master switch in sync with the settings + // document (R-26): the switch may be toggled via `ai.rbac_enabled`. + super::global::refresh_rbac_enabled_cache().await; + Ok(()) } @@ -176,6 +180,9 @@ impl ConfigService { .await; } + // Keep the cached RBAC/Warden master switch in sync (R-26). + super::global::refresh_rbac_enabled_cache().await; + Ok(()) } @@ -226,6 +233,8 @@ impl ConfigService { super::global::ConfigUpdateEvent::ModelConfigurationUpdated, ) .await; + // Keep the cached RBAC/Warden master switch in sync (R-26). + super::global::refresh_rbac_enabled_cache().await; Ok(ConfigImportResult { success: true, errors: Vec::new(), diff --git a/src/crates/assembly/core/src/service/config/types.rs b/src/crates/assembly/core/src/service/config/types.rs index 725e9c952e..b83026b7bb 100644 --- a/src/crates/assembly/core/src/service/config/types.rs +++ b/src/crates/assembly/core/src/service/config/types.rs @@ -782,6 +782,15 @@ pub struct AIConfig { /// Maximum number of rounds per dialog turn before soft-pausing. #[serde(default = "default_max_rounds")] pub max_rounds: usize, + + /// User-controllable master switch for the RBAC/Warden mechanism (R-26). + /// + /// When `false`, the RBAC tool-restriction checks and the Warden runtime + /// (turn/tool failure tracking, violation records, reminders) are fully + /// bypassed. Defaults to `true` (mechanism on). Users can turn it off in + /// the settings document under `ai.rbac_enabled`. + #[serde(default = "default_true")] + pub rbac_enabled: bool, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] @@ -1289,7 +1298,7 @@ pub enum AgentSubagentOverrideState { pub type ParentSubagentOverrideConfig = HashMap; pub type AgentSubagentOverrideConfig = HashMap; -pub const DEFAULT_MODEL_CONTEXT_WINDOW_TOKENS: u32 = 128_128; +pub const DEFAULT_MODEL_CONTEXT_WINDOW_TOKENS: u32 = 1_048_576; pub const MIN_MODEL_CONTEXT_WINDOW_TOKENS: u32 = 32_000; pub const MAX_CONFIGURED_OUTPUT_TOKENS_RATIO_PERCENT: u32 = 40; const AUTOMATIC_MAX_OUTPUT_TOKEN_TIERS: [u32; 5] = [8_000, 16_000, 24_000, 32_000, 64_000]; @@ -1779,6 +1788,7 @@ impl Default for AIConfig { computer_use_enabled: false, browser_control_preferred_browser: String::new(), max_rounds: default_max_rounds(), + rbac_enabled: true, } } } diff --git a/src/crates/assembly/core/src/service/dispatch/controller.rs b/src/crates/assembly/core/src/service/dispatch/controller.rs index f7d389bbed..2cc1828d15 100644 --- a/src/crates/assembly/core/src/service/dispatch/controller.rs +++ b/src/crates/assembly/core/src/service/dispatch/controller.rs @@ -167,7 +167,10 @@ pub struct DispatchAppendRequest { /// The wire shape and structural limits come from the shared contract; the /// controller only adds transport-owned policy (the device inline budget). -pub(super) use bitfun_services_core::dispatch_contract::DispatchAttachment as DispatchAttachmentPayload; +/// +/// Crate-internal alias: the module is private and the public dispatch facade +/// re-exports the request structs, not this name. +pub(crate) use bitfun_services_core::dispatch_contract::DispatchAttachment as DispatchAttachmentPayload; pub(super) fn validate_attachment_payloads( attachments: &[DispatchAttachmentPayload], diff --git a/src/crates/assembly/core/src/service/instruction_context.rs b/src/crates/assembly/core/src/service/instruction_context.rs index 4483e606b7..86b5e11957 100644 --- a/src/crates/assembly/core/src/service/instruction_context.rs +++ b/src/crates/assembly/core/src/service/instruction_context.rs @@ -255,6 +255,7 @@ mod tests { #[cfg(feature = "external-sources")] #[tokio::test] + #[allow(clippy::await_holding_lock)] // environment lock is intentionally held for the whole test body async fn local_user_instructions_precede_workspace_instructions_by_ecosystem_priority() { let _environment = lock_environment(); let temp = tempfile::tempdir().expect("tempdir"); @@ -385,6 +386,7 @@ mod tests { #[cfg(feature = "external-sources")] #[tokio::test] + #[allow(clippy::await_holding_lock)] // environment lock is intentionally held for the whole test body async fn opencode_global_config_resolves_relative_instructions_in_the_local_workspace() { let _environment = lock_environment(); let temp = tempfile::tempdir().expect("tempdir"); @@ -425,6 +427,7 @@ mod tests { #[cfg(feature = "external-sources")] #[tokio::test] + #[allow(clippy::await_holding_lock)] // environment lock is intentionally held for the whole test body async fn invalid_user_source_does_not_hide_workspace_instructions() { let _environment = lock_environment(); let temp = tempfile::tempdir().expect("tempdir"); @@ -459,6 +462,7 @@ mod tests { #[cfg(feature = "external-sources")] #[tokio::test] + #[allow(clippy::await_holding_lock)] // environment lock is intentionally held for the whole test body async fn a_user_configured_workspace_file_is_not_rendered_again_as_a_project_source() { let _environment = lock_environment(); let temp = tempfile::tempdir().expect("tempdir"); @@ -493,6 +497,7 @@ mod tests { #[cfg(feature = "external-sources")] #[tokio::test] + #[allow(clippy::await_holding_lock)] // environment lock is intentionally held for the whole test body async fn port_backed_workspace_never_falls_back_to_local_user_sources() { let _environment = lock_environment(); let temp = tempfile::tempdir().expect("tempdir"); diff --git a/src/crates/assembly/core/src/service/session_usage/service.rs b/src/crates/assembly/core/src/service/session_usage/service.rs index cf9753173f..fe0ec474c2 100644 --- a/src/crates/assembly/core/src/service/session_usage/service.rs +++ b/src/crates/assembly/core/src/service/session_usage/service.rs @@ -1758,6 +1758,7 @@ mod tests { parent_tool_call_id: Some("tool-1".to_string()), subagent_type: Some("Explore".to_string()), continuation_policy: None, + ..Default::default() }); let mut grandchild = SessionMetadata::new( "grandchild-session".to_string(), @@ -1774,6 +1775,7 @@ mod tests { parent_tool_call_id: Some("child-tool".to_string()), subagent_type: Some("Explore".to_string()), continuation_policy: None, + ..Default::default() }); let (session_ids, complete) = diff --git a/src/crates/assembly/core/src/service/snapshot/events.rs b/src/crates/assembly/core/src/service/snapshot/events.rs index edab4e970d..7b95fa0022 100644 --- a/src/crates/assembly/core/src/service/snapshot/events.rs +++ b/src/crates/assembly/core/src/service/snapshot/events.rs @@ -306,6 +306,8 @@ static mut GLOBAL_EVENT_EMITTER: Option) { + // SAFETY: the global emitter is written exactly once during process startup + // before any concurrent reader (get_event_emitter) can observe it. unsafe { GLOBAL_EVENT_EMITTER = Some(Arc::new(tokio::sync::RwLock::new( SnapshotEmitterAdapter::new(Some(emitter)), @@ -317,6 +319,8 @@ pub fn initialize_snapshot_event_emitter(emitter: Arc) { /// Gets the global event emitter. #[allow(static_mut_refs)] pub fn get_event_emitter() -> Option>> { + // SAFETY: the emitter is initialized before any concurrent access and never + // mutated afterwards, so a shared read of the static is sound. unsafe { GLOBAL_EVENT_EMITTER.clone() } } diff --git a/src/crates/assembly/core/src/service/snapshot/manager.rs b/src/crates/assembly/core/src/service/snapshot/manager.rs index 6507a89aa3..acb5048bba 100644 --- a/src/crates/assembly/core/src/service/snapshot/manager.rs +++ b/src/crates/assembly/core/src/service/snapshot/manager.rs @@ -963,7 +963,7 @@ fn is_symlink_or_reparse_point(metadata: &std::fs::Metadata) -> bool { { use std::os::windows::fs::MetadataExt; const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0400; - return metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0; + metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 } #[cfg(not(windows))] diff --git a/src/crates/assembly/core/src/service/workspace/service.rs b/src/crates/assembly/core/src/service/workspace/service.rs index a8c794d7ee..2ce214ff21 100644 --- a/src/crates/assembly/core/src/service/workspace/service.rs +++ b/src/crates/assembly/core/src/service/workspace/service.rs @@ -480,7 +480,7 @@ impl WorkspaceService { // Prefer the most recently accessed match when the path alone is ambiguous // (e.g. the same POSIX root opened on two SSH hosts). - matches.sort_by(|left, right| right.last_accessed.cmp(&left.last_accessed)); + matches.sort_by_key(|m| std::cmp::Reverse(m.last_accessed)); matches.first().map(|workspace| (*workspace).clone()) } diff --git a/src/crates/assembly/core/src/service_agent_runtime.rs b/src/crates/assembly/core/src/service_agent_runtime.rs index 6dcc53940f..73b8642b12 100644 --- a/src/crates/assembly/core/src/service_agent_runtime.rs +++ b/src/crates/assembly/core/src/service_agent_runtime.rs @@ -418,6 +418,7 @@ fn agent_input_attachment_from_image_context(context: ImageContextData) -> Agent )) } +#[allow(clippy::too_many_arguments)] fn core_agent_runtime_builder( submission: Arc, session_management: Arc, @@ -1364,6 +1365,7 @@ impl CoreServiceAgentRuntime { .map_err(|error| error.to_string()) } + #[allow(clippy::too_many_arguments)] pub(crate) fn product_agent_runtime( coordinator: Arc, scheduler: Arc, @@ -1413,6 +1415,7 @@ impl CoreServiceAgentRuntime { ) } + #[allow(clippy::too_many_arguments)] pub(crate) fn sdk_host_product_agent_runtime( coordinator: Arc, scheduler: Arc, @@ -1438,6 +1441,7 @@ impl CoreServiceAgentRuntime { ) } + #[allow(clippy::too_many_arguments)] fn product_agent_runtime_with_dialog_turn( coordinator: Arc, scheduler: Arc, diff --git a/src/crates/assembly/core/tests/rbac_master_switch.rs b/src/crates/assembly/core/tests/rbac_master_switch.rs new file mode 100644 index 0000000000..2199070f71 --- /dev/null +++ b/src/crates/assembly/core/tests/rbac_master_switch.rs @@ -0,0 +1,269 @@ +//! Integration tests for the user-controllable RBAC/Warden master switch +//! (R-26). +//! +//! The switch is a process-level cache (`crate::service::config::rbac_enabled`) +//! mirrored from the settings document (`ai.rbac_enabled`). Tests in this file +//! run in a dedicated test binary so toggling the global switch cannot race +//! with other lib unit tests; a static mutex serializes the tests inside this +//! file. + +use std::collections::BTreeSet; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::Duration; + +use bitfun_core::agentic::coordination::turn_outcome::TurnOutcomeStatus; +use bitfun_core::agentic::session::SessionManager; +use bitfun_core::agentic::tools::ToolUseContext; +use bitfun_core::agentic::warden::{ + runtime::{WardenRuntime, WardenToolOutcome}, ChallengePokeConfig, PenaltyLevel, +}; +use bitfun_core::agentic::WorkspaceBinding; +use bitfun_core::service::config::{rbac_enabled, set_rbac_enabled, AIConfig}; +use bitfun_runtime_ports::ToolRuntimeHandles; +use tool_runtime::context::PrimaryModelFacts; + +/// Serializes switch-toggling tests inside this binary. +fn switch_guard() -> std::sync::MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())).lock().unwrap() +} + +fn test_session_manager() -> Arc { + use bitfun_core::agentic::persistence::PersistenceManager; + use bitfun_core::agentic::session::{ + PromptCachePolicy, SessionContextStore, SessionManagerConfig, + }; + use bitfun_core::infrastructure::app_paths::PathManager; + + // Isolate storage via env overrides (this test binary is its own process, + // so the env vars cannot leak into other test binaries). + let root = std::env::temp_dir().join(format!("bitfun-rbac-switch-test-{}", uuid())); + std::env::set_var("BITFUN_E2E_USER_ROOT", root.join("user-root")); + std::env::set_var("BITFUN_E2E_HOME", root.join("home")); + let path_manager = Arc::new(PathManager::new().expect("path manager")); + let persistence_manager = + Arc::new(PersistenceManager::new(path_manager).expect("persistence manager")); + Arc::new(SessionManager::new( + Arc::new(SessionContextStore::new()), + persistence_manager, + SessionManagerConfig { + max_active_sessions: 100, + session_idle_timeout: Duration::from_secs(3600), + auto_save_interval: Duration::from_secs(300), + enable_persistence: false, + prompt_cache_policy: PromptCachePolicy::default(), + }, + )) +} + +fn uuid() -> String { + use uuid::Uuid; + Uuid::new_v4().to_string() +} + +fn restricted_context() -> ToolUseContext { + ToolUseContext { + tool_call_id: None, + agent_type: None, + session_id: None, + dialog_turn_id: None, + workspace: Some(WorkspaceBinding::new(None, std::path::PathBuf::from("/repo/project"))), + loaded_deferred_tool_specs: Vec::new(), + primary_model_facts: PrimaryModelFacts::default(), + custom_data: std::collections::HashMap::new(), + computer_use_host: None, + runtime_tool_restrictions: bitfun_core::agentic::tools::ToolRuntimeRestrictions { + allowed_tool_names: BTreeSet::new(), + denied_tool_names: BTreeSet::from(["Write".to_string()]), + denied_tool_messages: Default::default(), + path_policy: Default::default(), + allowed_operation_classes: BTreeSet::new(), + denied_operation_classes: BTreeSet::new(), + }, + runtime_handles: ToolRuntimeHandles::default(), + } +} + +// ============================================================================ +// R-26: config default and cache +// ============================================================================ + +#[test] +fn ai_config_defaults_to_rbac_enabled() { + let config = AIConfig::default(); + assert!(config.rbac_enabled, "rbac_enabled must default to true"); +} + +#[test] +fn switch_cache_defaults_to_enabled_and_toggles() { + let _guard = switch_guard(); + let previous = rbac_enabled(); + set_rbac_enabled(true); + assert!(rbac_enabled(), "cache must be on by default"); + set_rbac_enabled(false); + assert!(!rbac_enabled(), "cache must toggle off"); + set_rbac_enabled(true); + assert!(rbac_enabled(), "cache must toggle back on"); + set_rbac_enabled(previous); +} + +// ============================================================================ +// R-26: tool restriction gate bypass +// ============================================================================ + +#[test] +fn enforce_tool_runtime_restrictions_bypassed_when_switch_off() { + let _guard = switch_guard(); + let previous = rbac_enabled(); + set_rbac_enabled(false); + + let context = restricted_context(); + // Write is denied by the context restrictions, but the master switch off + // must bypass the gate entirely. + context + .enforce_tool_runtime_restrictions( + "Write", + &serde_json::json!({"file_path": "test.md", "content": "x"}), + ) + .expect("R-26: restriction gate bypassed when master switch is off"); + + set_rbac_enabled(previous); +} + +#[test] +fn enforce_tool_runtime_restrictions_active_when_switch_on() { + let _guard = switch_guard(); + let previous = rbac_enabled(); + set_rbac_enabled(true); + + let context = restricted_context(); + let err = context + .enforce_tool_runtime_restrictions( + "Write", + &serde_json::json!({"file_path": "test.md", "content": "x"}), + ) + .expect_err("R-26: restriction gate active when master switch is on"); + assert!(err.to_string().contains("denied"), "got: {err}"); + + set_rbac_enabled(previous); +} + +// ============================================================================ +// R-26: Warden runtime disabled when switch off +// ============================================================================ + +#[tokio::test] +async fn warden_runtime_off_disables_turn_and_tool_tracking() { + let _guard = switch_guard(); + let previous = rbac_enabled(); + set_rbac_enabled(false); + + let mut rt = WardenRuntime::new(test_session_manager()); + // Challenge at rate=1.0 would fire every turn if the runtime were active. + rt.set_challenge_config(ChallengePokeConfig::new( + 1.0, + 7, + BTreeSet::from(["iron-rules-compliance".to_string()]), + )); + + rt.on_turn_outcome("sess-off", TurnOutcomeStatus::Failed, "t1").await; + assert_eq!(rt.consecutive_failures("sess-off"), 0, "no failure tracking"); + assert!( + rt.shame_wall().entry_for_session("sess-off").is_none(), + "no violation recorded" + ); + assert!( + rt.take_pending_reminders("sess-off").is_empty(), + "no reminders queued (turn outcome)" + ); + + rt.on_tool_outcome("sess-off", "ExecCommand", "ExecCommand:{}", WardenToolOutcome::ExecutionFailed) + .await; + assert_eq!(rt.tool_failures("sess-off"), 0, "no tool failure tracking"); + assert!( + rt.take_pending_reminders("sess-off").is_empty(), + "no reminders queued (tool outcome)" + ); + + set_rbac_enabled(previous); +} + +#[tokio::test] +async fn warden_runtime_on_keeps_turn_and_tool_tracking() { + let _guard = switch_guard(); + let previous = rbac_enabled(); + set_rbac_enabled(true); + + let mut rt = WardenRuntime::new(test_session_manager()); + rt.set_challenge_config(ChallengePokeConfig::new( + f64::INFINITY, + 1, + BTreeSet::new(), + )); + + rt.on_turn_outcome("sess-on", TurnOutcomeStatus::Failed, "t1").await; + assert_eq!( + rt.consecutive_failures("sess-on"), + 0, + "first turn failure of a scene is exploratory" + ); + assert!( + rt.shame_wall().entry_for_session("sess-on").is_none(), + "no violation recorded for the exploratory first failure" + ); + + rt.on_turn_outcome("sess-on", TurnOutcomeStatus::Failed, "t2").await; + assert_eq!(rt.consecutive_failures("sess-on"), 1, "tracking active"); + assert_eq!( + rt.shame_wall().entry_for_session("sess-on").unwrap().cumulative_penalty_level, + PenaltyLevel::L1, + "violation recorded when switch is on" + ); + assert_eq!(rt.take_pending_reminders("sess-on").len(), 1); + + rt.on_tool_outcome("sess-on", "ExecCommand", "ExecCommand:{}", WardenToolOutcome::ExecutionFailed) + .await; + assert_eq!( + rt.tool_failures("sess-on"), + 0, + "first tool failure of a scene is exploratory" + ); + rt.on_tool_outcome("sess-on", "ExecCommand", "ExecCommand:{}", WardenToolOutcome::ExecutionFailed) + .await; + assert_eq!(rt.tool_failures("sess-on"), 1, "tool tracking active"); + + set_rbac_enabled(previous); +} + +#[test] +fn general_purpose_subagent_role_is_executor_and_readonly_allowed() { + use bitfun_core::agentic::tools::restrictions::{ + clear_session_role, general_purpose_tool_restrictions, get_default_permissions, + get_session_restrictions, get_session_role, set_session_role_with_restrictions, AgentRole, + OperationClass, + }; + // 默认 Executor 模板必须允许只读类(执行者读代码基本能力)。 + let executor = get_default_permissions(AgentRole::Executor); + assert!( + executor + .ensure_operation_allowed(OperationClass::ReadOnly, "Read") + .is_ok(), + "default Executor template must allow ReadOnly" + ); + // GeneralPurpose 专属模板允许只读侦察 + 执行。 + let gp = general_purpose_tool_restrictions(); + assert!(gp.ensure_operation_allowed(OperationClass::ReadOnly, "Read").is_ok()); + assert!(gp.ensure_operation_allowed(OperationClass::WriteFile, "Write").is_ok()); + assert!(gp.ensure_operation_allowed(OperationClass::ExecuteCode, "ExecCommand").is_ok()); + assert!(gp.ensure_tool_allowed("Read").is_ok()); + assert!(gp.ensure_tool_allowed("Glob").is_ok()); + assert!(gp.ensure_tool_allowed("Grep").is_ok()); + assert!(gp.ensure_tool_allowed("ExecCommand").is_ok()); + // 注册角色仍为 Executor 且 ReadOnly 工具可用(防回退)。 + let sid = format!("gp-role-{}", uuid()); + set_session_role_with_restrictions(&sid, AgentRole::Executor, gp).expect("register role"); + assert_eq!(get_session_role(&sid), Some(AgentRole::Executor)); + let effective = get_session_restrictions(&sid).expect("session restrictions"); + assert!(effective.ensure_operation_allowed(OperationClass::ReadOnly, "Read").is_ok()); + clear_session_role(&sid); +} diff --git a/src/crates/assembly/core/tests/rbac_poke_integration.rs b/src/crates/assembly/core/tests/rbac_poke_integration.rs new file mode 100644 index 0000000000..3922ca31d8 --- /dev/null +++ b/src/crates/assembly/core/tests/rbac_poke_integration.rs @@ -0,0 +1,858 @@ +//! Integration tests for RBAC+Poke system (Phase R-A.12). +//! +//! Covers 5 core scenarios: +//! 1. RBAC interception — Commander Write denied, Read allowed +//! 2. Warden Audit-Poke — Executor Write triggers Audit → self_check within 3 turns +//! 3. Challenge-Poke compliance — Challenge → iron-rule self-check within 5 turns +//! 4. Penalty execution — 3 violations → L3 penalty → reminder-only (R-25, +//! no RBAC demotion/freeze; WriteFile stays allowed) +//! 5. Shame wall persistence — entry written & serialized correctly +//! +//! All tests use isolated mock data and do **not** depend on a real BitFun runtime. + +use std::collections::BTreeSet; + +use bitfun_agent_tools::{ + PokeMessage, PokeResponse, PokeStatus, PokeType, PokeValidator, SelfCheckStatement, +}; +use bitfun_core::agentic::tools::restrictions::{ + classify_tool_call, get_session_restrictions, update_restrictions, AgentRole, OperationClass, + ToolRuntimeRestrictionsPatch, +}; +use bitfun_core::agentic::warden::{ + punishment_executor::PenaltyOutcome, runtime::resolve_audit_poke_from_judgement, + runtime::warden_enforcement_for_goal, ChallengePokeConfig, PenaltyLevel, PenaltyRequest, + PokePriorityManager, ShameWallRegistry, ViolationRecord, POKE_PENALTY_KIND, + SHAME_WALL_FILENAME, +}; +use bitfun_runtime_ports::{ + AgentDialogPrependedReminder, ThreadGoal, ThreadGoalStatus, WardenAuditJudgementResponse, +}; + +// ============================================================================ +// Test 1: RBAC interception +// ============================================================================ +// +// Scenario: +// 1. Create Commander session +// 2. Commander calls Write → RBAC rejects (Commander has no WRITE_FILE permission) +// 3. Commander calls Read → RBAC allows (Commander has READ_ONLY permission) +// +// Verification: +// - classify_tool_call("Write", …) → OperationClass::WriteFile +// - Commander's role template does NOT include WriteFile → ensure_operation_allowed fails +// - classify_tool_call("Read", …) → OperationClass::ReadOnly +// - Commander's role template DOES include ReadOnly → ensure_operation_allowed succeeds + +#[test] +fn rbac_interception_commander_write_blocked_read_allowed() { + // ── Setup: Register a Commander session ────────────────────────────── + let session_id = "test-cmdr-int-01"; + update_restrictions( + session_id, + Some(AgentRole::Commander), + ToolRuntimeRestrictionsPatch::default(), + ) + .expect("set Commander role restrictions"); + + let restrictions = get_session_restrictions(session_id) + .expect("Commander restrictions should exist after update"); + + // ── Commander calls Write ──────────────────────────────────────────── + let write_input = serde_json::json!({"file_path": "test.md", "content": "hello"}); + let write_class = classify_tool_call("Write", &write_input); + assert_eq!( + write_class, + OperationClass::WriteFile, + "Write tool should classify as WriteFile" + ); + + let write_result = restrictions.ensure_operation_allowed(OperationClass::WriteFile, "Write"); + assert!( + write_result.is_err(), + "Commander should NOT be allowed to perform WriteFile operations" + ); + + // ── Commander calls Read ───────────────────────────────────────────── + let read_input = serde_json::json!({"file_path": "test.md"}); + let read_class = classify_tool_call("Read", &read_input); + assert_eq!( + read_class, + OperationClass::ReadOnly, + "Read tool should classify as ReadOnly" + ); + + let read_result = restrictions.ensure_operation_allowed(OperationClass::ReadOnly, "Read"); + assert!( + read_result.is_ok(), + "Commander SHOULD be allowed to perform ReadOnly operations" + ); + + // ── Edge case: ExecCommand with write redirect ─────────────────────── + // Even if Commander has Write in allowed_tool_names, the operation class + // WriteFile is denied, so shell-based writes are also blocked. + let tee_input = serde_json::json!({"cmd": "echo x > file.txt"}); + let tee_class = classify_tool_call("ExecCommand", &tee_input); + assert_eq!( + tee_class, + OperationClass::WriteFile, + "ExecCommand with '>' should classify as WriteFile" + ); + let tee_result = + restrictions.ensure_operation_allowed(OperationClass::WriteFile, "ExecCommand"); + assert!( + tee_result.is_err(), + "Commander should NOT be allowed WriteFile even via ExecCommand" + ); +} + +// ============================================================================ +// Test 2: Warden Audit-Poke +// ============================================================================ +// +// Scenario: +// 1. Executor completes a Write tool call +// 2. Warden receives notification and sends Audit-Poke (deadline=3 turns) +// 3. Executor responds within 3 turns with a valid self_check +// 4. Warden validates the self_check → PASS +// +// Verification: +// - PokeMessage::poke_type == Audit, deadline_turns == 3 +// - PokeResponse contains self_check with non-empty phase/gate/summary/rules +// - PokeValidator::validate_audit_response returns true + +#[test] +fn warden_audit_poke_executor_self_check_within_deadline() { + // ── 1. Warden constructs an Audit-Poke message ─────────────────────── + let audit_poke = PokeMessage { + poke_id: "audit-poke-001".into(), + poke_type: PokeType::Audit, + rule_ids: vec![ + "R1: no_destructive_write".into(), + "R3: path_whitelist".into(), + ], + deadline_turns: 3, + evidence_required: Some(vec!["tool_call_log".into(), "phase_summary".into()]), + }; + + assert_eq!(audit_poke.poke_type, PokeType::Audit); + assert_eq!(audit_poke.deadline_turns, 3); + assert!(!audit_poke.poke_id.is_empty()); + assert_eq!(audit_poke.rule_ids.len(), 2); + + // ── 2. Executor prepares a self-check response (within deadline) ──── + let executor_self_check = SelfCheckStatement { + current_phase: "implementation".into(), + last_gate: "pre_write_check".into(), + tool_calls_summary: vec![ + "Read(main.rs)".into(), + "Edit(main.rs:42)".into(), + "Write(note.md)".into(), + ], + rules_checked: vec![ + "R1: no_destructive_write".into(), + "R3: path_whitelist".into(), + ], + }; + + let audit_response = PokeResponse { + poke_id: audit_poke.poke_id.clone(), + status: PokeStatus::Acknowledged, + self_check: Some(executor_self_check), + }; + + // ── 3. Warden validates the response ──────────────────────────────── + assert!( + PokeValidator::validate_audit_response(&audit_response), + "Audit response with valid self_check should pass validation" + ); + + // ── Edge: Deferred response within limit is still valid ────────────── + let deferred_response = PokeResponse { + poke_id: "audit-poke-002".into(), + status: PokeStatus::Deferred(2), + self_check: Some(SelfCheckStatement { + current_phase: "review".into(), + last_gate: "deferred".into(), + tool_calls_summary: vec!["Read(doc.md)".into()], + rules_checked: vec!["R1".into()], + }), + }; + assert!( + PokeValidator::validate_audit_response(&deferred_response), + "Audit response with deferral < 3 should still pass" + ); + + // ── Edge: Missing self_check should fail ───────────────────────────── + let bad_response = PokeResponse { + poke_id: "audit-poke-003".into(), + status: PokeStatus::Acknowledged, + self_check: None, + }; + assert!( + !PokeValidator::validate_audit_response(&bad_response), + "Audit response without self_check should fail" + ); + + // ── Edge: Empty phase should fail ──────────────────────────────────── + let empty_phase = PokeResponse { + poke_id: "audit-poke-004".into(), + status: PokeStatus::Acknowledged, + self_check: Some(SelfCheckStatement { + current_phase: "".into(), + last_gate: "gate".into(), + tool_calls_summary: vec!["Read".into()], + rules_checked: vec!["R1".into()], + }), + }; + assert!( + !PokeValidator::validate_audit_response(&empty_phase), + "Audit response with empty phase should fail" + ); +} + +// ============================================================================ +// Test 3: Challenge-Poke compliance +// ============================================================================ +// +// Scenario: +// 1. Warden sends Challenge-Poke (Poisson-sampled, deadline=5 turns) +// 2. Executor responds within 5 turns with iron-rule compliance self-check +// 3. Warden validates the response → PASS +// +// Verification: +// - ChallengePokeConfig builds correct Challenge-Poke messages +// - PokeValidator::validate_challenge_response accepts valid responses +// - PokePriorityManager tracks timeout correctly at the boundary + +#[test] +fn challenge_poke_compliance_within_deadline() { + // ── 1. Challenge-Poke configuration ────────────────────────────────── + let mut rules = BTreeSet::new(); + rules.insert("R-001".into()); + rules.insert("R-004".into()); + rules.insert("R-007".into()); + + let config = ChallengePokeConfig::new(6.5, 42, rules.clone()); + assert_eq!(config.deadline_turns, 5); + assert_eq!(config.max_defer_count, 3); + + let challenge_msg = config.build_challenge_message("challenge-poke-001".into()); + assert_eq!(challenge_msg.poke_type, PokeType::Challenge); + assert_eq!(challenge_msg.deadline_turns, 5); + assert!(challenge_msg.rule_ids.contains(&"R-001".to_string())); + + // ── 2. Executor self-check with iron-rule citations ────────────────── + let challenge_response = PokeResponse { + poke_id: challenge_msg.poke_id.clone(), + status: PokeStatus::Acknowledged, + self_check: Some(SelfCheckStatement { + current_phase: "execution".into(), + last_gate: "read_check".into(), + tool_calls_summary: vec!["Read(config.yaml)".into(), "Grep(pattern=secret)".into()], + rules_checked: vec![ + "R-001: no_hardcoded_secrets".into(), + "R-004: path_whitelist".into(), + "R-007: audit_log".into(), + ], + }), + }; + + // ── 3. Warden validates ────────────────────────────────────────────── + assert!( + PokeValidator::validate_challenge_response(&challenge_response), + "Challenge response with valid iron-rule self-check should pass" + ); + + // ── Edge: Deferred response within max_defer_count (≤ 3) ───────────── + let deferred_ok = PokeResponse { + poke_id: "challenge-poke-002".into(), + status: PokeStatus::Deferred(3), + self_check: Some(SelfCheckStatement { + current_phase: "planning".into(), + last_gate: "gate".into(), + tool_calls_summary: vec!["Read".into()], + rules_checked: vec!["R-001".into()], + }), + }; + assert!( + PokeValidator::validate_challenge_response(&deferred_ok), + "Challenge response with defer=3 should be valid" + ); + + // ── Edge: Deferred > 3 should fail ─────────────────────────────────── + let deferred_fail = PokeResponse { + poke_id: "challenge-poke-003".into(), + status: PokeStatus::Deferred(4), + self_check: Some(SelfCheckStatement { + current_phase: "planning".into(), + last_gate: "gate".into(), + tool_calls_summary: vec!["Read".into()], + rules_checked: vec!["R-001".into()], + }), + }; + assert!( + !PokeValidator::validate_challenge_response(&deferred_fail), + "Challenge response with defer=4 should fail" + ); + + // ── PokePriorityManager: timeout tracking at exact boundary ────────── + let mut manager = PokePriorityManager::new(); + manager.register_poke("challenge-boundary"); + // deadline = 5, advance exactly 5 turns + for _ in 0..5 { + manager.advance_turn(); + } + assert!( + manager.is_timeout("challenge-boundary", 5), + "Poke should time out after exactly 5 turns" + ); + // With deadline = 6, not yet timed out + assert!( + !manager.is_timeout("challenge-boundary", 6), + "Poke should NOT time out before 6-turn deadline" + ); +} + +// ============================================================================ +// Test 4: Penalty execution (R-25: reminder-only, no RBAC enforcement) +// ============================================================================ +// +// Scenario: +// 1. Executor session has Executor role (WriteFile + ExecuteCode allowed) +// 2. Simulate 3 violations → Warden prepares PenaltyRequest L3 +// 3. PunishmentExecutor executes L3 → records on shame wall + reminder, +// and per user ruling R-25 does NOT demote, freeze, or write any +// read-only restriction patch +// 4. After penalty, WriteFile operations remain allowed (no RBAC change) +// +// Verification: +// - PenaltyRequest data type round-trips correctly +// - PenaltyOutcome for L3 has session_frozen=false, rbac_change=None +// - get_session_restrictions is unchanged after L3 execution +// - Shame wall records the L3 violation + +#[test] +fn penalty_execution_l3_is_reminder_only() { + let session_id = "test-exec-penalty-01"; + + // ── 1. Set up as Executor (WRITE_FILE + EXECUTE_CODE allowed) ──────── + update_restrictions( + session_id, + Some(AgentRole::Executor), + ToolRuntimeRestrictionsPatch::default(), + ) + .expect("set Executor role"); + + let pre_restrictions = + get_session_restrictions(session_id).expect("Executor restrictions should exist"); + assert!( + pre_restrictions + .allowed_operation_classes + .contains(&OperationClass::WriteFile), + "Executor should allow WriteFile before penalty" + ); + assert!( + pre_restrictions + .allowed_operation_classes + .contains(&OperationClass::ExecuteCode), + "Executor should allow ExecuteCode before penalty" + ); + + // ── 2. Build a PenaltyRequest matching the L3 scenario ─────────────── + let violations = vec![ + ViolationRecord { + rule_id: "R-001".into(), + description: "Unauthorized write to restricted path".into(), + severity: "major".into(), + timestamp: "2025-01-15T10:00:00Z".into(), + evidence: serde_json::json!({"tool": "Write", "path": "/etc/config"}), + }, + ViolationRecord { + rule_id: "R-002".into(), + description: "Executed risky shell command without approval".into(), + severity: "major".into(), + timestamp: "2025-01-15T10:05:00Z".into(), + evidence: serde_json::json!({"tool": "ExecCommand", "cmd": "rm -rf /data"}), + }, + ViolationRecord { + rule_id: "R-003".into(), + description: "Repeated violation after L2 warning".into(), + severity: "critical".into(), + timestamp: "2025-01-15T10:10:00Z".into(), + evidence: serde_json::json!({"tool": "Write", "path": "/etc/shadow"}), + }, + ]; + + let penalty_request = PenaltyRequest { + target_session_id: session_id.to_string(), + level: PenaltyLevel::L3, + violations: violations.clone(), + requested_by: "warden-session-001".into(), + }; + + assert_eq!(penalty_request.level, PenaltyLevel::L3); + assert_eq!(penalty_request.target_session_id, session_id); + assert_eq!(penalty_request.violations.len(), 3); + + // ── 3. Simulate L3 execution outcome (R-25) ────────────────────────── + // execute_l3 now only records + reminds; the outcome carries no RBAC + // change and no freeze. + let outcome = PenaltyOutcome { + level: PenaltyLevel::L3, + prepended_reminders: vec![AgentDialogPrependedReminder { + kind: POKE_PENALTY_KIND.to_string(), + text: "[Penalty L3] Violation recorded — escalation level reached. No RBAC change." + .into(), + }], + rbac_change: None, + session_frozen: false, + permanent_mark: false, + notify_user: true, + }; + assert_eq!(outcome.level, PenaltyLevel::L3); + assert_eq!(outcome.rbac_change, None, "R-25: L3 must not demote"); + assert!(!outcome.session_frozen, "R-25: L3 must not freeze"); + assert!(outcome.notify_user); + assert!(!outcome.prepended_reminders.is_empty()); + + // ── 4. Verify post-penalty: RBAC restrictions are UNCHANGED ───────── + let post_restrictions = get_session_restrictions(session_id) + .expect("restrictions should still exist after R-25 penalty"); + assert!( + post_restrictions + .allowed_operation_classes + .contains(&OperationClass::WriteFile), + "R-25: after L3 penalty WriteFile must STILL be allowed (no RBAC change)" + ); + assert!( + post_restrictions + .allowed_operation_classes + .contains(&OperationClass::ExecuteCode), + "R-25: after L3 penalty ExecuteCode must STILL be allowed (no RBAC change)" + ); + assert_eq!(post_restrictions, pre_restrictions, "restrictions untouched"); + + // ── Verify tool-level enforcement is unchanged ─────────────────────── + let write_result = + post_restrictions.ensure_operation_allowed(OperationClass::WriteFile, "Write"); + assert!( + write_result.is_ok(), + "R-25: Write tool must remain allowed after L3 penalty" + ); + + // Executor template already includes ReadOnly; a read-only freeze would + // not change it. Read staying allowed proves no freeze patch was applied. + let read_result = post_restrictions.ensure_operation_allowed(OperationClass::ReadOnly, "Read"); + assert!( + read_result.is_ok(), + "R-25: Read stays allowed exactly as before the penalty (no read-only freeze)" + ); + + // ── Shame wall records the violation (audit trail preserved) ───────── + let mut registry = ShameWallRegistry::default(); + registry.upsert_entry( + session_id, + "agent", + session_id, + violations, + PenaltyLevel::L3, + "2025-01-15T10:10:00Z", + ); + let entry = registry.entry_for_session(session_id).expect("recorded"); + assert_eq!(entry.cumulative_penalty_level, PenaltyLevel::L3); + assert_eq!(entry.violations.len(), 3); +} + +// ============================================================================ +// Test 5: Shame wall persistence +// ============================================================================ +// +// Scenario: +// 1. After penalty execution, ShameWallRegistry contains an entry +// 2. The registry can be serialized to JSON (matches shame-wall-registry.json format) +// 3. The entry contains all required fields +// 4. POKE_PENALTY_KIND constant is consistent with prepended_reminders usage +// +// Verification: +// - ShameWallRegistry with entries serializes/deserializes correctly +// - SHAME_WALL_FILENAME matches the expected contract path +// - Violation records persist correctly with upsert +// - Registry query methods work (by user, by session) + +#[test] +fn shame_wall_persistence_after_penalty() { + // ── 1. Build a ShameWallRegistry with violation entries ────────────── + let mut registry = ShameWallRegistry::default(); + assert_eq!(registry.version, 1); + assert!(registry.entries.is_empty()); + + let v1 = ViolationRecord { + rule_id: "R-001".into(), + description: "Unauthorized write to /etc/config".into(), + severity: "major".into(), + timestamp: "2025-01-15T10:00:00Z".into(), + evidence: serde_json::json!({"tool": "Write", "path": "/etc/config"}), + }; + let v2 = ViolationRecord { + rule_id: "R-002".into(), + description: "Executed risky shell command".into(), + severity: "critical".into(), + timestamp: "2025-01-15T10:05:00Z".into(), + evidence: serde_json::json!({"tool": "ExecCommand", "cmd": "rm -rf /data"}), + }; + + // ── 2. Upsert entry for session-1 (first violation → L1) ──────────── + registry.upsert_entry( + "user-alpha", + "executor", + "session-penalty-1", + vec![v1.clone()], + PenaltyLevel::L1, + "2025-01-15T10:00:00Z", + ); + + assert_eq!(registry.entries.len(), 1); + let entry = ®istry.entries[0]; + assert_eq!(entry.session_id, "session-penalty-1"); + assert_eq!(entry.user_id, "user-alpha"); + assert_eq!(entry.violations.len(), 1); + assert_eq!(entry.cumulative_penalty_level, PenaltyLevel::L1); + assert!(!entry.created_at.is_empty()); + assert!(!entry.updated_at.is_empty()); + + // ── 3. Upsert again for same session (escalate → L3) ──────────────── + registry.upsert_entry( + "user-alpha", + "executor", + "session-penalty-1", + vec![v2.clone()], + PenaltyLevel::L3, + "2025-01-15T10:10:00Z", + ); + + assert_eq!( + registry.entries.len(), + 1, + "Should still be 1 entry (upserted)" + ); + assert_eq!( + registry.entries[0].violations.len(), + 2, + "Should have 2 accumulated violations" + ); + assert_eq!( + registry.entries[0].cumulative_penalty_level, + PenaltyLevel::L3, + "Penalty level should be escalated to L3" + ); + + // ── 4. Query methods ───────────────────────────────────────────────── + let user_entries = registry.entries_for_user("user-alpha"); + assert_eq!(user_entries.len(), 1); + + let session_entry = registry.entry_for_session("session-penalty-1"); + assert!(session_entry.is_some()); + assert_eq!(session_entry.unwrap().violations.len(), 2); + + let missing = registry.entry_for_session("nonexistent"); + assert!(missing.is_none()); + + // ── 5. JSON serialization round-trip (matches file format) ─────────── + let json = serde_json::to_string_pretty(®istry).expect("serialize registry"); + assert!(json.contains("session-penalty-1")); + assert!(json.contains("R-001")); + assert!(json.contains("R-002")); + assert!(json.contains("L3")); + + let deserialized: ShameWallRegistry = + serde_json::from_str(&json).expect("deserialize registry"); + assert_eq!(deserialized.version, 1); + assert_eq!(deserialized.entries.len(), 1); + assert_eq!(deserialized.entries[0].violations.len(), 2); + + // ── 6. Contract constants ──────────────────────────────────────────── + assert_eq!( + SHAME_WALL_FILENAME, ".master-framework/shame-wall-registry.json", + "SHAME_WALL_FILENAME must match the contract path" + ); + assert_eq!(POKE_PENALTY_KIND, "PokePenalty"); + + // ── 7. Multiple sessions (different users) ─────────────────────────── + registry.upsert_entry( + "user-beta", + "executor", + "session-penalty-2", + vec![v1], + PenaltyLevel::L1, + "2025-01-15T11:00:00Z", + ); + assert_eq!(registry.entries.len(), 2); + + let beta_entries = registry.entries_for_user("user-beta"); + assert_eq!(beta_entries.len(), 1); + + let alpha_entries = registry.entries_for_user("user-alpha"); + assert_eq!(alpha_entries.len(), 1); +} + +// ============================================================================ +// Additional contract verification tests +// ============================================================================ + +/// Verify the full penalty request → outcome → shame wall flow +/// integrates correctly across the data types. +#[test] +fn penalty_flow_end_to_end_data_types() { + // ── Build a PenaltyRequest ─────────────────────────────────────────── + let request = PenaltyRequest { + target_session_id: "flow-session-01".into(), + level: PenaltyLevel::L2, + violations: vec![ViolationRecord { + rule_id: "R-001".into(), + description: "Test violation".into(), + severity: "major".into(), + timestamp: "2025-01-01T00:00:00Z".into(), + evidence: serde_json::json!({"detail": "test"}), + }], + requested_by: "warden-flow-01".into(), + }; + + // Serialize/deserialize round-trip + let json = serde_json::to_string(&request).expect("serialize PenaltyRequest"); + let deser: PenaltyRequest = serde_json::from_str(&json).expect("deserialize PenaltyRequest"); + assert_eq!(deser.target_session_id, "flow-session-01"); + assert_eq!(deser.level, PenaltyLevel::L2); + assert_eq!(deser.violations.len(), 1); + assert_eq!(deser.requested_by, "warden-flow-01"); + + // ── Build PenaltyOutcome for L2 (R-25: reminder-only) ──────────────── + let outcome = PenaltyOutcome { + level: PenaltyLevel::L2, + prepended_reminders: vec![], + rbac_change: None, + session_frozen: false, + permanent_mark: false, + notify_user: false, + }; + assert_eq!(outcome.level, PenaltyLevel::L2); + assert_eq!(outcome.rbac_change, None, "R-25: L2 must not demote"); + + // ── Simulate the full shame-wall write ─────────────────────────────── + let mut registry = ShameWallRegistry::default(); + registry.upsert_entry( + &request.target_session_id, + "agent", + &request.target_session_id, + request.violations.clone(), + request.level, + "2025-01-01T00:00:00Z", + ); + + assert_eq!(registry.entries.len(), 1); + assert_eq!( + registry.entries[0].cumulative_penalty_level, + PenaltyLevel::L2 + ); +} + +/// Verify the Poisson scheduler integration with ChallengePokeConfig +/// produces expected behavior for the 5-turn deadline contract. +#[test] +fn challenge_poisson_scheduling_contract() { + use bitfun_core::agentic::warden::PoissonScheduler; + + // With rate=1.0, every round should poke (p=1.0) + let mut sched = PoissonScheduler::new(1.0, 100); + for _ in 0..20 { + assert!(sched.should_poke(), "rate=1.0 must poke every round"); + } + assert_eq!(sched.counter(), 20); + + // Deterministic seed produces identical sequences + let mut a = PoissonScheduler::new(6.5, 9999); + let mut b = PoissonScheduler::new(6.5, 9999); + for _ in 0..50 { + assert_eq!(a.should_poke(), b.should_poke()); + } + + // Expected pokes calculation + let sched = PoissonScheduler::new(6.5, 42); + let expected = sched.expected_pokes(1300); + assert!((expected - 200.0).abs() < f64::EPSILON); +} + +// ============================================================================ +// Test 6: LegionControl RBAC classification +// ============================================================================ +// +// Scenario: +// 1. Create Commander session +// 2. Commander calls LegionControl → classified as Communicate +// 3. Commander role template includes Communicate → ensure_operation_allowed succeeds +// +// Verification: +// - classify_tool_call("LegionControl", …) → OperationClass::Communicate +// - Commander is allowed to orchestrate legion topology (communicate class only) + +#[test] +fn rbac_legion_control_is_communicate_allowed_for_commander() { + // ── Setup: Register a Commander session ────────────────────────────── + let session_id = "test-cmdr-legion-01"; + update_restrictions( + session_id, + Some(AgentRole::Commander), + ToolRuntimeRestrictionsPatch::default(), + ) + .expect("set Commander role restrictions"); + + let restrictions = get_session_restrictions(session_id) + .expect("Commander restrictions should exist after update"); + + // ── LegionControl load action ──────────────────────────────────────── + let load_input = serde_json::json!({"action": "load", "preset_id": "three-souls"}); + let load_class = classify_tool_call("LegionControl", &load_input); + assert_eq!( + load_class, + OperationClass::Communicate, + "LegionControl should classify as Communicate" + ); + + let load_result = + restrictions.ensure_operation_allowed(OperationClass::Communicate, "LegionControl"); + assert!( + load_result.is_ok(), + "Commander SHOULD be allowed to perform Communicate operations (LegionControl)" + ); + + // ── LegionControl list action ──────────────────────────────────────── + let list_input = serde_json::json!({"action": "list"}); + let list_class = classify_tool_call("LegionControl", &list_input); + assert_eq!( + list_class, + OperationClass::Communicate, + "LegionControl list should classify as Communicate" + ); + + let list_result = + restrictions.ensure_operation_allowed(OperationClass::Communicate, "LegionControl"); + assert!( + list_result.is_ok(), + "Commander SHOULD be allowed to list legion presets" + ); +} + +// ============================================================================ +// Test 7: Batch-2 Warden goal switch + model-backed Audit-Poke judgement +// ============================================================================ +// +// Scenario: +// 1. Warden enforcement applies only while the session has an active +// thread goal (Active / BudgetLimited); Paused/Blocked/UsageLimited/ +// Complete goals and goal-less sessions skip the consecutive-failure +// accounting. +// 2. The model judgement verdict decides the final Audit-Poke: a decline +// suppresses the poke, a confirmation carries the model-selected rule +// ids / evidence, and an empty model rule list falls back to the +// mechanical candidates. + +fn test_goal(status: ThreadGoalStatus) -> ThreadGoal { + ThreadGoal { + goal_id: "goal-1".to_string(), + session_id: "session-1".to_string(), + objective: "Ship the refactor".to_string(), + status, + token_budget: None, + tokens_used: 0, + time_used_seconds: 0, + created_at: 1, + updated_at: 2, + auto_continuation_count: 0, + reference_files: vec!["docs/spec.md".to_string()], + } +} + +#[test] +fn warden_goal_switch_skips_non_active_goal_sessions() { + assert!( + warden_enforcement_for_goal(Some(&test_goal(ThreadGoalStatus::Active))), + "active goal keeps Warden enforcement" + ); + assert!( + warden_enforcement_for_goal(Some(&test_goal(ThreadGoalStatus::BudgetLimited))), + "budget-limited goal is still active" + ); + for status in [ + ThreadGoalStatus::Paused, + ThreadGoalStatus::Blocked, + ThreadGoalStatus::UsageLimited, + ThreadGoalStatus::Complete, + ] { + assert!( + !warden_enforcement_for_goal(Some(&test_goal(status))), + "non-active goal ({status:?}) opts out of Warden enforcement" + ); + } + assert!( + !warden_enforcement_for_goal(None), + "goal-less session opts out of Warden enforcement" + ); +} + +#[test] +fn warden_audit_poke_model_verdict_replaces_mechanical_rules() { + let mechanical = PokeMessage { + poke_id: "audit-tool-42".into(), + poke_type: PokeType::Audit, + rule_ids: vec![ + "R1: no_destructive_write".into(), + "R3: path_whitelist".into(), + ], + deadline_turns: 3, + evidence_required: Some(vec!["tool_call_log".into(), "phase_summary".into()]), + }; + + // The model declined the poke: no Audit-Poke is sent. + let declined = WardenAuditJudgementResponse { + should_poke: false, + rule_ids: Vec::new(), + evidence_requested: Vec::new(), + }; + assert!( + resolve_audit_poke_from_judgement(&mechanical, &declined).is_none(), + "a declining model verdict suppresses the Audit-Poke" + ); + + // The model confirms and selects its own rules + evidence. + let confirmed = WardenAuditJudgementResponse { + should_poke: true, + rule_ids: vec!["R2: execution_safety".into()], + evidence_requested: vec!["tool_call_log".into()], + }; + let poke = resolve_audit_poke_from_judgement(&mechanical, &confirmed) + .expect("confirmed poke is sent"); + assert_eq!(poke.poke_id, "audit-tool-42"); + assert_eq!(poke.poke_type, PokeType::Audit); + assert_eq!(poke.deadline_turns, 3); + assert_eq!(poke.rule_ids, vec!["R2: execution_safety"]); + assert_eq!(poke.evidence_required, Some(vec!["tool_call_log".into()])); + + // The model confirms without rules: mechanical candidates carry over + // (the fallback a port-unavailable judgement also lands on). + let bare_confirm = WardenAuditJudgementResponse { + should_poke: true, + rule_ids: Vec::new(), + evidence_requested: Vec::new(), + }; + let poke = resolve_audit_poke_from_judgement(&mechanical, &bare_confirm) + .expect("bare confirmation still pokes"); + assert_eq!( + poke.rule_ids, + vec!["R1: no_destructive_write", "R3: path_whitelist"], + "empty model rules fall back to mechanical candidates" + ); + assert_eq!( + poke.evidence_required, + Some(vec!["tool_call_log".into(), "phase_summary".into()]) + ); +} diff --git a/src/crates/assembly/external-sources/src/hook.rs b/src/crates/assembly/external-sources/src/hook.rs index 23fbb7b351..ba8e12b005 100644 --- a/src/crates/assembly/external-sources/src/hook.rs +++ b/src/crates/assembly/external-sources/src/hook.rs @@ -132,12 +132,14 @@ impl ExternalHookCatalogCoordinator { last_error: None, }); } - let mut snapshot = ExternalHookCatalogSnapshotV1::default(); - snapshot.discovery_pending = !generations.is_empty(); - snapshot.providers = generations - .iter() - .map(|provider| provider.identity.clone()) - .collect(); + let snapshot = ExternalHookCatalogSnapshotV1 { + discovery_pending: !generations.is_empty(), + providers: generations + .iter() + .map(|provider| provider.identity.clone()) + .collect(), + ..ExternalHookCatalogSnapshotV1::default() + }; Ok(Self { state: Mutex::new(HookCatalogState { context, diff --git a/src/crates/contracts/core-types/src/lib.rs b/src/crates/contracts/core-types/src/lib.rs index 8184d50f4f..5b3f5ec336 100644 --- a/src/crates/contracts/core-types/src/lib.rs +++ b/src/crates/contracts/core-types/src/lib.rs @@ -7,6 +7,7 @@ pub mod ai; pub mod errors; pub mod lsp; pub mod session; +pub mod session_tree; pub mod session_usage; pub mod speech; pub mod surface; diff --git a/src/crates/contracts/core-types/src/session.rs b/src/crates/contracts/core-types/src/session.rs index 54fcc354aa..7725e0ae85 100644 --- a/src/crates/contracts/core-types/src/session.rs +++ b/src/crates/contracts/core-types/src/session.rs @@ -7,6 +7,7 @@ pub enum SessionKind { Standard, Subagent, EphemeralChild, + EphemeralSubagent, } /// Whether a persisted subagent session may accept another delegated turn. diff --git a/src/crates/contracts/core-types/src/session_tree.rs b/src/crates/contracts/core-types/src/session_tree.rs new file mode 100644 index 0000000000..31e4f7ee0d --- /dev/null +++ b/src/crates/contracts/core-types/src/session_tree.rs @@ -0,0 +1,52 @@ +use serde::{Deserialize, Serialize}; + +/// Maximum allowed fission depth for subagent delegation trees. +/// Authoritative single source; `bitfun_runtime_ports::MAX_FISSION_DEPTH` re-exports this. +pub const MAX_FISSION_DEPTH: u8 = 10; + +/// Maximum nesting depth of the session tree (session tree layer limit). +/// Authoritative single source; coordinator initializes `SessionTreeManager::new` with this. +pub const MAX_TREE_DEPTH: u32 = 10; + +/// Hard recursion guard for session tree traversal (subtree/build_tree recursion), +/// prevents stack overflow in deep trees. Distinct from the tree layer limit above. +pub const MAX_TREE_RECURSION_DEPTH: u32 = 128; + +/// Maximum recursion depth for session tree serialization to prevent stack overflow. +pub const MAX_TREE_SERIALIZE_DEPTH: usize = 256; + +/// Position of a session in the conversation tree +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionTreePosition { + /// Parent session ID (None means root node) + pub parent_session_id: Option, + /// tool_call_id of the parent that created this session + pub parent_tool_call_id: Option, + /// Depth in the tree (root = 0) + pub depth: u32, + /// agent_type of the parent session that created this session + pub parent_agent_type: Option, +} + +/// Conversation tree node summary (for UI tree display) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionTreeNode { + pub session_id: String, + pub session_name: String, + pub agent_type: String, + pub agent_display_name: String, + pub depth: u32, + pub status: SessionTreeNodeStatus, + pub children: Vec, + pub is_acp_external: bool, + pub external_provider_label: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SessionTreeNodeStatus { + Running, + Completed, + Error(String), + Cancelled, +} diff --git a/src/crates/contracts/events/src/agentic.rs b/src/crates/contracts/events/src/agentic.rs index c022a5c14f..0adebf2378 100644 --- a/src/crates/contracts/events/src/agentic.rs +++ b/src/crates/contracts/events/src/agentic.rs @@ -24,6 +24,16 @@ pub struct SubagentParentInfo { pub session_id: String, #[serde(rename = "dialogTurnId")] pub dialog_turn_id: String, + #[serde( + default, + skip_serializing_if = "Option::is_none", + rename = "depth" + )] + pub depth: Option, + /// Delegated RBAC role key (R-14 B4); absent when the parent session has + /// no registered role. + #[serde(default, skip_serializing_if = "Option::is_none", rename = "role")] + pub role: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -70,6 +80,16 @@ pub struct DeepReviewQueueState { pub session_concurrency_high: bool, } +/// Sub-agent completion status. One-to-one with SubagentResultStatus. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum SubagentCompletionStatus { + Completed, + Failed, + Cancelled, + PartialTimeout, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type")] pub enum AgenticEvent { @@ -95,6 +115,12 @@ pub enum AgenticEvent { /// Remote SSH host for sessions bound to remote workspaces. #[serde(skip_serializing_if = "Option::is_none")] remote_ssh_host: Option, + /// Parent session that launched this session (delegated subagent case). + #[serde(default, skip_serializing_if = "Option::is_none")] + parent_session_id: Option, + /// Subagent type when this session is a delegated subagent session. + #[serde(default, skip_serializing_if = "Option::is_none")] + subagent_type: Option, }, SessionStateChanged { @@ -162,6 +188,19 @@ pub enum AgenticEvent { focused_review_display_label: Option, }, + /// Emitted when a sub-agent turn completes + SubagentTurnCompleted { + session_id: String, + subagent_dialog_turn_id: String, + parent_session_id: String, + parent_dialog_turn_id: String, + parent_tool_call_id: String, + agent_type: Option, + status: SubagentCompletionStatus, + #[serde(skip_serializing_if = "Option::is_none")] + output_text: Option, + }, + DialogTurnCompleted { session_id: String, turn_id: String, @@ -378,6 +417,9 @@ pub enum AgenticEvent { reason: String, }, + ReviewPropagationNeeded { + parent_session_id: String, + }, /// A persisted reasoning preset became unavailable for the session's /// concrete model and was canonically cleared to Auto. SessionReasoningPresetAutoCleared { @@ -640,6 +682,8 @@ impl AgenticEvent { | Self::DeepReviewQueueStateChanged { session_id, .. } | Self::SessionModelAutoMigrated { session_id, .. } | Self::SessionReasoningPresetAutoCleared { session_id, .. } => Some(session_id), + Self::SubagentTurnCompleted { session_id, .. } => Some(session_id), + Self::ReviewPropagationNeeded { parent_session_id, .. } => Some(parent_session_id), Self::SystemError { session_id, .. } => session_id.as_deref(), } } @@ -696,6 +740,7 @@ impl AgenticEvent { | Self::ThreadGoalUpdated { .. } | Self::UserSteeringInjected { .. } | Self::ContextCompressionCompleted { .. } => AgenticEventPriority::Normal, + Self::SubagentTurnCompleted { .. } => AgenticEventPriority::Normal, Self::ToolEvent { tool_event, .. } => tool_event.default_priority(), @@ -1036,6 +1081,12 @@ mod tests { } #[test] + fn subagent_completion_status_serializes_snake_case() { + let status = SubagentCompletionStatus::PartialTimeout; + let json = serde_json::to_string(&status).unwrap(); + assert_eq!(json, "\"partial_timeout\""); + } + fn reasoning_preset_auto_clear_is_a_high_priority_session_event() { let event = AgenticEvent::SessionReasoningPresetAutoCleared { session_id: "session-1".to_string(), diff --git a/src/crates/contracts/events/src/frontend_projection.rs b/src/crates/contracts/events/src/frontend_projection.rs index c606c2566a..3f3ff50c68 100644 --- a/src/crates/contracts/events/src/frontend_projection.rs +++ b/src/crates/contracts/events/src/frontend_projection.rs @@ -34,6 +34,8 @@ pub fn project_agentic_frontend_event(event: AgenticEvent) -> Option Some(AgenticFrontendEvent::new( "agentic://session-created", json!({ @@ -46,6 +48,8 @@ pub fn project_agentic_frontend_event(event: AgenticEvent) -> Option Some(AgenticFrontendEvent::new( @@ -482,6 +486,42 @@ pub fn project_agentic_frontend_event(event: AgenticEvent) -> Option None, + AgenticEvent::ReviewPropagationNeeded { .. } => None, + AgenticEvent::SubagentTurnCompleted { + session_id, + subagent_dialog_turn_id, + parent_session_id, + parent_dialog_turn_id, + parent_tool_call_id, + agent_type, + status, + output_text, + } => Some(AgenticFrontendEvent::new( + "agentic://subagent-turn-completed", + { + let mut p = serde_json::Map::new(); + p.insert("sessionId".to_string(), json!(session_id)); + p.insert("subagentDialogTurnId".to_string(), json!(subagent_dialog_turn_id)); + p.insert("parentSessionId".to_string(), json!(parent_session_id)); + p.insert("parentDialogTurnId".to_string(), json!(parent_dialog_turn_id)); + p.insert("parentToolCallId".to_string(), json!(parent_tool_call_id)); + if let Some(at) = agent_type { + p.insert("agentType".to_string(), json!(at)); + } + p.insert("status".to_string(), json!(status)); + // Coordinator emits SubagentTurnCompleted with output_text = None + // (see coordinator.rs start_background_subagent / follow-up) so the + // parent session does not receive the full subagent text twice + // ("notification turn + full-text event" dual-feed). Full text is + // carried by the subagent's own turn / on-disk record (P-03); + // the parent reads it via SessionHistory when needed. When + // output_text is None no outputText is projected. + if let Some(text) = output_text { + p.insert("outputText".to_string(), json!(text)); + } + serde_json::Value::Object(p) + }, + )), } } @@ -516,6 +556,8 @@ mod tests { workspace_id: Some("workspace-wt-1".to_string()), remote_connection_id: None, remote_ssh_host: None, + parent_session_id: Some("parent-session".to_string()), + subagent_type: Some("Explore".to_string()), }) .expect("projected"); @@ -523,6 +565,8 @@ mod tests { assert_eq!(projected.payload["projectWorkspacePath"], "/repo"); assert_eq!(projected.payload["executionTarget"]["worktreeId"], "wt-1"); assert_eq!(projected.payload["workspaceId"], "workspace-wt-1"); + assert_eq!(projected.payload["parentSessionId"], "parent-session"); + assert_eq!(projected.payload["subagentType"], "Explore"); } #[test] diff --git a/src/crates/contracts/product-domains/src/external_hook_import.rs b/src/crates/contracts/product-domains/src/external_hook_import.rs index ced6542990..4548227e90 100644 --- a/src/crates/contracts/product-domains/src/external_hook_import.rs +++ b/src/crates/contracts/product-domains/src/external_hook_import.rs @@ -560,7 +560,7 @@ fn hash_part(hasher: &mut Sha256, value: &[u8]) { hasher.update(value); } -fn validate_asset_path(path: &PathBuf) -> Result<(), ExternalSourceContractError> { +fn validate_asset_path(path: &Path) -> Result<(), ExternalSourceContractError> { if path.as_os_str().is_empty() || path.is_absolute() || path.components().count() > MAX_EXTERNAL_HOOK_IMPORT_ASSET_DEPTH diff --git a/src/crates/contracts/product-domains/src/miniapp/runtime_facade.rs b/src/crates/contracts/product-domains/src/miniapp/runtime_facade.rs index 5a9d296410..ff26b6f418 100644 --- a/src/crates/contracts/product-domains/src/miniapp/runtime_facade.rs +++ b/src/crates/contracts/product-domains/src/miniapp/runtime_facade.rs @@ -208,6 +208,7 @@ impl<'a> MiniAppRuntimeFacade<'a> { Ok(next) } + #[allow(clippy::too_many_arguments)] // shared private install funnel; refactor out of scope async fn install_strict_package( &self, id: String, diff --git a/src/crates/contracts/product-domains/src/tool_permissions.rs b/src/crates/contracts/product-domains/src/tool_permissions.rs index 7074d5c2b2..9a6e44ffd3 100644 --- a/src/crates/contracts/product-domains/src/tool_permissions.rs +++ b/src/crates/contracts/product-domains/src/tool_permissions.rs @@ -427,6 +427,7 @@ pub enum PermissionReplySource { rename_all = "snake_case", rename_all_fields = "camelCase" )] +#[allow(clippy::large_enum_variant)] // contract type; boxing changes the public API surface pub enum PermissionRequestEvent { Asked { request: PermissionRequest, diff --git a/src/crates/contracts/product-domains/tests/function_agent_contracts.rs b/src/crates/contracts/product-domains/tests/function_agent_contracts.rs index d3db55730b..a8d80dafab 100644 --- a/src/crates/contracts/product-domains/tests/function_agent_contracts.rs +++ b/src/crates/contracts/product-domains/tests/function_agent_contracts.rs @@ -203,6 +203,8 @@ fn noop_waker() -> Waker { unsafe fn drop(_: *const ()) {} static VTABLE: RawWakerVTable = RawWakerVTable::new(clone, wake, wake_by_ref, drop); + // SAFETY: The VTABLE's vtable functions never dereference the null data + // pointer, and the waker is only used to poll futures that never wake. unsafe { Waker::from_raw(RawWaker::new(std::ptr::null(), &VTABLE)) } } diff --git a/src/crates/contracts/product-domains/tests/miniapp_contracts.rs b/src/crates/contracts/product-domains/tests/miniapp_contracts.rs index d60fd0feeb..93d0457835 100644 --- a/src/crates/contracts/product-domains/tests/miniapp_contracts.rs +++ b/src/crates/contracts/product-domains/tests/miniapp_contracts.rs @@ -531,6 +531,8 @@ fn noop_waker() -> Waker { unsafe fn drop(_: *const ()) {} static VTABLE: RawWakerVTable = RawWakerVTable::new(clone, wake, wake_by_ref, drop); + // SAFETY: The VTABLE's vtable functions never dereference the null data + // pointer, and the waker is only used to poll futures that never wake. unsafe { Waker::from_raw(RawWaker::new(std::ptr::null(), &VTABLE)) } } diff --git a/src/crates/contracts/runtime-ports/Cargo.toml b/src/crates/contracts/runtime-ports/Cargo.toml index b142db5da6..dd055da501 100644 --- a/src/crates/contracts/runtime-ports/Cargo.toml +++ b/src/crates/contracts/runtime-ports/Cargo.toml @@ -21,7 +21,6 @@ tokio-util = { workspace = true } ts-rs = { workspace = true, optional = true } [features] -default = [] permission = ["dep:bitfun-product-domains"] # Derive `ts_rs::TS` on the wire types so downstream crates (app-server) can # export TypeScript bindings. The `permission` feature gates the product diff --git a/src/crates/contracts/runtime-ports/src/acp_client_port.rs b/src/crates/contracts/runtime-ports/src/acp_client_port.rs new file mode 100644 index 0000000000..6502477a2a --- /dev/null +++ b/src/crates/contracts/runtime-ports/src/acp_client_port.rs @@ -0,0 +1,264 @@ +//! ACP client runtime port. +//! +//! Core-defined boundary for the dedicated ACP tool family (`acp_control`, +//! `acp_message`, `acp_history`). The tools call these methods through the +//! coordinator-injected port while the desktop host provides the concrete +//! implementation backed by `AcpClientService`, so core keeps no dependency +//! on the ACP crate (architecture boundary). +//! +//! Every request/result is `Serialize + Deserialize` so the boundary can be +//! carried across process and workspace boundaries. + +use super::{PortError, PortErrorKind, PortResult, RuntimeServicePort}; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use tokio::sync::mpsc; + +/// `acp_control` action `create` request. +/// +/// Starts a real external ACP client process bound to a persisted session. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpClientCreateRequest { + /// Registered ACP client id (for example `codex` or `claude-code`). + pub client_id: String, + /// Workspace path the external ACP process runs in. + pub workspace_path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_connection_id: Option, +} + +/// Result of [`AcpClientPort::create_session`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpClientCreateResult { + pub session_id: String, + pub session_name: String, + pub agent_type: String, +} + +/// One registered ACP client entry from [`AcpClientPort::list_clients`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpClientSummary { + pub client_id: String, + pub name: String, + /// Aggregated client status (wire string from the ACP service). + pub status: String, + pub session_count: usize, + pub readonly: bool, +} + +/// Result of [`AcpClientPort::list_clients`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpClientListResult { + pub clients: Vec, +} + +/// `acp_control` action `delete` request. +/// +/// Releases the external ACP process/session bound to `session_id`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpClientReleaseRequest { + pub session_id: String, +} + +/// `acp_control` action `cancel` request. +/// +/// Cancels the running dialog turn of the external ACP session. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpClientCancelRequest { + pub session_id: String, +} + +/// `acp_message` request: forward one message to the external ACP process +/// and synchronously return its response text (true bridge, not a local +/// model consumption path). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpClientMessageRequest { + pub session_id: String, + pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout_seconds: Option, +} + +/// Result of [`AcpClientPort::send_message`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpClientMessageResult { + pub session_id: String, + /// Full response text produced by the external ACP agent. + pub response: String, +} + +/// One incrementally streamed output chunk of an ACP direct message. +/// +/// Mirrors the incremental events of `AcpClientService::prompt_agent_stream` +/// (the desktop implementation translates the ACP crate's stream events into +/// this boundary type), so core tools consume streaming without depending on +/// the ACP crate. `Text` chunks are part of the final response; `Thought` +/// chunks are informational only and do not contribute to it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum AcpClientStreamChunk { + /// One incremental text chunk of the external agent's response. + Text { text: String }, + /// One incremental thought chunk from the external agent. + Thought { text: String }, + /// The external agent completed its turn. + Completed, + /// The external turn was cancelled. + Cancelled, +} + +/// Sink receiving [`AcpClientStreamChunk`] items while a streamed ACP message +/// runs. Unbounded so the producer never drops a chunk when the consumer is +/// temporarily slower (for example while it emits per-chunk UI events). +pub type AcpClientStreamChunkSink = mpsc::UnboundedSender; + +/// `SessionMessage` ACP direct-path request: forward one message to the +/// external ACP agent bound to an internal BitFun session. +/// +/// Unlike [`AcpClientMessageRequest`] (which addresses a flow session id of +/// the shape `acp__`), this request addresses the internal +/// session id of an `acp__` session — the same session identity +/// the `acp____prompt` bridge tool (`AcpAgentTool`) uses, so the +/// external conversation state is shared with the delegated-turn path. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpClientBitfunMessageRequest { + /// Registered ACP client id (for example `codex` or `claude-code`). + pub client_id: String, + /// Internal BitFun session id the external ACP process is bound to. + pub bitfun_session_id: String, + pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout_seconds: Option, +} + +/// `acp_history` request: read the persisted transcript of an ACP session. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpClientHistoryRequest { + pub session_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_path: Option, +} + +/// One transcript entry from [`AcpClientPort::read_history`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpClientHistoryEntry { + /// Message role (for example `user` or `assistant`). + pub role: String, + pub content: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timestamp_ms: Option, +} + +/// Result of [`AcpClientPort::read_history`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpClientHistoryResult { + pub session_id: String, + pub entries: Vec, + #[serde(default)] + pub truncated: bool, +} + +/// ACP client runtime port. +/// +/// Implementations live on the product host (desktop) and forward every call +/// to the real `AcpClientService`; core tools never touch the ACP crate. +#[async_trait] +pub trait AcpClientPort: RuntimeServicePort + std::fmt::Debug { + /// Create a persisted ACP flow session and start the external client + /// process for it. Implementations must roll the record back when the + /// process start fails so no orphan record is left behind. + async fn create_session( + &self, + request: AcpClientCreateRequest, + ) -> PortResult; + + /// List registered ACP clients with their current runtime facts. + async fn list_clients(&self) -> PortResult; + + /// Release the external ACP process bound to `session_id`. + async fn release_session(&self, request: AcpClientReleaseRequest) -> PortResult<()>; + + /// Cancel the running dialog turn of the external ACP session. + async fn cancel_session(&self, request: AcpClientCancelRequest) -> PortResult<()>; + + /// Forward one message through the real channel and return the external + /// response synchronously. + async fn send_message( + &self, + request: AcpClientMessageRequest, + ) -> PortResult; + + /// Forward one message through the real channel and stream the external + /// response incrementally. Text chunks are pushed into `chunk_sink` as + /// they arrive; the returned result still carries the full response text + /// (including text that may have been emitted before an early error). + async fn send_message_stream( + &self, + request: AcpClientMessageRequest, + chunk_sink: AcpClientStreamChunkSink, + ) -> PortResult; + + /// Forward one message to the external ACP agent bound to an internal + /// BitFun session (`acp__` session) and return the external + /// response synchronously. This is the `SessionMessage` direct path: no + /// local model turn is involved, only the port call. + async fn send_message_to_bitfun_session( + &self, + request: AcpClientBitfunMessageRequest, + ) -> PortResult; + + /// Streaming variant of [`AcpClientPort::send_message_to_bitfun_session`]: + /// text chunks are pushed into `chunk_sink` as they arrive while the + /// returned result still carries the full response text. + async fn send_message_to_bitfun_session_stream( + &self, + request: AcpClientBitfunMessageRequest, + chunk_sink: AcpClientStreamChunkSink, + ) -> PortResult; + + /// Delete a temporary ACP session: release the external process (if one is + /// live) and remove the persisted flow-session record for `session_id`. + /// Used to recycle one-shot (`persistent=false`) ACP sessions created by + /// the Task tool. + /// + /// `workspace_path` is required to resolve the persisted record. + /// Implementations must reject a `None`/empty value with `InvalidRequest` + /// rather than silently releasing the process without deleting the record + /// (a release-only cleanup would leave an orphan record that keeps the + /// recycled session appearing in listings). Idempotent so a session with + /// no live process or record is a no-op success. + async fn delete_session_record( + &self, + session_id: String, + workspace_path: Option, + ) -> PortResult<()>; + + /// Read the persisted transcript of an ACP session. + async fn read_history( + &self, + request: AcpClientHistoryRequest, + ) -> PortResult; +} + +/// Error helper: wrap an implementation failure as a backend `PortError`. +pub fn acp_backend_error(message: impl Into) -> PortError { + PortError::new(PortErrorKind::Backend, message) +} diff --git a/src/crates/contracts/runtime-ports/src/lib.rs b/src/crates/contracts/runtime-ports/src/lib.rs index a3e065ae28..9f0ecf4890 100644 --- a/src/crates/contracts/runtime-ports/src/lib.rs +++ b/src/crates/contracts/runtime-ports/src/lib.rs @@ -17,6 +17,7 @@ pub use bitfun_core_types::{ WorktreeSummary, }; +mod acp_client_port; mod local_workspace_snapshot; #[cfg(feature = "permission")] mod permission; @@ -34,6 +35,12 @@ pub use bitfun_product_domains::tool_permissions::{ PermissionRuleset, PermissionRuntimeCeiling, PermissionRuntimeCeilingValidationError, ResolvedPermissionPolicy, ToolPermissionConfig, }; +pub use acp_client_port::{ + acp_backend_error, AcpClientBitfunMessageRequest, AcpClientCancelRequest, AcpClientCreateRequest, + AcpClientCreateResult, AcpClientHistoryEntry, AcpClientHistoryRequest, AcpClientHistoryResult, + AcpClientListResult, AcpClientMessageRequest, AcpClientMessageResult, AcpClientPort, + AcpClientReleaseRequest, AcpClientStreamChunk, AcpClientStreamChunkSink, AcpClientSummary, +}; pub use local_workspace_snapshot::{ LocalWorkspaceSnapshotPort, LocalWorkspaceSnapshotSessionRequest, LocalWorkspaceSnapshotStats, LocalWorkspaceSnapshotTurnRequest, @@ -105,6 +112,77 @@ impl std::fmt::Display for PortError { impl std::error::Error for PortError {} +/// Shared agent type used by SessionControl and SessionMessage tools. +/// +/// Known built-in variants have canonical serde representations: +/// - `Agentic` → `"agentic"` (canonical) +/// - `Plan` → `"Plan"` (canonical) +/// - `Cowork` → `"Cowork"` (canonical) +/// +/// Any unrecognised string deserializes into `Other(String)`, so the enum +/// automatically tolerates agent types added by custom or external registries +/// without requiring a crate-level code change. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(untagged)] +pub enum AgentType { + /// Known built-in variant: `agentic`. + #[serde(rename = "agentic", alias = "Agentic", alias = "AGENTIC")] + Agentic, + /// Known built-in variant: `Plan`. + #[serde(rename = "Plan", alias = "plan", alias = "PLAN")] + Plan, + /// Known built-in variant: `Cowork`. + #[serde(rename = "Cowork", alias = "cowork", alias = "COWORK")] + Cowork, + /// Known built-in variant: `DeepResearch` (official research agent). + #[serde(rename = "DeepResearch", alias = "deepresearch", alias = "DEEPRESEARCH")] + DeepResearch, + /// Catch-all for any agent type string not in the known set (custom / external). + #[serde(untagged)] + Other(String), +} + +impl AgentType { + /// Returns the canonical wire representation. + pub fn as_str(&self) -> &str { + match self { + Self::Agentic => "agentic", + Self::Plan => "Plan", + Self::Cowork => "Cowork", + Self::DeepResearch => "DeepResearch", + Self::Other(value) => value.as_str(), + } + } + + /// Default agent type used when none is specified. + pub const fn default_value() -> Self { + Self::Agentic + } + + /// Returns `true` if this is one of the three known built-in variants. + pub fn is_known_builtin(&self) -> bool { + matches!(self, Self::Agentic | Self::Plan | Self::Cowork | Self::DeepResearch) + } +} + +impl From<&str> for AgentType { + fn from(value: &str) -> Self { + match value { + "agentic" | "Agentic" | "AGENTIC" => Self::Agentic, + "Plan" | "plan" | "PLAN" => Self::Plan, + "Cowork" | "cowork" | "COWORK" => Self::Cowork, + "DeepResearch" | "deepresearch" | "DEEPRESEARCH" => Self::DeepResearch, + other => Self::Other(other.to_string()), + } + } +} + +impl std::fmt::Display for AgentType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum RuntimeServiceCapability { @@ -123,6 +201,7 @@ pub enum RuntimeServiceCapability { RemoteWorkspace, RemoteProjection, RemoteCapabilities, + AcpClient, } impl RuntimeServiceCapability { @@ -143,6 +222,7 @@ impl RuntimeServiceCapability { Self::RemoteWorkspace => "remote_workspace", Self::RemoteProjection => "remote_projection", Self::RemoteCapabilities => "remote_capabilities", + Self::AcpClient => "acp_client", } } } @@ -1157,6 +1237,10 @@ pub struct AgentSessionListRequest { pub remote_connection_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub remote_ssh_host: Option, + /// When true, hidden Subagent/Ephemeral sessions are included in the + /// listing (full conversation management). + #[serde(default)] + pub include_hidden: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -1178,6 +1262,15 @@ pub struct AgentSessionSummary { pub turn_count: usize, pub created_at_ms: u64, pub last_active_at_ms: u64, + /// Optional parent session ID for tree-structured display. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_session_id: Option, + /// Optional session runtime status (e.g. "idle", "active", "error"). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, + /// Warden daemon session marker. + #[serde(default)] + pub is_daemon: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -1614,7 +1707,7 @@ pub struct AgentSubmissionRequest { pub metadata: serde_json::Map, } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] #[serde( tag = "kind", @@ -1623,6 +1716,7 @@ pub struct AgentSubmissionRequest { deny_unknown_fields )] pub enum AgentDialogTurnExecution { + #[default] Standard, FreshExternalSubagent { ecosystem_id: String, @@ -1630,12 +1724,6 @@ pub enum AgentDialogTurnExecution { }, } -impl Default for AgentDialogTurnExecution { - fn default() -> Self { - Self::Standard - } -} - #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentDialogTurnRequest { @@ -1665,6 +1753,22 @@ pub struct AgentDialogTurnRequest { pub metadata: serde_json::Map, } +// --------------------------------------------------------------------------- +// prepended_reminders kind constants (Warden bootstrap/penalty injection kinds) +// --------------------------------------------------------------------------- + +/// `prepended_reminders` kind value for penalty/violation record injection. +/// +/// Injected into a violating session's context at every turn until cleared. +pub const POKE_PENALTY_KIND: &str = "PokePenalty"; + +/// `prepended_reminders` kind value for self-boot check (iron-rule summary + +/// Warden protocol declaration). +pub const SELF_BOOT_CHECK_KIND: &str = "SelfBootCheck"; + +/// `prepended_reminders` kind value for RBAC role-reminder injection. +pub const RBAC_ROLE_REMINDER_KIND: &str = "RbacRoleReminder"; + /// Text-only steering request for one exact running dialog turn. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -1674,6 +1778,8 @@ pub struct AgentDialogSteerRequest { pub content: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub display_content: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub prepended_reminders: Vec, } impl AgentDialogTurnExecution { @@ -1972,6 +2078,7 @@ pub struct RoundInjection { pub content: String, pub display_content: String, pub created_at: std::time::SystemTime, + pub prepended_reminders: Vec, } /// Observes round-boundary injections for a given running turn. @@ -2005,7 +2112,18 @@ pub const MAX_THREAD_GOAL_OBJECTIVE_CHARS: usize = 4_000; pub const MAX_CONTEXT_SUMMARY_CHARS: usize = 12_000; /// Max automatic goal continuation dialog turns per objective (legacy goal_mode parity). -pub const MAX_THREAD_GOAL_AUTO_CONTINUATIONS: u32 = 100; +/// +/// This is a defense-in-depth upper bound, not a user-configurable value. The thread-goal +/// auto-continuation counter (`auto_continuation_count` on `ThreadGoal`) is incremented +/// once per continuation turn and compared against this constant. When exceeded, the +/// goal transitions to `Blocked` to prevent runaway autonomous turns. +/// +/// Safety note: This value is intentionally bounded (10) because: +/// - Each continuation turn consumes model tokens (cost). +/// - The token budget (`token_budget` on `ThreadGoal`) acts as the primary soft limit. +/// - The task-level depth check (`Task` tool `max_depth`) acts as the structural hard limit. +/// - This counter is a secondary failsafe for edge cases where budgets are unset. +pub const MAX_THREAD_GOAL_AUTO_CONTINUATIONS: u32 = 10; /// Alias retained for migration from legacy `goal_mode` metadata and docs. pub const MAX_GOAL_CONTINUATIONS: u32 = MAX_THREAD_GOAL_AUTO_CONTINUATIONS; @@ -2064,6 +2182,12 @@ pub struct ThreadGoal { /// Auto-continuation dialog turns scheduled toward this goal (resets on new objective). #[serde(default)] pub auto_continuation_count: u32, + /// Files the goal references as authoritative context (workspace-relative + /// paths the agent should keep in sync while pursuing the goal). Attached + /// to model-backed Warden audit judgements so the LLM can decide pokes + /// against the actual goal context. + #[serde(default)] + pub reference_files: Vec, } impl ThreadGoal { @@ -2123,6 +2247,10 @@ pub struct AgentThreadGoalCreateRequest { pub objective: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub token_budget: Option, + /// Workspace-relative reference files the goal tracks as authoritative + /// context. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reference_files: Option>, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -2303,6 +2431,72 @@ pub trait AgentSubmissionPort: Send + Sync { async fn resolve_session_agent_type(&self, session_id: &str) -> PortResult>; } +/// Request for a model-backed Warden audit judgement. +/// +/// The judgement provider decides whether a finished tool call or failed turn +/// deserves a poke, which candidate rules apply, and what evidence should be +/// attached. When the port is unavailable or the judgement times out, the +/// caller falls back to the mechanical rule ladder. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WardenAuditJudgementRequest { + /// Session whose tool call / turn is being judged. + pub session_id: String, + /// Effective tool name of the finished tool call. + pub tool_name: String, + /// Effective arguments of the finished tool call (scene fingerprint). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_args: Option, + /// Candidate rule ids the mechanical ladder would apply. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub rule_ids: Vec, + /// Evidence summary available to the judgement (failure counts, error + /// text, phase/target facts the caller can provide). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub evidence: Option, +} + +/// Judgement result produced by a model-backed Warden provider. +/// +/// A provider must not fail the audit loop: `should_poke = false` with empty +/// rule ids is a valid "no poke" verdict. +/// +/// WARDEN-07: `shouldPoke` is intentionally *not* `#[serde(default)]`. A +/// verdict missing the field (or an empty object) fails to deserialize, so a +/// malformed model response falls back to the mechanical rule ladder instead +/// of silently defaulting to `false` and suppressing a poke. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WardenAuditJudgementResponse { + pub should_poke: bool, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub rule_ids: Vec, + /// Evidence items the model wants to see before poking (follow-ups). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub evidence_requested: Vec, +} + +/// Model-backed judgement for Warden audit decisions. +/// +/// Providers construct a judgement prompt from the request, parse the model +/// response as [`WardenAuditJudgementResponse`], and return an error when the +/// response cannot be parsed or the judgement times out; the caller then +/// falls back to the mechanical rule ladder. Providers that do not support +/// model judgement keep the default typed unsupported response. +#[async_trait::async_trait] +pub trait WardenModelJudgementPort: Send + Sync { + async fn judge_audit( + &self, + request: WardenAuditJudgementRequest, + ) -> PortResult { + let _ = request; + Err(PortError::new( + PortErrorKind::NotAvailable, + "model-backed warden judgement is not supported by this provider", + )) + } +} + #[async_trait::async_trait] pub trait AgentSessionManagementPort: Send + Sync { async fn list_sessions( @@ -2951,13 +3145,18 @@ impl DelegationPolicy { } pub fn spawn_child(self) -> Self { + let new_depth = self.nesting_depth.saturating_add(1); Self { - allow_subagent_spawn: false, - nesting_depth: self.nesting_depth.saturating_add(1), + allow_subagent_spawn: new_depth < MAX_FISSION_DEPTH, + nesting_depth: new_depth, } } } +/// Maximum allowed fission depth for subagent delegation trees. +/// Forwarded from the authoritative definition in `bitfun_core_types::session_tree`. +pub use bitfun_core_types::session_tree::MAX_FISSION_DEPTH; + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum SubagentContextMode { @@ -3717,6 +3916,7 @@ mod tests { content: "result".to_string(), display_content: "result".to_string(), created_at: std::time::SystemTime::UNIX_EPOCH, + prepended_reminders: Vec::new(), }, }; @@ -3745,6 +3945,7 @@ mod tests { created_at: 1, updated_at: 2, auto_continuation_count: 0, + reference_files: Vec::new(), }; assert!(active.is_active()); assert_eq!(active.remaining_tokens(), Some(9_900)); @@ -3920,6 +4121,7 @@ mod tests { turn_id: "turn_1".to_string(), content: "Please also check the tests".to_string(), display_content: Some("Also check tests".to_string()), + prepended_reminders: Vec::new(), }; let outcome = DialogSteerOutcome::Buffered { session_id: "session_1".to_string(), @@ -4001,6 +4203,7 @@ mod tests { created_at: 1, updated_at: 2, auto_continuation_count: 0, + reference_files: Vec::new(), }, }; @@ -4028,6 +4231,7 @@ mod tests { workspace_path: "/workspace/project".to_string(), objective: "Ship the refactor".to_string(), token_budget: Some(1000), + reference_files: None, }; let update_request = AgentThreadGoalUpdateStatusRequest { session_id: "session_1".to_string(), @@ -4186,6 +4390,7 @@ mod tests { workspace_path: "/workspace/project".to_string(), remote_connection_id: Some("conn-1".to_string()), remote_ssh_host: Some("host-1".to_string()), + include_hidden: false, }; let summary = AgentSessionSummary { session_id: "session_1".to_string(), @@ -4198,6 +4403,9 @@ mod tests { turn_count: 3, created_at_ms: 1000, last_active_at_ms: 2000, + parent_session_id: None, + status: None, + is_daemon: false, }; let delete_request = AgentSessionDeleteRequest { workspace_path: "/workspace/project".to_string(), @@ -4461,7 +4669,7 @@ mod tests { let child = top_level.spawn_child(); - assert!(!child.allow_subagent_spawn); + assert!(child.allow_subagent_spawn); assert_eq!(child.nesting_depth, 1); assert_eq!(child.spawn_child().nesting_depth, 2); } diff --git a/src/crates/contracts/runtime-ports/src/plugin.rs b/src/crates/contracts/runtime-ports/src/plugin.rs index 4883d2764c..db6e63e014 100644 --- a/src/crates/contracts/runtime-ports/src/plugin.rs +++ b/src/crates/contracts/runtime-ports/src/plugin.rs @@ -306,6 +306,7 @@ pub struct PermissionPromptDescriptor { tag = "status" )] #[non_exhaustive] +#[allow(clippy::large_enum_variant)] // contract type; boxing changes the public API surface pub enum PluginPermissionGate { PolicyAllowed { audit: PluginAuditRef, diff --git a/src/crates/execution/agent-runtime/Cargo.toml b/src/crates/execution/agent-runtime/Cargo.toml index cb06d24f71..6e26d8b9dd 100644 --- a/src/crates/execution/agent-runtime/Cargo.toml +++ b/src/crates/execution/agent-runtime/Cargo.toml @@ -10,9 +10,6 @@ autotests = false name = "bitfun_agent_runtime" crate-type = ["rlib"] -[features] -default = [] - [dependencies] async-trait = { workspace = true } bitfun-agent-stream = { path = "../agent-stream" } @@ -22,6 +19,7 @@ bitfun-events = { path = "../../contracts/events" } bitfun-harness = { path = "../harness" } bitfun-runtime-ports = { path = "../../contracts/runtime-ports", features = ["permission"] } bitfun-runtime-services = { path = "../runtime-services" } +bitfun-services-core = { path = "../../services/services-core", features = ["local-storage"] } dashmap = { workspace = true } hex = { workspace = true } log = { workspace = true } diff --git a/src/crates/execution/agent-runtime/src/custom_agent.rs b/src/crates/execution/agent-runtime/src/custom_agent.rs index c0f6cf412c..7ed02653f1 100644 --- a/src/crates/execution/agent-runtime/src/custom_agent.rs +++ b/src/crates/execution/agent-runtime/src/custom_agent.rs @@ -105,6 +105,7 @@ impl CustomAgentDefinitionError { } impl CustomAgentDefinition { + #[allow(clippy::too_many_arguments)] // field-level constructor; matches from_front_matter_fields pub fn new( id: String, name: String, diff --git a/src/crates/execution/agent-runtime/src/custom_subagent.rs b/src/crates/execution/agent-runtime/src/custom_subagent.rs index 65fc4fe210..7f3ded6918 100644 --- a/src/crates/execution/agent-runtime/src/custom_subagent.rs +++ b/src/crates/execution/agent-runtime/src/custom_subagent.rs @@ -111,6 +111,7 @@ pub fn custom_subagent_save_markdown_file( custom_agent_save_markdown_file(path, definition) } +#[allow(clippy::too_many_arguments)] // markdown-part writer for the public subagent save path pub fn custom_subagent_save_markdown_parts( path: impl AsRef, name: &str, diff --git a/src/crates/execution/agent-runtime/src/deep_review/budget.rs b/src/crates/execution/agent-runtime/src/deep_review/budget.rs index 5f95bcd510..8e0602ffe4 100644 --- a/src/crates/execution/agent-runtime/src/deep_review/budget.rs +++ b/src/crates/execution/agent-runtime/src/deep_review/budget.rs @@ -545,6 +545,7 @@ impl DeepReviewBudgetTracker { ) } + #[allow(clippy::too_many_arguments)] // policy-record API; grouping would churn all callers pub fn record_task_for_packet_with_focus( &self, parent_dialog_turn_id: &str, diff --git a/src/crates/execution/agent-runtime/src/deep_review/report.rs b/src/crates/execution/agent-runtime/src/deep_review/report.rs index 3f36c827b0..3b4e465f69 100644 --- a/src/crates/execution/agent-runtime/src/deep_review/report.rs +++ b/src/crates/execution/agent-runtime/src/deep_review/report.rs @@ -255,6 +255,9 @@ pub fn push_reliability_signal_if_missing(input: &mut Value, signal: Value) { let Some(kind) = signal.get("kind").and_then(Value::as_str) else { return; }; + if !input.is_object() { + return; + } if has_reliability_signal(input, kind) { return; } @@ -503,6 +506,9 @@ fn target_evidence_status(run_manifest: Option<&Value>) -> Option<&'static str> } pub fn apply_review_evidence_guardrail(input: &mut Value, run_manifest: Option<&Value>) { + if !input.is_object() { + return; + } if input .get("evidence_status") .and_then(Value::as_str) @@ -538,6 +544,9 @@ pub fn apply_review_evidence_guardrail(input: &mut Value, run_manifest: Option<& } pub fn apply_review_runtime_limitation(input: &mut Value, detail: &str) { + if !input.is_object() { + return; + } if input.get("evidence_status").and_then(Value::as_str) != Some("failed") { input["evidence_status"] = json!("limited"); } @@ -553,6 +562,9 @@ pub fn apply_review_runtime_limitation(input: &mut Value, detail: &str) { } pub fn apply_review_runtime_stale(input: &mut Value) { + if !input.is_object() { + return; + } if input.get("evidence_status").and_then(Value::as_str) != Some("failed") { input["evidence_status"] = json!("stale"); } @@ -663,6 +675,23 @@ mod tests { assert!(input.get("reliability_signals").is_none()); } + #[test] + fn report_writes_on_non_object_input_are_safe_noops() { + let mut input = json!([1, 2, 3]); + + push_reliability_signal_if_missing( + &mut input, + json!({ "kind": "cache_hit", "severity": "info" }), + ); + fill_deep_review_runtime_tracker_signal(&mut input, 3); + apply_review_evidence_guardrail(&mut input, None); + apply_review_runtime_limitation(&mut input, "test limitation"); + apply_review_runtime_stale(&mut input); + fill_deep_review_reliability_signals(&mut input, None, None); + + assert_eq!(input, json!([1, 2, 3])); + } + #[test] fn target_evidence_limit_has_a_distinct_warning_signal() { let manifest = json!({ diff --git a/src/crates/execution/agent-runtime/src/deep_review/task_execution.rs b/src/crates/execution/agent-runtime/src/deep_review/task_execution.rs index 8617a9b83b..dc7c8b654e 100644 --- a/src/crates/execution/agent-runtime/src/deep_review/task_execution.rs +++ b/src/crates/execution/agent-runtime/src/deep_review/task_execution.rs @@ -420,6 +420,7 @@ pub struct DeepReviewTaskCompletionResultInput<'a> { pub reason: Option<&'a str>, pub ledger_event_id: Option<&'a str>, pub retry_hint: &'a str, + pub session_id: Option<&'a str>, } pub fn deep_review_task_completion_result( @@ -435,6 +436,7 @@ pub fn deep_review_task_completion_result( reason: input.reason, ledger_event_id: input.ledger_event_id, partial_timeout_suffix: input.retry_hint, + session_id: input.session_id, }, ) } @@ -2112,6 +2114,7 @@ mod tests { reason: None, ledger_event_id: None, retry_hint: "", + session_id: None, }); assert_eq!(data["duration"], json!(42)); @@ -2136,6 +2139,7 @@ mod tests { reason: Some("timeout"), ledger_event_id: Some("event-1"), retry_hint: "\n\nretry", + session_id: None, }); assert_eq!(data["status"], "partial_timeout"); diff --git a/src/crates/execution/agent-runtime/src/deep_review/team_definition.rs b/src/crates/execution/agent-runtime/src/deep_review/team_definition.rs index 5881f33af0..6a8ddb1108 100644 --- a/src/crates/execution/agent-runtime/src/deep_review/team_definition.rs +++ b/src/crates/execution/agent-runtime/src/deep_review/team_definition.rs @@ -84,6 +84,7 @@ fn role( } } +#[allow(clippy::too_many_arguments)] // strategy manifest builder; all params are profile fields fn strategy_profile( level: &str, label: &str, diff --git a/src/crates/execution/agent-runtime/src/prompt.rs b/src/crates/execution/agent-runtime/src/prompt.rs index a576206bcc..6e665f0c37 100644 --- a/src/crates/execution/agent-runtime/src/prompt.rs +++ b/src/crates/execution/agent-runtime/src/prompt.rs @@ -251,6 +251,64 @@ pub fn render_runtime_context_reminder(facts: &RuntimeContextFacts) -> Option, + pub compression_preview_ratio: Option, +} + +/// Fully formatted runtime facts for prompt injection. Time strings are +/// formatted by the caller with `chrono::Local`, matching the GetTime tool +/// shape (RFC3339 seconds precision, `%A` weekday, `%:z` offset). +#[derive(Debug, Clone, PartialEq)] +pub struct RuntimeFactsInput { + pub local_time_rfc3339: String, + pub utc_time_rfc3339: String, + pub weekday_name: String, + pub weekday_number: u32, + pub local_hhmm: String, + pub timezone_offset: String, + pub context_usage_ratio: Option, + pub compression_preview_ratio: Option, +} + +/// Render the per-turn runtime facts reminder: current time facts + live +/// context usage percentage. Owner ruling (P-02): keep only the bare number +/// next to the real-time clock; the 30% warning, compression preview, and +/// peak/off-peak pricing guidance are removed (they wasted tokens and backfired). +pub fn render_runtime_facts_reminder(facts: &RuntimeFactsInput) -> String { + let mut lines = vec![ + "[Runtime Facts]".to_string(), + format!( + "- 当前本地时间: {}(周{} {})", + facts.local_time_rfc3339, facts.weekday_number, facts.weekday_name + ), + format!("- UTC 时间: {}", facts.utc_time_rfc3339), + format!("- 时区偏移: {}", facts.timezone_offset), + ]; + + // 主人裁决(P-02):上下文占比只保留纯数字,与实时时间并列即可; + // 删除 30% 提醒/压缩预览/峰谷定价长句("加那多戏还浪费 token,起反效果")。 + if let Some(usage_ratio) = facts.context_usage_ratio { + let percent = usage_percent(usage_ratio); + lines.push(format!("- 当前上下文占比: {}%", percent)); + } + + lines.join("\n") +} + +/// 0-100 integer percentage, rounded; clamped at 100 defensively. +fn usage_percent(usage_ratio: f32) -> u32 { + ((usage_ratio * 100.0).round() as u32).min(100) +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct PromptRelatedPath { pub path: String, @@ -670,11 +728,22 @@ pub struct PrependedPromptReminders { pub skill_listing: Option, pub agent_listing: Option, pub runtime_context: Option, + pub runtime_facts: Option, pub user_context: Option, } impl PrependedPromptReminders { pub fn ordered_reminders(&self) -> Vec<&str> { + let mut reminders = self.static_ordered_reminders(); + reminders.extend(self.dynamic_ordered_reminders()); + reminders + } + + /// Static reminders that stay stable across rounds within a turn: + /// deferred tool listing, skill listing, agent listing, runtime context. + /// These keep the provider-side prompt/prefix cache stable when injected + /// right after the system message (before the conversation history). + pub fn static_ordered_reminders(&self) -> Vec<&str> { let mut reminders = Vec::new(); if let Some(deferred_tool_listing) = self.deferred_tool_listing.as_deref() { reminders.push(deferred_tool_listing); @@ -688,6 +757,19 @@ impl PrependedPromptReminders { if let Some(runtime_context) = self.runtime_context.as_deref() { reminders.push(runtime_context); } + reminders + } + + /// Per-round dynamic reminders: runtime facts (live time + context usage + /// ratio, refreshed every round) and user context. These must be appended + /// at the end of the message sequence (after the newest user message) so + /// they never break the stable cache prefix built from the system message, + /// static reminders and the full conversation history. + pub fn dynamic_ordered_reminders(&self) -> Vec<&str> { + let mut reminders = Vec::new(); + if let Some(runtime_facts) = self.runtime_facts.as_deref() { + reminders.push(runtime_facts); + } if let Some(user_context) = self.user_context.as_deref() { reminders.push(user_context); } diff --git a/src/crates/execution/agent-runtime/src/prompt_cache.rs b/src/crates/execution/agent-runtime/src/prompt_cache.rs index 249fbd55bf..5ed93a1a05 100644 --- a/src/crates/execution/agent-runtime/src/prompt_cache.rs +++ b/src/crates/execution/agent-runtime/src/prompt_cache.rs @@ -232,6 +232,10 @@ impl PromptCacheScope { pub struct SessionPromptCacheStore { session_caches: Arc>, user_context_generations: Arc>, + /// P-18:记录每个 session 最近一次实际注入 User Context 时的缓存世代, + /// 用于会话级一次注入(新对话/压缩后注入 1 次,同世代后续轮不注入)。 + /// 与 user_context_generations 同生命周期(仅内存态,session 删除即清除)。 + user_context_injected_generations: Arc>, } pub enum PromptCacheLookup { @@ -251,6 +255,7 @@ impl SessionPromptCacheStore { Self { session_caches: Arc::new(DashMap::new()), user_context_generations: Arc::new(DashMap::new()), + user_context_injected_generations: Arc::new(DashMap::new()), } } @@ -386,6 +391,20 @@ impl SessionPromptCacheStore { true } + /// P-18:读取该 session 最近一次实际注入 User Context 时的缓存世代。 + /// None = 从未注入(新对话首轮应注入)。 + pub fn user_context_injected_generation(&self, session_id: &str) -> Option { + self.user_context_injected_generations + .get(session_id) + .map(|generation| *generation) + } + + /// P-18:记录该 session 已在指定缓存世代实际注入过 User Context。 + pub fn remember_user_context_injected_generation(&self, session_id: &str, generation: u64) { + self.user_context_injected_generations + .insert(session_id.to_string(), generation); + } + pub fn invalidate(&self, session_id: &str, scope: PromptCacheScope) -> bool { let _user_context_generation = if scope.clears_user_context() { let mut generation = self @@ -413,6 +432,7 @@ impl SessionPromptCacheStore { pub fn delete_session(&self, session_id: &str) { self.user_context_generations.remove(session_id); + self.user_context_injected_generations.remove(session_id); self.session_caches.remove(session_id); } } @@ -548,4 +568,64 @@ mod tests { .user_context .is_none()); } + + #[test] + fn user_context_injected_generation_starts_none_for_new_session() { + // P-18:新对话从未注入 User Context → None(首轮应注入,会话级一次)。 + let store = SessionPromptCacheStore::new(); + store.create_session("session-1"); + + assert_eq!(store.user_context_injected_generation("session-1"), None); + } + + #[test] + fn remember_user_context_injected_generation_records_generation() { + let store = SessionPromptCacheStore::new(); + store.create_session("session-1"); + let generation = store.user_context_generation("session-1"); + + store.remember_user_context_injected_generation("session-1", generation); + + assert_eq!( + store.user_context_injected_generation("session-1"), + Some(generation) + ); + } + + #[test] + fn user_context_invalidation_bumps_generation_so_reinjection_is_needed() { + // P-18:压缩/新对话使 User Context 缓存失效 → 世代递增 → + // 注入世代落后于当前世代 → 恢复后首轮需重新注入。 + let store = SessionPromptCacheStore::new(); + store.create_session("session-1"); + let generation = store.user_context_generation("session-1"); + store.remember_user_context_injected_generation("session-1", generation); + store.set_user_context( + "session-1", + CachedUserContext::new( + UserContextCacheIdentity::new("workspace_context"), + "cached user context", + ), + ); + + assert!(store.invalidate("session-1", PromptCacheScope::UserContext)); + + let next_generation = store.user_context_generation("session-1"); + assert!(next_generation > generation); + assert_ne!( + store.user_context_injected_generation("session-1"), + Some(next_generation) + ); + } + + #[test] + fn delete_session_clears_user_context_injected_generation() { + let store = SessionPromptCacheStore::new(); + store.create_session("session-1"); + store.remember_user_context_injected_generation("session-1", 3); + + store.delete_session("session-1"); + + assert_eq!(store.user_context_injected_generation("session-1"), None); + } } diff --git a/src/crates/execution/agent-runtime/src/runtime.rs b/src/crates/execution/agent-runtime/src/runtime.rs index 18419c1e01..0bfba9d065 100644 --- a/src/crates/execution/agent-runtime/src/runtime.rs +++ b/src/crates/execution/agent-runtime/src/runtime.rs @@ -40,6 +40,7 @@ use bitfun_runtime_ports::{ WorkspaceDiffSnapshot, }; use bitfun_runtime_services::RuntimeServices; +use bitfun_services_core::session::tree::SessionTreeManager; use crate::event_source::{AgentEventReceiver, AgentEventSource, AgentSessionEventReceiver}; use crate::permission::{PermissionRequestEventReceiver, PermissionRequestManager}; @@ -210,6 +211,7 @@ pub struct AgentRuntime { hook_registry: RuntimeHookRegistry, agent_registry: Option>, plugin_runtime: PluginRuntimeBinding, + session_tree: Option>, } impl std::fmt::Debug for AgentRuntime { @@ -385,6 +387,10 @@ impl std::fmt::Debug for AgentRuntime { .map(|_| ""), ) .field("plugin_runtime", &self.plugin_runtime.availability()) + .field( + "session_tree", + &self.session_tree.as_ref().map(|_| ""), + ) .finish() } } @@ -434,6 +440,7 @@ pub struct AgentRuntimeBuilder { hook_registry: RuntimeHookRegistry, agent_registry: Option>, plugin_runtime: PluginRuntimeBinding, + session_tree: Option>, } impl AgentRuntimeBuilder { @@ -621,6 +628,11 @@ impl AgentRuntimeBuilder { self } + pub fn with_session_tree(mut self, tree: Arc) -> Self { + self.session_tree = Some(tree); + self + } + pub fn build(self) -> Result { let Self { submission, @@ -653,6 +665,7 @@ impl AgentRuntimeBuilder { hook_registry, agent_registry, plugin_runtime, + session_tree, } = self; if plugin_runtime.is_client_binding() && !plugin_runtime.availability().is_executable() { @@ -690,6 +703,7 @@ impl AgentRuntimeBuilder { hook_registry, agent_registry, plugin_runtime, + session_tree, }) } } @@ -966,6 +980,10 @@ impl AgentRuntime { &self.plugin_runtime } + pub fn session_tree(&self) -> Option<&Arc> { + self.session_tree.as_ref() + } + pub fn registered_agent_ids(&self, query: RuntimeAgentRegistryQuery<'_>) -> Vec { self.agent_registry .as_ref() @@ -1642,6 +1660,20 @@ impl AgentRuntime { }) .await?; let agent_type = created.agent_type; + if let Some(ref tree) = self.session_tree { + if let Some(parent_id) = request.metadata.get("parent_session_id").and_then(|v| v.as_str()) { + let parent_depth = tree.get_depth(parent_id).unwrap_or(0); + let child_depth = parent_depth + 1; + if let Err(e) = tree.register_child(parent_id, &created.session_id, child_depth) { + log::warn!( + "Failed to register child session {} under parent {}: {:?}", + created.session_id, + parent_id, + e, + ); + } + } + } (created.session_id, Some(agent_type)) } }; @@ -1792,6 +1824,7 @@ mod tests { created_at: 1, updated_at: 2, auto_continuation_count: 0, + reference_files: Vec::new(), } } @@ -1813,6 +1846,9 @@ mod tests { turn_count: 3, created_at_ms: 1000, last_active_at_ms: 2000, + parent_session_id: None, + status: None, + is_daemon: false, }]) } @@ -1985,6 +2021,9 @@ mod tests { turn_count: 3, created_at_ms: 1000, last_active_at_ms: 2000, + parent_session_id: None, + status: None, + is_daemon: false, }, state: SessionState::Idle, }) @@ -2647,6 +2686,7 @@ mod tests { workspace_path: "/workspace/project".to_string(), remote_connection_id: None, remote_ssh_host: None, + include_hidden: false, }) .await .unwrap_err(); @@ -2668,6 +2708,7 @@ mod tests { workspace_path: "/workspace/project".to_string(), remote_connection_id: None, remote_ssh_host: None, + include_hidden: false, }) .await .expect("list sessions"); @@ -2892,6 +2933,7 @@ mod tests { workspace_path: "/workspace/project".to_string(), objective: "Ship runtime port".to_string(), token_budget: Some(1000), + reference_files: None, }) .await .expect("create goal"); @@ -3125,6 +3167,9 @@ mod tests { turn_count: 3, created_at_ms: 1000, last_active_at_ms: 2000, + parent_session_id: None, + status: None, + is_daemon: false, }, state: SessionState::Error { error: "recoverable failure".to_string(), @@ -3383,6 +3428,7 @@ mod tests { turn_id: "turn_1".to_string(), content: "check tests".to_string(), display_content: None, + prepended_reminders: Vec::new(), }) .await .expect_err("steering without a dialog-turn provider must fail"); @@ -3433,6 +3479,7 @@ mod tests { turn_id: "turn_1".to_string(), content: "check tests".to_string(), display_content: Some("Check tests".to_string()), + prepended_reminders: Vec::new(), }; let result = runtime @@ -3492,6 +3539,7 @@ mod tests { turn_id: "turn_1".to_string(), content: "check tests".to_string(), display_content: None, + prepended_reminders: Vec::new(), }) .await .expect_err("provider turn mismatch must fail closed"); @@ -3599,6 +3647,7 @@ mod tests { created_at: 1, updated_at: 2, auto_continuation_count: 0, + reference_files: Vec::new(), }, }) .await diff --git a/src/crates/execution/agent-runtime/src/scheduler.rs b/src/crates/execution/agent-runtime/src/scheduler.rs index 3cb6f9e1a3..4b1ca01ec2 100644 --- a/src/crates/execution/agent-runtime/src/scheduler.rs +++ b/src/crates/execution/agent-runtime/src/scheduler.rs @@ -4,15 +4,15 @@ use crate::events::turn_outcome_kind; use crate::thread_goal::{build_objective_updated_plan, build_thread_goal_continuation_plan}; use bitfun_runtime_ports::{ should_skip_agent_session_reply, should_suppress_agent_session_cancelled_reply, - AgentSessionReplyRoute, DialogQueuePriority, DialogRoundInjectionSource, - DialogSessionStateFact, DialogSteerOutcome, DialogSubmissionPolicy, DialogTriggerSource, - RoundInjection, RoundInjectionKind, RoundInjectionTarget, RoundInjectionToolPreemption, - ThreadGoal, + AgentDialogPrependedReminder, AgentSessionReplyRoute, DialogQueuePriority, + DialogRoundInjectionSource, DialogSessionStateFact, DialogSteerOutcome, + DialogSubmissionPolicy, DialogTriggerSource, RoundInjection, RoundInjectionKind, + RoundInjectionTarget, RoundInjectionToolPreemption, ThreadGoal, }; use std::collections::VecDeque; use std::fmt; use std::sync::Arc; -use std::time::SystemTime; +use std::time::{SystemTime, UNIX_EPOCH}; pub const DEFAULT_MAX_DIALOG_QUEUE_DEPTH: usize = 20; @@ -30,6 +30,7 @@ pub struct ActiveDialogTurn { } impl ActiveDialogTurn { + #[allow(clippy::too_many_arguments)] // state constructor; mirrors the struct fields pub fn new( turn_id: String, workspace_path: Option, @@ -127,6 +128,7 @@ pub struct ActiveDialogTurnStore { } #[derive(Debug)] +#[allow(clippy::large_enum_variant)] // matched turn is inherently larger than control outcomes pub enum ActiveDialogTurnTakeResult { Matched(ActiveDialogTurn), Absent, @@ -203,6 +205,15 @@ impl DialogReplySuppressionSet { .remove(&(session_id.to_string(), turn_id.to_string())) .is_some() } + + /// Remove every entry belonging to `session_id`, regardless of turn id. + /// + /// Session-end cleanup: a recycled session id must not inherit suppression + /// marks or retired-outcome tombstones from the previous session. + pub fn clear_session(&self, session_id: &str) { + self.inner + .retain(|(entry_session_id, _), _| entry_session_id != session_id); + } } #[derive(Debug, Default)] @@ -701,6 +712,7 @@ pub fn resolve_background_delivery_injection( content, display_content, created_at, + prepended_reminders: Vec::new(), } } @@ -893,8 +905,50 @@ pub fn resolve_turn_outcome_lifecycle_plan( } } +/// Current UTC time formatted as ISO-8601 with second precision and a `Z` +/// suffix (e.g. `2026-08-05T03:14:15Z`), matching the GetTime tool's `utc_time` +/// shape (see `get_time_tool.rs` `to_rfc3339_opts(SecondsFormat::Secs, true)`). +/// +/// std-only implementation: `bitfun-agent-runtime` deliberately has no +/// `chrono` dependency, so the civil-date conversion uses Howard Hinnant's +/// public-domain `civil_from_days` algorithm (from the C++ `` +/// compatibility paper), translated to Rust (not a Cargo dependency). +pub fn utc_iso8601_now() -> String { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + let total_seconds = now.as_secs() as i64; + let days = total_seconds.div_euclid(86_400); + let seconds_of_day = total_seconds.rem_euclid(86_400); + let (year, month, day) = civil_from_days(days); + let hour = seconds_of_day / 3_600; + let minute = (seconds_of_day % 3_600) / 60; + let second = seconds_of_day % 60; + format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z") +} + +/// Days since 1970-01-01 to a civil (year, month, day) date. +/// +/// Howard Hinnant's `civil_from_days` (public domain, C++ `` paper), +/// Rust translation, not a Cargo dependency. +fn civil_from_days(days: i64) -> (i64, u32, u32) { + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = (doy - (153 * mp + 2) / 5 + 1) as u32; + let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + let year = if month <= 2 { y + 1 } else { y }; + (year, month, day) +} + pub fn resolve_agent_session_reply_action( responder_session_id: &str, + responder_role: Option<&str>, + responder_depth: Option, active_turn: &ActiveDialogTurn, outcome: &TurnOutcome, suppressed_cancelled_reply: bool, @@ -915,19 +969,49 @@ pub fn resolve_agent_session_reply_action( .workspace_path() .unwrap_or(""); let status = outcome.status(); + let server_time = utc_iso8601_now(); + let mut reminder_lines = vec![ + "This message is an automated reply to a previous SessionMessage call, not a human user message." + .to_string(), + format!("From session: {responder_session_id}"), + format!("From workspace: {responder_workspace}"), + format!("Status: {status}"), + format!("Server time: {server_time}"), + ]; + if let Some(role) = responder_role { + reminder_lines.push(format!("From role: {role}")); + } + if let Some(depth) = responder_depth { + reminder_lines.push(format!("From depth: {depth}")); + } + // Rewrite the forwarded request metadata with the *responder* identity so + // the reply message never carries the original sender's badge (R-23). + let mut reply_metadata = match active_turn.user_message_metadata() { + Some(serde_json::Value::Object(map)) => map.clone(), + _ => serde_json::Map::new(), + }; + reply_metadata.retain(|key, _| !key.starts_with("sender")); + reply_metadata.insert( + "senderSessionId".to_string(), + serde_json::json!(responder_session_id), + ); + // Server-side timestamp for audit/timeline cross-checks. The forwarding + // side only strips `sender*` keys, so this key passes through untouched. + reply_metadata.insert("serverTime".to_string(), serde_json::json!(server_time)); + if let Some(role) = responder_role { + reply_metadata.insert("senderRole".to_string(), serde_json::json!(role)); + } + if let Some(depth) = responder_depth { + reply_metadata.insert("senderDepth".to_string(), serde_json::json!(depth)); + } AgentSessionReplyAction::Forward(AgentSessionReplyPlan { target_session_id: reply_route.source_session_id.clone(), target_workspace_path: reply_route.source_workspace_path.clone(), target_remote_connection_id: reply_route.source_remote_connection_id.clone(), target_remote_ssh_host: reply_route.source_remote_ssh_host.clone(), user_input: outcome.reply_text(), - reminder_text: format!( - "This message is an automated reply to a previous SessionMessage call, not a human user message.\n\ -From session: {responder_session_id}\n\ -From workspace: {responder_workspace}\n\ -Status: {status}" - ), - user_message_metadata: active_turn.user_message_metadata().cloned(), + reminder_text: reminder_lines.join("\n"), + user_message_metadata: Some(serde_json::Value::Object(reply_metadata)), }) } @@ -939,6 +1023,7 @@ pub fn resolve_dialog_steering_action( display_content: Option, steering_id: String, created_at: SystemTime, + prepended_reminders: Vec, ) -> DialogSteeringAction { if active_turn_id != Some(turn_id) { return DialogSteeringAction::Reject { @@ -958,6 +1043,7 @@ pub fn resolve_dialog_steering_action( content, display_content: display, created_at, + prepended_reminders, }, outcome: DialogSteerOutcome::Buffered { session_id: session_id.to_string(), @@ -1100,4 +1186,61 @@ mod tests { ); assert!(plan.dispatch_next()); } + + #[test] + fn dialog_steering_rejects_when_target_turn_is_not_running() { + let action = resolve_dialog_steering_action( + Some("turn-running"), + "session-1", + "turn-finished", + "urgent correction".to_string(), + None, + "steering-1".to_string(), + SystemTime::now(), + Vec::new(), + ); + + let DialogSteeringAction::Reject { error } = action else { + panic!("steering a non-running turn must be rejected"); + }; + assert!(error.contains("no longer running")); + } + + #[test] + fn dialog_steering_buffers_user_steering_for_the_active_turn() { + let action = resolve_dialog_steering_action( + Some("turn-running"), + "session-1", + "turn-running", + "urgent correction".to_string(), + Some("display text".to_string()), + "steering-1".to_string(), + SystemTime::now(), + Vec::new(), + ); + + let DialogSteeringAction::Buffer { injection, outcome } = action else { + panic!("steering the active turn must be buffered"); + }; + assert_eq!(injection.kind, RoundInjectionKind::UserSteering); + assert_eq!( + injection.execution_policy, + RoundInjectionKind::UserSteering.default_execution_policy() + ); + assert_eq!( + injection.target, + RoundInjectionTarget::ExactTurn("turn-running".to_string()) + ); + assert_eq!(injection.content, "urgent correction"); + assert_eq!(injection.display_content.as_str(), "display text"); + + let DialogSteerOutcome::Buffered { + session_id, + turn_id, + steering_id, + } = outcome; + assert_eq!(session_id, "session-1"); + assert_eq!(turn_id, "turn-running"); + assert_eq!(steering_id, "steering-1"); + } } diff --git a/src/crates/execution/agent-runtime/src/sdk.rs b/src/crates/execution/agent-runtime/src/sdk.rs index cc9f1eacd1..42e78f0869 100644 --- a/src/crates/execution/agent-runtime/src/sdk.rs +++ b/src/crates/execution/agent-runtime/src/sdk.rs @@ -308,6 +308,14 @@ impl AgentRuntimeBuilder { self } + pub fn with_session_tree( + mut self, + tree: Arc, + ) -> Self { + self.inner = self.inner.with_session_tree(tree); + self + } + pub fn build(self) -> Result { self.inner.build().map(|inner| AgentRuntime { inner }) } diff --git a/src/crates/execution/agent-runtime/src/session.rs b/src/crates/execution/agent-runtime/src/session.rs index c380648bcb..1e9225ebbd 100644 --- a/src/crates/execution/agent-runtime/src/session.rs +++ b/src/crates/execution/agent-runtime/src/session.rs @@ -199,6 +199,11 @@ pub struct SessionConfig { /// Mutable sessions leave this unset and continue to resolve selectors. #[serde(default, skip_serializing_if = "Option::is_none")] pub model_binding_fingerprint: Option, + /// Warden daemon session marker. + /// Daemon sessions are invisible to SessionControl(list) and cannot be + /// deleted via SessionControl(delete). + #[serde(default)] + pub is_daemon: bool, /// Durable owner of the logical main-agent route. External ownership is /// revalidated for every turn and never falls back by name alone. #[serde(default, skip_serializing_if = "is_local_agent_route_owner")] @@ -220,7 +225,7 @@ fn is_local_agent_route_owner(owner: &SessionAgentRouteOwner) -> bool { impl Default for SessionConfig { fn default() -> Self { Self { - max_context_tokens: 128128, + max_context_tokens: 1_048_576, auto_compact: true, enable_tools: true, safe_mode: true, @@ -237,6 +242,7 @@ impl Default for SessionConfig { continuation_policy: SessionContinuationPolicy::default(), model_binding_policy: SessionModelBindingPolicy::default(), model_binding_fingerprint: None, + is_daemon: false, agent_route_owner: SessionAgentRouteOwner::Local, } } @@ -275,6 +281,12 @@ pub struct SessionSummary { pub created_at: SystemTime, pub last_activity_at: SystemTime, pub state: SessionState, + /// Optional parent session ID for tree-structured display. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_session_id: Option, + /// Warden daemon session marker. + #[serde(default)] + pub is_daemon: bool, } /// Persisted session state sidecar used by product session storage. @@ -324,7 +336,8 @@ mod tests { fn session_config_default_preserves_existing_context_budget() { let config = SessionConfig::default(); - assert_eq!(config.max_context_tokens, 128128); + let expected_context_tokens: usize = 1_048_576; + assert_eq!(config.max_context_tokens, expected_context_tokens); assert!(config.auto_compact); assert!(config.enable_tools); assert!(config.safe_mode); @@ -498,29 +511,31 @@ mod tests { runtime_state: SessionState::Idle, }; + let expected = json!({ + "schema_version": 1, + "config": { + "max_context_tokens": 1_048_576, + "auto_compact": true, + "enable_tools": true, + "safe_mode": true, + "max_turns": 200, + "enable_context_compression": true, + "workspace_path": "/workspace", + "model_id": "model-a", + "is_daemon": false + }, + "snapshot_session_id": "snapshot-1", + "last_user_dialog_agent_type": "agentic", + "last_submitted_agent_type": "DeepReview", + "compression_state": { + "last_compression_at": null, + "compression_count": 2 + }, + "runtime_state": "Idle" + }); assert_eq!( serde_json::to_value(file).expect("persisted session state should serialize"), - json!({ - "schema_version": 1, - "config": { - "max_context_tokens": 128128, - "auto_compact": true, - "enable_tools": true, - "safe_mode": true, - "max_turns": 200, - "enable_context_compression": true, - "workspace_path": "/workspace", - "model_id": "model-a" - }, - "snapshot_session_id": "snapshot-1", - "last_user_dialog_agent_type": "agentic", - "last_submitted_agent_type": "DeepReview", - "compression_state": { - "last_compression_at": null, - "compression_count": 2 - }, - "runtime_state": "Idle" - }) + expected ); } } diff --git a/src/crates/execution/agent-runtime/src/session_control.rs b/src/crates/execution/agent-runtime/src/session_control.rs index b660e901c7..d8ab3ac670 100644 --- a/src/crates/execution/agent-runtime/src/session_control.rs +++ b/src/crates/execution/agent-runtime/src/session_control.rs @@ -11,6 +11,7 @@ pub enum SessionControlAction { Cancel, Delete, List, + Compact, } impl SessionControlAction { @@ -20,36 +21,15 @@ impl SessionControlAction { Self::Cancel => "cancel", Self::Delete => "delete", Self::List => "list", + Self::Compact => "compact", } } } -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -pub enum SessionControlAgentType { - #[serde(rename = "agentic", alias = "Agentic", alias = "AGENTIC")] - Agentic, - #[serde(rename = "Plan", alias = "plan", alias = "PLAN")] - Plan, - #[serde(rename = "Cowork", alias = "cowork", alias = "COWORK")] - Cowork, - #[serde( - rename = "DeepResearch", - alias = "deepresearch", - alias = "DEEPRESEARCH" - )] - DeepResearch, -} - -impl SessionControlAgentType { - pub const fn as_str(&self) -> &'static str { - match self { - Self::Agentic => "agentic", - Self::Plan => "Plan", - Self::Cowork => "Cowork", - Self::DeepResearch => "DeepResearch", - } - } -} +/// Re-export of the shared agent type enum from runtime-ports. +/// Covers official agent types (agentic / Plan / Cowork / DeepResearch) +/// plus any custom / external agent type strings (incl. `acp__` sessions). +pub use bitfun_runtime_ports::AgentType as SessionControlAgentType; #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] pub struct SessionControlInput { @@ -58,6 +38,18 @@ pub struct SessionControlInput { pub session_id: Option, pub session_name: Option, pub agent_type: Option, + /// Optional compact display name used by `list` compact output. Only + /// meaningful for `create`; the value is persisted as `shortName` in the + /// session's custom metadata so it survives restarts. + pub short_name: Option, + /// Optional model id used when creating the session. Only meaningful for + /// `create`; forwarded to the session config so the session is created + /// with the requested model (mirrors the Task(spawn) model_id parameter). + pub model_id: Option, + /// When true, `list` emits the full session tree (session_name included) + /// instead of the compact per-session line output. Only meaningful for + /// `list`. + pub detail: Option, } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] @@ -127,6 +119,43 @@ pub fn session_control_session_name_or_default(session_name: Option<&str>) -> St .to_string() } +/// Maximum number of characters a user-provided short name may keep. The cap +/// bounds `list` compact output; validation rejects longer values and the +/// compact renderer truncates defensively. +pub const SHORT_NAME_MAX_CHARS: usize = 60; + +/// Maximum number of characters a compact display name keeps from the full +/// session name when no explicit short name is set. Aliased to +/// [`SHORT_NAME_MAX_CHARS`] so both paths share a single bound. +pub const COMPACT_SESSION_NAME_MAX_CHARS: usize = SHORT_NAME_MAX_CHARS; + +/// Truncate a compact display name to at most [`COMPACT_SESSION_NAME_MAX_CHARS`] +/// characters with a trailing ellipsis. Character-based truncation keeps +/// multi-byte (CJK) names intact. +fn truncate_compact_display_name(name: &str) -> String { + let trimmed = name.trim(); + if trimmed.chars().count() <= COMPACT_SESSION_NAME_MAX_CHARS { + return trimmed.to_string(); + } + let truncated: String = trimmed + .chars() + .take(COMPACT_SESSION_NAME_MAX_CHARS) + .collect(); + format!("{truncated}...") +} + +/// Resolve the compact display name used by `list` compact output: the +/// explicit short name wins; otherwise the full session name is truncated to +/// [`COMPACT_SESSION_NAME_MAX_CHARS`] characters with a trailing ellipsis. +/// Both paths share the same character-based cap, so multi-byte (CJK) names +/// stay intact and a short name cannot exceed the bound. +pub fn compact_session_display_name(session_name: &str, short_name: Option<&str>) -> String { + if let Some(short_name) = short_name.filter(|value| !value.trim().is_empty()) { + return truncate_compact_display_name(short_name); + } + truncate_compact_display_name(session_name) +} + pub fn session_control_agent_type_or_default( agent_type: Option<&SessionControlAgentType>, ) -> String { @@ -162,6 +191,15 @@ fn validate_mutating_action_target( if input.session_name.is_some() { return invalid("session_name is only allowed for create"); } + if input.short_name.is_some() { + return invalid("short_name is only allowed for create"); + } + if input.model_id.is_some() { + return invalid("model_id is only allowed for create"); + } + if input.detail.is_some() { + return invalid("detail is only allowed for list"); + } let Some(session_id) = input.session_id.as_deref() else { return invalid(format!("session_id is required for {}", action.as_str())); @@ -170,7 +208,12 @@ fn validate_mutating_action_target( return invalid(message); } - if context.current_session_id == Some(session_id) && context.has_workspace_root { + // 守卫只依赖会话绑定等价判定:目标 session_id 与当前会话一致即拒绝, + // 不再依赖 workspace_root,避免远程/未绑定上下文绕过"不能操作当前会话"限制。 + // Compact 例外:允许压缩自己(含自己、含常驻 subagent 工位——契约)。 + if !matches!(action, SessionControlAction::Compact) + && context.current_session_id == Some(session_id) + { return invalid(format!( "cannot {} the current session from SessionControl", action.as_str() @@ -201,21 +244,44 @@ pub fn validate_session_control_input( match input.action { SessionControlAction::Create => { - if input.workspace.is_none() { + // workspace is optional: when omitted it falls back to the current + // workspace binding from context. + if input.workspace.is_none() && !context.has_workspace_root { return invalid("workspace is required for create"); } if input.session_id.is_some() { return invalid("session_id is not allowed for create"); } + if input.detail.is_some() { + return invalid("detail is only allowed for list"); + } + if let Some(short_name) = input.short_name.as_deref() { + if short_name.trim().chars().count() > SHORT_NAME_MAX_CHARS { + return invalid(format!( + "short_name must be at most {SHORT_NAME_MAX_CHARS} characters" + )); + } + } + if input + .model_id + .as_deref() + .is_some_and(|model_id| model_id.trim().is_empty()) + { + return invalid("model_id must not be empty when provided"); + } if context.current_session_id.is_none() { return invalid("create requires a creator session in tool context"); } } - SessionControlAction::Cancel | SessionControlAction::Delete => { + SessionControlAction::Cancel + | SessionControlAction::Delete + | SessionControlAction::Compact => { return validate_mutating_action_target(&input.action, input, context); } SessionControlAction::List => { - if input.workspace.is_none() { + // workspace is optional: when omitted it falls back to the current + // workspace binding from context. + if input.workspace.is_none() && !context.has_workspace_root { return invalid("workspace is required for list"); } if input.agent_type.is_some() { @@ -224,6 +290,12 @@ pub fn validate_session_control_input( if input.session_name.is_some() { return invalid("session_name is only allowed for create"); } + if input.short_name.is_some() { + return invalid("short_name is only allowed for create"); + } + if input.model_id.is_some() { + return invalid("model_id is only allowed for create"); + } if input.session_id.is_some() { return invalid("session_id is not allowed for list"); } @@ -251,6 +323,7 @@ pub fn render_session_control_tool_use_message(input: &Value) -> String { "create" => format!("Create session in {workspace}"), "cancel" => format!("Cancel active turn for session {session_id}"), "delete" => format!("Delete session {session_id}"), + "compact" => format!("Compact session {session_id}"), "list" => format!("List sessions in {workspace}"), _ => format!("Manage sessions in {workspace}"), } @@ -291,3 +364,169 @@ pub fn session_control_cancel_result_message( pub fn session_control_deleted_result_message(session_id: &str, workspace: &str) -> String { format!("Deleted session '{session_id}' from workspace '{workspace}'.") } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn context(current: Option<&str>) -> SessionControlValidationContext<'_> { + SessionControlValidationContext { + current_session_id: current, + has_workspace_root: true, + } + } + + #[test] + fn compact_action_parses_payload_session_id() { + let input: SessionControlInput = serde_json::from_value(json!({ + "action": "compact", + "session_id": "worker_1", + })) + .expect("compact payload must parse"); + assert_eq!(input.action, SessionControlAction::Compact); + assert_eq!(input.session_id.as_deref(), Some("worker_1")); + assert_eq!(SessionControlAction::Compact.as_str(), "compact"); + } + + #[test] + fn compact_validation_requires_session_id() { + let input = SessionControlInput { + action: SessionControlAction::Compact, + workspace: None, + session_id: None, + session_name: None, + agent_type: None, + short_name: None, + model_id: None, + detail: None, + }; + let result = validate_session_control_input(&input, context(None)); + assert!(!result.result); + assert!( + result + .message + .as_deref() + .unwrap_or_default() + .contains("session_id is required") + ); + } + + #[test] + fn compact_validation_rejects_non_mutating_fields() { + let input = SessionControlInput { + action: SessionControlAction::Compact, + workspace: None, + session_id: Some("worker_1".to_string()), + session_name: Some("should not be allowed".to_string()), + agent_type: None, + short_name: None, + model_id: None, + detail: None, + }; + let result = validate_session_control_input(&input, context(None)); + assert!(!result.result); + assert_eq!( + result.message.as_deref(), + Some("session_name is only allowed for create") + ); + } + + #[test] + fn compact_validation_allows_current_session() { + // Contract: compact supports "含自己" (current session and resident + // subagent workstations). The mutating guard must NOT reject self. + let input = SessionControlInput { + action: SessionControlAction::Compact, + workspace: None, + session_id: Some("self_1".to_string()), + session_name: None, + agent_type: None, + short_name: None, + model_id: None, + detail: None, + }; + let result = validate_session_control_input(&input, context(Some("self_1"))); + assert!(result.result, "compact of the current session must be allowed: {:?}", result.message); + } + + #[test] + fn compact_validation_rejects_invalid_session_id() { + let input = SessionControlInput { + action: SessionControlAction::Compact, + workspace: None, + session_id: Some("bad/id".to_string()), + session_name: None, + agent_type: None, + short_name: None, + model_id: None, + detail: None, + }; + let result = validate_session_control_input(&input, context(None)); + assert!(!result.result); + } + + #[test] + fn compact_render_mentions_session() { + let rendered = render_session_control_tool_use_message(&json!({ + "action": "compact", + "session_id": "worker_1", + })); + assert!(rendered.contains("Compact session")); + assert!(rendered.contains("worker_1")); + } + + #[test] + fn create_deserializes_and_validates_model_id() { + let input: SessionControlInput = serde_json::from_value(json!({ + "action": "create", + "workspace": std::env::temp_dir().to_string_lossy().to_string(), + "model_id": "claude-sonnet-4", + })) + .expect("create payload with model_id must parse"); + assert_eq!(input.model_id.as_deref(), Some("claude-sonnet-4")); + + let result = validate_session_control_input(&input, context(Some("creator_1"))); + assert!(result.result, "{:?}", result.message); + } + + #[test] + fn create_rejects_blank_model_id() { + let input = SessionControlInput { + action: SessionControlAction::Create, + workspace: Some(std::env::temp_dir().to_string_lossy().to_string()), + session_id: None, + session_name: None, + agent_type: None, + short_name: None, + model_id: Some(" ".to_string()), + detail: None, + }; + let result = validate_session_control_input(&input, context(Some("creator_1"))); + assert!(!result.result); + assert_eq!( + result.message.as_deref(), + Some("model_id must not be empty when provided") + ); + } + + #[test] + fn non_create_actions_reject_model_id() { + let input = SessionControlInput { + action: SessionControlAction::Compact, + workspace: None, + session_id: Some("worker_1".to_string()), + session_name: None, + agent_type: None, + short_name: None, + model_id: Some("claude-sonnet-4".to_string()), + detail: None, + }; + let result = validate_session_control_input(&input, context(None)); + assert!(!result.result); + assert_eq!( + result.message.as_deref(), + Some("model_id is only allowed for create") + ); + } +} diff --git a/src/crates/execution/agent-runtime/src/skills/selection.rs b/src/crates/execution/agent-runtime/src/skills/selection.rs index 754fb3f559..d962df12c8 100644 --- a/src/crates/execution/agent-runtime/src/skills/selection.rs +++ b/src/crates/execution/agent-runtime/src/skills/selection.rs @@ -55,6 +55,7 @@ impl SkillCandidate { } #[derive(Debug, Clone)] +#[allow(clippy::large_enum_variant)] // found skill carries full SkillInfo; control outcomes are small pub enum ExplicitSkillInvocationResolution { Found(SkillInfo), NotFound, diff --git a/src/crates/execution/agent-runtime/src/subagent_task.rs b/src/crates/execution/agent-runtime/src/subagent_task.rs index e9380839eb..f864942bfe 100644 --- a/src/crates/execution/agent-runtime/src/subagent_task.rs +++ b/src/crates/execution/agent-runtime/src/subagent_task.rs @@ -12,6 +12,7 @@ pub struct SubagentTaskCompletionResultInput<'a> { pub reason: Option<&'a str>, pub ledger_event_id: Option<&'a str>, pub partial_timeout_suffix: &'a str, + pub session_id: Option<&'a str>, } pub fn subagent_task_completion_result( @@ -22,7 +23,7 @@ pub fn subagent_task_completion_result( } else { "completed" }; - let assistant_message = if input.is_partial_timeout { + let mut assistant_message = if input.is_partial_timeout { format!( "{} timed out with partial result:\n\n{}\n{}", input.delegate_target_label, input.result_text, input.partial_timeout_suffix @@ -33,12 +34,22 @@ pub fn subagent_task_completion_result( input.delegate_target_label, input.result_text ) }; + if let Some(session_id) = input.session_id { + assistant_message.push_str(&format!( + "\nUse this session_id to continue the same subagent.", + session_id + )); + } let mut data = json!({ "duration": input.duration_ms, "context_mode": input.context_mode, "status": status }); + if let Some(session_id) = input.session_id { + data["session_id"] = json!(session_id); + } + if input.is_partial_timeout { data["partial_output"] = json!(input.result_text); if let Some(reason) = input.reason { diff --git a/src/crates/execution/agent-runtime/src/thread_goal.rs b/src/crates/execution/agent-runtime/src/thread_goal.rs index b88b25b0a3..0ef8aed471 100644 --- a/src/crates/execution/agent-runtime/src/thread_goal.rs +++ b/src/crates/execution/agent-runtime/src/thread_goal.rs @@ -314,6 +314,7 @@ fn migrate_legacy_goal_mode( created_at, updated_at: created_at, auto_continuation_count: 0, + reference_files: Vec::new(), }) } @@ -373,11 +374,39 @@ pub struct SetThreadGoalRequest { pub objective: Option, pub status: Option, pub token_budget: Option>, + /// Workspace-relative reference files the goal tracks. `Some` replaces + /// the goal's list when the objective is also updated; `None` leaves the + /// existing list untouched. + pub reference_files: Option>, pub replace_existing: bool, pub now_epoch_seconds: i64, pub new_goal_id: String, } +/// Explicit status transitions must respect the resume contract: only +/// resumable statuses (`Paused`/`Blocked`/`UsageLimited`) may move back to +/// `Active`, and a `Blocked -> Active` resume resets the auto-continuation +/// counter so the resumed goal gets a fresh continuation budget instead of +/// immediately re-blocking on the stale count. +fn apply_goal_status_transition( + existing: &mut ThreadGoal, + status: ThreadGoalStatus, +) -> Result<(), ThreadGoalRuntimeError> { + if status == ThreadGoalStatus::Active && existing.status != ThreadGoalStatus::Active { + if !thread_goal_status_is_resumable(existing.status) { + return Err(ThreadGoalRuntimeError::Validation(format!( + "cannot resume goal from status {}", + existing.status.as_str() + ))); + } + if existing.status == ThreadGoalStatus::Blocked { + existing.auto_continuation_count = 0; + } + } + existing.status = status; + Ok(()) +} + pub fn build_set_thread_goal_result( request: SetThreadGoalRequest, ) -> Result { @@ -415,6 +444,9 @@ pub fn build_set_thread_goal_result( if let Some(token_budget) = request.token_budget { existing.token_budget = token_budget; } + if let Some(reference_files) = request.reference_files { + existing.reference_files = reference_files; + } existing.updated_at = request.now_epoch_seconds; existing } else { @@ -429,6 +461,7 @@ pub fn build_set_thread_goal_result( created_at: request.now_epoch_seconds, updated_at: request.now_epoch_seconds, auto_continuation_count: 0, + reference_files: request.reference_files.unwrap_or_default(), } } } else { @@ -439,7 +472,7 @@ pub fn build_set_thread_goal_result( ))); }; if let Some(status) = request.status { - existing.status = status; + apply_goal_status_transition(&mut existing, status)?; } if let Some(token_budget) = request.token_budget { existing.token_budget = token_budget; diff --git a/src/crates/execution/agent-runtime/src/thread_goal_tools.rs b/src/crates/execution/agent-runtime/src/thread_goal_tools.rs index c30463d469..60eced53ad 100644 --- a/src/crates/execution/agent-runtime/src/thread_goal_tools.rs +++ b/src/crates/execution/agent-runtime/src/thread_goal_tools.rs @@ -15,6 +15,11 @@ pub const UPDATE_GOAL_TOOL_NAME: &str = "update_goal"; pub struct CreateGoalArgs { pub objective: String, pub token_budget: Option, + /// Workspace-relative reference files the goal tracks as authoritative + /// context (e.g. spec/task files the agent keeps in sync). Omitted when + /// the goal has no reference files. + #[serde(default)] + pub reference_files: Option>, } #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] @@ -62,8 +67,11 @@ pub fn parse_update_goal_status(raw: &str) -> Result Ok(ThreadGoalStatus::Complete), "blocked" => Ok(ThreadGoalStatus::Blocked), + // `resume` maps to `Active`; the runtime transition gate enforces + // that only resumable statuses may move back to `Active`. + "resume" => Ok(ThreadGoalStatus::Active), other => Err(ThreadGoalToolError::validation(format!( - "update_goal status must be complete or blocked, got {other}" + "update_goal status must be complete, blocked, or resume, got {other}" ))), } } diff --git a/src/crates/execution/agent-runtime/tests/agent_definition_contracts/prompt_contracts.rs b/src/crates/execution/agent-runtime/tests/agent_definition_contracts/prompt_contracts.rs index 7273e26ab0..4d2796f1e5 100644 --- a/src/crates/execution/agent-runtime/tests/agent_definition_contracts/prompt_contracts.rs +++ b/src/crates/execution/agent-runtime/tests/agent_definition_contracts/prompt_contracts.rs @@ -1,12 +1,26 @@ use bitfun_agent_runtime::prompt::{ render_project_layout, render_prompt_environment_info, render_runtime_context_reminder, - render_user_context_reminder, render_workspace_context, PrependedPromptReminders, - ProjectLayoutFacts, PromptEnvironmentFacts, PromptRelatedPath, RemoteExecutionHints, - RuntimeContextFacts, RuntimeContextNeeds, RuntimeShellFacts, ToolListingSections, - UserContextPolicy, UserContextSection, WorkspaceContextFacts, WorktreeContextFacts, + render_runtime_facts_reminder, render_user_context_reminder, render_workspace_context, + PrependedPromptReminders, ProjectLayoutFacts, PromptEnvironmentFacts, PromptRelatedPath, + RemoteExecutionHints, RuntimeContextFacts, RuntimeContextNeeds, RuntimeFactsInput, + RuntimeShellFacts, ToolListingSections, UserContextPolicy, UserContextSection, + WorkspaceContextFacts, WorktreeContextFacts, }; use bitfun_core_types::{SessionExecutionTarget, SessionExecutionTargetKind, WorktreeLifecycle}; +fn sample_runtime_facts_input(context_usage_ratio: Option) -> RuntimeFactsInput { + RuntimeFactsInput { + local_time_rfc3339: "2026-08-05T10:30:00+08:00".to_string(), + utc_time_rfc3339: "2026-08-05T02:30:00Z".to_string(), + weekday_name: "Wednesday".to_string(), + weekday_number: 3, + local_hhmm: "10:30".to_string(), + timezone_offset: "+08:00".to_string(), + context_usage_ratio, + compression_preview_ratio: Some(0.9), + } +} + #[test] fn user_context_policy_preserves_order_and_deduplicates_sections() { let policy = UserContextPolicy::empty() @@ -78,6 +92,7 @@ fn prepended_prompt_reminders_keep_runtime_injection_order() { skill_listing: Some("skills".to_string()), agent_listing: Some("agents".to_string()), runtime_context: Some("runtime-context".to_string()), + runtime_facts: Some("runtime-facts".to_string()), user_context: Some("user-context".to_string()), }; @@ -88,6 +103,7 @@ fn prepended_prompt_reminders_keep_runtime_injection_order() { "skills", "agents", "runtime-context", + "runtime-facts", "user-context" ] ); @@ -96,6 +112,80 @@ fn prepended_prompt_reminders_keep_runtime_injection_order() { .is_empty()); } +#[test] +fn runtime_facts_reminder_renders_time_and_offset_facts() { + let reminder = render_runtime_facts_reminder(&sample_runtime_facts_input(Some(0.35))); + + assert!(reminder.starts_with("[Runtime Facts]")); + assert!(reminder.contains("当前本地时间: 2026-08-05T10:30:00+08:00(周3 Wednesday)")); + assert!(reminder.contains("UTC 时间: 2026-08-05T02:30:00Z")); + assert!(reminder.contains("时区偏移: +08:00")); + assert!(reminder.contains("当前上下文占比: 35%")); +} + +#[test] +fn runtime_facts_reminder_formats_usage_percent_with_rounding_and_clamping() { + assert!(render_runtime_facts_reminder(&sample_runtime_facts_input(Some(0.35))) + .contains("当前上下文占比: 35%")); + assert!(render_runtime_facts_reminder(&sample_runtime_facts_input(Some(0.0))) + .contains("当前上下文占比: 0%")); + assert!(render_runtime_facts_reminder(&sample_runtime_facts_input(Some(0.004))) + .contains("当前上下文占比: 0%")); + assert!(render_runtime_facts_reminder(&sample_runtime_facts_input(Some(0.999))) + .contains("当前上下文占比: 100%")); + assert!(render_runtime_facts_reminder(&sample_runtime_facts_input(Some(1.5))) + .contains("当前上下文占比: 100%")); +} + +#[test] +fn runtime_facts_reminder_tiered_guidance_covers_high_usage_compression_and_normal() { + // P-02: the 30% hallucination guardrail and compression preview lines were + // removed by the owner ruling. The reminder now always emits the bare usage + // percentage next to the clock; the tiered guidance text must not reappear. + let high = render_runtime_facts_reminder(&sample_runtime_facts_input(Some(0.35))); + assert!(high.contains("当前上下文占比: 35%")); + assert!(!high.contains("上下文已超 30%")); + assert!(!high.contains("即将自动压缩")); + assert!(!high.contains("DeepSeek 峰谷定价")); + + let preview = render_runtime_facts_reminder(&sample_runtime_facts_input(Some(0.9))); + assert!(preview.contains("当前上下文占比: 90%")); + assert!(!preview.contains("即将自动压缩")); + assert!(!preview.contains("上下文已超 30%")); + + let normal = render_runtime_facts_reminder(&sample_runtime_facts_input(Some(0.05))); + assert!(normal.contains("当前上下文占比: 5%")); + assert!(!normal.contains("上下文已超 30%")); + assert!(!normal.contains("即将自动压缩")); +} + +#[test] +fn runtime_facts_reminder_omits_usage_lines_when_ratio_is_absent() { + let reminder = render_runtime_facts_reminder(&sample_runtime_facts_input(None)); + + assert!(!reminder.contains("当前上下文占比")); + assert!(!reminder.contains("上下文已超 30%")); + assert!(!reminder.contains("即将自动压缩")); + assert!(reminder.contains("当前本地时间")); +} + +#[test] +fn runtime_facts_reminder_omits_compression_preview_text() { + // P-02: the compression preview was removed by the owner ruling. Setting a + // preview ratio (or leaving it missing) must not emit the old preview text. + let mut input = sample_runtime_facts_input(Some(0.95)); + input.compression_preview_ratio = None; + let reminder = render_runtime_facts_reminder(&input); + assert!(!reminder.contains("即将自动压缩")); + assert!(reminder.contains("当前上下文占比: 95%")); + + let mut input = sample_runtime_facts_input(Some(0.5)); + input.compression_preview_ratio = Some(0.9); + let reminder = render_runtime_facts_reminder(&input); + assert!(!reminder.contains("即将自动压缩")); + assert!(reminder.contains("当前上下文占比: 50%")); +} + #[test] fn prompt_environment_info_preserves_local_and_remote_guidance() { let local = render_prompt_environment_info(PromptEnvironmentFacts { diff --git a/src/crates/execution/agent-runtime/tests/agent_long_horizon_contracts/thread_goal_contracts.rs b/src/crates/execution/agent-runtime/tests/agent_long_horizon_contracts/thread_goal_contracts.rs index 3dbb6aafb3..fd32ab4c5e 100644 --- a/src/crates/execution/agent-runtime/tests/agent_long_horizon_contracts/thread_goal_contracts.rs +++ b/src/crates/execution/agent-runtime/tests/agent_long_horizon_contracts/thread_goal_contracts.rs @@ -24,6 +24,7 @@ fn goal(status: ThreadGoalStatus) -> ThreadGoal { created_at: 1, updated_at: 2, auto_continuation_count: 0, + reference_files: Vec::new(), } } @@ -49,6 +50,7 @@ fn set_thread_goal_creates_new_active_goal_with_trimmed_objective() { objective: Some(" finish migration ".to_string()), status: Some(ThreadGoalStatus::Active), token_budget: Some(Some(5000)), + reference_files: None, replace_existing: false, now_epoch_seconds: 10, new_goal_id: "goal-new".to_string(), @@ -63,6 +65,78 @@ fn set_thread_goal_creates_new_active_goal_with_trimmed_objective() { assert_eq!(result.goal.updated_at, 10); } +#[test] +fn reference_files_persist_through_create_update_and_serde_round_trip() { + let reference_files = vec!["docs/spec.md".to_string(), "plans/todo.md".to_string()]; + + // Creation carries the reference files onto the goal. + let created = build_set_thread_goal_result(SetThreadGoalRequest { + session_id: "s1".to_string(), + existing: None, + objective: Some("ship".to_string()), + status: Some(ThreadGoalStatus::Active), + token_budget: None, + reference_files: Some(reference_files.clone()), + replace_existing: false, + now_epoch_seconds: 10, + new_goal_id: "goal-new".to_string(), + }) + .expect("goal should be created"); + assert_eq!(created.goal.reference_files, reference_files); + + // An objective-only update without reference files keeps the list. + let updated = build_set_thread_goal_result(SetThreadGoalRequest { + session_id: "s1".to_string(), + existing: Some(created.goal.clone()), + objective: Some("ship v2".to_string()), + status: None, + token_budget: None, + reference_files: None, + replace_existing: false, + now_epoch_seconds: 11, + new_goal_id: "unused".to_string(), + }) + .expect("goal should be updated"); + assert_eq!(updated.goal.objective, "ship v2"); + assert_eq!(updated.goal.reference_files, reference_files, "objective update keeps reference files"); + + // Explicit replacement swaps the list. + let replaced = build_set_thread_goal_result(SetThreadGoalRequest { + session_id: "s1".to_string(), + existing: Some(updated.goal.clone()), + objective: Some("ship v3".to_string()), + status: None, + token_budget: None, + reference_files: Some(vec!["CHANGELOG.md".to_string()]), + replace_existing: false, + now_epoch_seconds: 12, + new_goal_id: "unused".to_string(), + }) + .expect("goal should be updated"); + assert_eq!(replaced.goal.reference_files, vec!["CHANGELOG.md"]); + + // Serde round-trip preserves the field. + let json = serde_json::to_string(&replaced.goal).expect("serialize goal"); + let restored: ThreadGoal = serde_json::from_str(&json).expect("deserialize goal"); + assert_eq!(restored.reference_files, vec!["CHANGELOG.md"]); + + // Legacy payloads without the field still parse (serde default). + let legacy = serde_json::json!({ + "goalId": "g1", + "sessionId": "s1", + "objective": "legacy", + "status": "active", + "createdAt": 1, + "updatedAt": 2 + }); + let restored_legacy: ThreadGoal = + serde_json::from_value(legacy).expect("legacy goal parses"); + assert!( + restored_legacy.reference_files.is_empty(), + "missing referenceFiles defaults to an empty list" + ); +} + #[test] fn set_thread_goal_updates_existing_objective_and_resets_continuation_count() { let mut existing = goal(ThreadGoalStatus::BudgetLimited); @@ -75,6 +149,7 @@ fn set_thread_goal_updates_existing_objective_and_resets_continuation_count() { objective: Some("new".to_string()), status: Some(ThreadGoalStatus::Active), token_budget: None, + reference_files: None, replace_existing: false, now_epoch_seconds: 11, new_goal_id: "unused".to_string(), @@ -100,6 +175,7 @@ fn set_thread_goal_replaces_existing_goal_when_requested() { objective: Some("new objective".to_string()), status: Some(ThreadGoalStatus::Active), token_budget: Some(Some(1000)), + reference_files: None, replace_existing: true, now_epoch_seconds: 12, new_goal_id: "goal-new".to_string(), @@ -122,6 +198,7 @@ fn set_thread_goal_rejects_invalid_budget_and_missing_update_target() { objective: Some("goal".to_string()), status: Some(ThreadGoalStatus::Active), token_budget: Some(Some(0)), + reference_files: None, replace_existing: false, now_epoch_seconds: 1, new_goal_id: "g1".to_string(), @@ -137,6 +214,7 @@ fn set_thread_goal_rejects_invalid_budget_and_missing_update_target() { objective: None, status: Some(ThreadGoalStatus::Complete), token_budget: None, + reference_files: None, replace_existing: false, now_epoch_seconds: 1, new_goal_id: "g1".to_string(), @@ -147,6 +225,111 @@ fn set_thread_goal_rejects_invalid_budget_and_missing_update_target() { .contains("no goal exists")); } +#[test] +fn set_thread_goal_resume_transition_activates_only_resumable_statuses() { + // Blocked -> resume (Active): succeeds and resets the auto-continuation + // counter so the resumed goal gets a fresh continuation budget. + let mut blocked = goal(ThreadGoalStatus::Blocked); + blocked.auto_continuation_count = MAX_THREAD_GOAL_AUTO_CONTINUATIONS; + let resumed = build_set_thread_goal_result(SetThreadGoalRequest { + session_id: "s1".to_string(), + existing: Some(blocked), + objective: None, + status: Some(ThreadGoalStatus::Active), + token_budget: None, + reference_files: None, + replace_existing: false, + now_epoch_seconds: 50, + new_goal_id: "unused".to_string(), + }) + .expect("blocked goal should resume"); + assert_eq!(resumed.goal.status, ThreadGoalStatus::Active); + assert_eq!(resumed.goal.auto_continuation_count, 0); + + // Paused -> resume: succeeds and preserves the continuation counter. + let mut paused = goal(ThreadGoalStatus::Paused); + paused.auto_continuation_count = 5; + let resumed_paused = build_set_thread_goal_result(SetThreadGoalRequest { + session_id: "s1".to_string(), + existing: Some(paused), + objective: None, + status: Some(ThreadGoalStatus::Active), + token_budget: None, + reference_files: None, + replace_existing: false, + now_epoch_seconds: 51, + new_goal_id: "unused".to_string(), + }) + .expect("paused goal should resume"); + assert_eq!(resumed_paused.goal.status, ThreadGoalStatus::Active); + assert_eq!(resumed_paused.goal.auto_continuation_count, 5); + + // UsageLimited -> resume: succeeds. + let mut usage_limited = goal(ThreadGoalStatus::UsageLimited); + usage_limited.auto_continuation_count = 3; + let resumed_usage = build_set_thread_goal_result(SetThreadGoalRequest { + session_id: "s1".to_string(), + existing: Some(usage_limited), + objective: None, + status: Some(ThreadGoalStatus::Active), + token_budget: None, + reference_files: None, + replace_existing: false, + now_epoch_seconds: 52, + new_goal_id: "unused".to_string(), + }) + .expect("usage-limited goal should resume"); + assert_eq!(resumed_usage.goal.status, ThreadGoalStatus::Active); + assert_eq!(resumed_usage.goal.auto_continuation_count, 3); + + // Active -> Active: idempotent and succeeds. + let active = build_set_thread_goal_result(SetThreadGoalRequest { + session_id: "s1".to_string(), + existing: Some(goal(ThreadGoalStatus::Active)), + objective: None, + status: Some(ThreadGoalStatus::Active), + token_budget: None, + reference_files: None, + replace_existing: false, + now_epoch_seconds: 53, + new_goal_id: "unused".to_string(), + }) + .expect("active goal should stay active"); + assert_eq!(active.goal.status, ThreadGoalStatus::Active); + + // Complete -> resume: rejected. + let complete_error = build_set_thread_goal_result(SetThreadGoalRequest { + session_id: "s1".to_string(), + existing: Some(goal(ThreadGoalStatus::Complete)), + objective: None, + status: Some(ThreadGoalStatus::Active), + token_budget: None, + reference_files: None, + replace_existing: false, + now_epoch_seconds: 54, + new_goal_id: "unused".to_string(), + }) + .expect_err("complete goal must not resume") + .to_string(); + assert!(complete_error.contains("cannot resume goal from status complete")); + + // BudgetLimited -> resume: rejected. + let budget_error = build_set_thread_goal_result(SetThreadGoalRequest { + session_id: "s1".to_string(), + existing: Some(goal(ThreadGoalStatus::BudgetLimited)), + objective: None, + status: Some(ThreadGoalStatus::Active), + token_budget: None, + reference_files: None, + replace_existing: false, + now_epoch_seconds: 55, + new_goal_id: "unused".to_string(), + }) + .expect_err("budget-limited goal must not resume") + .to_string(); + assert!(budget_error.contains("cannot resume goal from status budgetLimited")); +} + #[test] fn continuation_outcome_increments_active_goal_and_builds_plan() { let runtime = ThreadGoalRuntime::new(); @@ -175,7 +358,7 @@ fn continuation_outcome_increments_active_goal_and_builds_plan() { .as_ref() .expect("active goal should schedule continuation") .display_message - .contains("1/100")); + .contains("1/10")); } #[test] @@ -253,7 +436,7 @@ fn prompt_and_tool_response_contracts_match_thread_goal_wire_shape() { ); let plan = build_thread_goal_continuation_plan(&goal(ThreadGoalStatus::Active)); - assert_eq!(plan.user_message_metadata["autoContinuationMax"], 100); + assert_eq!(plan.user_message_metadata["autoContinuationMax"], 10); } #[test] @@ -348,5 +531,5 @@ fn turn_filtering_and_retry_policies_preserve_goal_mode_semantics() { "insufficient_quota: billing hard limit" )); assert!(!is_usage_limit_message("tool failed")); - assert_eq!(MAX_GOAL_CONTINUATIONS, 100); + assert_eq!(MAX_GOAL_CONTINUATIONS, 10); } diff --git a/src/crates/execution/agent-runtime/tests/agent_long_horizon_contracts/thread_goal_tool_handler_contracts.rs b/src/crates/execution/agent-runtime/tests/agent_long_horizon_contracts/thread_goal_tool_handler_contracts.rs index f9138531de..457ec148bb 100644 --- a/src/crates/execution/agent-runtime/tests/agent_long_horizon_contracts/thread_goal_tool_handler_contracts.rs +++ b/src/crates/execution/agent-runtime/tests/agent_long_horizon_contracts/thread_goal_tool_handler_contracts.rs @@ -15,6 +15,7 @@ fn goal(status: ThreadGoalStatus) -> ThreadGoal { created_at: 1, updated_at: 2, auto_continuation_count: 0, + reference_files: Vec::new(), } } @@ -28,12 +29,16 @@ fn update_goal_status_parser_preserves_legacy_values_and_errors() { parse_update_goal_status("BLOCKED").expect("blocked should parse"), ThreadGoalStatus::Blocked ); + assert_eq!( + parse_update_goal_status("resume").expect("resume should parse"), + ThreadGoalStatus::Active + ); assert_eq!( parse_update_goal_status("paused") .expect_err("unsupported status should fail") .to_string(), - "update_goal status must be complete or blocked, got paused" + "update_goal status must be complete, blocked, or resume, got paused" ); } diff --git a/src/crates/execution/agent-runtime/tests/agent_session_contracts/scheduler_contracts.rs b/src/crates/execution/agent-runtime/tests/agent_session_contracts/scheduler_contracts.rs index 8d36501b94..c04a9af1f0 100644 --- a/src/crates/execution/agent-runtime/tests/agent_session_contracts/scheduler_contracts.rs +++ b/src/crates/execution/agent-runtime/tests/agent_session_contracts/scheduler_contracts.rs @@ -2,7 +2,7 @@ use bitfun_agent_runtime::scheduler::{ build_thread_goal_objective_updated_delivery_plan, build_thread_goal_resumed_delivery_plan, resolve_agent_session_reply_action, resolve_background_delivery_action, resolve_background_delivery_injection, resolve_background_delivery_injection_for_turn, - resolve_dialog_start_route, resolve_dialog_steering_action, ActiveDialogTurn, + resolve_dialog_start_route, resolve_dialog_steering_action, utc_iso8601_now, ActiveDialogTurn, ActiveDialogTurnStore, AgentSessionReplyAction, BackgroundDeliveryAction, BackgroundDeliveryFacts, BackgroundInjectionKind, DialogReplySuppressionSet, DialogRoundInjectionInterrupt, DialogStartRoute, DialogStartRouteFacts, DialogSteeringAction, @@ -149,6 +149,7 @@ fn thread_goal() -> ThreadGoal { created_at: 1, updated_at: 2, auto_continuation_count: 2, + reference_files: Vec::new(), } } @@ -445,7 +446,7 @@ fn agent_session_reply_action_forwards_completed_outcome_with_legacy_reminder_te final_response: "done".to_string(), }; - let action = resolve_agent_session_reply_action("target-session", &turn, &outcome, false); + let action = resolve_agent_session_reply_action("target-session", None, None, &turn, &outcome, false); let AgentSessionReplyAction::Forward(plan) = action else { panic!("agent-session completion should forward a reply"); @@ -455,17 +456,26 @@ fn agent_session_reply_action_forwards_completed_outcome_with_legacy_reminder_te assert_eq!(plan.target_remote_connection_id.as_deref(), Some("conn-1")); assert_eq!(plan.target_remote_ssh_host.as_deref(), Some("host-1")); assert_eq!(plan.user_input, "done"); + let Some(serde_json::Value::Object(metadata)) = plan.user_message_metadata else { + panic!("reply should carry user message metadata"); + }; + assert_eq!(metadata["kind"], serde_json::json!("session_message")); assert_eq!( - plan.user_message_metadata, - Some(serde_json::json!({"kind": "session_message"})) - ); - assert_eq!( - plan.reminder_text, + metadata["senderSessionId"], + serde_json::json!("target-session") + ); + let metadata_server_time = metadata["serverTime"] + .as_str() + .expect("reply metadata should carry a serverTime string"); + assert_utc_iso8601(metadata_server_time); + assert!(plan.reminder_text.starts_with( "This message is an automated reply to a previous SessionMessage call, not a human user message.\n\ From session: target-session\n\ From workspace: workspace\n\ -Status: completed" - ); +Status: completed\n\ +Server time: " + )); + assert_reminder_server_time_matches_metadata(&plan.reminder_text, metadata_server_time); } #[test] @@ -475,7 +485,7 @@ fn agent_session_reply_action_suppresses_cancelled_auto_reply_when_requested() { turn_id: "turn-1".to_string(), }; - let action = resolve_agent_session_reply_action("target-session", &turn, &outcome, true); + let action = resolve_agent_session_reply_action("target-session", None, None, &turn, &outcome, true); assert_eq!( action, @@ -501,11 +511,110 @@ fn agent_session_reply_action_ignores_non_agent_session_turns() { final_response: "done".to_string(), }; - let action = resolve_agent_session_reply_action("target-session", &turn, &outcome, false); + let action = resolve_agent_session_reply_action("target-session", None, None, &turn, &outcome, false); assert_eq!(action, AgentSessionReplyAction::NoReply); } +#[test] +fn agent_session_reply_action_includes_responder_identity() { + let turn = agent_session_turn("source-session"); + let outcome = TurnOutcome::Completed { + turn_id: "turn-1".to_string(), + final_response: "done".to_string(), + }; + + let action = resolve_agent_session_reply_action( + "target-session", + Some("Commander"), + Some(0), + &turn, + &outcome, + false, + ); + + let AgentSessionReplyAction::Forward(plan) = action else { + panic!("agent-session completion should forward a reply"); + }; + assert!(plan.reminder_text.contains("From role: Commander")); + assert!(plan.reminder_text.contains("From depth: 0")); + assert!(plan.reminder_text.contains("Server time: ")); + let Some(serde_json::Value::Object(metadata)) = plan.user_message_metadata else { + panic!("reply should carry user message metadata"); + }; + assert_eq!(metadata["kind"], serde_json::json!("session_message")); + assert_eq!( + metadata["senderSessionId"], + serde_json::json!("target-session") + ); + assert_eq!(metadata["senderRole"], serde_json::json!("Commander")); + assert_eq!(metadata["senderDepth"], serde_json::json!(0)); + let metadata_server_time = metadata["serverTime"] + .as_str() + .expect("reply metadata should carry a serverTime string"); + assert_utc_iso8601(metadata_server_time); + assert_reminder_server_time_matches_metadata(&plan.reminder_text, metadata_server_time); +} + +#[test] +fn agent_session_reply_action_rewrites_stale_sender_metadata() { + // Simulate a forwarded request whose metadata carries the original + // sender badge (e.g. commander -> executor). The reply must not echo + // the original sender identity back to the requester. + let mut metadata = serde_json::json!({ + "kind": "session_message", + "senderSessionId": "commander-session", + "senderRole": "Commander", + "senderDepth": 0, + "senderName": "Mengdie" + }); + metadata["kind"] = serde_json::json!("session_message"); + let active_turn = ActiveDialogTurn::new( + "turn-1".to_string(), + Some("workspace".to_string()), + Some("target-conn".to_string()), + Some("target-host".to_string()), + "agentic".to_string(), + "run task".to_string(), + Some(metadata), + DialogSubmissionPolicy::for_source(DialogTriggerSource::AgentSession), + Some(AgentSessionReplyRoute { + source_session_id: "source-session".to_string(), + source_workspace_path: "workspace".to_string(), + source_remote_connection_id: Some("conn-1".to_string()), + source_remote_ssh_host: Some("host-1".to_string()), + }), + ); + let outcome = TurnOutcome::Completed { + turn_id: "turn-1".to_string(), + final_response: "done".to_string(), + }; + + let action = resolve_agent_session_reply_action( + "executor-session", + Some("Executor"), + Some(1), + &active_turn, + &outcome, + false, + ); + + let AgentSessionReplyAction::Forward(plan) = action else { + panic!("agent-session completion should forward a reply"); + }; + let metadata = plan.user_message_metadata.unwrap(); + assert_eq!(metadata["senderSessionId"], "executor-session"); + assert_eq!(metadata["senderRole"], "Executor"); + assert_eq!(metadata["senderDepth"], 1); + assert!(!metadata.as_object().unwrap().contains_key("senderName")); + assert_eq!(metadata["kind"], "session_message"); + let metadata_server_time = metadata["serverTime"] + .as_str() + .expect("rewritten reply metadata should carry a serverTime string"); + assert_utc_iso8601(metadata_server_time); + assert_reminder_server_time_matches_metadata(&plan.reminder_text, metadata_server_time); +} + #[test] fn dialog_steering_action_buffers_exact_running_turn_with_display_fallback() { let created_at = SystemTime::UNIX_EPOCH; @@ -518,6 +627,7 @@ fn dialog_steering_action_buffers_exact_running_turn_with_display_fallback() { None, "steer-id".to_string(), created_at, + Vec::new(), ); let DialogSteeringAction::Buffer { injection, outcome } = action else { @@ -556,6 +666,7 @@ fn dialog_steering_action_rejects_when_target_turn_is_not_running() { Some("display".to_string()), "steer-id".to_string(), SystemTime::UNIX_EPOCH, + Vec::new(), ); assert_eq!( @@ -659,6 +770,7 @@ fn exact_turn_msg(turn_id: &str, content: &str) -> RoundInjection { content: content.to_string(), display_content: content.to_string(), created_at: SystemTime::now(), + prepended_reminders: Vec::new(), } } @@ -671,6 +783,7 @@ fn current_turn_msg(content: &str) -> RoundInjection { content: content.to_string(), display_content: content.to_string(), created_at: SystemTime::now(), + prepended_reminders: Vec::new(), } } @@ -692,3 +805,38 @@ fn agent_session_turn(source_session_id: &str) -> ActiveDialogTurn { }), ) } + +/// Validates the `2026-08-05T03:14:15Z` shape produced by +/// `utc_iso8601_now` (ISO-8601 UTC, second precision, `Z` suffix). +fn assert_utc_iso8601(value: &str) { + let bytes = value.as_bytes(); + assert_eq!(bytes.len(), 20, "ISO-8601 second precision length, got: {value}"); + assert_eq!(&bytes[4..5], b"-", "year-month separator, got: {value}"); + assert_eq!(&bytes[7..8], b"-", "month-day separator, got: {value}"); + assert_eq!(&bytes[10..11], b"T", "date-time separator, got: {value}"); + assert_eq!(&bytes[13..14], b":", "hour-minute separator, got: {value}"); + assert_eq!(&bytes[16..17], b":", "minute-second separator, got: {value}"); + assert_eq!(bytes[19], b'Z', "UTC suffix, got: {value}"); + for [start, end] in [[0, 4], [5, 7], [8, 10], [11, 13], [14, 16], [17, 19]] { + assert!( + bytes[start..end].iter().all(u8::is_ascii_digit), + "digits expected in {start}..{end}, got: {value}" + ); + } +} + +#[test] +fn utc_iso8601_now_returns_iso8601_utc_shape() { + assert_utc_iso8601(&utc_iso8601_now()); +} + +/// Asserts the `Server time:` line in `reminder_text` equals the +/// `serverTime` metadata value, so audit logs and metadata stay aligned. +fn assert_reminder_server_time_matches_metadata(reminder_text: &str, metadata_server_time: &str) { + let server_time_line = reminder_text + .lines() + .find(|line| line.starts_with("Server time: ")) + .unwrap_or_else(|| panic!("reminder text should carry a Server time line: {reminder_text}")); + assert_eq!(&server_time_line["Server time: ".len()..], metadata_server_time); +} + diff --git a/src/crates/execution/agent-runtime/tests/agent_session_contracts/session_control_contracts.rs b/src/crates/execution/agent-runtime/tests/agent_session_contracts/session_control_contracts.rs index 0a41a42101..957bf1352f 100644 --- a/src/crates/execution/agent-runtime/tests/agent_session_contracts/session_control_contracts.rs +++ b/src/crates/execution/agent-runtime/tests/agent_session_contracts/session_control_contracts.rs @@ -13,6 +13,9 @@ fn base_input(action: SessionControlAction) -> SessionControlInput { session_id: None, session_name: None, agent_type: None, + short_name: None, + model_id: None, + detail: None, } } @@ -115,3 +118,23 @@ fn routes_cancel_through_scheduler_only_when_requester_and_scheduler_exist() { SessionControlCancelRoute::CoordinatorDirect ); } + +#[test] +fn create_parses_model_id_and_forwards_to_validation() { + let input: SessionControlInput = serde_json::from_value(json!({ + "action": "create", + "workspace": std::env::temp_dir().to_string_lossy().to_string(), + "model_id": "claude-sonnet-4", + })) + .expect("create payload with model_id must parse"); + assert_eq!(input.model_id.as_deref(), Some("claude-sonnet-4")); + + let result = validate_session_control_input( + &input, + SessionControlValidationContext { + current_session_id: Some("session_a"), + has_workspace_root: true, + }, + ); + assert!(result.result, "{:?}", result.message); +} diff --git a/src/crates/execution/agent-stream/src/lib.rs b/src/crates/execution/agent-stream/src/lib.rs index f10b2cad9c..cb017b19dc 100644 --- a/src/crates/execution/agent-stream/src/lib.rs +++ b/src/crates/execution/agent-stream/src/lib.rs @@ -1169,13 +1169,20 @@ impl StreamProcessor { } if let Some(reason) = finish_reason { - let completion = tool_call_completion.unwrap_or(ToolCallCompletion::Unknown); - let _ = ctx.finalize_all_pending_tool_calls( - ToolCallBoundary::FinishReason, - completion, - ); - if is_token_limit_finish_reason(&reason) { - ctx.token_limit_finish_reason = Some(reason); + // Some providers (e.g. CodeBuddy cloud) send an empty + // finish_reason placeholder on every delta chunk. It is + // not a real completion signal, so it must not + // finalize pending tool calls mid-stream. + if !reason.is_empty() { + let completion = + tool_call_completion.unwrap_or(ToolCallCompletion::Unknown); + let _ = ctx.finalize_all_pending_tool_calls( + ToolCallBoundary::FinishReason, + completion, + ); + if is_token_limit_finish_reason(&reason) { + ctx.token_limit_finish_reason = Some(reason); + } } } } diff --git a/src/crates/execution/runtime-services/src/lib.rs b/src/crates/execution/runtime-services/src/lib.rs index 644fcffc8e..c229728eea 100644 --- a/src/crates/execution/runtime-services/src/lib.rs +++ b/src/crates/execution/runtime-services/src/lib.rs @@ -161,6 +161,9 @@ impl RuntimeServices { RuntimeServiceCapability::RemoteWorkspace => self.remote_workspace.is_some(), RuntimeServiceCapability::RemoteProjection => self.remote_projection.is_some(), RuntimeServiceCapability::RemoteCapabilities => self.remote_capabilities.is_some(), + // The ACP client port is injected through the coordinator boundary + // (desktop host), not through the typed RuntimeServices assembly. + RuntimeServiceCapability::AcpClient => false, } } diff --git a/src/crates/execution/tool-contracts/src/execution_gate.rs b/src/crates/execution/tool-contracts/src/execution_gate.rs index 9a197745df..9b8e6f6384 100644 --- a/src/crates/execution/tool-contracts/src/execution_gate.rs +++ b/src/crates/execution/tool-contracts/src/execution_gate.rs @@ -1,8 +1,9 @@ use crate::{ - validate_deferred_tool_usage, validate_tool_allowed_by_list, DeferredToolUsageError, - LoadedDeferredToolSpec, ToolExecutionAccessError, ToolRestrictionError, - ToolRuntimeRestrictions, + classify_tool_call, validate_deferred_tool_usage, validate_tool_allowed_by_list, + DeferredToolUsageError, LoadedDeferredToolSpec, ToolExecutionAccessError, + ToolRestrictionError, ToolRuntimeRestrictions, }; +use serde_json::Value; use std::fmt; #[derive(Debug, Clone, Copy)] @@ -10,6 +11,7 @@ pub struct ToolExecutionAdmissionRequest<'a> { pub tool_name: &'a str, pub allowed_tools: &'a [String], pub runtime_tool_restrictions: &'a ToolRuntimeRestrictions, + pub tool_arguments: &'a Value, pub invocation_is_deferred: bool, pub deferred_tools: &'a [String], pub loaded_deferred_tool_specs: &'a [LoadedDeferredToolSpec], @@ -45,6 +47,13 @@ pub fn validate_tool_execution_admission( .runtime_tool_restrictions .ensure_tool_allowed(request.tool_name) .map_err(ToolExecutionAdmissionRejection::RuntimeRestriction)?; + request + .runtime_tool_restrictions + .ensure_operation_allowed( + classify_tool_call(request.tool_name, request.tool_arguments), + request.tool_name, + ) + .map_err(ToolExecutionAdmissionRejection::RuntimeRestriction)?; validate_deferred_tool_usage( request.tool_name, request.invocation_is_deferred, diff --git a/src/crates/execution/tool-contracts/src/framework.rs b/src/crates/execution/tool-contracts/src/framework.rs index 943fe620ec..03ee1fcef2 100644 --- a/src/crates/execution/tool-contracts/src/framework.rs +++ b/src/crates/execution/tool-contracts/src/framework.rs @@ -119,6 +119,16 @@ impl fmt::Display for DeferredToolUsageError { impl std::error::Error for DeferredToolUsageError {} +impl DeferredToolUsageError { + /// Whether the error reports a stale loaded spec that the runtime may + /// recover from by reloading the spec and re-running admission. The + /// `RequiresGetToolSpec` state is deliberately not auto-recovered: the + /// model must still call GetToolSpec first to unlock a deferred tool. + pub fn is_stale_spec(&self) -> bool { + matches!(self, Self::StaleSpec { .. }) + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum ToolExecutionAccessError { NotInAllowedList { @@ -2215,6 +2225,105 @@ pub fn build_tool_path_policy_denial_message( ) } +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +pub enum OperationClass { + WriteFile, + DeleteFile, + ExecuteCode, + ReadOnly, + Communicate, +} + +/// Classify an ExecCommand/Bash tool input by inspecting the command string. +/// Returns the most specific [`OperationClass`] based on heuristics. +fn classify_exec_command(input: &Value) -> OperationClass { + let cmd = input + .get("cmd") + .and_then(|v| v.as_str()) + .or_else(|| input.get("command").and_then(|v| v.as_str())) + .unwrap_or(""); + + let cmd_lower = cmd.to_lowercase(); + + // ── Delete operations ────────────────────────────────────────────── + // Detect file/directory deletion commands: rm, rmdir, del, Remove-Item, + // erase, unlink, rd. The `erase` and `unlink` aliases were previously + // missed, so `erase foo.txt` was classified ExecuteCode and could slip + // past DeleteFile-only gates (LEGION-14). + // + // `rm -rf` 无空格变体(rm-rf、rm-rf/、rm-f 等)也必须命中;`mv`/`move`/ + // `ren`/`rename` 可覆盖目标文件(覆盖即删除目标),同样归为删除类(LEGION-14)。 + if cmd_lower.contains("rm ") + || cmd_lower.contains("rm-r") + || cmd_lower.contains("rm-f") + || cmd_lower.contains("rmdir ") + || cmd_lower.starts_with("rmdir") + || cmd_lower.contains("del ") + || cmd_lower.contains("remove-item") + || cmd_lower.contains("erase ") + || cmd_lower.starts_with("erase") + || cmd_lower.contains("unlink ") + || cmd_lower.starts_with("unlink") + || cmd_lower.contains("rd ") + || cmd_lower.starts_with("rd ") + || cmd_lower.contains("mv ") + || cmd_lower.contains("mv-f") + || cmd_lower.contains("move ") + || cmd_lower.starts_with("move") + || cmd_lower.contains("move-item") + || cmd_lower.contains("ren ") + || cmd_lower.contains("rename ") + || cmd_lower.starts_with("rename") + || cmd_lower.contains("rename-item") + { + return OperationClass::DeleteFile; + } + + // ── Write operations ─────────────────────────────────────────────── + // Shell redirects (>, >>) write to a file or device + if cmd.contains('>') { + return OperationClass::WriteFile; + } + + // tee command writes output to files (in addition to stdout) + if cmd_lower.contains(" tee ") || cmd_lower.starts_with("tee ") { + return OperationClass::WriteFile; + } + + // PowerShell write cmdlets + if cmd_lower.contains("out-file") + || cmd_lower.contains("set-content") + || cmd_lower.contains("add-content") + { + return OperationClass::WriteFile; + } + + // Default: arbitrary/unknown commands are ExecuteCode + OperationClass::ExecuteCode +} + +/// Map a tool name and its input arguments to the corresponding [`OperationClass`]. +/// +/// This is used by the RBAC system to enforce operation-level restrictions +/// on tool calls, beyond simple tool-name allow/deny lists. +pub fn classify_tool_call(tool_name: &str, input: &Value) -> OperationClass { + match tool_name { + "Write" | "Edit" => OperationClass::WriteFile, + "Delete" => OperationClass::DeleteFile, + "ExecCommand" | "Bash" => classify_exec_command(input), + // LEGION-08: read-only scanners belong to ReadOnly; the session todo + // list writer belongs to Communicate so RBAC gates it like the other + // session-mutating tools instead of defaulting to ExecuteCode. + "Read" | "Grep" | "Glob" | "SessionHistory" | "WorkspaceScan" => { + OperationClass::ReadOnly + } + "SessionMessage" | "SessionControl" | "LegionControl" | "TodoWrite" => { + OperationClass::Communicate + } + _ => OperationClass::ExecuteCode, + } +} + #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct ToolRuntimeRestrictions { #[serde(default)] @@ -2225,6 +2334,10 @@ pub struct ToolRuntimeRestrictions { pub denied_tool_messages: BTreeMap, #[serde(default)] pub path_policy: ToolPathPolicy, + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + pub allowed_operation_classes: BTreeSet, + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + pub denied_operation_classes: BTreeSet, } const MINIAPP_HEADLESS_AGENT_SURFACE: &str = "miniapp_agent"; @@ -2371,6 +2484,68 @@ pub fn tool_restrictions_for_delegation_policy( restrictions } +/// Tool set for delegated subagent runs (Task spawn chain and SessionControl / +/// SessionMessage work sessions). +/// +/// Subagents must not reach interactive host surfaces (ControlHub / GenerativeUI), +/// hosted review flows (ReviewPlatform), MiniApp lifecycle management +/// (InitMiniApp / FinalizeMiniApp / PublishMiniApp / PageDeploy / PagePublish) or +/// block on background-task coordination (AgentWait). AskUserQuestion is kept +/// deliberately: subagents may still ask their commander for decisions. +pub fn subagent_tool_restrictions() -> ToolRuntimeRestrictions { + const DENIED_TOOLS: &[(&str, &str)] = &[ + ( + "ControlHub", + "ControlHub is unavailable in delegated subagent runs.", + ), + ( + "GenerativeUI", + "GenerativeUI is unavailable in delegated subagent runs.", + ), + ( + "ReviewPlatform", + "ReviewPlatform is unavailable in delegated subagent runs.", + ), + ( + "InitMiniApp", + "InitMiniApp is unavailable in delegated subagent runs.", + ), + ( + "FinalizeMiniApp", + "FinalizeMiniApp is unavailable in delegated subagent runs.", + ), + ( + "PublishMiniApp", + "PublishMiniApp is unavailable in delegated subagent runs.", + ), + ( + "PageDeploy", + "PageDeploy is unavailable in delegated subagent runs.", + ), + ( + "PagePublish", + "PagePublish is unavailable in delegated subagent runs.", + ), + ( + "AgentWait", + "AgentWait is unavailable in delegated subagent runs.", + ), + ]; + + let mut denied_tool_names = BTreeSet::new(); + let mut denied_tool_messages = BTreeMap::new(); + for (name, message) in DENIED_TOOLS { + denied_tool_names.insert((*name).to_string()); + denied_tool_messages.insert((*name).to_string(), (*message).to_string()); + } + + ToolRuntimeRestrictions { + denied_tool_names, + denied_tool_messages, + ..Default::default() + } +} + impl ToolRuntimeRestrictions { pub fn is_tool_allowed(&self, tool_name: &str) -> bool { (self.allowed_tool_names.is_empty() || self.allowed_tool_names.contains(tool_name)) @@ -2393,6 +2568,102 @@ impl ToolRuntimeRestrictions { Ok(()) } + + /// Check whether the given [`OperationClass`] is allowed by these restrictions. + /// + /// Returns `Ok(())` if the operation class is not denied and is either explicitly + /// allowed or the allowed set is empty (allow by default). + pub fn ensure_operation_allowed( + &self, + class: OperationClass, + tool_name: &str, + ) -> Result<(), ToolRestrictionError> { + if self.denied_operation_classes.contains(&class) { + return Err(ToolRestrictionError::OperationClassNotAllowed { + operation_class: class, + tool_name: tool_name.to_string(), + }); + } + + if !self.allowed_operation_classes.is_empty() + && !self.allowed_operation_classes.contains(&class) + { + return Err(ToolRestrictionError::OperationClassNotAllowed { + operation_class: class, + tool_name: tool_name.to_string(), + }); + } + + Ok(()) + } + + /// Merge another restriction set into this one (used for static injection at + /// session creation: role template + subagent deny list). + /// + /// Deny sets are unioned (the merged result denies everything either side + /// denies). Allow sets are intersected when both sides are non-empty, so a + /// narrow role template cannot widen a deny list, and vice versa. + pub fn merge(&mut self, other: &ToolRuntimeRestrictions) { + for name in &other.denied_tool_names { + self.denied_tool_names.insert(name.clone()); + } + for (name, message) in &other.denied_tool_messages { + self.denied_tool_messages + .insert(name.clone(), message.clone()); + } + self.allowed_tool_names = merge_allow_sets(&self.allowed_tool_names, &other.allowed_tool_names); + self.allowed_operation_classes = merge_allow_sets( + &self.allowed_operation_classes, + &other.allowed_operation_classes, + ); + for class in &other.denied_operation_classes { + self.denied_operation_classes.insert(class.clone()); + } + } + + /// Apply a runtime patch to modify restrictions on-the-fly. + pub fn apply_patch(&mut self, patch: ToolRuntimeRestrictionsPatch) { + if let Some(allowed) = patch.allowed_tool_names { + self.allowed_tool_names = allowed; + } + if let Some(denied) = patch.denied_tool_names { + self.denied_tool_names = denied; + } + if let Some(allowed_ops) = patch.allowed_operation_classes { + self.allowed_operation_classes = allowed_ops; + } + if let Some(denied_ops) = patch.denied_operation_classes { + self.denied_operation_classes = denied_ops; + } + if let Some(path_policy) = patch.path_policy { + self.path_policy = path_policy; + } + } +} + +fn merge_allow_sets( + current: &BTreeSet, + other: &BTreeSet, +) -> BTreeSet { + if other.is_empty() { + current.clone() + } else if current.is_empty() { + other.clone() + } else { + current.intersection(other).cloned().collect() + } +} + +/// Runtime patch for modifying a session's tool restrictions. +/// +/// Only `Some` fields are applied; `None` fields leave the current value unchanged. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ToolRuntimeRestrictionsPatch { + pub allowed_tool_names: Option>, + pub denied_tool_names: Option>, + pub allowed_operation_classes: Option>, + pub denied_operation_classes: Option>, + pub path_policy: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -2404,6 +2675,10 @@ pub enum ToolRestrictionError { NotAllowed { tool_name: String, }, + OperationClassNotAllowed { + operation_class: OperationClass, + tool_name: String, + }, } impl fmt::Display for ToolRestrictionError { @@ -2425,6 +2700,14 @@ impl fmt::Display for ToolRestrictionError { "Tool '{}' is not allowed by runtime restrictions", tool_name ), + Self::OperationClassNotAllowed { + operation_class, + tool_name, + } => write!( + formatter, + "Operation class '{:?}' from tool '{}' is not allowed by runtime restrictions", + operation_class, tool_name + ), } } } @@ -2513,6 +2796,7 @@ impl ToolResult { #[cfg(test)] mod tests { use super::*; + use bitfun_runtime_ports::MAX_FISSION_DEPTH; use serde_json::json; struct TestTool { @@ -2607,9 +2891,21 @@ mod tests { #[test] fn delegation_policy_tool_restrictions_block_recursive_subagents() { - let restrictions = - tool_restrictions_for_delegation_policy(DelegationPolicy::top_level().spawn_child()); + // At depth 1 (top_level.spawn_child()), further subagent spawn is allowed + // because MAX_FISSION_DEPTH is 10. Only at depth >= MAX_FISSION_DEPTH + // should Task be blocked. + let child = DelegationPolicy::top_level().spawn_child(); + assert!(child.allow_subagent_spawn); + let restrictions = tool_restrictions_for_delegation_policy(child); + assert!(restrictions.is_tool_allowed("Task")); + // At MAX_FISSION_DEPTH, further subagent spawn is blocked. + let mut deep = DelegationPolicy::top_level(); + for _ in 0..MAX_FISSION_DEPTH { + deep = deep.spawn_child(); + } + assert!(!deep.allow_subagent_spawn); + let restrictions = tool_restrictions_for_delegation_policy(deep); assert!(!restrictions.is_tool_allowed("Task")); assert!(restrictions.is_tool_allowed("Read")); assert_eq!( @@ -2659,6 +2955,8 @@ mod tests { denied_tool_names: ["Write"].into_iter().map(str::to_string).collect(), denied_tool_messages: Default::default(), path_policy: ToolPathPolicy::default(), + allowed_operation_classes: Default::default(), + denied_operation_classes: Default::default(), }; assert!(!restrictions.is_tool_allowed("Write")); @@ -2726,6 +3024,411 @@ mod tests { assert_eq!(registry.get_tool_names(), vec!["Read", "Write"]); } + // ── classify_exec_command tests ──────────────────────────────────── + + #[test] + fn classify_exec_command_rm_is_delete() { + let input = json!({ "cmd": "rm -rf /data" }); + assert_eq!( + classify_exec_command(&input), + OperationClass::DeleteFile + ); + } + + #[test] + fn classify_exec_command_rmdir_is_delete() { + let input = json!({ "cmd": "rmdir /s /q temp_dir" }); + assert_eq!( + classify_exec_command(&input), + OperationClass::DeleteFile + ); + } + + #[test] + fn classify_exec_command_del_is_delete() { + let input = json!({ "cmd": "del /f old_file.txt" }); + assert_eq!( + classify_exec_command(&input), + OperationClass::DeleteFile + ); + } + + #[test] + fn classify_exec_command_remove_item_is_delete() { + let input = json!({ "cmd": "Remove-Item -Path 'C:\\temp\\file.txt'" }); + assert_eq!( + classify_exec_command(&input), + OperationClass::DeleteFile + ); + } + + #[test] + fn classify_exec_command_redirect_write_is_write() { + let input = json!({ "cmd": "echo x >> file" }); + assert_eq!( + classify_exec_command(&input), + OperationClass::WriteFile + ); + } + + #[test] + fn classify_exec_command_redirect_overwrite_is_write() { + let input = json!({ "cmd": "echo x > file" }); + assert_eq!( + classify_exec_command(&input), + OperationClass::WriteFile + ); + } + + #[test] + fn classify_exec_command_tee_is_write() { + let input = json!({ "cmd": "echo 'hello' | tee output.txt" }); + assert_eq!( + classify_exec_command(&input), + OperationClass::WriteFile + ); + } + + #[test] + fn classify_exec_command_standalone_tee_is_write() { + let input = json!({ "cmd": "tee output.txt" }); + assert_eq!( + classify_exec_command(&input), + OperationClass::WriteFile + ); + } + + #[test] + fn classify_exec_command_out_file_is_write() { + let input = json!({ "cmd": "Out-File -FilePath test.txt -InputObject $data" }); + assert_eq!( + classify_exec_command(&input), + OperationClass::WriteFile + ); + } + + #[test] + fn classify_exec_command_set_content_is_write() { + let input = json!({ "cmd": "Set-Content -Path file.txt -Value 'data'" }); + assert_eq!( + classify_exec_command(&input), + OperationClass::WriteFile + ); + } + + #[test] + fn classify_exec_command_add_content_is_write() { + let input = json!({ "cmd": "Add-Content -Path file.txt -Value 'data'" }); + assert_eq!( + classify_exec_command(&input), + OperationClass::WriteFile + ); + } + + #[test] + fn classify_exec_command_echo_alone_is_execute() { + // echo without redirect does NOT write a file + let input = json!({ "cmd": "echo hello" }); + assert_eq!( + classify_exec_command(&input), + OperationClass::ExecuteCode + ); + } + + #[test] + fn classify_exec_command_cat_alone_is_execute() { + // cat without redirect does NOT write a file + let input = json!({ "cmd": "cat file.txt" }); + assert_eq!( + classify_exec_command(&input), + OperationClass::ExecuteCode + ); + } + + #[test] + fn classify_exec_command_cat_pipe_is_execute() { + // pipe to cat (without redirect) does NOT write a file + let input = json!({ "cmd": "ls | cat" }); + assert_eq!( + classify_exec_command(&input), + OperationClass::ExecuteCode + ); + } + + #[test] + fn classify_exec_command_dir_is_execute() { + let input = json!({ "cmd": "dir" }); + assert_eq!( + classify_exec_command(&input), + OperationClass::ExecuteCode + ); + } + + #[test] + fn classify_exec_command_ls_is_execute() { + let input = json!({ "cmd": "ls -la" }); + assert_eq!( + classify_exec_command(&input), + OperationClass::ExecuteCode + ); + } + + #[test] + fn classify_exec_command_grep_is_execute() { + let input = json!({ "cmd": "grep pattern file.txt" }); + assert_eq!( + classify_exec_command(&input), + OperationClass::ExecuteCode + ); + } + + #[test] + fn classify_exec_command_echo_pipe_grep_is_execute() { + let input = json!({ "cmd": "echo 'pattern' | grep foo" }); + assert_eq!( + classify_exec_command(&input), + OperationClass::ExecuteCode + ); + } + + #[test] + fn classify_exec_command_multi_line_redirect_is_write() { + let input = json!({ "cmd": "cat > file.txt << EOF\nhello\nEOF" }); + assert_eq!( + classify_exec_command(&input), + OperationClass::WriteFile + ); + } + + #[test] + fn classify_exec_command_piped_tee_is_write() { + let input = json!({ "cmd": "ls -la | tee listing.txt" }); + assert_eq!( + classify_exec_command(&input), + OperationClass::WriteFile + ); + } + + #[test] + fn classify_exec_command_empty_cmd_is_execute() { + let input = json!({ "cmd": "" }); + assert_eq!( + classify_exec_command(&input), + OperationClass::ExecuteCode + ); + } + + #[test] + fn classify_exec_command_missing_cmd_is_execute() { + let input = json!({}); + assert_eq!( + classify_exec_command(&input), + OperationClass::ExecuteCode + ); + } + + #[test] + fn classify_exec_command_uses_cmd_field_before_command_field() { + let input = json!({ "cmd": "echo hello", "command": "rm file" }); + assert_eq!( + classify_exec_command(&input), + OperationClass::ExecuteCode + ); + } + + #[test] + fn classify_exec_command_falls_back_to_command_field() { + let input = json!({ "command": "rm file.txt" }); + assert_eq!( + classify_exec_command(&input), + OperationClass::DeleteFile + ); + } + + #[test] + fn classify_exec_command_erase_is_delete() { + // LEGION-14: Windows `erase` alias must classify as DeleteFile. + let input = json!({ "cmd": "erase report.tmp" }); + assert_eq!( + classify_exec_command(&input), + OperationClass::DeleteFile + ); + let input = json!({ "cmd": "erase" }); + assert_eq!( + classify_exec_command(&input), + OperationClass::DeleteFile + ); + } + + #[test] + fn classify_exec_command_unlink_is_delete() { + // LEGION-14: POSIX `unlink` single-file deletion alias. + let input = json!({ "cmd": "unlink lockfile" }); + assert_eq!( + classify_exec_command(&input), + OperationClass::DeleteFile + ); + } + + #[test] + fn classify_exec_command_rd_is_delete() { + // LEGION-14: Windows `rd` (remove directory) alias. + let input = json!({ "cmd": "rd /s /q build" }); + assert_eq!( + classify_exec_command(&input), + OperationClass::DeleteFile + ); + } + + #[test] + fn classify_exec_command_rm_rf_no_space_is_delete() { + // LEGION-14: `rm -rf` 无空格变体(省略 rm 与旗标之间的空格)。 + let cases = [ + "rm-rf /data", + "rm-rf/data", + "rm-r /data", + "rm-f /data/file.txt", + ]; + for c in cases { + assert_eq!( + classify_exec_command(&json!({ "cmd": c })), + OperationClass::DeleteFile, + "cmd: {c}" + ); + } + } + + #[test] + fn classify_exec_command_move_is_delete() { + // LEGION-14: `mv`/`move` 可覆盖(覆盖即删除)目标文件。 + let cases = [ + "mv a.txt b.txt", + "mv -f a.txt b.txt", + "mv-f a.txt b.txt", + "move /y a.txt b.txt", + "move a.txt b.txt", + "move-item -Path a.txt -Destination b.txt -Force", + ]; + for c in cases { + assert_eq!( + classify_exec_command(&json!({ "cmd": c })), + OperationClass::DeleteFile, + "cmd: {c}" + ); + } + } + + #[test] + fn classify_exec_command_ren_is_delete() { + // LEGION-14: `ren`/`rename`/`rename-item` 可覆盖(覆盖即删除)目标文件。 + let cases = [ + "ren a.txt b.txt", + "rename a.txt b.txt", + "rename-item -Path a.txt -NewName b.txt", + ]; + for c in cases { + assert_eq!( + classify_exec_command(&json!({ "cmd": c })), + OperationClass::DeleteFile, + "cmd: {c}" + ); + } + } + + // ── classify_tool_call tests ────────────────────────────────────── + + #[test] + fn classify_tool_call_write_is_write_file() { + assert_eq!( + classify_tool_call("Write", &json!({})), + OperationClass::WriteFile + ); + } + + #[test] + fn classify_tool_call_edit_is_write_file() { + assert_eq!( + classify_tool_call("Edit", &json!({})), + OperationClass::WriteFile + ); + } + + #[test] + fn classify_tool_call_delete_is_delete_file() { + assert_eq!( + classify_tool_call("Delete", &json!({})), + OperationClass::DeleteFile + ); + } + + #[test] + fn classify_tool_call_read_is_readonly() { + assert_eq!( + classify_tool_call("Read", &json!({})), + OperationClass::ReadOnly + ); + } + + #[test] + fn classify_tool_call_grep_is_readonly() { + assert_eq!( + classify_tool_call("Grep", &json!({})), + OperationClass::ReadOnly + ); + } + + #[test] + fn classify_tool_call_glob_is_readonly() { + assert_eq!( + classify_tool_call("Glob", &json!({})), + OperationClass::ReadOnly + ); + } + + #[test] + fn classify_tool_call_session_message_is_communicate() { + assert_eq!( + classify_tool_call("SessionMessage", &json!({})), + OperationClass::Communicate + ); + } + + #[test] + fn classify_tool_call_legion_control_is_communicate() { + assert_eq!( + classify_tool_call("LegionControl", &json!({"action": "load"})), + OperationClass::Communicate + ); + } + + #[test] + fn classify_tool_call_unknown_is_execute_code() { + assert_eq!( + classify_tool_call("UnknownTool", &json!({})), + OperationClass::ExecuteCode + ); + } + + #[test] + fn classify_tool_call_workspace_scan_is_readonly() { + // LEGION-08: WorkspaceScan lists workspaces without modifying them. + assert_eq!( + classify_tool_call("WorkspaceScan", &json!({ "scope": "opened" })), + OperationClass::ReadOnly + ); + } + + #[test] + fn classify_tool_call_todo_write_is_communicate() { + // LEGION-08: TodoWrite mutates the session todo list, so it belongs to + // the Communicate class like the other session-mutating tools instead of + // defaulting to ExecuteCode. + assert_eq!( + classify_tool_call("TodoWrite", &json!({ "todos": [] })), + OperationClass::Communicate + ); + } + #[test] fn market_strict_miniapp_runs_keep_web_research_and_drop_host_reach() { let restrictions = miniapp_market_strict_agent_tool_restrictions(); diff --git a/src/crates/execution/tool-contracts/src/lib.rs b/src/crates/execution/tool-contracts/src/lib.rs index 397fd4aea7..86b415311a 100644 --- a/src/crates/execution/tool-contracts/src/lib.rs +++ b/src/crates/execution/tool-contracts/src/lib.rs @@ -14,6 +14,7 @@ pub mod framework; pub mod input_validator; pub mod mcp_tool_bridge; pub mod permission_intent; +pub mod poke; pub mod tool_execution_presentation; pub mod tool_result_storage; pub mod tool_snapshot; @@ -56,6 +57,7 @@ pub use framework::{ build_get_tool_spec_duplicate_load_result, build_prompt_visible_tool_manifest_definitions, build_tool_manifest_policy_tools, build_tool_path_policy_denial_message, build_tool_runtime_artifact_reference, build_tool_session_runtime_artifact_reference, + classify_tool_call, collect_loaded_deferred_tool_specs, get_tool_spec_input_schema, get_tool_spec_is_concurrency_safe, get_tool_spec_is_readonly, get_tool_spec_short_description, is_bitfun_current_session_uri, is_bitfun_runtime_uri, is_bitfun_tool_uri, @@ -73,7 +75,8 @@ pub use framework::{ resolve_host_path, resolve_host_path_with_workspace, resolve_readonly_enabled_tools, resolve_tool_manifest_policy, resolve_tool_path_with_context, resolve_tool_path_with_context_roots, resolve_workspace_tool_path, - sort_tool_manifest_definitions, summarize_get_tool_spec_deferred_tools, + sort_tool_manifest_definitions, subagent_tool_restrictions, + summarize_get_tool_spec_deferred_tools, tool_manifest_sort_rank, tool_path_is_effectively_absolute, tool_restrictions_for_delegation_policy, validate_deferred_tool_usage, validate_get_tool_spec_input, validate_tool_allowed_by_list, ContextualToolManifest, @@ -81,7 +84,7 @@ pub use framework::{ DynamicToolInfo, GetToolSpecCatalogProvider, GetToolSpecDeferredToolSummary, GetToolSpecDetail, GetToolSpecExecutionError, GetToolSpecExecutionPlan, GetToolSpecLoadObservation, GetToolSpecRuntime, LoadedDeferredToolSpec, ParsedBitFunCurrentSessionUri, - ParsedBitFunRuntimeUri, PortableToolContextProvider, PromptVisibleToolManifestItem, + ParsedBitFunRuntimeUri, PortableToolContextProvider, OperationClass, PromptVisibleToolManifestItem, SnapshotToolDecorator, SnapshotToolWrapper, SnapshotToolWrapperRef, StaticToolMaterializationError, StaticToolProvider, StaticToolProviderFactory, StaticToolProviderGroup, StaticToolProviderPlan, ToolCatalogRuntime, @@ -89,8 +92,9 @@ pub use framework::{ ToolExposure, ToolManifestDefinition, ToolManifestPolicyResolution, ToolManifestPolicyTool, ToolPathBackend, ToolPathContractError, ToolPathOperation, ToolPathPolicy, ToolPathResolution, ToolRef, ToolRegistry, ToolRegistryItem, ToolRenderOptions, ToolRestrictionError, ToolResult, - ToolRuntimeAssembly, ToolRuntimeRestrictions, ToolWorkspaceKind, ValidationResult, - BITFUN_CURRENT_SESSION_URI_PREFIX, BITFUN_RUNTIME_URI_PREFIX, GET_TOOL_SPEC_TOOL_NAME, + ToolRuntimeAssembly, ToolRuntimeRestrictions, ToolRuntimeRestrictionsPatch, ToolWorkspaceKind, + ValidationResult, BITFUN_CURRENT_SESSION_URI_PREFIX, BITFUN_RUNTIME_URI_PREFIX, + GET_TOOL_SPEC_TOOL_NAME, }; pub use input_validator::InputValidator; pub use mcp_tool_bridge::{ @@ -102,6 +106,10 @@ pub use mcp_tool_bridge::{ MCP_TOOL_DELIMITER, MCP_TOOL_PREFIX, }; pub use permission_intent::PermissionIntent; +pub use poke::{ + AppealStatement, PokeMessage, PokeResponse, PokeStatus, PokeType, PokeValidator, + SelfCheckStatement, +}; pub use tool_execution_presentation::{ build_invalid_tool_call_error_message, build_normal_tool_json_repair_notice, build_permission_denied_tool_presentation, build_tool_call_truncation_recovery_notice, diff --git a/src/crates/execution/tool-contracts/src/poke.rs b/src/crates/execution/tool-contracts/src/poke.rs new file mode 100644 index 0000000000..6ca0e86102 --- /dev/null +++ b/src/crates/execution/tool-contracts/src/poke.rs @@ -0,0 +1,408 @@ +//! Audit-Poke protocol types and validation logic. +//! +//! This module defines the Poke protocol used by Warden to send audit +//! and challenge messages to Executor agents, and by Executor to respond +//! with self-check statements or appeals. +//! +//! # Protocol overview +//! +//! - **Audit-Poke**: Event-triggered (after Write/Edit/Delete/Exec), 3-turn deadline. +//! - **Challenge-Poke**: Poisson-sampled (avg 5–8 turns), 5-turn deadline. +//! +//! All types implement `Serialize`/`Deserialize` for transport via `SessionMessage`. + +use serde::{Deserialize, Serialize}; + +// --------------------------------------------------------------------------- +// PokeType +// --------------------------------------------------------------------------- + +/// The type of a Poke message. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PokeType { + /// Audit-Poke: event-triggered (after Write/Edit/Delete/Exec), 3-turn deadline. + Audit, + /// Challenge-Poke: Poisson-sampled (avg 5–8 turns), 5-turn deadline. + Challenge, +} + +// --------------------------------------------------------------------------- +// PokeMessage — Warden → Executor +// --------------------------------------------------------------------------- + +/// A Poke message sent from Warden to Executor requesting a self-check. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PokeMessage { + /// Unique identifier for this poke (used to correlate response). + pub poke_id: String, + /// Whether this is an Audit or Challenge poke. + pub poke_type: PokeType, + /// Iron-rule IDs that triggered this poke. + pub rule_ids: Vec, + /// Number of turns the Executor has to respond. + pub deadline_turns: u32, + /// Optional list of specific evidence items requested. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub evidence_required: Option>, +} + +// --------------------------------------------------------------------------- +// PokeStatus +// --------------------------------------------------------------------------- + +/// The status of an Executor's response to a Poke. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PokeStatus { + /// The Executor acknowledges the poke and provides a self-check. + Acknowledged, + /// The Executor defers the response; the count tracks how many times deferred. + Deferred(u32), + /// The Executor appeals, claiming the poke is invalid or mis-attributed. + Appeal(AppealStatement), +} + +// --------------------------------------------------------------------------- +// SelfCheckStatement +// --------------------------------------------------------------------------- + +/// A self-check statement provided by the Executor in response to a Poke. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SelfCheckStatement { + /// The current phase the Executor is in. + pub current_phase: String, + /// The last approval gate passed. + pub last_gate: String, + /// Summary of tool calls made since the last check. + pub tool_calls_summary: Vec, + /// List of iron rules that were checked. + pub rules_checked: Vec, +} + +// --------------------------------------------------------------------------- +// AppealStatement +// --------------------------------------------------------------------------- + +/// An appeal statement submitted when the Executor disputes a Poke. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AppealStatement { + /// Identifier of the specific violation being appealed. + pub violation_id: String, + /// Human-readable reason for the appeal. + pub reason: String, + /// Supporting evidence references. + pub evidence: Vec, +} + +// --------------------------------------------------------------------------- +// PokeResponse — Executor → Warden +// --------------------------------------------------------------------------- + +/// A response from the Executor to a Poke message. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PokeResponse { + /// Must match the `poke_id` from the corresponding `PokeMessage`. + pub poke_id: String, + /// The status of this response. + pub status: PokeStatus, + /// Self-check statement (required when status is `Acknowledged` or `Deferred`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub self_check: Option, +} + +// --------------------------------------------------------------------------- +// PokeValidator +// --------------------------------------------------------------------------- + +/// Validator for Poke responses. +/// +/// Provides business‑rule checks for both Audit and Challenge responses. +pub struct PokeValidator; + +impl PokeValidator { + /// Validate an Audit-Poke response. + /// + /// Audit responses **must**: + /// - Have a matching `poke_id` (checked by caller; we validate presence). + /// - Have `status` = `Acknowledged` (deferral is allowed but must include a self-check). + /// - Include a `self_check` with non-empty `current_phase`, `last_gate`, and `tool_calls_summary`. + /// - Include at least one entry in `rules_checked`. + pub fn validate_audit_response(response: &PokeResponse) -> bool { + // Must include a self-check + let Some(ref sc) = response.self_check else { + return false; + }; + + // Check required fields are non-empty + if sc.current_phase.is_empty() || sc.last_gate.is_empty() { + return false; + } + + // Must have at least one tool call and one rule checked + if sc.tool_calls_summary.is_empty() || sc.rules_checked.is_empty() { + return false; + } + + // For Audit, Acknowledged is the standard; Deferred is allowed but suspicious. + // Appeal is also valid but requires an AppealStatement. + match &response.status { + PokeStatus::Acknowledged => true, + PokeStatus::Deferred(_) => true, + PokeStatus::Appeal(appeal) => { + // Appeal must have a non-empty reason + !appeal.reason.is_empty() + } + } + } + + /// Validate a Challenge-Poke response. + /// + /// Challenge responses **must**: + /// - Have a matching `poke_id` (checked by caller; we validate presence). + /// - Include a `self_check` with non-empty `current_phase`, `last_gate`, and `tool_calls_summary`. + /// - Include at least one entry in `rules_checked`. + /// - If status is `Deferred`, the defer count must be ≤ 3. + /// - If status is `Appeal`, the `AppealStatement` must have a non-empty `reason` and at least + /// one piece of `evidence`. + pub fn validate_challenge_response(response: &PokeResponse) -> bool { + // Must include a self-check + let Some(ref sc) = response.self_check else { + return false; + }; + + // Check required fields are non-empty + if sc.current_phase.is_empty() || sc.last_gate.is_empty() { + return false; + } + + // Must have at least one tool call and one rule checked + if sc.tool_calls_summary.is_empty() || sc.rules_checked.is_empty() { + return false; + } + + match &response.status { + PokeStatus::Acknowledged => true, + PokeStatus::Deferred(count) => { + // Challenge-Poke allows max 3 consecutive defers + *count <= 3 + } + PokeStatus::Appeal(appeal) => { + // Appeal must have a non-empty reason and at least one evidence item + !appeal.reason.is_empty() && !appeal.evidence.is_empty() + } + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + // --- Helpers --- + + fn sample_self_check() -> SelfCheckStatement { + SelfCheckStatement { + current_phase: "execution".into(), + last_gate: "pre_write_check".into(), + tool_calls_summary: vec!["Read(file.txt)".into(), "Write(file.txt)".into()], + rules_checked: vec!["R1: no_destructive_write".into(), "R3: path_whitelist".into()], + } + } + + fn sample_audit_response(status: PokeStatus) -> PokeResponse { + PokeResponse { + poke_id: "poke-001".into(), + self_check: Some(sample_self_check()), + status, + } + } + + fn sample_challenge_response(status: PokeStatus) -> PokeResponse { + PokeResponse { + poke_id: "poke-002".into(), + self_check: Some(sample_self_check()), + status, + } + } + + // --- Audit validation --- + + #[test] + fn audit_acknowledged_passes() { + let resp = sample_audit_response(PokeStatus::Acknowledged); + assert!(PokeValidator::validate_audit_response(&resp)); + } + + #[test] + fn audit_deferred_passes() { + let resp = sample_audit_response(PokeStatus::Deferred(1)); + assert!(PokeValidator::validate_audit_response(&resp)); + } + + #[test] + fn audit_appeal_with_reason_passes() { + let resp = sample_audit_response(PokeStatus::Appeal(AppealStatement { + violation_id: "V-001".into(), + reason: "The write was to a permitted path".into(), + evidence: vec![], + })); + assert!(PokeValidator::validate_audit_response(&resp)); + } + + #[test] + fn audit_missing_self_check_fails() { + let resp = PokeResponse { + poke_id: "poke-001".into(), + self_check: None, + status: PokeStatus::Acknowledged, + }; + assert!(!PokeValidator::validate_audit_response(&resp)); + } + + #[test] + fn audit_empty_phase_fails() { + let mut sc = sample_self_check(); + sc.current_phase.clear(); + let resp = PokeResponse { + poke_id: "poke-001".into(), + self_check: Some(sc), + status: PokeStatus::Acknowledged, + }; + assert!(!PokeValidator::validate_audit_response(&resp)); + } + + #[test] + fn audit_empty_tool_summary_fails() { + let mut sc = sample_self_check(); + sc.tool_calls_summary.clear(); + let resp = PokeResponse { + poke_id: "poke-001".into(), + self_check: Some(sc), + status: PokeStatus::Acknowledged, + }; + assert!(!PokeValidator::validate_audit_response(&resp)); + } + + #[test] + fn audit_empty_rules_checked_fails() { + let mut sc = sample_self_check(); + sc.rules_checked.clear(); + let resp = PokeResponse { + poke_id: "poke-001".into(), + self_check: Some(sc), + status: PokeStatus::Acknowledged, + }; + assert!(!PokeValidator::validate_audit_response(&resp)); + } + + // --- Challenge validation --- + + #[test] + fn challenge_acknowledged_passes() { + let resp = sample_challenge_response(PokeStatus::Acknowledged); + assert!(PokeValidator::validate_challenge_response(&resp)); + } + + #[test] + fn challenge_deferred_within_limit_passes() { + let resp = sample_challenge_response(PokeStatus::Deferred(3)); + assert!(PokeValidator::validate_challenge_response(&resp)); + } + + #[test] + fn challenge_deferred_exceeds_limit_fails() { + let resp = sample_challenge_response(PokeStatus::Deferred(4)); + assert!(!PokeValidator::validate_challenge_response(&resp)); + } + + #[test] + fn challenge_appeal_with_evidence_passes() { + let resp = sample_challenge_response(PokeStatus::Appeal(AppealStatement { + violation_id: "V-002".into(), + reason: "Command was read-only".into(), + evidence: vec!["cargo check output".into()], + })); + assert!(PokeValidator::validate_challenge_response(&resp)); + } + + #[test] + fn challenge_appeal_missing_evidence_fails() { + let resp = sample_challenge_response(PokeStatus::Appeal(AppealStatement { + violation_id: "V-002".into(), + reason: "Command was read-only".into(), + evidence: vec![], + })); + assert!(!PokeValidator::validate_challenge_response(&resp)); + } + + #[test] + fn challenge_appeal_empty_reason_fails() { + let resp = sample_challenge_response(PokeStatus::Appeal(AppealStatement { + violation_id: "V-002".into(), + reason: "".into(), + evidence: vec!["log.txt".into()], + })); + assert!(!PokeValidator::validate_challenge_response(&resp)); + } + + #[test] + fn challenge_missing_self_check_fails() { + let resp = PokeResponse { + poke_id: "poke-002".into(), + self_check: None, + status: PokeStatus::Acknowledged, + }; + assert!(!PokeValidator::validate_challenge_response(&resp)); + } + + // --- Serialization round-trip --- + + #[test] + fn poke_message_round_trip() { + let msg = PokeMessage { + poke_id: "pm-001".into(), + poke_type: PokeType::Audit, + rule_ids: vec!["R1".into(), "R3".into()], + deadline_turns: 3, + evidence_required: Some(vec!["tool_call_log".into()]), + }; + let json = serde_json::to_string(&msg).expect("serialize"); + let deserialized: PokeMessage = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(msg.poke_id, deserialized.poke_id); + assert_eq!(msg.poke_type, deserialized.poke_type); + assert_eq!(msg.rule_ids, deserialized.rule_ids); + assert_eq!(msg.deadline_turns, deserialized.deadline_turns); + assert_eq!(msg.evidence_required, deserialized.evidence_required); + } + + #[test] + fn poke_response_round_trip() { + let resp = PokeResponse { + poke_id: "pr-001".into(), + status: PokeStatus::Appeal(AppealStatement { + violation_id: "V-001".into(), + reason: "test appeal".into(), + evidence: vec!["e1".into()], + }), + self_check: Some(SelfCheckStatement { + current_phase: "review".into(), + last_gate: "approval".into(), + tool_calls_summary: vec!["Read".into()], + rules_checked: vec!["R2".into()], + }), + }; + let json = serde_json::to_string(&resp).expect("serialize"); + let deserialized: PokeResponse = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(resp.poke_id, deserialized.poke_id); + assert_eq!(resp.status, deserialized.status); + } +} diff --git a/src/crates/execution/tool-contracts/tests/tool_contracts.rs b/src/crates/execution/tool-contracts/tests/tool_contracts.rs index c4778a2f34..cf94dbcb8c 100644 --- a/src/crates/execution/tool-contracts/tests/tool_contracts.rs +++ b/src/crates/execution/tool-contracts/tests/tool_contracts.rs @@ -27,12 +27,12 @@ use bitfun_agent_tools::{ sort_tool_manifest_definitions, summarize_get_tool_spec_deferred_tools, tool_path_is_effectively_absolute, validate_deferred_tool_usage, validate_get_tool_spec_input, validate_mcp_tool_bridge_input, validate_tool_allowed_by_list, - validate_tool_execution_admission, CallDeferredToolInputError, DynamicMcpToolInfo, - DynamicToolInfo, GetToolSpecDeferredToolSummary, GetToolSpecExecutionError, - GetToolSpecExecutionPlan, GetToolSpecLoadObservation, GetToolSpecRuntime, InputValidator, - LoadedDeferredToolSpec, McpToolBridgeBehaviorHints, McpToolBridgeDefinitionInput, - PromptVisibleToolManifestItem, ResolvedToolInvocation, ToolContextFacts, - ToolExecutionAdmissionRejection, ToolExecutionAdmissionRequest, ToolExposure, + validate_tool_execution_admission, CallDeferredToolInputError, DeferredToolUsageError, + DynamicMcpToolInfo, DynamicToolInfo, GetToolSpecDeferredToolSummary, + GetToolSpecExecutionError, GetToolSpecExecutionPlan, GetToolSpecLoadObservation, + GetToolSpecRuntime, InputValidator, LoadedDeferredToolSpec, McpToolBridgeBehaviorHints, + McpToolBridgeDefinitionInput, PromptVisibleToolManifestItem, ResolvedToolInvocation, + ToolContextFacts, ToolExecutionAdmissionRejection, ToolExecutionAdmissionRequest, ToolExposure, ToolImageAttachment, ToolManifestDefinition, ToolManifestPolicyTool, ToolPathBackend, ToolPathOperation, ToolPathResolution, ToolRenderOptions, ToolResult, ToolRuntimeRestrictions, ToolWorkspaceKind, ValidationResult, CALL_DEFERRED_TOOL_NAME, GET_TOOL_SPEC_TOOL_NAME, @@ -717,6 +717,8 @@ fn runtime_restrictions_keep_allow_deny_semantics_without_core_dependency() { denied_tool_names: ["Write"].into_iter().map(str::to_string).collect(), denied_tool_messages: Default::default(), path_policy: Default::default(), + allowed_operation_classes: Default::default(), + denied_operation_classes: Default::default(), }; assert!(restrictions.is_tool_allowed("Read")); @@ -1458,6 +1460,51 @@ fn deferred_tool_usage_gate_preserves_get_tool_spec_unlock_contract() { .expect("GetToolSpec itself is the unlock path"); } +#[test] +fn deferred_stale_spec_error_classification_enables_auto_reload_only() { + let stale = DeferredToolUsageError::StaleSpec { + tool_name: "WebFetch".to_string(), + loaded_generation: 41, + current_generation: 42, + get_tool_spec_tool_name: GET_TOOL_SPEC_TOOL_NAME.to_string(), + }; + assert!(stale.is_stale_spec(), "stale specs must be auto-reloadable"); + + let requires = DeferredToolUsageError::RequiresGetToolSpec { + tool_name: "WebFetch".to_string(), + get_tool_spec_tool_name: GET_TOOL_SPEC_TOOL_NAME.to_string(), + }; + assert!( + !requires.is_stale_spec(), + "RequiresGetToolSpec must keep requiring an explicit GetToolSpec call" + ); + + let gateway = DeferredToolUsageError::RequiresGateway { + tool_name: "WebFetch".to_string(), + gateway_tool_name: CALL_DEFERRED_TOOL_NAME.to_string(), + }; + assert!( + !gateway.is_stale_spec(), + "gateway contract violations must not be auto-recovered" + ); + + let admission_stale = ToolExecutionAdmissionRejection::Deferred(stale); + let admission_requires = ToolExecutionAdmissionRejection::Deferred(requires); + let admission_gateway = ToolExecutionAdmissionRejection::Deferred(gateway); + assert!(matches!( + &admission_stale, + ToolExecutionAdmissionRejection::Deferred(error) if error.is_stale_spec() + )); + assert!(!matches!( + &admission_requires, + ToolExecutionAdmissionRejection::Deferred(error) if error.is_stale_spec() + )); + assert!(!matches!( + &admission_gateway, + ToolExecutionAdmissionRejection::Deferred(error) if error.is_stale_spec() + )); +} + #[test] fn tool_allowed_list_gate_preserves_pipeline_rejection_contract() { validate_tool_allowed_by_list("Read", &[]) @@ -1485,6 +1532,7 @@ fn tool_execution_admission_gate_preserves_pipeline_rejection_order() { tool_name: "WebFetch", allowed_tools: &["Read".to_string()], runtime_tool_restrictions: &restrictions, + tool_arguments: &json!({}), invocation_is_deferred: true, deferred_tools: &["WebFetch".to_string()], loaded_deferred_tool_specs: &[], @@ -1508,6 +1556,7 @@ fn tool_execution_admission_gate_preserves_pipeline_rejection_order() { tool_name: "WebFetch", allowed_tools: &["WebFetch".to_string()], runtime_tool_restrictions: &restrictions, + tool_arguments: &json!({}), invocation_is_deferred: true, deferred_tools: &["WebFetch".to_string()], loaded_deferred_tool_specs: &[], @@ -1531,6 +1580,7 @@ fn tool_execution_admission_gate_preserves_pipeline_rejection_order() { tool_name: "WebFetch", allowed_tools: &["WebFetch".to_string()], runtime_tool_restrictions: &ToolRuntimeRestrictions::default(), + tool_arguments: &json!({}), invocation_is_deferred: true, deferred_tools: &["WebFetch".to_string()], loaded_deferred_tool_specs: &[], diff --git a/src/crates/execution/tool-execution/src/context.rs b/src/crates/execution/tool-execution/src/context.rs index dd6a4fba79..84d4f420ea 100644 --- a/src/crates/execution/tool-execution/src/context.rs +++ b/src/crates/execution/tool-execution/src/context.rs @@ -254,6 +254,8 @@ mod tests { denied_tool_names: BTreeSet::from(["Bash".to_string()]), denied_tool_messages: Default::default(), path_policy: Default::default(), + allowed_operation_classes: Default::default(), + denied_operation_classes: Default::default(), }, }); diff --git a/src/crates/execution/tool-execution/src/fs/mod.rs b/src/crates/execution/tool-execution/src/fs/mod.rs index a51bb0690e..86f15a0747 100644 --- a/src/crates/execution/tool-execution/src/fs/mod.rs +++ b/src/crates/execution/tool-execution/src/fs/mod.rs @@ -49,11 +49,13 @@ pub fn path_has_multiple_hard_links(path: &std::path::Path) -> std::io::Result 1); + Ok(information.nNumberOfLinks > 1) } #[cfg(not(any(unix, windows)))] diff --git a/src/crates/execution/tool-provider-groups/src/lib.rs b/src/crates/execution/tool-provider-groups/src/lib.rs index 5f781e8dca..458bc051a1 100644 --- a/src/crates/execution/tool-provider-groups/src/lib.rs +++ b/src/crates/execution/tool-provider-groups/src/lib.rs @@ -126,6 +126,7 @@ const PRODUCT_TOOL_PROVIDER_GROUP_PLAN: &[ToolProviderGroupPlan] = &[ "analyze_image", "Glob", "Grep", + "WorkspaceScan", "Write", "Edit", "Delete", @@ -150,6 +151,9 @@ const PRODUCT_TOOL_PROVIDER_GROUP_PLAN: &[ToolProviderGroupPlan] = &[ "create_goal", "update_goal", "CreatePlan", + "PlanList", + "PlanRead", + "PlanUpdate", "submit_code_review", "GetToolSpec", "CallDeferredTool", @@ -164,7 +168,16 @@ const PRODUCT_TOOL_PROVIDER_GROUP_PLAN: &[ToolProviderGroupPlan] = &[ ToolProviderGroupPlan { provider_id: "core.session", feature_groups: CORE_SESSION_FEATURE_GROUPS, - tool_names: &["SessionControl", "SessionMessage", "SessionHistory", "Cron"], + tool_names: &[ + "SessionControl", + "LegionControl", + "SessionMessage", + "SessionHistory", + "acp_control", + "acp_message", + "acp_history", + "Cron", + ], }, ToolProviderGroupPlan { provider_id: "core.integration", @@ -360,6 +373,7 @@ mod tests { "analyze_image", "Glob", "Grep", + "WorkspaceScan", "Write", "Edit", "Delete", @@ -378,6 +392,9 @@ mod tests { "create_goal", "update_goal", "CreatePlan", + "PlanList", + "PlanRead", + "PlanUpdate", "submit_code_review", "GetToolSpec", "CallDeferredTool", @@ -387,8 +404,12 @@ mod tests { "UpdateCanvas", "PatchCanvas", "SessionControl", + "LegionControl", "SessionMessage", "SessionHistory", + "acp_control", + "acp_message", + "acp_history", "Cron", "WebSearch", "WebFetch", diff --git a/src/crates/interfaces/acp/Cargo.toml b/src/crates/interfaces/acp/Cargo.toml index 6563705fbf..41040575ca 100644 --- a/src/crates/interfaces/acp/Cargo.toml +++ b/src/crates/interfaces/acp/Cargo.toml @@ -32,6 +32,7 @@ dashmap = { workspace = true } log = { workspace = true } uuid = { workspace = true } sha2 = { workspace = true } +which = { workspace = true } [dev-dependencies] tokio = { workspace = true, features = ["rt-multi-thread"] } diff --git a/src/crates/interfaces/acp/src/client/builtin_clients.rs b/src/crates/interfaces/acp/src/client/builtin_clients.rs index e1aafbb8ec..14c51e0754 100644 --- a/src/crates/interfaces/acp/src/client/builtin_clients.rs +++ b/src/crates/interfaces/acp/src/client/builtin_clients.rs @@ -93,6 +93,8 @@ pub(crate) fn default_config_for_builtin_client(client_id: &str) -> Option Option { + which::which(command) + .ok() + .map(|path| path.to_string_lossy().to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn detects_existing_command() { + let cmd = if cfg!(windows) { "cmd.exe" } else { "sh" }; + let result = detect_cli(cmd).await; + assert!(result.is_some(), "expected {} to be found on PATH", cmd); + } + + #[tokio::test] + async fn returns_none_for_missing_command() { + let result = detect_cli("bitfun-definitely-does-not-exist-xyz-12345").await; + assert!(result.is_none()); + } +} diff --git a/src/crates/interfaces/acp/src/client/config.rs b/src/crates/interfaces/acp/src/client/config.rs index 0bd1ed3a72..0645645222 100644 --- a/src/crates/interfaces/acp/src/client/config.rs +++ b/src/crates/interfaces/acp/src/client/config.rs @@ -25,6 +25,10 @@ pub struct AcpClientConfig { pub readonly: bool, #[serde(default)] pub permission_mode: AcpClientPermissionMode, + #[serde(default)] + pub category: Option, + #[serde(default)] + pub description: Option, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] @@ -33,6 +37,7 @@ pub enum AcpClientPermissionMode { #[default] Ask, AllowOnce, + AllowAlways, RejectOnce, } @@ -49,6 +54,8 @@ pub struct AcpClientInfo { pub status: AcpClientStatus, pub tool_name: String, pub session_count: usize, + pub category: Option, + pub description: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -110,4 +117,15 @@ mod tests { assert_eq!(mode, AcpClientPermissionMode::Ask); assert_eq!(serde_json::to_string(&mode).unwrap(), "\"ask\""); } + + #[test] + fn allow_always_round_trips_as_snake_case() { + let mode = AcpClientPermissionMode::AllowAlways; + + assert_eq!(serde_json::to_string(&mode).unwrap(), "\"allow_always\""); + assert_eq!( + serde_json::from_str::("\"allow_always\"").unwrap(), + AcpClientPermissionMode::AllowAlways + ); + } } diff --git a/src/crates/interfaces/acp/src/client/launch_policy.rs b/src/crates/interfaces/acp/src/client/launch_policy.rs new file mode 100644 index 0000000000..e0f3f65ee2 --- /dev/null +++ b/src/crates/interfaces/acp/src/client/launch_policy.rs @@ -0,0 +1,79 @@ +use std::collections::HashMap; + +use super::config::AcpClientConfig; + +/// Result of applying launch policy to an ACP client config. +#[derive(Debug, Clone, Default)] +pub struct LaunchPolicyResult { + pub additional_args: Vec, + pub additional_env: HashMap, +} + +/// Apply per-backend launch policy rules. +/// Backend detection uses client_id substring match (case-insensitive). +/// - codex: injects `-c sandbox_mode="workspace-write"` etc. +/// - all others: no-op +pub fn apply_launch_policy(_config: &AcpClientConfig, client_id: &str) -> LaunchPolicyResult { + let lower = client_id.to_lowercase(); + + if lower.contains("codex") { + LaunchPolicyResult { + additional_args: vec![ + "-c".to_string(), + "shell_environment_policy.inherit=all".to_string(), + "-c".to_string(), + "shell_environment_policy.include_only=[]".to_string(), + "-c".to_string(), + "sandbox_mode=\"workspace-write\"".to_string(), + ], + additional_env: HashMap::new(), + } + } else { + LaunchPolicyResult::default() + } +} + +#[cfg(test)] +mod tests { + use super::super::config::AcpClientPermissionMode; + use super::*; + + fn test_config() -> AcpClientConfig { + AcpClientConfig { + name: None, + command: "npx".to_string(), + args: vec![], + env: HashMap::new(), + enabled: true, + readonly: false, + permission_mode: AcpClientPermissionMode::Ask, + category: None, + description: None, + } + } + + #[test] + fn codex_backend_gets_sandbox_args() { + let result = apply_launch_policy(&test_config(), "codex"); + assert_eq!(result.additional_args.len(), 6); + assert!(result.additional_args[5].contains("workspace-write")); + } + + #[test] + fn codex_case_insensitive_match() { + let result = apply_launch_policy(&test_config(), "Codex-ACP"); + assert!(!result.additional_args.is_empty()); + } + + #[test] + fn claude_backend_noop() { + let result = apply_launch_policy(&test_config(), "claude-code"); + assert!(result.additional_args.is_empty()); + } + + #[test] + fn unknown_backend_noop() { + let result = apply_launch_policy(&test_config(), "goose"); + assert!(result.additional_args.is_empty()); + } +} diff --git a/src/crates/interfaces/acp/src/client/manager.rs b/src/crates/interfaces/acp/src/client/manager.rs index 8e6563ec3d..3d76b2c768 100644 --- a/src/crates/interfaces/acp/src/client/manager.rs +++ b/src/crates/interfaces/acp/src/client/manager.rs @@ -42,11 +42,13 @@ use super::config::{ AcpClientConfig, AcpClientConfigFile, AcpClientInfo, AcpClientPermissionMode, AcpClientRequirementProbe, AcpClientStatus, RemoteAcpClientRequirementSnapshot, }; +use super::launch_policy::apply_launch_policy; +use super::probe::{TryConnectResult, ACP_HANDSHAKE_TIMEOUT_SECS, CLI_DETECT_TIMEOUT_SECS}; use super::remote_capability_store::RemoteAcpCapabilityStore; use super::remote_session::{preferred_resume_strategies, AcpRemoteSessionStrategy}; use super::remote_shell::{remote_user_shell_command, render_remote_env_assignments, shell_escape}; use super::requirements::{ - acp_requirement_spec, apply_command_environment, install_npm_cli_package, + acp_requirement_spec, apply_command_environment, expand_env_vars, install_npm_cli_package, install_remote_npm_cli_package, predownload_npm_adapter, probe_executable, probe_npm_adapter, probe_remote_executable, probe_remote_npx_adapter, resolve_configured_command, }; @@ -300,6 +302,8 @@ impl AcpClientService { id, status, session_count, + category: config.category.clone(), + description: config.description.clone(), }); } infos.sort_by(|a, b| a.id.cmp(&b.id)); @@ -1138,6 +1142,7 @@ impl AcpClientService { )) } + #[allow(clippy::too_many_arguments)] // public convenience entry point over resolved session fields pub async fn prompt_agent( self: &Arc, client_id: &str, @@ -1182,13 +1187,14 @@ impl AcpClientService { tokio::time::timeout(Duration::from_secs(seconds), run) .await .map_err(|_| { - BitFunError::tool(format!("ACP client timed out after {}s", seconds)) + BitFunError::tool(format!("ACP client turn timed out after {}s", seconds)) })? } else { run.await } } + #[allow(clippy::too_many_arguments)] // public streaming entry point over resolved session fields pub async fn prompt_agent_stream( self: &Arc, client_id: &str, @@ -1284,7 +1290,7 @@ impl AcpClientService { tokio::time::timeout(Duration::from_secs(seconds), run) .await .map_err(|_| { - BitFunError::tool(format!("ACP client timed out after {}s", seconds)) + BitFunError::tool(format!("ACP client turn timed out after {}s", seconds)) })? } else { run.await @@ -1555,6 +1561,7 @@ impl AcpClientService { } } + #[allow(clippy::too_many_arguments)] // remote session attach carries protocol resolution state async fn attach_remote_session( &self, client: &Arc, @@ -1638,6 +1645,35 @@ impl AcpClientService { debug!("Registering ACP client tool: name={}", tool.name()); registry.register_tool(tool); } + drop(registry); + + // Also register each ACP client as a SubAgent in the global AgentRegistry + // so they appear in the agent selector and can be targeted by + // SessionControl / SessionMessage for legion orchestration. + let agent_registry = + bitfun_core::agentic::agents::get_agent_registry(); + // Clean up ALL previously registered ACP agents first, mirroring the + // tool-side `unregister_tools_by_prefix` above — otherwise clients + // that were disabled or removed keep their `acp__` agent (Mode) + // registered forever. + agent_registry.unregister_agents_by_prefix( + bitfun_core::agentic::agents::AcpAgent::agent_id_prefix(), + ); + for (client_id, config) in configs.iter().filter(|(_, c)| c.enabled) { + let agent = Arc::new( + bitfun_core::agentic::agents::AcpAgent::new( + client_id.clone(), + config.name.clone().unwrap_or_else(|| client_id.clone()), + ), + ); + agent_registry.register_agent( + agent, + bitfun_core::agentic::agents::AgentCategory::Mode, + bitfun_core::agentic::agents::AgentSource::Builtin, + None, + None, + ); + } } async fn handle_permission_request( @@ -1654,6 +1690,16 @@ impl AcpClientService { true, )); } + AcpClientPermissionMode::AllowAlways => { + // No-approval automation mode: auto-select the allow-always + // option (falling back to any approve-style option) without + // human intervention. + return Ok(select_permission_by_kind( + &request, + PermissionOptionKind::AllowAlways, + true, + )); + } AcpClientPermissionMode::RejectOnce => { return Ok(select_permission_by_kind( &request, @@ -1711,6 +1757,10 @@ impl AcpClientService { .unwrap_or(AcpClientPermissionMode::Ask) } + fn expand_configured_args(args: &[String]) -> Vec { + args.iter().map(|arg| expand_env_vars(arg)).collect() + } + async fn start_local_transport( &self, client_id: &str, @@ -1720,13 +1770,26 @@ impl AcpClientService { let program = resolve_configured_command(&config.command, &config.env); let mut command = bitfun_core::util::process_manager::create_tokio_command(&program); command - .args(&config.args) + .args(Self::expand_configured_args(&config.args)) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::inherit()); apply_command_environment(&mut command, Some(&config.env)); configure_process_group(&mut command); + // Apply per-backend launch policy (e.g. codex workspace-write sandbox) + // so external ACP clients run with the configured execution environment. + let launch_policy = apply_launch_policy(config, client_id); + command.args(&launch_policy.additional_args); + apply_command_environment( + &mut command, + if launch_policy.additional_env.is_empty() { + None + } else { + Some(&launch_policy.additional_env) + }, + ); + let mut child = command.spawn().map_err(|error| { BitFunError::service(format!( "Failed to spawn ACP client '{}': {}", @@ -1858,6 +1921,201 @@ impl AcpClientService { config, }) } + + pub async fn detect_client_cli( + self: &Arc, + client_id: &str, + ) -> BitFunResult> { + let config_file = self.load_config_file().await?; + let config = resolve_config_for_client(&config_file, client_id, None) + .ok_or_else(|| BitFunError::NotFound(format!("ACP client not found: {}", client_id)))?; + Ok(super::cli_detect::detect_cli(&config.command).await) + } + + pub async fn try_connect_client( + self: &Arc, + client_id: &str, + ) -> BitFunResult { + let cli_result = tokio::time::timeout( + Duration::from_secs(CLI_DETECT_TIMEOUT_SECS), + self.detect_client_cli(client_id), + ) + .await; + + match cli_result { + Ok(Ok(Some(_path))) => {} + Ok(Ok(None)) => { + let config_file = self.load_config_file().await?; + let config = + resolve_config_for_client(&config_file, client_id, None).ok_or_else(|| { + BitFunError::NotFound(format!("ACP client not found: {}", client_id)) + })?; + return Ok(TryConnectResult::FailCli { + error: format!("{} is not available on PATH", config.command), + }); + } + Ok(Err(error)) => { + return Ok(TryConnectResult::FailCli { + error: error.to_string(), + }); + } + Err(_) => { + let config_file = self.load_config_file().await?; + let config = + resolve_config_for_client(&config_file, client_id, None).ok_or_else(|| { + BitFunError::NotFound(format!("ACP client not found: {}", client_id)) + })?; + return Ok(TryConnectResult::FailCli { + error: format!( + "CLI detection timed out after {}s for {}", + CLI_DETECT_TIMEOUT_SECS, config.command, + ), + }); + } + } + + match tokio::time::timeout( + Duration::from_secs(ACP_HANDSHAKE_TIMEOUT_SECS), + self.run_probe_handshake(client_id), + ) + .await + { + Ok(result) => result, + Err(_) => Ok(TryConnectResult::FailAcp { + error: format!( + "ACP handshake timed out after {}s", + ACP_HANDSHAKE_TIMEOUT_SECS, + ), + }), + } + } + + async fn run_probe_handshake( + self: &Arc, + client_id: &str, + ) -> BitFunResult { + let config_file = self.load_config_file().await?; + let config = resolve_config_for_client(&config_file, client_id, None) + .ok_or_else(|| BitFunError::NotFound(format!("ACP client not found: {}", client_id)))?; + + let program = resolve_configured_command(&config.command, &config.env); + let mut command = bitfun_core::util::process_manager::create_tokio_command(&program); + command + .args(Self::expand_configured_args(&config.args)) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()); + apply_command_environment(&mut command, Some(&config.env)); + configure_process_group(&mut command); + + let mut child = command.spawn().map_err(|error| { + BitFunError::service(format!( + "Failed to spawn ACP client '{}': {}", + client_id, error + )) + })?; + + let stdout = match child.stdout.take() { + Some(stdout) => stdout, + None => { + terminate_child_process_tree("probe", child).await; + return Err(BitFunError::service(format!( + "ACP client '{}' stdout is unavailable", + client_id + ))); + } + }; + let stdin = match child.stdin.take() { + Some(stdin) => stdin, + None => { + terminate_child_process_tree("probe", child).await; + return Err(BitFunError::service(format!( + "ACP client '{}' stdin is unavailable", + client_id + ))); + } + }; + + let transport = ByteStreams::new(Box::pin(stdin.compat_write()), Box::pin(stdout.compat())); + + let (result_tx, mut result_rx) = + oneshot::channel::>(); + + let probe_task = tokio::spawn(async move { + let connect_result = Client + .builder() + .name("bitfun-acp-probe") + .on_receive_request( + async move |_request: RequestPermissionRequest, responder, _cx| { + responder.respond_with_result(Ok(RequestPermissionResponse::new( + RequestPermissionOutcome::Cancelled, + ))) + }, + agent_client_protocol::on_receive_request!(), + ) + .connect_with(transport, async move |cx| { + let init = InitializeRequest::new(ProtocolVersion::V1) + .client_capabilities(ClientCapabilities::new()) + .client_info(Implementation::new( + "bitfun-desktop", + env!("CARGO_PKG_VERSION"), + )); + let _init_response = cx.send_request(init).block_task().await?; + + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + let _session_response = cx + .send_request(NewSessionRequest::new(&cwd)) + .block_task() + .await?; + + Ok(()) + }) + .await; + + match connect_result { + Ok(()) => { + let _ = result_tx.send(Ok(())); + } + Err(error) => { + let _ = result_tx.send(Err(error)); + } + } + }); + + let handshake_result = tokio::time::timeout( + Duration::from_secs(ACP_HANDSHAKE_TIMEOUT_SECS), + &mut result_rx, + ) + .await; + + probe_task.abort(); + terminate_child_process_tree("probe", child).await; + + match handshake_result { + Ok(Ok(Ok(()))) => Ok(TryConnectResult::Success), + Ok(Ok(Err(error))) => { + if is_auth_error(&error) { + Ok(TryConnectResult::FailAuth { + error: error.to_string(), + login_hint: auth_login_hint(client_id), + }) + } else { + Ok(TryConnectResult::FailAcp { + error: error.to_string(), + }) + } + } + Ok(Err(_)) => Ok(TryConnectResult::FailAcp { + error: "ACP client exited before handshake completed".to_string(), + }), + Err(_) => Ok(TryConnectResult::FailAcp { + error: format!( + "ACP handshake timed out after {}s", + ACP_HANDSHAKE_TIMEOUT_SECS, + ), + }), + } + } } fn resolve_config_for_client( @@ -2528,6 +2786,44 @@ fn is_startup_timeout_error(error: &BitFunError) -> bool { error.to_string().contains(STARTUP_TIMEOUT_ERROR_PREFIX) } +fn is_auth_error(error: &agent_client_protocol::Error) -> bool { + let msg = error.to_string().to_lowercase(); + msg.contains("auth") + || msg.contains("unauthorized") + || msg.contains("401") + || msg.contains("403") + || msg.contains("api key") + || msg.contains("apikey") +} + +/// Returns login guidance for a client that surfaced an auth error. +/// +/// Only built-in clients with a known login command produce a hint; custom +/// clients return None so we never guess at provider-specific instructions. +// Ref: AionCore crates/aionui-ai-agent/src/protocol/send_error.rs:279-290 — AuthRequired +// 映射为 CheckAgentLogin 引导;custom_agent_probe.rs:234-240 — probe 阶段显式区分 +// "可达但需登录"。Rust 翻译实现,非 Cargo 依赖。 +fn auth_login_hint(client_id: &str) -> Option { + match client_id { + "codex" => Some( + "Codex requires login. Run `codex login` in a terminal to authenticate with your \ + ChatGPT account." + .to_string(), + ), + "claude-code" => Some( + "Claude Code requires login. Run `claude /login` in a terminal (or start \ + `npx @anthropic-ai/claude-code` once) to authenticate." + .to_string(), + ), + "opencode" => Some( + "OpenCode requires authorization. Run `opencode auth login` in a terminal to \ + authenticate." + .to_string(), + ), + _ => None, + } +} + fn select_permission_by_kind( request: &RequestPermissionRequest, preferred: PermissionOptionKind, @@ -2598,6 +2894,8 @@ mod tests { enabled: true, readonly: false, permission_mode: AcpClientPermissionMode::Ask, + category: None, + description: None, }, )) } @@ -2634,6 +2932,37 @@ mod tests { assert_eq!(select_permission_option_id(&options, true), "yes-once"); } + #[test] + fn allow_always_mode_auto_approves_with_allow_always_option() { + let request = RequestPermissionRequest::new( + "session-1".to_string(), + agent_client_protocol::schema::ToolCallUpdate::new( + "tool-1", + agent_client_protocol::schema::ToolCallUpdateFields::default(), + ), + vec![ + PermissionOption::new( + "allow-once", + "Allow Once", + PermissionOptionKind::AllowOnce, + ), + PermissionOption::new( + "allow-always", + "Always Allow", + PermissionOptionKind::AllowAlways, + ), + PermissionOption::new("no-once", "Reject", PermissionOptionKind::RejectOnce), + ], + ); + + let response = select_permission_by_kind(&request, PermissionOptionKind::AllowAlways, true); + + let RequestPermissionOutcome::Selected(selected) = response.outcome else { + panic!("AllowAlways must auto-select a permission option"); + }; + assert_eq!(selected.option_id, "allow-always".into()); + } + #[test] fn selects_actual_permission_option_id_for_rejection() { let options = vec![ @@ -2644,6 +2973,21 @@ mod tests { assert_eq!(select_permission_option_id(&options, false), "no-once"); } + #[test] + fn auth_login_hint_covers_builtin_clients_only() { + let codex = auth_login_hint("codex").expect("codex hint"); + assert!(codex.contains("codex login")); + + let claude = auth_login_hint("claude-code").expect("claude-code hint"); + assert!(claude.contains("claude /login")); + + let opencode = auth_login_hint("opencode").expect("opencode hint"); + assert!(opencode.contains("opencode auth login")); + + assert!(auth_login_hint("custom-agent").is_none()); + assert!(auth_login_hint("").is_none()); + } + #[test] fn formats_startup_timeout_error_message() { assert_eq!( @@ -2680,6 +3024,8 @@ mod tests { enabled: true, readonly: false, permission_mode: AcpClientPermissionMode::Ask, + category: None, + description: None, }; let command = render_remote_client_command(&config, Some("/srv/my repo")).expect("command"); @@ -2706,6 +3052,8 @@ mod tests { enabled: true, readonly: false, permission_mode: AcpClientPermissionMode::Ask, + category: None, + description: None, }, )]), }; @@ -2721,4 +3069,18 @@ mod tests { assert_eq!(resolved.env.get("BASE").map(String::as_str), Some("1")); assert!(resolved.enabled); } + + #[test] + fn new_session_request_serializes_with_explicit_empty_mcp_servers() { + // Regression guard: some ACP agents (e.g. codebuddy) strictly validate + // session/new and reject a request whose JSON omits the mcpServers key + // (-32602). NewSessionRequest::new must keep serializing the explicit + // empty array, so mcp_servers must not gain skip_serializing_if. + let request = NewSessionRequest::new(PathBuf::from("/tmp/work")); + let json = serde_json::to_value(&request).expect("serialize new session request"); + assert_eq!( + json.get("mcpServers"), + Some(&serde_json::Value::Array(vec![])) + ); + } } diff --git a/src/crates/interfaces/acp/src/client/mod.rs b/src/crates/interfaces/acp/src/client/mod.rs index 12fce4ead6..218fb388bc 100644 --- a/src/crates/interfaces/acp/src/client/mod.rs +++ b/src/crates/interfaces/acp/src/client/mod.rs @@ -1,6 +1,9 @@ mod builtin_clients; +mod cli_detect; mod config; +mod launch_policy; mod manager; +mod probe; mod remote_capability_store; mod remote_session; mod remote_shell; @@ -16,11 +19,16 @@ pub use config::{ AcpClientRequirementProbe, AcpClientStatus, AcpRequirementProbeItem, RemoteAcpClientRequirementSnapshot, }; +pub use launch_policy::{apply_launch_policy, LaunchPolicyResult}; pub use manager::{ AcpClientPermissionResponse, AcpClientService, AcpSessionConfigValue, CreateAcpFlowSessionRecordResponse, SetAcpSessionConfigOptionRequest, SetAcpSessionModelRequest, SubmitAcpPermissionResponseRequest, }; +pub use probe::{ + TryConnectResult, ACP_HANDSHAKE_TIMEOUT_SECS, CLI_DETECT_TIMEOUT_SECS, + TRY_CONNECT_TOTAL_TIMEOUT_SECS, +}; pub use session_options::{ AcpAvailableCommand, AcpPlanEntry, AcpSessionConfigKind, AcpSessionConfigOption, AcpSessionConfigSelectOption, AcpSessionContextUsage, AcpSessionModelOption, AcpSessionOptions, diff --git a/src/crates/interfaces/acp/src/client/probe.rs b/src/crates/interfaces/acp/src/client/probe.rs new file mode 100644 index 0000000000..3a6645c3a7 --- /dev/null +++ b/src/crates/interfaces/acp/src/client/probe.rs @@ -0,0 +1,71 @@ +//! Two-step probe for ACP agent connectivity. +//! +//! Step 1: `which` check — detect CLI on system PATH (5 s timeout). +//! Step 2: Spawn + ACP initialize + session/new handshake (30 s timeout). +//! +//! The probe always cleans up the spawned process, including any +//! grandchild processes orphaned by wrapper CLIs. + +use serde::{Deserialize, Serialize}; + +/// Two-step probe result for ACP agent connectivity. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "step", rename_all = "snake_case")] +pub enum TryConnectResult { + /// Both steps succeeded — agent is reachable and usable. + Success, + /// Step 1 failed — the CLI command was not found on PATH. + FailCli { error: String }, + /// Step 2 failed — ACP initialize or session/new failed. + FailAcp { error: String }, + /// Step 2 reached initialize but session/new failed with auth. + FailAuth { + error: String, + /// Login guidance for the client when the provider exposes one. + #[serde(default)] + login_hint: Option, + }, +} + +/// Timeout for Step 1: CLI detect on PATH. +pub const CLI_DETECT_TIMEOUT_SECS: u64 = 5; + +/// Timeout for Step 2: ACP initialize + session/new handshake. +pub const ACP_HANDSHAKE_TIMEOUT_SECS: u64 = 30; + +/// Total probe timeout (Step 1 + Step 2 upper bound). +pub const TRY_CONNECT_TOTAL_TIMEOUT_SECS: u64 = 35; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fail_auth_serializes_with_login_hint() { + let result = TryConnectResult::FailAuth { + error: "session/new failed: auth_required".to_string(), + login_hint: Some("Run `codex login` in a terminal".to_string()), + }; + + let json = serde_json::to_string(&result).unwrap(); + assert_eq!( + json, + r#"{"step":"fail_auth","error":"session/new failed: auth_required","login_hint":"Run `codex login` in a terminal"}"# + ); + } + + #[test] + fn fail_auth_deserializes_legacy_json_without_login_hint() { + let legacy = r#"{"step":"fail_auth","error":"session/new failed: auth_required"}"#; + + let result: TryConnectResult = serde_json::from_str(legacy).unwrap(); + + match result { + TryConnectResult::FailAuth { error, login_hint } => { + assert_eq!(error, "session/new failed: auth_required"); + assert!(login_hint.is_none()); + } + other => panic!("expected FailAuth, got {other:?}"), + } + } +} diff --git a/src/crates/interfaces/acp/src/client/requirements.rs b/src/crates/interfaces/acp/src/client/requirements.rs index df6eb8aba4..85b06ff684 100644 --- a/src/crates/interfaces/acp/src/client/requirements.rs +++ b/src/crates/interfaces/acp/src/client/requirements.rs @@ -376,12 +376,51 @@ pub(crate) async fn install_remote_npm_cli_package( } } +/// Expand Windows-style `%VAR%` environment references in a configured +/// command string (e.g. `%APPDATA%\npm\claude-agent-acp.cmd`). `%%` is an +/// escaped literal `%`. Variables that are not set are kept verbatim so the +/// original placeholder stays visible in error output. +pub(crate) fn expand_env_vars(value: &str) -> String { + if !value.contains('%') { + return value.to_string(); + } + + let mut expanded = String::with_capacity(value.len()); + let mut remaining = value; + while let Some(start) = remaining.find('%') { + expanded.push_str(&remaining[..start]); + remaining = &remaining[start + 1..]; + let Some(end) = remaining.find('%') else { + // Unclosed '%': keep the remainder verbatim. + expanded.push('%'); + expanded.push_str(remaining); + return expanded; + }; + let name = &remaining[..end]; + remaining = &remaining[end + 1..]; + if name.is_empty() { + // "%%" is an escaped literal '%'. + expanded.push('%'); + } else if let Ok(value) = env::var(name) { + expanded.push_str(&value); + } else { + // Unset variable: keep the placeholder verbatim. + expanded.push('%'); + expanded.push_str(name); + expanded.push('%'); + } + } + expanded.push_str(remaining); + expanded +} + pub(crate) fn resolve_configured_command( command: &str, extra_env: &HashMap, ) -> PathBuf { + let command = expand_env_vars(command); let configured_path = configured_path_value(extra_env); - find_executable_with_path(command, configured_path.as_deref()) + find_executable_with_path(&command, configured_path.as_deref()) .unwrap_or_else(|| PathBuf::from(command)) } @@ -489,13 +528,14 @@ fn find_executable(command: &str) -> Option { } fn find_executable_with_path(command: &str, configured_path: Option<&OsStr>) -> Option { - let command_path = PathBuf::from(command); + let command = expand_env_vars(command); + let command_path = PathBuf::from(&command); if command_path.components().count() > 1 { return executable_file(&command_path).then_some(command_path); } for directory in command_search_paths(configured_path) { - for candidate in executable_candidates(&directory, command) { + for candidate in executable_candidates(&directory, &command) { if executable_file(&candidate) { return Some(candidate); } @@ -678,6 +718,81 @@ mod tests { assert_eq!(codex.bin, "codex-acp"); } + #[test] + fn expand_env_vars_replaces_set_windows_variables() { + const TEST_VAR: &str = "BITFUN_ACP_TEST_EXPAND_VAR"; + std::env::set_var(TEST_VAR, r"C:\Users\test\AppData\Roaming"); + + let expanded = expand_env_vars(r"%BITFUN_ACP_TEST_EXPAND_VAR%\npm\claude-agent-acp.cmd"); + + std::env::remove_var(TEST_VAR); + assert_eq!( + expanded, + r"C:\Users\test\AppData\Roaming\npm\claude-agent-acp.cmd" + ); + } + + /// Real-environment counterpart: on Windows, `%APPDATA%` must expand to + /// the live APPDATA value, matching the absolute paths used in the L0 ACP + /// registries (e.g. `%APPDATA%\npm\claude.exe` and the ACP dispatcher at + /// `%APPDATA%\BitFun\skills\acp-agent-dispatcher\acp_call.cjs`). + #[cfg(windows)] + #[test] + fn expand_env_vars_resolves_real_appdata_like_l0_configs() { + let appdata = std::env::var("APPDATA").expect("APPDATA should be set on Windows"); + + assert_eq!( + expand_env_vars(r"%APPDATA%\npm\claude.exe"), + format!(r"{}\npm\claude.exe", appdata) + ); + assert_eq!( + expand_env_vars(r"%APPDATA%\BitFun\skills\acp-agent-dispatcher\acp_call.cjs"), + format!(r"{}\BitFun\skills\acp-agent-dispatcher\acp_call.cjs", appdata) + ); + } + + #[test] + fn expand_env_vars_keeps_unset_variables_literal() { + assert_eq!( + expand_env_vars(r"%BITFUN_ACP_TEST_UNSET_VAR%\npm\codex-acp.cmd"), + r"%BITFUN_ACP_TEST_UNSET_VAR%\npm\codex-acp.cmd" + ); + } + + #[test] + fn expand_env_vars_escapes_double_percent_and_keeps_plain_input() { + assert_eq!(expand_env_vars("100%%done"), "100%done"); + assert_eq!(expand_env_vars("plain-command"), "plain-command"); + assert_eq!(expand_env_vars("unclosed-%placeholder"), "unclosed-%placeholder"); + } + + #[test] + fn resolve_configured_command_expands_env_vars_in_command() { + const TEST_VAR: &str = "BITFUN_ACP_TEST_CMD_DIR"; + let test_dir = env::temp_dir().join(format!("bitfun-acp-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&test_dir).expect("test dir should be created"); + + #[cfg(windows)] + let file_name = "bitfun-env-tool.cmd"; + #[cfg(not(windows))] + let file_name = "bitfun-env-tool"; + + let executable = test_dir.join(file_name); + std::fs::write(&executable, b"").expect("test executable should be written"); + + let command = format!( + "%{TEST_VAR}%{}{}", + std::path::MAIN_SEPARATOR, + file_name + ); + std::env::set_var(TEST_VAR, &test_dir); + let resolved = resolve_configured_command(&command, &HashMap::new()); + std::env::remove_var(TEST_VAR); + + let _ = std::fs::remove_dir_all(&test_dir); + assert_eq!(resolved, executable); + } + #[test] fn command_search_paths_keep_configured_path_first() { let configured_paths = env::join_paths([ diff --git a/src/crates/interfaces/acp/src/client/tool.rs b/src/crates/interfaces/acp/src/client/tool.rs index 3d62b997e1..b29e720d63 100644 --- a/src/crates/interfaces/acp/src/client/tool.rs +++ b/src/crates/interfaces/acp/src/client/tool.rs @@ -56,6 +56,21 @@ fn acp_external_agent_definition_for_config( }) } +/// Rejects tool execution for ACP clients configured as read-only. +/// +/// A read-only ACP client may still be probed, but execution must never +/// reach the external agent: the tool call is refused at the entry point +/// so no external process is invoked on its behalf. +fn reject_readonly_client(read_only: bool, client_id: &str) -> BitFunResult<()> { + if read_only { + return Err(BitFunError::tool(format!( + "ACP client '{}' is read-only; execution was rejected", + client_id + ))); + } + Ok(()) +} + #[async_trait] impl Tool for AcpAgentTool { fn name(&self) -> &str { @@ -115,6 +130,7 @@ impl Tool for AcpAgentTool { input: &Value, context: &ToolUseContext, ) -> BitFunResult> { + reject_readonly_client(self.definition.read_only, &self.client_id)?; let bitfun_session_id = context.session_id.clone().ok_or_else(|| { BitFunError::tool("ACP tool requires an active BitFun session".to_string()) })?; @@ -183,6 +199,8 @@ mod tests { enabled: true, readonly: true, permission_mode: AcpClientPermissionMode::Ask, + category: None, + description: None, }; let definition = acp_external_agent_definition_for_config("codex", &config); @@ -192,4 +210,19 @@ mod tests { assert_eq!(definition.user_facing_name, "Codex (ACP)"); assert!(definition.read_only); } + + #[test] + fn readonly_client_execution_is_rejected_before_external_agent() { + let error = reject_readonly_client(true, "codex").unwrap_err(); + + let message = error.to_string(); + assert!(message.contains("codex")); + assert!(message.contains("read-only")); + assert!(message.contains("rejected")); + } + + #[test] + fn writable_client_execution_is_allowed() { + assert!(reject_readonly_client(false, "codex").is_ok()); + } } diff --git a/src/crates/interfaces/acp/src/client/tool_card_bridge/tool_params.rs b/src/crates/interfaces/acp/src/client/tool_card_bridge/tool_params.rs index 0dcc106e32..0ef541ba3b 100644 --- a/src/crates/interfaces/acp/src/client/tool_card_bridge/tool_params.rs +++ b/src/crates/interfaces/acp/src/client/tool_card_bridge/tool_params.rs @@ -47,30 +47,26 @@ pub(super) fn normalize_tool_params( } } } - "LS" => { - if !normalized.contains_key("path") { - if let Some(value) = normalized - .get("directory") - .or_else(|| normalized.get("dir")) - .or_else(|| normalized.get("target_directory")) - .or_else(|| normalized.get("targetDirectory")) - .cloned() - { - normalized.insert("path".to_string(), value); - } + "LS" if !normalized.contains_key("path") => { + if let Some(value) = normalized + .get("directory") + .or_else(|| normalized.get("dir")) + .or_else(|| normalized.get("target_directory")) + .or_else(|| normalized.get("targetDirectory")) + .cloned() + { + normalized.insert("path".to_string(), value); } } - "Grep" => { - if !normalized.contains_key("pattern") { - if let Some(value) = normalized - .get("query") - .or_else(|| normalized.get("text")) - .or_else(|| normalized.get("search_pattern")) - .or_else(|| normalized.get("searchPattern")) - .cloned() - { - normalized.insert("pattern".to_string(), value); - } + "Grep" if !normalized.contains_key("pattern") => { + if let Some(value) = normalized + .get("query") + .or_else(|| normalized.get("text")) + .or_else(|| normalized.get("search_pattern")) + .or_else(|| normalized.get("searchPattern")) + .cloned() + { + normalized.insert("pattern".to_string(), value); } } "Glob" => { diff --git a/src/crates/interfaces/acp/src/runtime/session.rs b/src/crates/interfaces/acp/src/runtime/session.rs index 9488411c6b..6b702c2478 100644 --- a/src/crates/interfaces/acp/src/runtime/session.rs +++ b/src/crates/interfaces/acp/src/runtime/session.rs @@ -448,6 +448,7 @@ impl BitfunAcpRuntime { workspace_path: cwd.to_string_lossy().to_string(), remote_connection_id: None, remote_ssh_host: None, + include_hidden: false, }) .await .map_err(Self::runtime_error)?; diff --git a/src/crates/interfaces/app-server/tests/agent_kernel.rs b/src/crates/interfaces/app-server/tests/agent_kernel.rs index 48a6a57027..ff901b89ff 100644 --- a/src/crates/interfaces/app-server/tests/agent_kernel.rs +++ b/src/crates/interfaces/app-server/tests/agent_kernel.rs @@ -264,6 +264,9 @@ impl AgentSessionRestorePort for SessionControlProvider { turn_count: 4, created_at_ms: 10, last_active_at_ms: 20, + parent_session_id: None, + status: None, + is_daemon: false, }, state: SessionState::Processing { current_turn_id: "turn-active".to_string(), @@ -385,6 +388,9 @@ impl bitfun_agent_runtime::sdk::AgentSessionRestorePort for Phase2Provider { turn_count: 1, created_at_ms: 10, last_active_at_ms: 20, + parent_session_id: None, + status: None, + is_daemon: false, }, state: SessionState::Processing { current_turn_id: "turn-active".to_string(), @@ -830,6 +836,7 @@ async fn phase2_mutations_route_through_runtime_owner_ports() { turn_id: "turn-active".to_string(), content: "keep going".to_string(), display_content: None, + prepended_reminders: Vec::new(), })) .await .expect("steer turn"); @@ -1456,6 +1463,7 @@ async fn list_sessions_maps_missing_port_to_internal_error() { workspace_path: ".".to_string(), remote_connection_id: None, remote_ssh_host: None, + include_hidden: false, }, ))) .await; diff --git a/src/crates/services/relay-service/src/db.rs b/src/crates/services/relay-service/src/db.rs index cb9f53dd0c..219e2762a8 100644 --- a/src/crates/services/relay-service/src/db.rs +++ b/src/crates/services/relay-service/src/db.rs @@ -373,6 +373,7 @@ impl UserRow { /// out-of-band (e.g. an admin import tool) so the relay never sees a /// password. Kept as a DB primitive for that future tooling. #[allow(dead_code)] + #[allow(clippy::too_many_arguments)] // row insert primitive; mirrors users table columns pub async fn create( pool: &DbPool, user_id: &str, @@ -1050,6 +1051,7 @@ impl SyncSessionRow { /// Enforces optional per-user active session count and total encrypted-byte /// quotas. Product defaults are effectively unlimited (`i32::MAX`); pass /// lower ceilings when an operator needs to bound account storage. + #[allow(clippy::too_many_arguments)] // upsert primitive; mirrors sync_sessions columns pub async fn upsert_with_quota( pool: &DbPool, user_id: &str, @@ -1933,6 +1935,7 @@ impl PageWithUsername { } impl PageVersionRow { + #[allow(clippy::too_many_arguments)] // row insert primitive; mirrors page_versions columns pub async fn insert( pool: &DbPool, user_id: &str, diff --git a/src/crates/services/relay-service/src/relay/device_manager.rs b/src/crates/services/relay-service/src/relay/device_manager.rs index 0bba46e1a4..aed3f4430e 100644 --- a/src/crates/services/relay-service/src/relay/device_manager.rs +++ b/src/crates/services/relay-service/src/relay/device_manager.rs @@ -111,6 +111,7 @@ impl DeviceManager { /// for the same `(user_id, device_id)` (reconnect). Returns the list of /// *other* online device ids in the account so the caller can push a /// presence update. + #[allow(clippy::too_many_arguments)] // device registration carries all connection facts pub fn register( &self, user_id: &str, @@ -177,6 +178,7 @@ impl DeviceManager { /// Stage a connection while an async post-registration token check runs. /// It cannot receive routed messages or presence and cannot evict an /// already-authorized connection for the same physical device. + #[allow(clippy::too_many_arguments)] // pending registration carries all connection facts pub fn register_pending( &self, user_id: &str, diff --git a/src/crates/services/relay-service/src/routes/websocket.rs b/src/crates/services/relay-service/src/routes/websocket.rs index a1a5fa1181..50323469d2 100644 --- a/src/crates/services/relay-service/src/routes/websocket.rs +++ b/src/crates/services/relay-service/src/routes/websocket.rs @@ -270,7 +270,7 @@ async fn handle_socket(socket: WebSocket, state: AppState) { } match msg_result { Ok(Message::Text(text)) => { - if !handle_text_message( + let keep_going = handle_text_message( &text, conn_id, &state, @@ -278,8 +278,8 @@ async fn handle_socket(socket: WebSocket, state: AppState) { &force_close_tx, &mut token_expiry_task, ) - .await - { + .await; + if !keep_going { break; } } diff --git a/src/crates/services/services-core/Cargo.toml b/src/crates/services/services-core/Cargo.toml index cddc14b3fd..f94908e588 100644 --- a/src/crates/services/services-core/Cargo.toml +++ b/src/crates/services/services-core/Cargo.toml @@ -15,7 +15,9 @@ async-trait = { workspace = true, optional = true } bitfun-core-types = { path = "../../contracts/core-types", optional = true } bitfun-events = { path = "../../contracts/events", optional = true } bitfun-runtime-ports = { path = "../../contracts/runtime-ports", optional = true } -tokio = { workspace = true, features = ["rt", "time"] } +dashmap = { workspace = true } +futures = { workspace = true } +tokio = { workspace = true, features = ["fs", "io-util", "process", "rt", "sync", "time"] } serde = { workspace = true } serde_json = { workspace = true } serde_yaml = { workspace = true, optional = true } diff --git a/src/crates/services/services-core/src/bounded_fs.rs b/src/crates/services/services-core/src/bounded_fs.rs index 50fa90372c..ec3a47bd4b 100644 --- a/src/crates/services/services-core/src/bounded_fs.rs +++ b/src/crates/services/services-core/src/bounded_fs.rs @@ -11,7 +11,7 @@ pub fn is_symlink_or_reparse(metadata: &std::fs::Metadata) -> bool { { use std::os::windows::fs::MetadataExt; const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0400; - return metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0; + metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 } #[cfg(not(windows))] false diff --git a/src/crates/services/services-core/src/json_store.rs b/src/crates/services/services-core/src/json_store.rs index 2cacc4b385..badcd4104a 100644 --- a/src/crates/services/services-core/src/json_store.rs +++ b/src/crates/services/services-core/src/json_store.rs @@ -493,6 +493,9 @@ impl JsonFileStore { let temp = Self::windows_extended_path(tmp_path)?; let target = Self::windows_extended_path(target_path)?; + // SAFETY: `temp` and `target` are extended-length UTF-16 paths owned by + // local `OsString`-backed buffers; their pointers stay valid for the + // duration of the call and both buffers are null-terminated. let result = unsafe { if target_path.exists() { ReplaceFileW( diff --git a/src/crates/services/services-core/src/session/lineage.rs b/src/crates/services/services-core/src/session/lineage.rs index 3deeed8e27..e20e05cd95 100644 --- a/src/crates/services/services-core/src/session/lineage.rs +++ b/src/crates/services/services-core/src/session/lineage.rs @@ -217,12 +217,17 @@ pub fn collect_hidden_subagent_cascade( &child_session_ids_by_parent, &mut visited, &mut ordered_session_ids, + 0, ); } ordered_session_ids } +/// Maximum recursion depth for subagent post-order traversal. +/// Guards against runaway chains in malformed metadata (defense-in-depth). +const MAX_SUBAGENT_RECURSION_DEPTH: u32 = 256; + /// Builds the complete subagent Session tree containing `anchor_session_id`. /// /// The snapshot stays flat so callers can project it for their own surface @@ -345,7 +350,17 @@ fn collect_subagent_post_order( child_session_ids_by_parent: &HashMap>, visited: &mut HashSet, ordered_session_ids: &mut Vec, + recursion_depth: u32, ) { + if recursion_depth > MAX_SUBAGENT_RECURSION_DEPTH { + log::warn!( + "collect_subagent_post_order: max recursion depth {} exceeded at session_id={}", + MAX_SUBAGENT_RECURSION_DEPTH, + session_id + ); + return; + } + if !visited.insert(session_id.to_string()) { return; } @@ -357,6 +372,7 @@ fn collect_subagent_post_order( child_session_ids_by_parent, visited, ordered_session_ids, + recursion_depth + 1, ); } } @@ -628,6 +644,7 @@ mod tests { parent_tool_call_id: None, subagent_type: None, continuation_policy: None, + ..Default::default() }, ); @@ -659,6 +676,7 @@ mod tests { parent_tool_call_id: None, subagent_type: None, continuation_policy: None, + ..Default::default() }); let mut grandchild = metadata("grandchild"); @@ -798,6 +816,7 @@ mod tests { parent_tool_call_id: None, subagent_type: None, continuation_policy: None, + ..Default::default() }); source.todos = Some(json!([{ "id": "todo" }])); source.deep_review_run_manifest = Some(json!({ "run": "manifest" })); diff --git a/src/crates/services/services-core/src/session/metadata.rs b/src/crates/services/services-core/src/session/metadata.rs index 69bfa7ea1c..bd36aa1c04 100644 --- a/src/crates/services/services-core/src/session/metadata.rs +++ b/src/crates/services/services-core/src/session/metadata.rs @@ -27,6 +27,7 @@ pub struct SessionMetadataBuildFacts<'a> { pub workspace_hostname: Option<&'a str>, pub new_session_memory_mode: SessionMemoryMode, pub existing: Option<&'a SessionMetadata>, + pub is_daemon: bool, } pub fn build_session_metadata(facts: SessionMetadataBuildFacts<'_>) -> SessionMetadata { @@ -90,6 +91,10 @@ pub fn build_session_metadata(facts: SessionMetadataBuildFacts<'_>) -> SessionMe workspace_hostname: facts.workspace_hostname.map(str::to_string), unread_completion: existing.and_then(|value| value.unread_completion.clone()), needs_user_attention: existing.and_then(|value| value.needs_user_attention.clone()), + runtime_state: existing.and_then(|value| value.runtime_state.clone()), + is_daemon: existing + .map(|value| value.is_daemon) + .unwrap_or(facts.is_daemon), } } @@ -99,7 +104,9 @@ fn build_session_relationship( ) -> Option { let mut relationship = existing.and_then(normalized_session_relationship); let kind = match session_kind { - SessionKind::Subagent => SessionRelationshipKind::Subagent, + SessionKind::Subagent | SessionKind::EphemeralSubagent => { + SessionRelationshipKind::Subagent + } SessionKind::EphemeralChild => SessionRelationshipKind::Btw, SessionKind::Standard => return relationship, }; @@ -185,6 +192,7 @@ pub fn normalized_session_relationship(metadata: &SessionMetadata) -> Option = session_ids + .iter() + .map(|sid| { + let sid = sid.clone(); + async move { + let metadata = self.load_metadata(&sid).await; + (sid, metadata) + } + }) + .collect(); + + let results = futures::future::join_all(handles).await; + + let mut metadata_list = Vec::new(); + for (session_id, result) in results { + match result { Ok(Some(metadata)) => metadata_list.push(metadata), Ok(None) => {} Err(error) => { @@ -327,6 +348,20 @@ impl SessionMetadataStore { } pub async fn list_metadata(&self) -> Result, SessionMetadataStoreError> { + self.list_metadata_with_options(false).await + } + + /// Lists session metadata. With `include_internal` the visible index is + /// bypassed and every metadata directory is scanned (same semantics as + /// `list_metadata_including_internal`), so hidden Subagent/Ephemeral + /// sessions become visible for full conversation management. + pub async fn list_metadata_with_options( + &self, + include_internal: bool, + ) -> Result, SessionMetadataStoreError> { + if include_internal { + return self.list_metadata_including_internal().await; + } if !self.sessions_root().exists() { return Ok(Vec::new()); } @@ -367,6 +402,28 @@ impl SessionMetadataStore { cursor: Option<&str>, limit: usize, ) -> Result { + self.list_metadata_page_with_options(cursor, limit, false).await + } + + /// Paginated variant of [`list_metadata_with_options`]. With + /// `include_internal` the visible index is bypassed and the page is built + /// from a full metadata scan so hidden sessions participate in pagination. + pub async fn list_metadata_page_with_options( + &self, + cursor: Option<&str>, + limit: usize, + include_internal: bool, + ) -> Result { + if include_internal { + let mut sessions = self.scan_metadata_dirs().await?; + sessions.sort_by_key(|metadata| std::cmp::Reverse(metadata.last_active_at)); + return Ok(build_session_metadata_page_with_options( + sessions, + cursor, + limit, + true, + )); + } if !self.sessions_root().exists() { return Ok(empty_session_metadata_page()); } @@ -917,6 +974,41 @@ mod tests { ); } + #[tokio::test] + async fn metadata_store_with_options_includes_hidden_sessions() { + let dir = tempdir().expect("tempdir"); + let store = SessionMetadataStore::new(dir.path()); + let mut hidden = metadata("hidden", 30); + hidden.session_kind = bitfun_core_types::SessionKind::Subagent; + store + .save_metadata(&hidden) + .await + .expect("save hidden metadata"); + + assert!(store + .list_metadata_with_options(false) + .await + .expect("visible list") + .is_empty()); + assert_eq!( + store + .list_metadata_with_options(true) + .await + .expect("full list") + .len(), + 1 + ); + assert_eq!( + store + .list_metadata_page_with_options(None, 10, true) + .await + .expect("full page") + .sessions + .len(), + 1 + ); + } + #[tokio::test] async fn metadata_store_delete_session_updates_visible_index() { let dir = tempdir().expect("tempdir"); diff --git a/src/crates/services/services-core/src/session/mod.rs b/src/crates/services/services-core/src/session/mod.rs index 31b0445036..499dcd389f 100644 --- a/src/crates/services/services-core/src/session/mod.rs +++ b/src/crates/services/services-core/src/session/mod.rs @@ -6,6 +6,7 @@ mod metadata; mod metadata_store; mod migration; pub mod page; +pub mod tree; pub mod types; mod write_lock; diff --git a/src/crates/services/services-core/src/session/page.rs b/src/crates/services/services-core/src/session/page.rs index 59c571d384..d38de74c36 100644 --- a/src/crates/services/services-core/src/session/page.rs +++ b/src/crates/services/services-core/src/session/page.rs @@ -35,11 +35,24 @@ pub fn build_session_metadata_page( indexed_sessions: Vec, cursor: Option<&str>, limit: usize, +) -> SessionMetadataPage { + build_session_metadata_page_with_options(indexed_sessions, cursor, limit, false) +} + +/// Paginated session metadata builder. With `include_hidden`, sessions hidden +/// from user lists (Subagent/Ephemeral) participate in pagination for full +/// conversation management. +pub fn build_session_metadata_page_with_options( + indexed_sessions: Vec, + cursor: Option<&str>, + limit: usize, + include_hidden: bool, ) -> SessionMetadataPage { let visible_sessions = indexed_sessions .into_iter() .filter(|metadata| { - !metadata.should_hide_from_user_lists() && metadata.status != SessionStatus::Archived + (include_hidden || !metadata.should_hide_from_user_lists()) + && metadata.status != SessionStatus::Archived }) .collect::>(); let visible_ids = visible_sessions diff --git a/src/crates/services/services-core/src/session/tree.rs b/src/crates/services/services-core/src/session/tree.rs new file mode 100644 index 0000000000..4bf399552f --- /dev/null +++ b/src/crates/services/services-core/src/session/tree.rs @@ -0,0 +1,604 @@ +use crate::session::types::{SessionMetadata, SessionRelationshipKind}; +use bitfun_core_types::session_tree::{ + SessionTreeNode, SessionTreeNodeStatus, MAX_TREE_RECURSION_DEPTH, +}; +use dashmap::DashMap; +use std::collections::HashMap; + +/// Session tree error types +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SessionTreeError { + CycleDetected { child_id: String, ancestor: String }, + SelfReference(String), +} + +/// Conversation tree manager - pure in-memory data structure, not persisted. +/// All relationship data is read from SessionMetadata.relationship. +/// Hard recursion limit - traversal is truncated beyond this depth to prevent stack overflow. +/// Value is the authoritative `MAX_TREE_RECURSION_DEPTH` in `bitfun_core_types::session_tree`. +pub struct SessionTreeManager { + /// parent_id -> child_ids mapping + edges: DashMap>, + /// child_id -> parent_id reverse index (O(1) parent lookup) + child_to_parent: DashMap, + /// session_id -> depth mapping + depths: DashMap, + /// Maximum nesting depth + pub max_depth: u32, +} + +impl SessionTreeManager { + pub fn new(max_depth: u32) -> Self { + Self { + edges: DashMap::new(), + child_to_parent: DashMap::new(), + depths: DashMap::new(), + max_depth, + } + } + + /// Register a parent-child relationship + /// Depth values exceeding max_depth are clamped with a warning instead of + /// rejecting the registration, preventing cascading failures in deep trees. + pub fn register_child(&self, parent_id: &str, child_id: &str, depth: u32) -> Result<(), SessionTreeError> { + if child_id == parent_id { + return Err(SessionTreeError::SelfReference(child_id.to_string())); + } + let clamped_depth = if depth > self.max_depth { + log::warn!( + "register_child: depth {} exceeds max_depth {} for child_id={}, clamping", + depth, self.max_depth, child_id + ); + self.max_depth + } else { + depth + }; + let mut current = parent_id.to_string(); + loop { + match self.get_parent(¤t) { + Some(p) if p == child_id => { + return Err(SessionTreeError::CycleDetected { + child_id: child_id.to_string(), + ancestor: current, + }); + } + Some(p) => current = p, + None => break, + } + } + self.edges + .entry(parent_id.to_string()) + .or_default() + .push(child_id.to_string()); + self.child_to_parent + .insert(child_id.to_string(), parent_id.to_string()); + self.depths.insert(child_id.to_string(), clamped_depth); + Ok(()) + } + + /// Calculate subtree max depth (iterative DFS to prevent stack overflow). + pub fn subtree_depth(&self, session_id: &str) -> u32 { + let mut max_depth: u32 = 0; + let mut stack: Vec<(String, u32)> = vec![(session_id.to_string(), 0)]; + let mut visited = std::collections::HashSet::new(); + + while let Some((id, recursion_depth)) = stack.pop() { + if recursion_depth > MAX_TREE_RECURSION_DEPTH { + continue; + } + if !visited.insert(id.clone()) { + continue; + } + let own = self.depths.get(&id).map(|d| *d).unwrap_or(0); + max_depth = max_depth.max(own); + if let Some(children) = self.edges.get(&id) { + for child_id in children.iter() { + stack.push((child_id.clone(), recursion_depth + 1)); + } + } + } + + max_depth + } + + /// Get direct child node IDs + pub fn get_children(&self, session_id: &str) -> Vec { + self.edges + .get(session_id) + .map(|children| children.clone()) + .unwrap_or_default() + } + + /// Get all descendant node IDs (direct and indirect children), BFS traversal + pub fn get_descendants(&self, session_id: &str) -> Vec { + let mut result = Vec::new(); + let mut stack = vec![session_id.to_string()]; + let mut seen = std::collections::HashSet::new(); + seen.insert(session_id.to_string()); // exclude self + while let Some(id) = stack.pop() { + for child in self.get_children(&id) { + if seen.insert(child.clone()) { + result.push(child.clone()); + stack.push(child); + } + } + } + result + } + + /// Get the parent node (O(1) reverse-index lookup) + pub fn get_parent(&self, session_id: &str) -> Option { + self.child_to_parent + .get(session_id) + .map(|entry| entry.value().clone()) + } + + /// Get the depth of a node (O(1) lookup) + pub fn get_depth(&self, session_id: &str) -> Option { + self.depths + .get(session_id) + .map(|entry| *entry) + } + + /// Collect all ancestor session_ids along the parent chain (nearest first) + pub fn walk_ancestors(&self, session_id: &str) -> Vec { + let mut ancestors = Vec::new(); + let mut current = session_id.to_string(); + while let Some(parent) = self.get_parent(¤t) { + ancestors.push(parent.clone()); + current = parent; + } + ancestors + } + + /// Build a SessionTreeNode tree from sessions metadata + pub fn build_tree( + &self, + root_id: &str, + sessions: &[SessionMetadata], + ) -> Option { + let session_map: HashMap<&str, &SessionMetadata> = + sessions.iter().map(|s| (s.session_id.as_str(), s)).collect(); + self.build_tree_impl(root_id, &session_map, &mut std::collections::HashSet::new(), 0) + } + + fn build_tree_impl( + &self, + root_id: &str, + sessions: &HashMap<&str, &SessionMetadata>, + visited: &mut std::collections::HashSet, + recursion_depth: u32, + ) -> Option { + if recursion_depth > MAX_TREE_RECURSION_DEPTH { + return None; + } + if !visited.insert(root_id.to_string()) { + return None; + } + let root = sessions.get(root_id)?; + let relationship = root.relationship.as_ref(); + let is_acp_external = relationship + .and_then(|r| r.kind.as_ref()) + .map(|k| matches!(k, SessionRelationshipKind::Subagent)) + .unwrap_or(false); + + Some(SessionTreeNode { + session_id: root.session_id.clone(), + session_name: root.session_name.clone(), + agent_type: root.agent_type.clone(), + agent_display_name: root.agent_type.clone(), + depth: root + .relationship + .as_ref() + .and_then(|r| r.depth) + .unwrap_or(0), + status: session_status_to_tree_node_status(&root.status), + children: self + .get_children(root_id) + .iter() + .filter_map(|child_id| self.build_tree_impl(child_id, sessions, visited, recursion_depth + 1)) + .collect(), + is_acp_external, + external_provider_label: relationship.and_then(|r| r.subagent_type.clone()), + }) + } + + /// Remove a subtree (iterative, not recursive - prevents stack overflow) + /// Uses a HashSet to deduplicate IDs during BFS traversal, avoiding duplicate + /// iteration over already-visited nodes in diamond-shaped subagent graphs. + pub fn remove_subtree(&self, session_id: &str) { + let mut stack = vec![session_id.to_string()]; + let mut to_remove = Vec::new(); + let mut seen = std::collections::HashSet::new(); + while let Some(id) = stack.pop() { + if !seen.insert(id.clone()) { + continue; + } + to_remove.push(id.clone()); + for child in self.get_children(&id) { + stack.push(child); + } + } + for id in &to_remove { + if let Some(parent_id) = self.get_parent(id) { + if let Some(mut parent_children) = self.edges.get_mut(&parent_id) { + parent_children.retain(|x| x != id); + } + } + self.edges.remove(id); + self.child_to_parent.remove(id); + self.depths.remove(id); + } + } + + /// Cycle detection: whether target_agent_type already appears in the ancestor chain of parent_id + pub fn check_cycle( + &self, + parent_id: &str, + target_agent_type: &str, + agent_types: &DashMap, + ) -> bool { + let mut current = parent_id.to_string(); + while let Some(parent) = self.get_parent(¤t) { + if let Some(agent_type) = agent_types.get(&parent) { + if agent_type.as_str() == target_agent_type { + return true; + } + } + current = parent; + } + false + } + + /// Batch-load tree relationships from sessions + /// + /// SESSION-11 rebuild fallback: the SessionControl create chain persists + /// the session record first and writes the structured SessionRelationship + /// afterwards (create_session -> persist_session_lineage -> register_child). + /// A crash between those steps leaves a persisted session without a + /// relationship, which previously made its parent-child lineage invisible + /// in the tree forever after restart. Pass 1 loads the authoritative + /// relationship edges as before; pass 2 re-hangs relationship-less sessions + /// from the creator marker (`session-`) or the + /// `parentSessionId` free-form custom-metadata key, so the lost lineage is + /// rebuilt instead of dropped. + pub fn load_from_sessions(&self, sessions: &[SessionMetadata]) { + self.edges.clear(); + self.child_to_parent.clear(); + self.depths.clear(); + for session in sessions { + if let Some(ref relationship) = session.relationship { + if let Some(ref parent_id) = relationship.parent_session_id { + let depth = relationship.depth.unwrap_or(1); + if let Err(e) = self.register_child(parent_id, &session.session_id, depth) { + log::warn!( + "Failed to register child session {} under {} in tree during load: {:?}", + session.session_id, parent_id, e + ); + } + } + } + } + for session in sessions { + if session.relationship.is_some() { + continue; + } + let Some(parent_id) = lineage_rebuild_parent_session_id(session) else { + continue; + }; + if parent_id == session.session_id { + log::warn!( + "Skipping SESSION-11 lineage rebuild for {}: creator marker points at the session itself", + session.session_id + ); + continue; + } + // Best-effort depth: parent depth + 1 when the parent is already + // registered (pass 1 or an earlier pass-2 rebuild), otherwise the + // same default as the authoritative path. + let depth = self.get_depth(&parent_id).map(|d| d + 1).unwrap_or(1); + if let Err(e) = self.register_child(&parent_id, &session.session_id, depth) { + log::warn!( + "SESSION-11 lineage rebuild failed for session {} under {}: {:?}", + session.session_id, parent_id, e + ); + } + } + } +} + +/// SESSION-11: recover the lost parent session id of a session record whose +/// SessionRelationship was never persisted (crash window between +/// create_session and persist_session_lineage). The SessionControl, +/// SessionMessage (Task), LegionControl and Worktree create chains all persist +/// the creator marker `session-` into the top-level +/// created_by field; a free-form `parentSessionId` custom-metadata key and a +/// custom-metadata `createdBy` marker (same shape) are honored defensively. +/// Non-marker creator values (not prefixed with `session-`) are not lineage +/// facts and are ignored. +fn lineage_rebuild_parent_session_id(session: &SessionMetadata) -> Option { + if let Some(serde_json::Value::Object(metadata)) = session.custom_metadata.as_ref() { + if let Some(parent_id) = metadata + .get("parentSessionId") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return Some(parent_id.to_string()); + } + } + session + .created_by + .as_deref() + .and_then(creator_marker_parent_session_id) + .or_else(|| { + session + .custom_metadata + .as_ref() + .and_then(|value| value.get("createdBy")) + .and_then(|value| value.as_str()) + .and_then(creator_marker_parent_session_id) + }) +} + +/// Parse the `session-` creator marker produced by +/// `session_control_creator_marker`. Returns None for any other shape so +/// non-lineage creator values are never mistaken for a parent relationship. +fn creator_marker_parent_session_id(marker: &str) -> Option { + let parent_id = marker.trim().strip_prefix("session-")?; + let parent_id = parent_id.trim(); + (!parent_id.is_empty()).then(|| parent_id.to_string()) +} + +fn session_status_to_tree_node_status( + status: &crate::session::types::SessionStatus, +) -> SessionTreeNodeStatus { + match status { + crate::session::types::SessionStatus::Active => SessionTreeNodeStatus::Running, + crate::session::types::SessionStatus::Completed => { + SessionTreeNodeStatus::Completed + } + crate::session::types::SessionStatus::Archived => { + SessionTreeNodeStatus::Completed + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::session::types::SessionRelationship; + + fn make_metadata(id: &str, parent_id: Option<&str>, depth: Option) -> SessionMetadata { + SessionMetadata { + session_id: id.to_string(), + session_name: format!("Session {}", id), + agent_type: "agentic".to_string(), + last_user_dialog_agent_type: None, + last_submitted_agent_type: None, + created_by: None, + session_kind: bitfun_core_types::SessionKind::Standard, + memory_mode: crate::session::types::SessionMemoryMode::Enabled, + model_name: "model".to_string(), + created_at: 1, + last_active_at: 1, + last_finished_at: None, + turn_count: 0, + message_count: 0, + tool_call_count: 0, + status: crate::session::types::SessionStatus::Active, + terminal_session_id: None, + snapshot_session_id: None, + tags: vec![], + custom_metadata: None, + relationship: parent_id.map(|pid| SessionRelationship { + kind: Some(SessionRelationshipKind::Subagent), + parent_session_id: Some(pid.to_string()), + depth, + ..Default::default() + }), + todos: None, + review_action_state: None, + deep_review_run_manifest: None, + review_target_evidence: None, + deep_review_cache: None, + workspace_path: None, + workspace_hostname: None, + unread_completion: None, + needs_user_attention: None, + runtime_state: None, + project_workspace_path: None, + execution_target: None, + is_daemon: false, + } + } + + #[test] + fn register_and_query_child() { + let mgr = SessionTreeManager::new(5); + mgr.register_child("root", "child-1", 1).unwrap(); + assert_eq!(mgr.get_children("root"), vec!["child-1"]); + assert_eq!(mgr.get_parent("child-1"), Some("root".to_string())); + } + + #[test] + fn depth_calculation_five_levels() { + let mgr = SessionTreeManager::new(5); + mgr.register_child("root", "l1", 1).unwrap(); + mgr.register_child("l1", "l2", 2).unwrap(); + mgr.register_child("l2", "l3", 3).unwrap(); + mgr.register_child("l3", "l4", 4).unwrap(); + mgr.register_child("l4", "l5", 5).unwrap(); + assert_eq!(mgr.subtree_depth("root"), 5); + } + + #[test] + fn cycle_detection_same_agent_type() { + let mgr = SessionTreeManager::new(5); + mgr.register_child("root", "a", 1).unwrap(); + let agent_types: DashMap = DashMap::new(); + agent_types.insert("root".to_string(), "agentic".to_string()); + agent_types.insert("a".to_string(), "agentic".to_string()); + assert!(mgr.check_cycle("a", "agentic", &agent_types)); + } + + #[test] + fn cycle_detection_different_agent_type_allowed() { + let mgr = SessionTreeManager::new(5); + mgr.register_child("root", "a", 1).unwrap(); + let agent_types: DashMap = DashMap::new(); + agent_types.insert("root".to_string(), "agentic".to_string()); + agent_types.insert("a".to_string(), "Explore".to_string()); + assert!(!mgr.check_cycle("a", "Explore", &agent_types)); + } + + #[test] + fn remove_subtree_cascading() { + let mgr = SessionTreeManager::new(5); + mgr.register_child("root", "a", 1).unwrap(); + mgr.register_child("a", "b", 2).unwrap(); + mgr.register_child("b", "c", 3).unwrap(); + mgr.remove_subtree("a"); + assert!(mgr.get_children("a").is_empty()); + assert!(mgr.get_children("b").is_empty()); + assert!(mgr.get_parent("a").is_none()); + } + + #[test] + fn build_tree_three_levels() { + let mgr = SessionTreeManager::new(5); + mgr.register_child("root", "a", 1).unwrap(); + mgr.register_child("a", "b", 2).unwrap(); + + let sessions = vec![ + make_metadata("root", None, Some(0)), + make_metadata("a", Some("root"), Some(1)), + make_metadata("b", Some("a"), Some(2)), + ]; + + let tree = mgr.build_tree("root", &sessions).expect("root should exist"); + assert_eq!(tree.children.len(), 1); + assert_eq!(tree.children[0].session_id, "a"); + assert_eq!(tree.children[0].children.len(), 1); + assert_eq!(tree.children[0].children[0].session_id, "b"); + } + + #[test] + fn max_depth_limit_enforced() { + let mgr = SessionTreeManager::new(5); + mgr.register_child("root", "l1", 1).unwrap(); + mgr.register_child("l1", "l2", 2).unwrap(); + mgr.register_child("l2", "l3", 3).unwrap(); + mgr.register_child("l3", "l4", 4).unwrap(); + mgr.register_child("l4", "l5", 5).unwrap(); + // l5 depth is 5, reaching max_depth; no further child can be created + let child_depth = 6; + assert!(child_depth > mgr.max_depth); + } + + #[test] + fn walk_ancestors_from_leaf() { + let mgr = SessionTreeManager::new(5); + mgr.register_child("root", "a", 1).unwrap(); + mgr.register_child("a", "b", 2).unwrap(); + mgr.register_child("b", "c", 3).unwrap(); + let ancestors = mgr.walk_ancestors("c"); + assert_eq!(ancestors, vec!["b", "a", "root"]); + } + + #[test] + fn test_register_child_rejects_cycle() { + let mgr = SessionTreeManager::new(5); + mgr.register_child("A", "B", 1).unwrap(); + mgr.register_child("B", "C", 2).unwrap(); + let result = mgr.register_child("C", "A", 3); + assert!(matches!(result, Err(SessionTreeError::CycleDetected { .. }))); + } + + #[test] + fn test_register_child_rejects_self_reference() { + let mgr = SessionTreeManager::new(5); + let result = mgr.register_child("A", "A", 1); + assert!(matches!(result, Err(SessionTreeError::SelfReference(_)))); + } + + #[test] + fn test_register_child_clamps_excessive_depth() { + let mgr = SessionTreeManager::new(5); + // Depth 6 exceeds max_depth 5, should be clamped rather than rejected. + let result = mgr.register_child("A", "B", 6); + assert!(result.is_ok()); + // The registered depth is clamped to max_depth. + assert_eq!(mgr.get_depth("B"), Some(5)); + } + + #[test] + fn load_from_sessions_rebuilds_lineage_from_created_by_marker() { + // SESSION-11: a session persisted in the crash window between + // create_session and persist_session_lineage has no relationship but + // keeps the `session-` creator marker in created_by. + let mgr = SessionTreeManager::new(5); + let parent = make_metadata("parent", None, Some(0)); + let mut orphan = make_metadata("child", None, None); + orphan.created_by = Some("session-parent".to_string()); + mgr.load_from_sessions(&[parent, orphan]); + assert_eq!(mgr.get_parent("child"), Some("parent".to_string())); + assert_eq!(mgr.get_depth("child"), Some(1)); + } + + #[test] + fn load_from_sessions_ignores_non_marker_created_by() { + // Creator values that are not `session-` markers are not lineage facts. + let mgr = SessionTreeManager::new(5); + let mut orphan = make_metadata("child", None, None); + orphan.created_by = Some("some-external-creator".to_string()); + mgr.load_from_sessions(&[orphan]); + assert_eq!(mgr.get_parent("child"), None); + } + + #[test] + fn load_from_sessions_uses_parent_session_id_custom_metadata() { + // Defensive path: free-form custom-metadata parentSessionId key. + let mgr = SessionTreeManager::new(5); + let parent = make_metadata("parent", None, Some(0)); + let mut orphan = make_metadata("child", None, None); + orphan.custom_metadata = Some(serde_json::json!({ "parentSessionId": "parent" })); + mgr.load_from_sessions(&[parent, orphan]); + assert_eq!(mgr.get_parent("child"), Some("parent".to_string())); + } + + #[test] + fn load_from_sessions_uses_custom_metadata_created_by_marker() { + // Defensive path: custom-metadata createdBy marker (same shape). + let mgr = SessionTreeManager::new(5); + let parent = make_metadata("parent", None, Some(0)); + let mut orphan = make_metadata("child", None, None); + orphan.custom_metadata = Some(serde_json::json!({ "createdBy": "session-parent" })); + mgr.load_from_sessions(&[parent, orphan]); + assert_eq!(mgr.get_parent("child"), Some("parent".to_string())); + } + + #[test] + fn load_from_sessions_lineage_rebuild_inherits_parent_depth() { + // The rebuilt child inherits parent depth + 1 when the parent is + // already registered through its own authoritative relationship. + let mgr = SessionTreeManager::new(5); + let parent = make_metadata("parent", Some("root"), Some(1)); + let mut orphan = make_metadata("child", None, None); + orphan.created_by = Some("session-parent".to_string()); + mgr.load_from_sessions(&[parent, orphan]); + assert_eq!(mgr.get_parent("child"), Some("parent".to_string())); + assert_eq!(mgr.get_depth("child"), Some(2)); + } + + #[test] + fn load_from_sessions_skips_self_reference_marker() { + // A marker pointing at the session itself must not create a self loop. + let mgr = SessionTreeManager::new(5); + let mut orphan = make_metadata("selfish", None, None); + orphan.created_by = Some("session-selfish".to_string()); + mgr.load_from_sessions(&[orphan]); + assert_eq!(mgr.get_parent("selfish"), None); + assert_eq!(mgr.get_children("selfish"), Vec::::new()); + } +} diff --git a/src/crates/services/services-core/src/session/types.rs b/src/crates/services/services-core/src/session/types.rs index 7da2e98399..a7dd60bf3f 100644 --- a/src/crates/services/services-core/src/session/types.rs +++ b/src/crates/services/services-core/src/session/types.rs @@ -63,6 +63,8 @@ pub struct SessionRelationship { pub subagent_type: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub continuation_policy: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub depth: Option, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] @@ -297,6 +299,22 @@ pub struct SessionMetadata { alias = "needsUserAttention" )] pub needs_user_attention: Option, + + /// Cached runtime state (serialized SessionState) populated on save so list + /// callers can avoid an extra per‑session state‑file read. + #[serde( + default, + skip_serializing_if = "Option::is_none", + alias = "runtime_state", + alias = "runtimeState" + )] + pub runtime_state: Option, + + /// Warden daemon session marker. + /// Daemon sessions are invisible to SessionControl(list) and cannot be + /// deleted via SessionControl(delete). + #[serde(default)] + pub is_daemon: bool, } /// Session status @@ -1056,6 +1074,8 @@ impl SessionMetadata { workspace_hostname: None, unread_completion: None, needs_user_attention: None, + runtime_state: None, + is_daemon: false, } } @@ -1083,7 +1103,7 @@ impl SessionMetadata { } pub fn is_subagent(&self) -> bool { - matches!(self.session_kind, SessionKind::Subagent) + matches!(self.session_kind, SessionKind::Subagent | SessionKind::EphemeralSubagent) } pub fn is_standard(&self) -> bool { @@ -1093,7 +1113,7 @@ impl SessionMetadata { pub fn is_internal_hidden(&self) -> bool { matches!( self.session_kind, - SessionKind::Subagent | SessionKind::EphemeralChild + SessionKind::Subagent | SessionKind::EphemeralChild | SessionKind::EphemeralSubagent ) } @@ -1426,6 +1446,7 @@ mod tests { parent_tool_call_id: None, subagent_type: None, continuation_policy: Some(SessionContinuationPolicy::FreshOnly), + ..Default::default() }); let json = serde_json::to_value(&metadata).expect("metadata should serialize"); diff --git a/src/crates/services/services-integrations/src/function_agents.rs b/src/crates/services/services-integrations/src/function_agents.rs index c50a73ede6..60de9619bd 100644 --- a/src/crates/services/services-integrations/src/function_agents.rs +++ b/src/crates/services/services-integrations/src/function_agents.rs @@ -84,6 +84,9 @@ fn git_stdout_lenient(repo_path: &Path, args: &[&str]) -> AgentResult { .output() .map_err(|e| AgentError::git_error(format!("Failed to run git {:?}: {}", args, e)))?; + if !output.status.success() { + return Ok(String::new()); + } Ok(String::from_utf8_lossy(&output.stdout).to_string()) } diff --git a/src/crates/services/services-integrations/src/hook_import.rs b/src/crates/services/services-integrations/src/hook_import.rs index 3ab7b7b509..44fc50b51e 100644 --- a/src/crates/services/services-integrations/src/hook_import.rs +++ b/src/crates/services/services-integrations/src/hook_import.rs @@ -386,8 +386,10 @@ impl HookImportStore { if !matches!(load_index(&index_path).await?, LoadedIndex::Corrupt(_)) { return Err(HookImportStoreError::InvalidInput("store is not corrupt")); } - let mut index = StoreIndexV1::default(); - index.generation = reset_generation(); + let index = StoreIndexV1 { + generation: reset_generation(), + ..StoreIndexV1::default() + }; json_store .write_atomic_strict(&index_path, &index) .await diff --git a/src/crates/services/services-integrations/src/miniapp/worker_pool.rs b/src/crates/services/services-integrations/src/miniapp/worker_pool.rs index 2a6d91409a..5919c44ed8 100644 --- a/src/crates/services/services-integrations/src/miniapp/worker_pool.rs +++ b/src/crates/services/services-integrations/src/miniapp/worker_pool.rs @@ -352,6 +352,7 @@ impl JsWorkerPool { .map_err(MiniAppWorkerPoolError::validation) } + #[allow(clippy::too_many_arguments)] // spawn + invoke descriptor for a worker pub async fn call_with_app_dir( &self, worker_key: &str, diff --git a/src/crates/services/services-integrations/src/plugin_source.rs b/src/crates/services/services-integrations/src/plugin_source.rs index fa50a80c20..4d02701c68 100644 --- a/src/crates/services/services-integrations/src/plugin_source.rs +++ b/src/crates/services/services-integrations/src/plugin_source.rs @@ -2342,6 +2342,9 @@ fn replace_file_atomically(temp_path: &Path, target_path: &Path) -> io::Result<( .encode_wide() .chain(std::iter::once(0)) .collect::>(); + // SAFETY: `target`, `temp` and `backup` are NUL-terminated wide strings + // allocated above; the Win32 calls only read them for the duration of the + // call and require no Rust-side aliasing. let result = unsafe { if target_path.exists() { ReplaceFileW( @@ -2396,6 +2399,8 @@ fn restore_windows_backup_after_replace_failure( .encode_wide() .chain(std::iter::once(0)) .collect::>(); + // SAFETY: `backup` and `target` are NUL-terminated wide strings + // allocated above; both remain valid for the duration of the call. let restore = unsafe { MoveFileExW( PCWSTR(backup.as_ptr()), @@ -2504,6 +2509,8 @@ fn trust_file_identity(file: &std::fs::File) -> io::Result { }; let mut information = BY_HANDLE_FILE_INFORMATION::default(); + // SAFETY: `file`'s handle is valid via AsRawHandle and `information` is a + // live mutable reference that the call fills in. unsafe { GetFileInformationByHandle(HANDLE(file.as_raw_handle()), &mut information) .map_err(|error| io::Error::other(error.to_string()))?; @@ -2728,6 +2735,9 @@ fn windows_handle_path(file: &std::fs::File) -> io::Result> { let handle = HANDLE(file.as_raw_handle()); let mut buffer = vec![0_u16; 512]; loop { + // SAFETY: `handle` derives from a live File via AsRawHandle and + // `buffer` is a mutable slice with a capacity large enough for any + // path the API reports; the returned length drives resize/truncate. let length = unsafe { GetFinalPathNameByHandleW(handle, &mut buffer, VOLUME_NAME_DOS) }; if length == 0 { return Err(io::Error::last_os_error()); diff --git a/src/crates/services/services-integrations/src/remote_connect.rs b/src/crates/services/services-integrations/src/remote_connect.rs index c25ff13fd5..11d0d9a907 100644 --- a/src/crates/services/services-integrations/src/remote_connect.rs +++ b/src/crates/services/services-integrations/src/remote_connect.rs @@ -4019,6 +4019,7 @@ mod tests { } #[derive(Default)] + #[allow(dead_code)] struct FakeInteractionHost; #[async_trait::async_trait] diff --git a/src/crates/services/services-integrations/src/remote_connect/bot/feishu.rs b/src/crates/services/services-integrations/src/remote_connect/bot/feishu.rs index 58dea5ffb6..000bc6103f 100644 --- a/src/crates/services/services-integrations/src/remote_connect/bot/feishu.rs +++ b/src/crates/services/services-integrations/src/remote_connect/bot/feishu.rs @@ -397,14 +397,12 @@ impl FeishuWsConnection { return Ok(None); }; match frame.method { - FRAME_TYPE_DATA => { - if frame.get_header("type").unwrap_or("") == "event" { - let response = FeishuFrame::new_response(&frame, 200); - return Ok(Some(FeishuWsEvent { - payload: frame.payload, - response, - })); - } + FRAME_TYPE_DATA if frame.get_header("type").unwrap_or("") == "event" => { + let response = FeishuFrame::new_response(&frame, 200); + return Ok(Some(FeishuWsEvent { + payload: frame.payload, + response, + })); } FRAME_TYPE_CONTROL => { debug!( diff --git a/src/crates/services/services-integrations/src/remote_connect/page_upload.rs b/src/crates/services/services-integrations/src/remote_connect/page_upload.rs index 13f518adae..e397663d2d 100644 --- a/src/crates/services/services-integrations/src/remote_connect/page_upload.rs +++ b/src/crates/services/services-integrations/src/remote_connect/page_upload.rs @@ -210,6 +210,7 @@ pub async fn save_page_version_from_inline_files( /// Save (and optionally deploy) a page from either a local directory or inline files. /// /// Exactly one of `directory` / `files` must be provided. +#[allow(clippy::too_many_arguments)] // CLI/HTTP entry point carrying publish options pub async fn publish_page_content_on_relay( relay_url: &str, token: &str, diff --git a/src/crates/services/services-integrations/src/remote_connect/relay_client.rs b/src/crates/services/services-integrations/src/remote_connect/relay_client.rs index e7fad23361..f8486b59c1 100644 --- a/src/crates/services/services-integrations/src/remote_connect/relay_client.rs +++ b/src/crates/services/services-integrations/src/remote_connect/relay_client.rs @@ -690,6 +690,7 @@ mod tests { } #[cfg(windows)] +#[allow(clippy::items_after_test_module)] // windows-only connector builder lives after the test module for file scoping fn build_windows_rustls_connector() -> Result { // Install the ring CryptoProvider as the process-level default. // Required by rustls 0.23+ when `default-features = false`. diff --git a/src/crates/services/services-integrations/src/remote_connect/session_store.rs b/src/crates/services/services-integrations/src/remote_connect/session_store.rs index fa9e585e5a..f9e2d20cdf 100644 --- a/src/crates/services/services-integrations/src/remote_connect/session_store.rs +++ b/src/crates/services/services-integrations/src/remote_connect/session_store.rs @@ -342,6 +342,7 @@ pub struct LoadedSession { /// Load and decrypt the session from disk. /// Returns `Ok(None)` if the file doesn't exist (not an error). +#[allow(clippy::type_complexity)] // legacy tuple projection of the loaded session pub fn load_session() -> Result> { Ok(load_session_detailed()?.map(|s| (s.token, s.user_id, s.master_key, s.relay_url))) } diff --git a/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs b/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs index b1953e707a..cd9e797eb5 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs @@ -3612,6 +3612,7 @@ mod tests { /// Mirrors a real `bitfun`: answers `--version` and has a `dispatch` /// subcommand. + #[allow(dead_code)] // used only by unix-gated fixtures below const DISPATCH_CAPABLE_PRIMARY: &str = "#!/bin/bash\n\ if [ \"${1:-}\" = dispatch ]; then\n\ if [ \"${2:-}\" = probe ]; then\n\ @@ -3622,6 +3623,7 @@ mod tests { echo \"bitfun 1.2.3\"\n"; /// Has the dispatch command but predates safe worker profile selection. + #[allow(dead_code)] // used only by unix-gated fixtures below const UNSAFE_DISPATCH_PRIMARY: &str = "#!/bin/bash\n\ if [ \"${1:-}\" = dispatch ]; then\n\ if [ \"${2:-}\" = probe ]; then echo '{\"capabilities\":[]}' ; fi\n\ @@ -3631,6 +3633,7 @@ mod tests { /// Mirrors a release that predates dispatch: the binary is healthy and /// reports the right version, but clap rejects the subcommand. + #[allow(dead_code)] // used only by unix-gated fixtures below const DISPATCH_LESS_PRIMARY: &str = "#!/bin/bash\n\ if [ \"${1:-}\" = dispatch ]; then\n\ echo \"error: unrecognized subcommand 'dispatch'\" >&2\n\ @@ -3638,6 +3641,7 @@ mod tests { fi\n\ echo \"bitfun 1.2.3\"\n"; + #[allow(dead_code)] // used only by unix-gated fixtures below const SIBLING_RESOLVING_COMPANION: &str = "#!/bin/bash\n\ echo 'Warning: `bitfun-cli` is deprecated; use `bitfun` instead.' >&2\n\ here=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n\ diff --git a/src/crates/services/services-integrations/src/remote_ssh/manager.rs b/src/crates/services/services-integrations/src/remote_ssh/manager.rs index 91bb4758b5..229e35c0de 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/manager.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/manager.rs @@ -3028,9 +3028,9 @@ impl SSHConnectionManager { .or_else(|| entry.as_ref().and_then(|entry| entry.port)) .unwrap_or(22); let identity_file = entry.as_ref().and_then(|entry| entry.identity_file.clone()); - let auth = if identity_file.is_some() { + let auth = if let Some(identity_file) = identity_file { SSHAuthMethod::PrivateKey { - key_path: identity_file.expect("identity_file.is_some was checked"), + key_path: identity_file, passphrase: None, certificate_path: entry .as_ref() diff --git a/src/crates/services/services-integrations/src/remote_ssh/relay_deploy.rs b/src/crates/services/services-integrations/src/remote_ssh/relay_deploy.rs index 6ab81b05e1..b9dce6fb1b 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/relay_deploy.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/relay_deploy.rs @@ -1551,9 +1551,10 @@ mod tests { classify_docker_access, decide_task_status, deploy_body_script_with_image, install_docker_body_script, interactive_driver_script, parse_preflight, prepare_helpers_bash, release_binary_deploy_bash, release_tag_for_version, - split_poll_stdout, stage_scripts_command, to_unix_script, validate_relay_image_descriptor, - verify_minisign, DockerAccessMode, RelayImageDescriptor, RelayTaskStatus, - RELAY_IMAGE_REPOSITORY, RELAY_MIRROR_SH, RELAY_RELEASE_DOWNLOAD_SH, RELEASE_PUBKEY, + split_poll_stdout, stage_scripts_command, sync_source_bash, to_unix_script, + validate_relay_image_descriptor, verified_checksum_exports, verify_minisign, + DockerAccessMode, RelayImageDescriptor, RelayTaskStatus, RELAY_IMAGE_REPOSITORY, + RELAY_MIRROR_SH, RELAY_RELEASE_DOWNLOAD_SH, RELEASE_PUBKEY, }; fn test_image_descriptor() -> RelayImageDescriptor { diff --git a/src/crates/services/services-integrations/src/remote_ssh/remote_exec.rs b/src/crates/services/services-integrations/src/remote_ssh/remote_exec.rs index 35bc0614ba..1b2bbc87a9 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/remote_exec.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/remote_exec.rs @@ -1378,14 +1378,18 @@ fn new_chunk_id() -> String { #[cfg(test)] mod tests { use super::{ - decode_utf8_stream, new_session_id, workspace_pipe_owner, HeadTailText, OutputState, - OutputStream, PendingUtf8Streams, + decode_utf8_stream, new_session_id, HeadTailText, OutputStream, PendingUtf8Streams, }; - use crate::remote_ssh::transport::WorkspaceStdio; use std::collections::HashMap; + + #[cfg(unix)] + use super::{workspace_pipe_owner, OutputState}; + #[cfg(unix)] + use crate::remote_ssh::transport::WorkspaceStdio; + #[cfg(unix)] use std::sync::Arc; - use tokio::sync::mpsc; - use tokio::time::Duration; + #[cfg(unix)] + use tokio::{sync::mpsc, time::Duration}; #[cfg(unix)] async fn pipe_owner_exit_code(script: &str) -> Option { diff --git a/src/crates/services/services-integrations/src/remote_ssh/remote_fs.rs b/src/crates/services/services-integrations/src/remote_ssh/remote_fs.rs index 7416befa96..ad58318b2f 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/remote_fs.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/remote_fs.rs @@ -594,8 +594,10 @@ mod tests { #[test] fn sftp_special_files_are_not_reported_as_regular_files() { - let mut attrs = russh_sftp::protocol::FileAttributes::default(); - attrs.permissions = Some(0o010644); + let attrs = russh_sftp::protocol::FileAttributes { + permissions: Some(0o010644), + ..Default::default() + }; let entry = remote_file_entry_from_metadata("/workspace/pipe", attrs); diff --git a/src/crates/services/services-integrations/src/remote_ssh/transport.rs b/src/crates/services/services-integrations/src/remote_ssh/transport.rs index c0729994dd..53db067c69 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/transport.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/transport.rs @@ -194,6 +194,7 @@ impl WorkspaceStdio { } #[cfg(test)] + #[allow(dead_code)] pub(crate) fn spawn_local_process(executable: &str, args: &[String]) -> anyhow::Result { Self::spawn_local_process_with_signal_hook(executable, args, None) } @@ -773,6 +774,7 @@ mod ssh_channel_tests { #[cfg(test)] mod tests { + #[allow(unused_imports)] use super::*; #[test] diff --git a/src/crates/services/services-integrations/src/review_platform.rs b/src/crates/services/services-integrations/src/review_platform.rs index dcd87661cb..c9906f8f67 100644 --- a/src/crates/services/services-integrations/src/review_platform.rs +++ b/src/crates/services/services-integrations/src/review_platform.rs @@ -1074,6 +1074,7 @@ impl ReviewPlatformService { .await } + #[allow(clippy::too_many_arguments)] // evidence fetch mirroring issue identity + paging pub async fn issue( &self, platform: ReviewPlatformKind, @@ -1158,6 +1159,7 @@ impl ReviewPlatformService { .await } + #[allow(clippy::too_many_arguments)] // diff fetch mirroring PR revisions + paging pub async fn pull_request_file_diff( &self, repository_path: &str, @@ -5038,6 +5040,9 @@ fn replace_token_store_file_atomically( .encode_wide() .chain(std::iter::once(0)) .collect::>(); + // SAFETY: `target`, `temp` and `backup` are NUL-terminated wide strings + // allocated above; the Win32 calls only read them for the duration of the + // call and require no Rust-side aliasing. let result = unsafe { if target_path.exists() { ReplaceFileW( diff --git a/src/crates/services/services-integrations/src/speech/downloader.rs b/src/crates/services/services-integrations/src/speech/downloader.rs index e93f8890c7..fa8c5e1e1e 100644 --- a/src/crates/services/services-integrations/src/speech/downloader.rs +++ b/src/crates/services/services-integrations/src/speech/downloader.rs @@ -60,6 +60,7 @@ where store.status_for_manifest(manifest).await } +#[allow(clippy::too_many_arguments)] // resume + progress context for one artifact async fn ensure_artifact_downloaded( store: &SpeechModelStore, manifest: &SpeechModelManifest, @@ -148,6 +149,7 @@ where ))) } +#[allow(clippy::too_many_arguments)] // download + resume context for one source async fn download_source( client: &reqwest::Client, source_url: &str, diff --git a/src/crates/services/services-integrations/src/workspace_search/flashgrep/protocol.rs b/src/crates/services/services-integrations/src/workspace_search/flashgrep/protocol.rs index 7ad51afe0a..ec70908c9c 100644 --- a/src/crates/services/services-integrations/src/workspace_search/flashgrep/protocol.rs +++ b/src/crates/services/services-integrations/src/workspace_search/flashgrep/protocol.rs @@ -270,6 +270,7 @@ pub(crate) struct NotificationEnvelope { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] +#[allow(clippy::large_enum_variant)] // response/notification payloads differ structurally pub(crate) enum ServerMessage { Response(ResponseEnvelope), Notification(NotificationEnvelope), diff --git a/src/crates/services/services-integrations/tests/file_watch_contracts.rs b/src/crates/services/services-integrations/tests/file_watch_contracts.rs index e58626b0aa..068d33a799 100644 --- a/src/crates/services/services-integrations/tests/file_watch_contracts.rs +++ b/src/crates/services/services-integrations/tests/file_watch_contracts.rs @@ -62,9 +62,11 @@ fn file_watch_worker_does_not_extend_tokio_runtime_lifetime() { #[tokio::test] async fn file_watch_publishes_debounced_batches_to_backend_subscribers() { let temp = tempfile::tempdir().expect("tempdir"); - let mut config = FileWatcherConfig::default(); - config.debounce_interval_ms = 40; - config.ignore_hidden_files = false; + let config = FileWatcherConfig { + debounce_interval_ms: 40, + ignore_hidden_files: false, + ..Default::default() + }; let service = FileWatchService::new(config.clone()); let mut events = service.subscribe(); service @@ -128,9 +130,11 @@ async fn a_narrow_duplicate_registration_does_not_downgrade_recursive_watch() { let temp = tempfile::tempdir().expect("tempdir"); let nested = temp.path().join("nested"); fs::create_dir_all(&nested).expect("nested directory"); - let mut recursive = FileWatcherConfig::default(); - recursive.debounce_interval_ms = 40; - recursive.ignore_hidden_files = false; + let mut recursive = FileWatcherConfig { + debounce_interval_ms: 40, + ignore_hidden_files: false, + ..Default::default() + }; let service = FileWatchService::new(recursive.clone()); let mut events = service.subscribe(); service @@ -183,9 +187,11 @@ async fn re_registering_a_recreated_root_resumes_watching() { let temp = tempfile::tempdir().expect("tempdir"); let root = temp.path().join("root"); fs::create_dir_all(&root).expect("root directory"); - let mut config = FileWatcherConfig::default(); - config.debounce_interval_ms = 40; - config.ignore_hidden_files = false; + let config = FileWatcherConfig { + debounce_interval_ms: 40, + ignore_hidden_files: false, + ..Default::default() + }; let service = FileWatchService::new(config.clone()); let mut events = service.subscribe(); service @@ -222,9 +228,11 @@ async fn re_registering_a_recreated_root_resumes_watching() { #[tokio::test] async fn atomic_rename_keeps_the_non_temporary_destination_path() { let temp = tempfile::tempdir().expect("tempdir"); - let mut config = FileWatcherConfig::default(); - config.debounce_interval_ms = 40; - config.ignore_hidden_files = false; + let config = FileWatcherConfig { + debounce_interval_ms: 40, + ignore_hidden_files: false, + ..Default::default() + }; let service = FileWatchService::new(config.clone()); let mut events = service.subscribe(); service diff --git a/src/crates/services/terminal/src/shell/detection/selection.rs b/src/crates/services/terminal/src/shell/detection/selection.rs index ee373df082..757198a3af 100644 --- a/src/crates/services/terminal/src/shell/detection/selection.rs +++ b/src/crates/services/terminal/src/shell/detection/selection.rs @@ -9,7 +9,7 @@ impl ShellDetector { pub fn get_default_shell() -> DetectedShell { #[cfg(windows)] { - return Self::find_shell(&ShellType::PowerShellCore) + Self::find_shell(&ShellType::PowerShellCore) .or_else(|| Self::find_shell(&ShellType::PowerShell)) .or_else(|| Self::find_shell(&ShellType::Cmd)) .unwrap_or_else(|| { @@ -18,7 +18,7 @@ impl ShellDetector { PathBuf::from("cmd.exe"), "Command Prompt", ) - }); + }) } #[cfg(not(windows))] { @@ -41,7 +41,7 @@ impl ShellDetector { if matches!(shell_type, ShellType::Bash) { return platform::detect_git_bash(); } - return Self::validate_first_candidate(Self::candidates_for_shell(shell_type)); + Self::validate_first_candidate(Self::candidates_for_shell(shell_type)) } #[cfg(not(windows))] { diff --git a/src/crates/services/terminal/src/shell/detection/tests.rs b/src/crates/services/terminal/src/shell/detection/tests.rs index cabf40d26c..1eef15ee98 100644 --- a/src/crates/services/terminal/src/shell/detection/tests.rs +++ b/src/crates/services/terminal/src/shell/detection/tests.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use super::{ path, shell_display_name, DetectedShell, ShellCandidate, ShellDetector, ShellDiscoverySource, @@ -179,7 +179,7 @@ fn windows_known_pwsh_locations_cover_user_package_and_both_program_files_views( assert_eq!(candidates[2].source, ShellDiscoverySource::PackageManager); assert_eq!(candidates[3].source, ShellDiscoverySource::SystemInstall); assert!(candidates.iter().any(|candidate| candidate.path - == PathBuf::from(r"C:\Program Files (x86)\PowerShell\7\pwsh.exe"))); + == Path::new(r"C:\Program Files (x86)\PowerShell\7\pwsh.exe"))); } #[cfg(windows)] diff --git a/src/shared/ai-provider-catalog/providers.json b/src/shared/ai-provider-catalog/providers.json index 389199229d..c4ea2ef65a 100644 --- a/src/shared/ai-provider-catalog/providers.json +++ b/src/shared/ai-provider-catalog/providers.json @@ -294,6 +294,32 @@ "curated_models": ["glm-5.2", "deepseek-v4-flash", "deepseek-v4-pro"], "additional_models": [] } + }, + { + "id": "codebuddy", + "display_order": 90, + "region": "any", + "name": "CodeBuddy (local gateway)", + "description": "Tencent CodeBuddy coding plan via local `codebuddy --serve` gateway", + "help_url": "https://www.codebuddy.ai/docs/zh/cli/http-api", + "requires_api_key": false, + "catalog_provider_ids": [], + "endpoints": [ + { + "id": "default", + "base_url": "http://127.0.0.1:8080", + "api_format": "codebuddy", + "label": "default", + "is_default": true, + "trusted_for_auto_detection": false, + "catalog_provider_ids": [] + } + ], + "model_policy": { + "mode": "curated", + "curated_models": ["codebuddy"], + "additional_models": [] + } } ] } diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss index 5ca20c00cd..169cd980ac 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss @@ -127,9 +127,10 @@ margin-top: -2px; min-height: 24px; font-size: var(--bf-appearance-token-font-size-xs); - padding-left: calc(#{$size-gap-1} + 14px); + padding-left: calc(16px * var(--indent-level, 1)); position: relative; + &::before { content: ''; position: absolute; @@ -170,6 +171,9 @@ } } + + + &__inline-item-icon-slot { position: relative; flex: 0 0 16px; diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx index 0b94d08d35..ceee6acc22 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx @@ -108,6 +108,13 @@ const resolveSessionModeType = (session: Session): SessionMode => { const getTitle = (session: Session): string => resolveSessionTitle(session, (key, options) => i18nService.t(key, options)); +const dispatchFilterKey = (session: Session): string => { + const target = session.config.dispatchTarget; + if (target?.kind === 'ssh') return `ssh:${target.connectionId}`; + if (target?.kind === 'device') return `device:${target.deviceId}`; + return 'local'; +}; + const countTopLevelSessionsInScope = ( sessions: Iterable, workspacePath?: string, @@ -115,7 +122,7 @@ const countTopLevelSessionsInScope = ( remoteSshHost?: string | null, ): number => { const scopedSessions = Array.from(sessions).filter((session: Session) => { - if (session.isTransient || session.sessionKind === 'subagent') { + if (session.isTransient) { return false; } if (workspacePath) { @@ -202,6 +209,7 @@ const SessionsSection: React.FC = ({ const dispatchTransportByJobId = useDispatchJobStore(state => state.transportByJobId); const [editingSessionId, setEditingSessionId] = useState(null); const [editingTitle, setEditingTitle] = useState(''); + const [dispatchTargetFilter, setDispatchTargetFilter] = useState('all'); const [expandLevel, setExpandLevel] = useState<0 | 1 | 2>(0); // Level-2 ("show all") renders in pages of 200 rows so a huge session // history cannot mount thousands of un-virtualized rows at once. @@ -389,7 +397,8 @@ const SessionsSection: React.FC = ({ cursor, remoteConnectionId || undefined, remoteSshHost || undefined, - source + source, + true, ); if (metadataLoadRequestIdRef.current === requestId) { const syncedTopLevelCount = countTopLevelSessionsInScope( @@ -621,9 +630,6 @@ const SessionsSection: React.FC = ({ if (s.isTransient) { return false; } - if (s.sessionKind === 'subagent') { - return false; - } if (workspacePath) { return sessionBelongsToWorkspaceNavRow(s, workspacePath, remoteConnectionId, remoteSshHost); } @@ -660,7 +666,39 @@ const SessionsSection: React.FC = ({ }; }, [sessions]); - const topLevelSessions = allTopLevelSessions; + const dispatchTargetFilterOptions = useMemo(() => { + const options = new Map(); + for (const session of allTopLevelSessions) { + const key = dispatchFilterKey(session); + if (key === 'local') { + options.set(key, t('nav.sessions.filterLocal')); + continue; + } + const target = session.config.dispatchTarget; + if (target?.kind === 'ssh' || target?.kind === 'device') { + options.set(key, target.displayName); + } + } + return Array.from(options.entries()).map(([value, label]) => ({ value, label })); + }, [allTopLevelSessions, t]); + + useEffect(() => { + if ( + dispatchTargetFilter !== 'all' + && !dispatchTargetFilterOptions.some(option => option.value === dispatchTargetFilter) + ) { + setDispatchTargetFilter('all'); + } + }, [dispatchTargetFilter, dispatchTargetFilterOptions]); + + const topLevelSessions = useMemo( + () => dispatchTargetFilter === 'all' + ? allTopLevelSessions + : allTopLevelSessions.filter( + session => dispatchFilterKey(session) === dispatchTargetFilter, + ), + [allTopLevelSessions, dispatchTargetFilter], + ); const sessionDisplayLimit = useMemo(() => { const total = topLevelSessions.length; @@ -670,14 +708,17 @@ const SessionsSection: React.FC = ({ return SESSIONS_LEVEL_0; }, [topLevelSessions.length, expandLevel, level2DisplayCount]); - const totalTopLevelSessionCount = getEffectiveTopLevelSessionCount( - metadataPageState.totalTopLevelCount, - metadataPageState.syncedTopLevelCount, - allTopLevelSessions.length, - metadataPageState.isLoading, - ); + const totalTopLevelSessionCount = dispatchTargetFilter === 'all' + ? getEffectiveTopLevelSessionCount( + metadataPageState.totalTopLevelCount, + metadataPageState.syncedTopLevelCount, + allTopLevelSessions.length, + metadataPageState.isLoading, + ) + : topLevelSessions.length; const hasMoreUnloadedSessions = - allTopLevelSessions.length < totalTopLevelSessionCount; + dispatchTargetFilter === 'all' + && allTopLevelSessions.length < totalTopLevelSessionCount; const expandToggleState = getSessionExpandToggleState(totalTopLevelSessionCount, expandLevel); useEffect(() => { @@ -774,12 +815,17 @@ const SessionsSection: React.FC = ({ const visibleItems = useMemo(() => { const visibleParents = topLevelSessions.slice(0, sessionDisplayLimit); - const out: Array<{ session: Session; level: 0 | 1 }> = []; - for (const p of visibleParents) { - out.push({ session: p, level: 0 }); - const children = childrenByParent.get(p.sessionId) || []; - for (const c of children) out.push({ session: c, level: 1 }); - } + const out: Array<{ session: Session; depth: number }> = []; + + const walk = (sessions: Session[], depth: number) => { + for (const s of sessions) { + out.push({ session: s, depth }); + const children = childrenByParent.get(s.sessionId) || []; + walk(children, depth + 1); + } + }; + + walk(visibleParents, 0); return out; }, [childrenByParent, sessionDisplayLimit, topLevelSessions]); @@ -1188,10 +1234,32 @@ const SessionsSection: React.FC = ({ return (
- {visibleItems.map(({ session, level }) => { + {dispatchTargetFilterOptions.length > 1 ? ( + + ) : null} + {topLevelSessions.length === 0 ? ( +
+ {t('nav.sessions.noSessionsForTarget')} +
+ ) : null} + {visibleItems.map(({ session, depth }) => { const isEditing = editingSessionId === session.sessionId; const relationship = resolveSessionRelationship(session); - const isChildSession = level === 1 && relationship.displayAsChild; + const isChildSession = depth > 0 && relationship.displayAsChild; const childSessionBadge = getChildSessionBadge(relationship.kind); const parentReviewActivity = deriveSessionReviewActivity( flowChatState, @@ -1334,7 +1402,7 @@ const SessionsSection: React.FC = ({
0 && 'is-child', isChildSession && 'is-btw-child', isRowActive && 'is-active', isEditing && 'is-editing', @@ -1342,6 +1410,7 @@ const SessionsSection: React.FC = ({ ] .filter(Boolean) .join(' ')} + style={depth > 0 ? { '--indent-level': depth } as React.CSSProperties : undefined} data-bf-component="sessions-section" data-bf-part="row" data-bf-state={[ @@ -1352,7 +1421,7 @@ const SessionsSection: React.FC = ({ data-testid="nav-session-item" data-session-id={session.sessionId} data-session-kind={relationship.kind} - data-session-level={String(level)} + data-session-level={String(depth)} data-session-active={isRowActive ? 'true' : 'false'} onPointerDown={event => handleSessionOpenPointerDown(event, session)} onClick={() => handleSwitch(session.sessionId)} diff --git a/src/web-ui/src/app/hooks/useDialogCompletionNotify.test.ts b/src/web-ui/src/app/hooks/useDialogCompletionNotify.test.ts index 240070f1dc..8f20ca2114 100644 --- a/src/web-ui/src/app/hooks/useDialogCompletionNotify.test.ts +++ b/src/web-ui/src/app/hooks/useDialogCompletionNotify.test.ts @@ -26,7 +26,7 @@ const session = (overrides: Partial = {}): Session => ({ lastActiveAt: 1000, error: null, todos: [], - maxContextTokens: 128128, + maxContextTokens: 1048576, mode: 'agentic', workspacePath: '/workspace', parentSessionId: undefined, diff --git a/src/web-ui/src/app/layout/AppLayout.tsx b/src/web-ui/src/app/layout/AppLayout.tsx index c092dacd0a..355fef5d11 100644 --- a/src/web-ui/src/app/layout/AppLayout.tsx +++ b/src/web-ui/src/app/layout/AppLayout.tsx @@ -53,6 +53,9 @@ const ToolbarMode = lazy(() => const FloatingMiniChat = lazy(() => import('./FloatingMiniChat').then(module => ({ default: module.FloatingMiniChat })) ); +const BeeColonyMonitor = lazy(() => + import('./BeeColonyMonitor').then(module => ({ default: module.BeeColonyMonitor })) +); const AboutDialog = lazy(() => import('../components/AboutDialog').then(module => ({ default: module.AboutDialog })) ); @@ -774,6 +777,13 @@ const AppLayout: React.FC = ({ className = '' }) => { )} + + {/* Agent scenes: bee colony architecture monitor (self-gates to agentic tabs) */} + {!isWelcomeScene && isAgentScene && ( + + + + )}
{/* Dialogs (previously owned by TitleBar) */} diff --git a/src/web-ui/src/app/layout/BeeColonyMonitor.appearance.ts b/src/web-ui/src/app/layout/BeeColonyMonitor.appearance.ts new file mode 100644 index 0000000000..fb4862ee8e --- /dev/null +++ b/src/web-ui/src/app/layout/BeeColonyMonitor.appearance.ts @@ -0,0 +1,9 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const beeColonyMonitorAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'bee-colony-monitor', + parts: [ + { id: 'root' }, { id: 'backdrop' }, { id: 'trigger' }, { id: 'panel' }, + { id: 'header' }, { id: 'body' }, + ], +}; diff --git a/src/web-ui/src/app/layout/BeeColonyMonitor.scss b/src/web-ui/src/app/layout/BeeColonyMonitor.scss new file mode 100644 index 0000000000..8a43ae51da --- /dev/null +++ b/src/web-ui/src/app/layout/BeeColonyMonitor.scss @@ -0,0 +1,152 @@ +/** + * BeeColonyMonitor — floating trigger button + expandable panel for the + * bee-colony-dag MiniApp. Follows the FloatingMiniChat floating-panel pattern. + */ + +@use '../../component-library/styles/tokens' as *; + +$bee-button-size: 42px; +$bee-button-offset: 20px; +$bee-panel-width: min(480px, calc(100vw - 32px)); +$bee-panel-height: min(620px, calc(100vh - 48px)); + +.bee-monitor { + position: fixed; + bottom: $bee-button-offset; + right: $bee-button-offset; + z-index: $z-overlay + 1; + pointer-events: none; + + &--open { + pointer-events: auto; + } +} + +.bee-monitor__backdrop { + position: fixed; + inset: 0; + z-index: 0; + pointer-events: auto; +} + +.bee-monitor__button { + position: relative; + z-index: 2; + pointer-events: auto; + width: $bee-button-size; + height: $bee-button-size; + border-radius: 50%; + border: 1px solid var(--bf-appearance-token-border-strong); + background: var(--surface-2); + color: var(--bf-appearance-token-color-text-primary); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35); + + &:hover { + background: var(--surface-3); + } +} + +.bee-monitor__panel { + position: fixed; + bottom: calc(#{$bee-button-offset} + #{$bee-button-size} + 12px); + right: $bee-button-offset; + z-index: 1; + width: $bee-panel-width; + height: $bee-panel-height; + display: flex; + flex-direction: column; + background: var(--surface-1); + border: 1px solid var(--bf-appearance-token-border-subtle); + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); + opacity: 0; + transform: translateY(8px); + visibility: hidden; + transition: + opacity 160ms ease, + transform 160ms ease, + visibility 160ms; + + &--open { + opacity: 1; + transform: translateY(0); + visibility: visible; + } + + &--maximized { + width: min(860px, calc(100vw - 32px)); + height: min(760px, calc(100vh - 48px)); + } +} + +.bee-monitor__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + border-bottom: 1px solid var(--bf-appearance-token-border-subtle); + flex-shrink: 0; +} + +.bee-monitor__title { + font-size: var(--bf-appearance-token-flowchat-font-size-sm); + font-weight: var(--bf-appearance-token-font-weight-bold); + color: var(--bf-appearance-token-color-text-primary); +} + +.bee-monitor__header-actions { + display: flex; + align-items: center; + gap: 6px; +} + +.bee-monitor__header-btn { + display: flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--bf-appearance-token-color-text-secondary); + cursor: pointer; + + &:hover { + background: var(--surface-hover); + color: var(--bf-appearance-token-color-text-primary); + } +} + +.bee-monitor__body { + flex: 1; + overflow-y: auto; + overflow-x: hidden; +} + +.bee-monitor__loading { + padding: 24px; + text-align: center; + color: var(--bf-appearance-token-color-text-secondary); + font-size: var(--bf-appearance-token-flowchat-font-size-sm); +} + +.bee-monitor__error { + padding: 20px 24px; + color: var(--bf-appearance-token-color-text-secondary); + font-size: var(--bf-appearance-token-flowchat-font-size-sm); + + p { + margin: 0 0 6px; + color: var(--bf-appearance-token-color-error); + font-weight: var(--bf-appearance-token-font-weight-bold); + } + + small { + color: var(--bf-appearance-token-color-text-muted); + } +} diff --git a/src/web-ui/src/app/layout/BeeColonyMonitor.tsx b/src/web-ui/src/app/layout/BeeColonyMonitor.tsx new file mode 100644 index 0000000000..041fcfe980 --- /dev/null +++ b/src/web-ui/src/app/layout/BeeColonyMonitor.tsx @@ -0,0 +1,180 @@ +/** + * BeeColonyMonitor — fixed floating panel that renders the bee-colony-dag + * MiniApp DAG visualization. Always accessible via a nav button; stays + * visible alongside other content without taking a full scene tab. + * + * Pattern: FloatingMiniChat-style floating panel with MiniAppRunner inside. + */ +import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react'; +import { GitBranch, X, Minimize2, Maximize2 } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { miniAppAPI } from '@/infrastructure/api/service-api/MiniAppAPI'; +import type { MiniApp } from '@/infrastructure/api/service-api/MiniAppAPI'; +import { useAppearance } from '@/infrastructure/appearance/hooks/useAppearance'; +import { useCurrentWorkspace } from '@/infrastructure/contexts/WorkspaceContext'; +import { createLogger } from '@/shared/utils/logger'; +import MiniAppRunner from '@/app/scenes/miniapps/components/MiniAppRunner'; +import { useSceneStore } from '@/app/stores/sceneStore'; +import './BeeColonyMonitor.scss'; + +const log = createLogger('BeeColonyMonitor'); + +const BEE_COLONY_APP_ID = 'bee-colony-dag'; + +export const BeeColonyMonitor: React.FC = () => { + const { t } = useTranslation('flow-chat'); + const { current } = useAppearance(); + const themeType = current?.mode; + const { workspacePath } = useCurrentWorkspace(); + const activeTabId = useSceneStore((s) => s.activeTabId); + + const [isOpen, setIsOpen] = useState(false); + const [app, setApp] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [maximized, setMaximized] = useState(false); + const lastLoadedThemeRef = useRef(null); + + // Only show in agent scene (where the DAG is relevant) + const isAgentScene = useMemo( + () => typeof activeTabId === 'string' && activeTabId.startsWith('agentic:'), + [activeTabId], + ); + + const loadApp = useCallback(async () => { + setLoading(true); + setError(null); + try { + const loaded = await miniAppAPI.getMiniApp( + BEE_COLONY_APP_ID, + themeType ?? 'dark', + workspacePath || undefined, + ); + if (!loaded?.compiled_html?.trim()) { + setError(t('layout.beeColony.notReady')); + setApp(null); + return; + } + setApp(loaded); + } catch (err) { + log.error('Failed to load bee colony MiniApp', err); + setError(String(err)); + setApp(null); + } finally { + setLoading(false); + } + }, [themeType, workspacePath, t]); + + // UI-10: 打开面板时加载;主题切换时强制重载(重新编译该主题的 DAG)。 + // 关闭面板时重置已加载主题,下次打开再重新加载。 + useEffect(() => { + if (!isOpen) { + lastLoadedThemeRef.current = null; + return; + } + if (lastLoadedThemeRef.current !== themeType) { + lastLoadedThemeRef.current = themeType ?? 'dark'; + void loadApp(); + } + }, [isOpen, themeType, loadApp]); + + const handleToggle = useCallback(() => { + setIsOpen((prev) => !prev); + }, []); + + const handleClose = useCallback(() => { + setIsOpen(false); + }, []); + + // Don't render in non-agent scenes + if (!isAgentScene) return null; + + return ( +
+ {/* Backdrop */} + {isOpen && ( +
+ )} + + {/* Trigger button — always visible in agent scenes */} + + + {/* Floating panel */} +
+ {/* Header */} +
+ {t('layout.beeColony.title')} +
+ + +
+
+ + {/* Body */} +
+ {loading && ( +
{t('layout.beeColony.loading')}
+ )} + {error && !app && ( +
+

{t('layout.beeColony.notReady')}

+ {t('layout.beeColony.notReadyDetail', { error })} +
+ )} + {app && } +
+
+
+ ); +}; + +export default BeeColonyMonitor; diff --git a/src/web-ui/src/app/scenes/agents/AgentsScene.tsx b/src/web-ui/src/app/scenes/agents/AgentsScene.tsx index d996d21099..4c7f0624b7 100644 --- a/src/web-ui/src/app/scenes/agents/AgentsScene.tsx +++ b/src/web-ui/src/app/scenes/agents/AgentsScene.tsx @@ -3,6 +3,7 @@ import type { TFunction } from 'i18next'; import { Bot, Cpu, + GitBranch, RotateCcw, Pencil, Plus, @@ -25,6 +26,7 @@ import { import AgentCard from './components/AgentCard'; import CoreAgentCard, { type CoreAgentMeta } from './components/CoreAgentCard'; import CreateAgentPage from './components/CreateAgentPage'; +import CreateLegionPage from './components/CreateLegionPage'; import { AgentCapabilityTooltip, type AgentCapabilityTooltipField, @@ -183,6 +185,7 @@ const AgentsHomeView: React.FC = () => { setAgentFilterLevel, setAgentFilterType, openCreateAgent, + openCreateLegion, openEditAgent, } = useAgentsStore(); const [selectedAgentId, setSelectedAgentId] = React.useState(null); @@ -755,6 +758,15 @@ const AgentsHomeView: React.FC = () => { ))}
+ + + + + ) : null} + + ); +}; + +export default CreateLegionPage; diff --git a/src/web-ui/src/app/scenes/agents/components/LegionCard.appearance.ts b/src/web-ui/src/app/scenes/agents/components/LegionCard.appearance.ts new file mode 100644 index 0000000000..e5b66c8b63 --- /dev/null +++ b/src/web-ui/src/app/scenes/agents/components/LegionCard.appearance.ts @@ -0,0 +1,8 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const legionCardAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'legion-card', + parts: [ + { id: 'root' }, + ], +}; diff --git a/src/web-ui/src/app/scenes/agents/components/LegionCard.tsx b/src/web-ui/src/app/scenes/agents/components/LegionCard.tsx new file mode 100644 index 0000000000..1de13168ba --- /dev/null +++ b/src/web-ui/src/app/scenes/agents/components/LegionCard.tsx @@ -0,0 +1,85 @@ +import React from 'react'; +import { GitBranch, Users, Network } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { Badge } from '@/component-library'; +import type { LegionPattern } from '../data/orchestration-patterns'; +import './LegionCard.scss'; + +interface LegionCardProps { + pattern: LegionPattern; + index?: number; + onOpenDetails: (pattern: LegionPattern) => void; +} + +const LegionCard: React.FC = ({ + pattern, + index = 0, + onOpenDetails, +}) => { + const { t } = useTranslation('scenes/agents'); + const gateNodes = pattern.nodes.filter((n) => n.gate).length; + const openDetails = () => onOpenDetails(pattern); + + const complexityLabel = + t(`legionPattern.complexityLabel.l${pattern.complexityLevel}`, { + defaultValue: `L${pattern.complexityLevel}`, + }); + + return ( +
e.key === 'Enter' && openDetails()} + aria-label={pattern.name} + data-testid="legion-list-item" + data-legion-id={pattern.id} + data-bf-component="legion-card" + data-bf-part="root" + > +
+
+
+ +
+
+
+
+ {pattern.name} +
+ + {complexityLabel} + +
+
+
+
+ +
+

{pattern.description}

+
+ +
+
+ + + {t('legionPattern.nodesCount', { count: pattern.nodes.length })} + + + + {t('legionPattern.edgesCount', { count: pattern.edges.length })} + + {gateNodes > 0 ? ( + + {gateNodes} {t('legionPattern.meta.gate')} + + ) : null} +
+
+
+ ); +}; + +export default LegionCard; diff --git a/src/web-ui/src/app/scenes/agents/data/orchestration-patterns.ts b/src/web-ui/src/app/scenes/agents/data/orchestration-patterns.ts new file mode 100644 index 0000000000..ca7e440bce --- /dev/null +++ b/src/web-ui/src/app/scenes/agents/data/orchestration-patterns.ts @@ -0,0 +1,392 @@ +/** + * 18 built-in orchestration patterns for legion templates. + * Each pattern maps to the orchestration-patterns skill library. + */ +export interface LegionPatternNode { + id: string; + agent: string; + role: string; + prompt: string; + gate?: boolean; +} + +export interface LegionPatternEdge { + from: string; + to: string; + condition?: string; +} + +export interface LegionPattern { + id: string; + name: string; + description: string; + complexityLevel: number; + nodes: LegionPatternNode[]; + edges: LegionPatternEdge[]; +} + +const PATTERNS: LegionPattern[] = [ + { + id: 'sparc-dev', + name: 'SPARC Development', + description: '5-stage SPARC development pipeline: specification → pseudocode → architecture → refinement → completion', + complexityLevel: 4, + nodes: [ + { id: 'researcher', agent: 'Plan', role: 'Research Bee', prompt: 'Gather requirements, define acceptance criteria, identify constraints and edge cases.' }, + { id: 'decomposer', agent: 'Plan', role: 'Decompose Bee', prompt: 'Decompose into executable sub-tasks, annotate complexity, define dependencies.' }, + { id: 'architect', agent: 'agentic', role: 'Architect Bee', prompt: 'Design modules, define interfaces, resolve constraints.' }, + { id: 'implementer', agent: 'agentic', role: 'Implement Bee', prompt: 'Implement according to architecture and interface contracts.' }, + { id: 'tester', agent: 'agentic', role: 'Test Bee', prompt: 'Write and run automated tests. Coverage ≥ 80%, all ACs pass.' }, + { id: 'reviewer', agent: 'DeepReview', role: 'Review Bee', prompt: 'Code review and documentation generation.', gate: true }, + ], + edges: [ + { from: 'researcher', to: 'decomposer' }, + { from: 'decomposer', to: 'architect' }, + { from: 'architect', to: 'implementer' }, + { from: 'architect', to: 'tester' }, + { from: 'implementer', to: 'reviewer' }, + { from: 'tester', to: 'reviewer' }, + { from: 'reviewer', to: 'implementer', condition: 'fail' }, + { from: 'reviewer', to: 'tester', condition: 'fail' }, + ], + }, + { + id: 'cicd-pipeline', + name: 'CI/CD Pipeline', + description: 'Lint → Unit test → Build → Integration test → Security audit → Deploy → Verify', + complexityLevel: 5, + nodes: [ + { id: 'lint', agent: 'agentic', role: 'Lint Bee', prompt: 'Run linter, type checker, security scan. Gate: zero errors.' }, + { id: 'unit-test', agent: 'agentic', role: 'Unit Test Bee', prompt: 'Run unit tests across multiple environments. Gate: all pass, coverage ≥ 80%.' }, + { id: 'build', agent: 'agentic', role: 'Build Bee', prompt: 'Compile, package, upload artifact. Gate: build succeeds.' }, + { id: 'integration', agent: 'agentic', role: 'Integration Bee', prompt: 'Deploy to staging, run integration tests, smoke test.' }, + { id: 'security-audit', agent: 'agentic', role: 'Security Bee', prompt: 'Dependency vulnerability scan, container scan, compliance check.' }, + { id: 'deploy', agent: 'agentic', role: 'Deploy Bee', prompt: 'Rollout with health check. Gate: health passes.' }, + { id: 'verify', agent: 'DeepReview', role: 'Verify Bee', prompt: 'Smoke test production, monitor metrics, rollback if needed.', gate: true }, + ], + edges: [ + { from: 'lint', to: 'unit-test' }, + { from: 'unit-test', to: 'build' }, + { from: 'build', to: 'integration' }, + { from: 'integration', to: 'security-audit' }, + { from: 'security-audit', to: 'deploy' }, + { from: 'deploy', to: 'verify' }, + ], + }, + { + id: 'fan-out-converge', + name: 'Fan-out Converge', + description: 'Dispatch → Parallel research (N bees) → Synthesize → Final review', + complexityLevel: 5, + nodes: [ + { id: 'dispatch', agent: 'Team', role: 'Commander', prompt: 'Evaluate task, match pattern, build team, assign sub-goals.' }, + { id: 'researcher-1', agent: 'agentic', role: 'Research Bee A', prompt: 'Research scope A independently and report structured results.' }, + { id: 'researcher-2', agent: 'agentic', role: 'Research Bee B', prompt: 'Research scope B independently and report structured results.' }, + { id: 'researcher-3', agent: 'agentic', role: 'Research Bee C', prompt: 'Research scope C independently and report structured results.' }, + { id: 'synthesizer', agent: 'agentic', role: 'Synthesize Bee', prompt: 'Collect results, resolve conflicts, merge outputs, check consistency.' }, + { id: 'reviewer', agent: 'DeepReview', role: 'Review Bee', prompt: 'Review merged output, generate final report.', gate: true }, + ], + edges: [ + { from: 'dispatch', to: 'researcher-1' }, + { from: 'dispatch', to: 'researcher-2' }, + { from: 'dispatch', to: 'researcher-3' }, + { from: 'researcher-1', to: 'synthesizer' }, + { from: 'researcher-2', to: 'synthesizer' }, + { from: 'researcher-3', to: 'synthesizer' }, + { from: 'synthesizer', to: 'reviewer' }, + { from: 'reviewer', to: 'synthesizer', condition: 'fail' }, + ], + }, + { + id: 'triad-minimal', + name: 'Three-Bee Minimal', + description: 'Prompt Bee → Execute Bee → Review Bee. Atomic execution unit.', + complexityLevel: 2, + nodes: [ + { id: 'prompt-bee', agent: 'Plan', role: 'Prompt Bee', prompt: 'Analyze task, inject relevant skills and templates.' }, + { id: 'execute-bee', agent: 'agentic', role: 'Execute Bee', prompt: 'Execute the task using the provided methodology.' }, + { id: 'review-bee', agent: 'DeepReview', role: 'Review Bee', prompt: 'Audit behavior and output, gate pass/fail.', gate: true }, + ], + edges: [ + { from: 'prompt-bee', to: 'execute-bee' }, + { from: 'execute-bee', to: 'review-bee' }, + { from: 'review-bee', to: 'execute-bee', condition: 'fail' }, + { from: 'review-bee', to: 'prompt-bee', condition: 'fail' }, + ], + }, + { + id: 'state-machine', + name: 'State Machine', + description: 'Multi-state flow with conditional branches and escalation.', + complexityLevel: 6, + nodes: [ + { id: 'pending', agent: 'Plan', role: 'Assess Bee', prompt: 'Evaluate task complexity and route to appropriate state.' }, + { id: 'executing', agent: 'agentic', role: 'Execute Bee', prompt: 'Execute. On success → review. On failure (≤3) → retry. On failure (>3) → escalate.' }, + { id: 'reviewing', agent: 'DeepReview', role: 'Review Bee', prompt: 'Review. On pass → complete. On fix (≤3 rounds) → back to executing.' }, + { id: 'escalated', agent: 'Team', role: 'Escalation', prompt: 'Human-in-the-loop decision: confirm fix or abandon.' }, + { id: 'completed', agent: 'agentic', role: 'Doc Bee', prompt: 'Generate completion report.' }, + { id: 'failed', agent: 'agentic', role: 'Doc Bee', prompt: 'Generate failure report with root cause.' }, + ], + edges: [ + { from: 'pending', to: 'executing' }, + { from: 'executing', to: 'reviewing', condition: 'success' }, + { from: 'executing', to: 'failed', condition: 'exhausted' }, + { from: 'reviewing', to: 'completed', condition: 'pass' }, + { from: 'reviewing', to: 'executing', condition: 'fix' }, + { from: 'reviewing', to: 'escalated', condition: 'max_rounds' }, + { from: 'escalated', to: 'executing', condition: 'confirm' }, + { from: 'escalated', to: 'failed', condition: 'abandon' }, + ], + }, + { + id: 'deep-research', + name: 'Deep Research', + description: '6-phase research pipeline with parallel specialists, debate, and arbitration.', + complexityLevel: 6, + nodes: [ + { id: 'planner', agent: 'Plan', role: 'Planner', prompt: 'Query understanding, ambiguity detection, sub-question decomposition.' }, + { id: 'primary', agent: 'agentic', role: 'Primary Source', prompt: 'Primary source specialist research.' }, + { id: 'news', agent: 'agentic', role: 'News Specialist', prompt: 'News and timeline research.' }, + { id: 'expert', agent: 'agentic', role: 'Expert Opinion', prompt: 'Expert opinion research.' }, + { id: 'counter', agent: 'agentic', role: 'Counter Evidence', prompt: 'Counter-evidence research.' }, + { id: 'advocate', agent: 'agentic', role: 'Advocate', prompt: 'Defend findings in adversarial debate.' }, + { id: 'critic', agent: 'agentic', role: 'Critic', prompt: 'Challenge findings in adversarial debate.' }, + { id: 'fact-checker', agent: 'agentic', role: 'Fact Checker', prompt: 'Resolve conflicts into HARD_CONFLICT / GENUINE_UNCERTAINTY / UNVERIFIED.' }, + { id: 'arbitrator', agent: 'DeepReview', role: 'Arbitrator', prompt: 'Research Manager arbitration with verdict markers.', gate: true }, + { id: 'reporter', agent: 'agentic', role: 'Reporter', prompt: 'Generate final report with citation index.' }, + ], + edges: [ + { from: 'planner', to: 'primary' }, + { from: 'planner', to: 'news' }, + { from: 'planner', to: 'expert' }, + { from: 'planner', to: 'counter' }, + { from: 'primary', to: 'advocate' }, + { from: 'news', to: 'advocate' }, + { from: 'expert', to: 'advocate' }, + { from: 'counter', to: 'critic' }, + { from: 'advocate', to: 'fact-checker' }, + { from: 'critic', to: 'fact-checker' }, + { from: 'fact-checker', to: 'arbitrator' }, + { from: 'arbitrator', to: 'reporter', condition: 'pass' }, + { from: 'arbitrator', to: 'fact-checker', condition: 'contest' }, + ], + }, + { + id: 'react-loop', + name: 'ReAct Loop', + description: 'Thought → Action → Observation loop with stop condition.', + complexityLevel: 1, + nodes: [ + { id: 'react-agent', agent: 'agentic', role: 'ReAct Agent', prompt: 'Think → Act → Observe loop until stop condition or final answer.' }, + ], + edges: [], + }, + { + id: 'plan-exec-reflect', + name: 'Plan-Execute-Reflect', + description: 'Plan → Execute step by step → Draft → Reflect and critique → Refine or stop.', + complexityLevel: 3, + nodes: [ + { id: 'planner', agent: 'Plan', role: 'Planner', prompt: 'Create a structured plan with dependencies.' }, + { id: 'executor', agent: 'agentic', role: 'Executor', prompt: 'Execute the plan step by step.' }, + { id: 'reflector', agent: 'DeepReview', role: 'Reflector', prompt: 'Reflect on the draft, critique quality, decide refine or stop.', gate: true }, + ], + edges: [ + { from: 'planner', to: 'executor' }, + { from: 'executor', to: 'reflector' }, + { from: 'reflector', to: 'executor', condition: 'refine' }, + ], + }, + { + id: 'event-driven', + name: 'Event-Driven Response', + description: 'Detect → Classify → Triage → Resolve → Postmortem. For incidents and alerts.', + complexityLevel: 4, + nodes: [ + { id: 'detector', agent: 'agentic', role: 'Detector', prompt: 'Detect event source, classify severity (P0-P4), tag category.' }, + { id: 'triage', agent: 'agentic', role: 'Triage', prompt: 'Assess impact, identify root cause, propose fix.' }, + { id: 'resolver', agent: 'agentic', role: 'Resolver', prompt: 'Apply fix, verify resolution, restore service.' }, + { id: 'postmortem', agent: 'agentic', role: 'Postmortem', prompt: 'Document timeline, identify prevention, generate report.' }, + ], + edges: [ + { from: 'detector', to: 'triage' }, + { from: 'triage', to: 'resolver' }, + { from: 'resolver', to: 'postmortem' }, + ], + }, + { + id: 'coding-agent', + name: 'Coding Agent', + description: 'Repo inspection → Scoped plan → File edits → Tests & checks → Patch & summary.', + complexityLevel: 3, + nodes: [ + { id: 'inspector', agent: 'agentic', role: 'Inspector', prompt: 'Inspect repository structure, understand codebase.' }, + { id: 'planner', agent: 'Plan', role: 'Planner', prompt: 'Create scoped implementation plan.' }, + { id: 'editor', agent: 'agentic', role: 'Editor', prompt: 'Implement changes with minimal diff.' }, + { id: 'tester', agent: 'agentic', role: 'Tester', prompt: 'Run tests and checks.' }, + { id: 'reviewer', agent: 'DeepReview', role: 'Reviewer', prompt: 'Review diff, logs, summary.', gate: true }, + ], + edges: [ + { from: 'inspector', to: 'planner' }, + { from: 'planner', to: 'editor' }, + { from: 'editor', to: 'tester' }, + { from: 'tester', to: 'reviewer' }, + { from: 'reviewer', to: 'editor', condition: 'fail' }, + ], + }, + { + id: 'dag-data-pipeline', + name: 'DAG Data Pipeline', + description: 'Extract → Transform (parallel partitions) → Validate → Load → Report.', + complexityLevel: 4, + nodes: [ + { id: 'extract', agent: 'agentic', role: 'Extractor', prompt: 'Connect source, validate connection, pull incremental data.' }, + { id: 'transform-a', agent: 'agentic', role: 'Transform A', prompt: 'Clean and transform partition A.' }, + { id: 'transform-b', agent: 'agentic', role: 'Transform B', prompt: 'Clean and transform partition B.' }, + { id: 'validator', agent: 'agentic', role: 'Validator', prompt: 'Run quality rules, check anomalies, generate quality report.' }, + { id: 'loader', agent: 'agentic', role: 'Loader', prompt: 'Connect target, write data, verify row count.' }, + { id: 'reporter', agent: 'agentic', role: 'Reporter', prompt: 'Generate execution report, log metrics.' }, + ], + edges: [ + { from: 'extract', to: 'transform-a' }, + { from: 'extract', to: 'transform-b' }, + { from: 'transform-a', to: 'validator' }, + { from: 'transform-b', to: 'validator' }, + { from: 'validator', to: 'loader' }, + { from: 'loader', to: 'reporter' }, + ], + }, + { + id: 'pr-code-review', + name: 'PR Code Review', + description: 'PR created → Lint → Code review (max 3 rounds) → Merge → Deploy.', + complexityLevel: 3, + nodes: [ + { id: 'lint', agent: 'agentic', role: 'Lint Bee', prompt: 'Check diff size, run automated lint, verify PR template.' }, + { id: 'reviewer', agent: 'DeepReview', role: 'Review Bee', prompt: 'Review logic, check test coverage, verify no regression.' }, + { id: 'merger', agent: 'agentic', role: 'Merge Bee', prompt: 'Rebase, resolve conflicts, run CI again.' }, + { id: 'deployer', agent: 'agentic', role: 'Deploy Bee', prompt: 'Deploy with promotion staging → production.' }, + ], + edges: [ + { from: 'lint', to: 'reviewer' }, + { from: 'reviewer', to: 'merger', condition: 'approved' }, + { from: 'reviewer', to: 'lint', condition: 'changes_requested' }, + { from: 'merger', to: 'deployer' }, + ], + }, + { + id: 'deploy-orchestration', + name: 'Deploy Orchestration', + description: 'Configure → Schedule → Health check → Rolling update → Self-heal loop.', + complexityLevel: 5, + nodes: [ + { id: 'configure', agent: 'agentic', role: 'Config Bee', prompt: 'Define desired state, set resource limits, configure probes.' }, + { id: 'scheduler', agent: 'agentic', role: 'Schedule Bee', prompt: 'Match nodes, pull images, start containers.' }, + { id: 'health-check', agent: 'agentic', role: 'Health Bee', prompt: 'Readiness, liveness, startup probes.' }, + { id: 'updater', agent: 'agentic', role: 'Update Bee', prompt: 'Rolling update, verify each batch, zero downtime.' }, + { id: 'healer', agent: 'agentic', role: 'Healer Bee', prompt: 'Continuous pod/node health monitoring, auto-restart/scale/migrate.' }, + ], + edges: [ + { from: 'configure', to: 'scheduler' }, + { from: 'scheduler', to: 'health-check' }, + { from: 'health-check', to: 'updater' }, + { from: 'updater', to: 'healer' }, + ], + }, + { + id: 'six-layer-runtime', + name: 'Six-Layer Agent Runtime', + description: 'Intent dispatch → State & memory → Execution sandbox → Tool boundary → Control → Endpoint.', + complexityLevel: 7, + nodes: [ + { id: 'intent', agent: 'Team', role: 'Intent Layer', prompt: 'Receive task/event, dispatch to appropriate handler, spawn sub-agents.' }, + { id: 'state', agent: 'agentic', role: 'State Layer', prompt: 'Manage working memory, persist artifacts, create checkpoints.' }, + { id: 'exec', agent: 'agentic', role: 'Exec Layer', prompt: 'Execute in sandbox/container with appropriate environment.' }, + { id: 'tool', agent: 'agentic', role: 'Tool Layer', prompt: 'Bridge to MCP/A2A/ANP protocols, call external tools.' }, + { id: 'control', agent: 'DeepReview', role: 'Control Layer', prompt: 'Policy approval, behavior evaluation, guard enforcement.' }, + { id: 'endpoint', agent: 'agentic', role: 'Endpoint Layer', prompt: 'Deliver results to user interface or API consumer.' }, + ], + edges: [ + { from: 'intent', to: 'state' }, + { from: 'state', to: 'exec' }, + { from: 'exec', to: 'tool' }, + { from: 'tool', to: 'control' }, + { from: 'control', to: 'endpoint' }, + ], + }, + { + id: 'memory-retrieval', + name: 'Memory & Retrieval', + description: 'Working memory → Promote/discard → Episodic/Semantic memory → Retrieval → Notes → Task context.', + complexityLevel: 4, + nodes: [ + { id: 'working', agent: 'agentic', role: 'Working Memory', prompt: 'Current session state, lightweight, in-process.' }, + { id: 'episodic', agent: 'agentic', role: 'Episodic Store', prompt: 'Store bounded events with structured metadata + similarity search.' }, + { id: 'semantic', agent: 'agentic', role: 'Semantic Store', prompt: 'Persist cross-task facts, dedup, normalize relations.' }, + { id: 'retrieval', agent: 'agentic', role: 'Retrieval Layer', prompt: 'Hybrid search: keyword + dense retrieval + structured filters.' }, + { id: 'context', agent: 'agentic', role: 'Context Builder', prompt: 'Assemble notes and artifacts into task context for model call.' }, + ], + edges: [ + { from: 'working', to: 'episodic' }, + { from: 'working', to: 'semantic' }, + { from: 'episodic', to: 'retrieval' }, + { from: 'semantic', to: 'retrieval' }, + { from: 'retrieval', to: 'context' }, + ], + }, + { + id: 'customer-support', + name: 'Customer Support', + description: 'Triage → Policy grounding → Draft → Guardrails → Human review queue.', + complexityLevel: 3, + nodes: [ + { id: 'triage', agent: 'agentic', role: 'Triage', prompt: 'Classify case type, urgency, sentiment, requested outcome.' }, + { id: 'policy', agent: 'agentic', role: 'Policy Agent', prompt: 'Ground response in explicit policy documents.' }, + { id: 'drafter', agent: 'agentic', role: 'Drafter', prompt: 'Draft response. Never auto-send — final decision is human.' }, + { id: 'guard', agent: 'DeepReview', role: 'Guardrail', prompt: 'Reject refunds, legal commitments, high-risk actions.', gate: true }, + ], + edges: [ + { from: 'triage', to: 'policy' }, + { from: 'policy', to: 'drafter' }, + { from: 'drafter', to: 'guard' }, + { from: 'guard', to: 'drafter', condition: 'fail' }, + ], + }, + { + id: 'evaluation-observability', + name: 'Evaluation & Observability', + description: 'Offline eval → Online monitoring → Structured traces → Failure triage.', + complexityLevel: 5, + nodes: [ + { id: 'offline', agent: 'agentic', role: 'Offline Eval', prompt: 'Run benchmarks on known tasks, compare prompts/models/tools.' }, + { id: 'online', agent: 'agentic', role: 'Online Monitor', prompt: 'Collect production signals: success rate, latency, escalation rate.' }, + { id: 'tracer', agent: 'agentic', role: 'Tracer', prompt: 'Capture structured traces: tool inputs/outputs, state transitions.' }, + { id: 'triage', agent: 'DeepReview', role: 'Triage', prompt: 'Failure triage from traces: prompt / tool / model decisions.' }, + ], + edges: [ + { from: 'offline', to: 'triage' }, + { from: 'online', to: 'triage' }, + { from: 'tracer', to: 'triage' }, + ], + }, + { + id: 'workflow-agent-hybrid', + name: 'Workflow-Agent Hybrid', + description: 'Known path → workflow. Unknown path → agent. Hybrid embeds agent nodes in workflow or vice versa.', + complexityLevel: 5, + nodes: [ + { id: 'classifier', agent: 'Plan', role: 'Classifier', prompt: 'Evaluate: is the path known and rules stable (workflow) or unknown/variable (agent)?' }, + { id: 'workflow', agent: 'agentic', role: 'Workflow', prompt: 'Predefined ordered execution for deterministic business logic.' }, + { id: 'agent-node', agent: 'agentic', role: 'Agent Node', prompt: 'Autonomous decision-making for bounded exploration and judgment.' }, + { id: 'compliance', agent: 'DeepReview', role: 'Compliance', prompt: 'Wrap agent outputs in workflow controls: compliance, approval, irreversible ops.', gate: true }, + ], + edges: [ + { from: 'classifier', to: 'workflow' }, + { from: 'classifier', to: 'agent-node' }, + { from: 'agent-node', to: 'compliance' }, + { from: 'workflow', to: 'compliance' }, + ], + }, +]; + +export default PATTERNS; diff --git a/src/web-ui/src/app/utils/projectSessionWorkspace.test.ts b/src/web-ui/src/app/utils/projectSessionWorkspace.test.ts index f965373538..025abc9f97 100644 --- a/src/web-ui/src/app/utils/projectSessionWorkspace.test.ts +++ b/src/web-ui/src/app/utils/projectSessionWorkspace.test.ts @@ -31,7 +31,7 @@ const createSession = (overrides: Partial = {}): Session => ({ lastActiveAt: 1, error: null, isHistorical: false, - maxContextTokens: 128128, + maxContextTokens: 1048576, mode: 'agentic', workspacePath: '/workspace/BitFun', workspaceId: 'workspace-1', diff --git a/src/web-ui/src/component-library/components/FlowChatCards/TodoCard/TodoCard.tsx b/src/web-ui/src/component-library/components/FlowChatCards/TodoCard/TodoCard.tsx index 619405948e..add92a0bfd 100644 --- a/src/web-ui/src/component-library/components/FlowChatCards/TodoCard/TodoCard.tsx +++ b/src/web-ui/src/component-library/components/FlowChatCards/TodoCard/TodoCard.tsx @@ -14,6 +14,7 @@ export interface TodoItem { id: string; content: string; status: 'pending' | 'in_progress' | 'completed'; + dependencies?: string[]; } export interface TodoCardProps extends Omit { diff --git a/src/web-ui/src/flow_chat/components/ChatInput.scss b/src/web-ui/src/flow_chat/components/ChatInput.scss index 71da60a4d0..26e1415ea5 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.scss +++ b/src/web-ui/src/flow_chat/components/ChatInput.scss @@ -718,6 +718,7 @@ &__target-switcher { display: flex; + flex-wrap: wrap; align-items: center; gap: 0.125rem; padding-bottom: 7px; diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index ad471a2378..10e0163fa7 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -29,8 +29,9 @@ import { FlowChatStore } from '../store/FlowChatStore'; import { useAcpPlan } from '../hooks/useAcpPlan'; import { filterSlashCommands, useAcpSlashCommands } from '../hooks/useAcpSlashCommands'; import { acpSessionRef, acpSlashCommandText } from '../utils/acpSession'; +import { conversationLevelLabel } from '../utils/conversationLevelLabel'; import { AcpPlanPanel } from './AcpPlanPanel'; -import type { FlowChatState } from '../types/flow-chat'; +import type { FlowChatState, Session } from '../types/flow-chat'; import type { ContextItem, DirectoryContext, @@ -275,7 +276,95 @@ type SlashPickerItem = | SlashAcpCommandItem | SlashSkillItem | SlashExternalPromptCommandItem; -type ChatInputTarget = 'main' | 'btw'; +type ChatInputTarget = 'main' | 'btw' | { sessionId: string }; + +export interface ConversationLevelEntry { + sessionId: string; + level: number; + session?: Session; + isDescendant: boolean; +} + +/** + * Build the conversation hierarchy levels (L0..LN) around the current session: + * the ancestor chain from the root conversation down to the current session, + * then all descendant child sessions (BFS, ordered by createdAt then depth). + */ +export function buildConversationHierarchy( + sessions: ReadonlyMap, + currentSessionId?: string | null, +): ConversationLevelEntry[] { + if (!currentSessionId) { + return []; + } + + const chain: Session[] = []; + let cursorId: string | undefined = currentSessionId; + let guard = 0; + while (cursorId && guard++ < 128) { + const session = sessions.get(cursorId); + if (!session) { + break; + } + chain.unshift(session); + cursorId = resolveSessionRelationship(session).parentSessionId; + } + if (chain.length === 0) { + return []; + } + + const childrenByParent = new Map(); + for (const session of sessions.values()) { + const parentId = resolveSessionRelationship(session).parentSessionId; + if (!parentId) { + continue; + } + const list = childrenByParent.get(parentId); + if (list) { + list.push(session); + } else { + childrenByParent.set(parentId, [session]); + } + } + for (const list of childrenByParent.values()) { + list.sort( + (a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0) || (a.depth ?? 0) - (b.depth ?? 0), + ); + } + + const descendants: Session[] = []; + const queue: string[] = [currentSessionId]; + const visited = new Set([currentSessionId]); + while (queue.length > 0) { + const parentId = queue.shift()!; + for (const child of childrenByParent.get(parentId) ?? []) { + if (visited.has(child.sessionId)) { + continue; + } + visited.add(child.sessionId); + descendants.push(child); + queue.push(child.sessionId); + } + } + + const levels: ConversationLevelEntry[] = chain.map((session, index) => ({ + sessionId: session.sessionId, + level: index, + session, + isDescendant: false, + })); + let nextLevel = chain.length; + for (const session of descendants) { + levels.push({ + sessionId: session.sessionId, + level: nextLevel, + session, + isDescendant: true, + }); + nextLevel += 1; + } + return levels; +} function nativePromptCommandCandidateId( kind: Exclude, @@ -482,7 +571,11 @@ export const ChatInput: React.FC = ({ ? activeBtwSessionData.childSessionId : undefined; const effectiveTargetSessionId = - inputTarget === 'btw' && activeBtwSessionId ? activeBtwSessionId : currentSessionId; + inputTarget === 'btw' && activeBtwSessionId + ? activeBtwSessionId + : typeof inputTarget === 'object' + ? inputTarget.sessionId + : currentSessionId; const effectiveTargetSessionIdRef = useRef(effectiveTargetSessionId); effectiveTargetSessionIdRef.current = effectiveTargetSessionId; @@ -567,7 +660,42 @@ export const ChatInput: React.FC = ({ ? flowChatState.sessions.get(activeBtwSessionId) : undefined; const activeBtwRelationship = resolveSessionRelationship(activeBtwSession); - const showTargetSwitcher = !!activeBtwSessionId; + const conversationLevels = useMemo( + () => buildConversationHierarchy(flowChatState.sessions, currentSessionId), + [flowChatState.sessions, currentSessionId], + ); + const showTargetSwitcher = !!activeBtwSessionId || conversationLevels.length > 1; + const handleSelectConversationLevel = useCallback( + (sessionId: string) => { + setInputTarget({ sessionId }); + const session = flowChatState.sessions.get(sessionId); + if (!session) { + return; + } + const relationship = resolveSessionRelationship(session); + if (!relationship.canOpenInAuxPane || !relationship.parentSessionId) { + return; + } + const kind = session.sessionKind; + openBtwSessionInAuxPane({ + childSessionId: sessionId, + parentSessionId: relationship.parentSessionId, + workspacePath: session.workspacePath, + sessionKind: + kind === 'subagent' || + kind === 'review' || + kind === 'deep_review' || + kind === 'miniapp' || + kind === 'btw' + ? kind + : 'btw', + parentToolCallId: session.parentToolCallId, + subagentType: session.subagentType, + sessionTitle: session.title, + }); + }, + [flowChatState.sessions], + ); const activeBtwKind = activeBtwRelationship.kind === 'review' || activeBtwRelationship.kind === 'deep_review' || @@ -1083,12 +1211,18 @@ export const ChatInput: React.FC = ({ (state: FlowChatState): string => { const parts: string[] = [state.activeSessionId ?? '']; // Track sessions that ChatInput reads in render body (lines 278, 288, 304, 619) - const sessionIds = [ - state.activeSessionId, - currentSessionId, - effectiveTargetSessionId, - activeBtwSessionId, - ].filter((id): id is string => !!id); + const sessionIds = new Set( + [ + state.activeSessionId, + currentSessionId, + effectiveTargetSessionId, + activeBtwSessionId, + ].filter((id): id is string => !!id), + ); + // Track every session in the conversation hierarchy so level tabs stay in sync. + for (const level of buildConversationHierarchy(state.sessions, currentSessionId)) { + sessionIds.add(level.sessionId); + } for (const id of sessionIds) { const s = state.sessions.get(id); if (s) { @@ -1100,7 +1234,9 @@ export const ChatInput: React.FC = ({ `${s.needsUserAttention ? '1':'0'}|${s.dialogTurns.length}|` + `${JSON.stringify(s.config.dispatchTarget ?? null)}|` + `${s.config.dispatchApprovalPolicy ?? ''}|${s.config.dispatchJobState ?? ''}|` + - `${sessionWorktreeBindingSubscriptionKey(s)}` + `${sessionWorktreeBindingSubscriptionKey(s)}|` + + `${s.parentSessionId ?? ''}|${s.sessionKind ?? ''}|${s.depth ?? ''}|` + + `${s.createdAt ?? ''}|${JSON.stringify(s.btwOrigin ?? null)}` ); } } @@ -1131,10 +1267,19 @@ export const ChatInput: React.FC = ({ }, [currentSessionId, effectiveTargetSessionId, activeBtwSessionId]); useEffect(() => { - if (!showTargetSwitcher || !activeBtwSessionId) { - setInputTarget('main'); - } - }, [activeBtwSessionId, showTargetSwitcher]); + setInputTarget(prev => { + if (typeof prev === 'object') { + const stillInHierarchy = conversationLevels.some( + level => level.sessionId === prev.sessionId, + ); + return showTargetSwitcher && stillInHierarchy ? prev : 'main'; + } + if (prev === 'btw' && !activeBtwSessionId) { + return 'main'; + } + return prev; + }); + }, [activeBtwSessionId, conversationLevels, showTargetSwitcher]); useEffect(() => { setChatInputActive(inputState.isActive); @@ -1939,7 +2084,7 @@ export const ChatInput: React.FC = ({ }; const loadVisibility = async () => { try { - applyVisibility(await configManager.getOptionalConfig(configPath)); + applyVisibility(await configManager.getConfig(configPath)); } catch (error) { log.warn('Failed to load permission mode control visibility preference', error); applyVisibility(true); @@ -4847,14 +4992,22 @@ export const ChatInput: React.FC = ({ e.preventDefault(); - const isBtwCommand = isSlashCommand(inputState.value.trim(), '/btw'); + const promptSlashCommandsEnabled = !isAcpInputSession; + const isBtwCommand = + promptSlashCommandsEnabled && + caps.ops.has('btw') && + isSlashCommand(inputState.value.trim(), '/btw'); if (isBtwCommand) { // Allow /btw submission even while the main session is generating. void submitBtwFromInput(); return; } - if (isGoalSlashCommand(inputState.value.trim())) { + const isGoalCommand = + promptSlashCommandsEnabled && + caps.ops.has('goal') && + isGoalSlashCommand(inputState.value.trim()); + if (isGoalCommand) { void submitGoalFromInput(); return; } @@ -4872,7 +5025,7 @@ export const ChatInput: React.FC = ({ e.preventDefault(); void handleCancelCurrentTask(); } - }, [handleSendOrCancel, submitBtwFromInput, submitGoalFromInput, derivedState, dispatchInput, handleCancelCurrentTask, slashCommandState, getFilteredSelectableModes, getActiveSlashPickerItems, selectSlashCommandMode, selectSlashCommandAction, selectSlashExternalPromptCommand, selectSlashPromptCommand, selectSlashAcpCommand, selectSlashSkill, canSwitchModes, getRichTextInlineTriggerController, historyIndex, inputHistory, savedDraft, inputState.value, currentSessionId, isBtwSession, showTargetSwitcher, setInputTarget, removeContext, t]); + }, [handleSendOrCancel, submitBtwFromInput, submitGoalFromInput, derivedState, dispatchInput, handleCancelCurrentTask, slashCommandState, getFilteredSelectableModes, getActiveSlashPickerItems, selectSlashCommandMode, selectSlashCommandAction, selectSlashExternalPromptCommand, selectSlashPromptCommand, selectSlashAcpCommand, selectSlashSkill, canSwitchModes, getRichTextInlineTriggerController, historyIndex, inputHistory, savedDraft, inputState.value, currentSessionId, isBtwSession, showTargetSwitcher, setInputTarget, removeContext, isAcpInputSession, caps.ops, t]); const handleImeCompositionStart = useCallback(() => { isImeComposingRef.current = true; @@ -5181,6 +5334,28 @@ export const ChatInput: React.FC = ({ {activeBtwSessionTitle} )} + {conversationLevels.map(entry => { + const isLevelActive = + typeof inputTarget === 'object' && inputTarget.sessionId === entry.sessionId; + const levelTitle = isLevelActive + ? entry.session?.title?.trim() || t('session.untitled') + : ''; + return ( + + ); + })} )}
@@ -5257,9 +5432,6 @@ export const ChatInput: React.FC = ({ isOpen={mentionState.isActive} searchQuery={mentionState.query} workspacePath={sessionBoundWorkspacePath} - workspaceId={hasRegisteredWorkspace - ? undefined - : effectiveTargetSession?.workspaceId || workspace?.id} excludeSessionId={effectiveTargetSessionId || undefined} anchorRef={mentionAnchorRef} onSelect={(context: FileContext | DirectoryContext | SessionReferenceContext) => { @@ -5989,6 +6161,7 @@ export const ChatInput: React.FC = ({ ? { visible: true, goal: threadGoalController.goal, + goalChain: threadGoalController.goalChain, onOpen: () => { void threadGoalController.openGoalEntry(); }, diff --git a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.scss b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.scss index 759f425def..fe89bb546d 100644 --- a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.scss +++ b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.scss @@ -390,6 +390,73 @@ } } + /* Thread goal chain: compact chips, one per goal level. */ + &__goal-chain { + display: inline-flex; + align-items: center; + gap: 2px; + max-width: 260px; + overflow: hidden; + } + + &__goal-chain-chip { + display: inline-flex; + align-items: center; + gap: 4px; + max-width: 140px; + padding: 1px 6px; + border-radius: 999px; + font-size: 11px; + line-height: 16px; + color: var(--bf-appearance-token-color-text-muted); + background: color-mix(in srgb, var(--bf-appearance-token-color-text-muted) 8%, var(--bf-appearance-token-element-bg-medium)); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + &--current { + color: var(--bf-appearance-token-color-warning); + background: color-mix(in srgb, var(--bf-appearance-token-color-warning) 10%, var(--bf-appearance-token-element-bg-medium)); + cursor: pointer; + + &:hover, + &:focus-visible { + background: color-mix(in srgb, var(--bf-appearance-token-color-warning) 16%, var(--bf-appearance-token-element-bg-medium)); + outline: none; + } + } + + /* Empty entry (no goal on this level): muted Target icon placeholder. */ + &--empty { + color: var(--bf-appearance-token-color-text-muted); + opacity: 0.72; + } + } + + &__goal-chain-chip-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__goal-chain-chip-icon { + display: inline-flex; + align-items: center; + justify-content: center; + + svg { + color: currentColor; + } + } + + &__goal-chain-sep { + color: var(--bf-appearance-token-color-text-muted); + opacity: 0.5; + font-size: 12px; + line-height: 16px; + user-select: none; + } + /* Keep the usage action compact, but large enough to read beside the strip text. */ &__usage-btn.icon-btn { width: 16px; diff --git a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx index ee2cf7a3db..83405a1c02 100644 --- a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx @@ -16,9 +16,10 @@ import { ShieldCheck, Square, SquareCheck, + Target, } from 'lucide-react'; import { ThreadGoalStripButton } from './thread-goal/ThreadGoalStripButton'; -import type { ThreadGoalSnapshot } from '../services/goalService'; +import type { GoalChainEntry, ThreadGoalSnapshot } from '../services/goalService'; import { Tooltip, IconButton } from '@/component-library'; import { useGitState } from '@/tools/git/hooks/useGitState'; import type { SessionExecutionTarget } from '@/infrastructure/api/service-api/WorktreeAPI'; @@ -44,6 +45,7 @@ export interface ChatInputWorkspaceStripProps { threadGoal?: { visible: boolean; goal: ThreadGoalSnapshot | null; + goalChain?: GoalChainEntry[]; onOpen: () => void; }; /** Global native-tool permission mode exposed as a compact strip control. */ @@ -527,10 +529,69 @@ export const ChatInputWorkspaceStrip: React.FC = (
) : null} {showGoal ? ( - + threadGoal.goalChain && threadGoal.goalChain.length > 0 ? ( +
+ {threadGoal.goalChain.map((entry, index) => { + const isLast = index === threadGoal.goalChain!.length - 1; + const hasGoal = !!entry.goal?.objective; + const objective = entry.goal?.objective ?? ''; + const truncated = + objective.length > 24 + ? objective.slice(0, 24) + '\u2026' + : objective; + return ( + + { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + threadGoal.onOpen(); + } + } + : undefined + } + > + {hasGoal ? ( + + {truncated} + + ) : ( + + + + )} + + {!isLast && ( + + › + + )} + + ); + })} +
+ ) : ( + + ) ) : null} {showUsage ? ( diff --git a/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx b/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx index c472e24df5..1ef583f8c1 100644 --- a/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx +++ b/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx @@ -248,9 +248,23 @@ export const BtwSessionPanel: React.FC = ({ }, [childSessionId, childSession, parentSession, workspacePath]); useEffect(() => { - if (!childSession?.isHistorical || childSession.historyState !== 'metadata-only') return; - void loadChildHistory().catch(() => undefined); - }, [childSession?.historyState, childSession?.isHistorical, loadChildHistory]); + if (!childSessionId || !childSession) return; + // Auto-load history for any session shell that has no dialog turns and has + // not reached a renderable ('ready'), in-flight ('hydrating'), or failed + // state. Event-created placeholder shells (e.g. subagents) are + // 'new'/'metadata-only' with empty turns and previously never loaded, + // leaving an empty conversation; 'failed' stays manual so the retry entry + // is visible instead of looping automatically. + if (childSession.dialogTurns.length > 0) return; + if ( + childSession.historyState === 'ready' || + childSession.historyState === 'hydrating' || + childSession.historyState === 'failed' + ) return; + void loadChildHistory().catch(error => { + log.error('Failed to auto-load child session history', { childSessionId, error }); + }); + }, [childSessionId, childSession, loadChildHistory]); const updateScrollAffordance = useCallback(() => { const container = scrollContainerRef.current; @@ -1058,7 +1072,24 @@ export const BtwSessionPanel: React.FC = ({ )} {virtualItems.length === 0 ? ( !isReviewDetail || reviewDetailNotices.length === 0 ? ( -
{t('session.empty')}
+ childSession.historyState === 'failed' ? ( +
+ {t('childSession.reviewDetail.loadFailed', { label: childBadgeLabel })} + +
+ ) : ( +
{t('session.empty')}
+ ) ) : null ) : ( virtualItems.map((item, index) => ( diff --git a/src/web-ui/src/flow_chat/components/modern/UserMessageEditComposer.tsx b/src/web-ui/src/flow_chat/components/modern/UserMessageEditComposer.tsx index 9dd8781c7e..8493f43975 100644 --- a/src/web-ui/src/flow_chat/components/modern/UserMessageEditComposer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/UserMessageEditComposer.tsx @@ -24,7 +24,6 @@ interface UserMessageEditComposerProps { onCancel: () => void; presentation?: ComposerPresentation | null; workspacePath?: string; - workspaceId?: string; excludeSessionId?: string; } @@ -43,7 +42,6 @@ const RichUserMessageEditComposer: React.FC = onCancel, presentation, workspacePath, - workspaceId, excludeSessionId, }) => { const editorRef = useRef(null); @@ -132,7 +130,6 @@ const RichUserMessageEditComposer: React.FC = isOpen={mentionState.isActive} searchQuery={mentionState.query} workspacePath={workspacePath} - workspaceId={workspaceId} excludeSessionId={excludeSessionId} anchorRef={mentionAnchorRef} onSelect={handleSelectContext} @@ -182,7 +179,6 @@ export const UserMessageEditComposer: React.FC = ( onCancel, presentation, workspacePath, - workspaceId, excludeSessionId, }) => { const textareaRef = useRef(null); @@ -228,7 +224,6 @@ export const UserMessageEditComposer: React.FC = ( onCancel={onCancel} presentation={presentation} workspacePath={workspacePath} - workspaceId={workspaceId} excludeSessionId={excludeSessionId} /> ); diff --git a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.scss b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.scss index e88d76b833..aee948cc9d 100644 --- a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.scss +++ b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.scss @@ -85,6 +85,7 @@ .user-message-item__main { display: flex; + flex-wrap: wrap; align-items: flex-start; gap: var(--bf-appearance-token-flowchat-inline-gap); } @@ -94,6 +95,25 @@ display: contents; } +.user-message-item__sender-badge { + display: inline-flex; + align-items: center; + /* The badge owns the first row; the message content wraps to the next one. */ + flex: 0 0 100%; + width: fit-content; + margin: 0.1rem 0 0.25rem; + padding: 0.12rem 0.44rem; + border-radius: 999px; + font-size: var(--bf-appearance-token-flowchat-font-size-xxs); + font-weight: 600; + line-height: var(--bf-appearance-token-flowchat-compact-line-height); + letter-spacing: 0; + user-select: none; + color: var(--bf-appearance-token-color-accent-500); + background: color-mix(in srgb, var(--bf-appearance-token-color-accent-500) 12%, transparent); + border: 1px solid color-mix(in srgb, var(--bf-appearance-token-color-accent-500) 28%, transparent); +} + .user-message-item__steering-tag { display: inline-flex; align-items: center; diff --git a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.test.tsx b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.test.tsx index 8570937a01..948ded5a33 100644 --- a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.test.tsx @@ -714,4 +714,72 @@ describe('UserMessageItem steering tag', () => { editedContent: 'edited older window prompt', })); }); + + it('renders a sender identity badge for forwarded agent messages', () => { + act(() => { + root.render( + + + , + ); + }); + + const badge = container.querySelector('.user-message-item__sender-badge'); + expect(badge?.textContent).toBe('[Commander L0] Mengdie'); + }); + + it('renders a fallback role when sender metadata lacks role and depth', () => { + act(() => { + root.render( + + + , + ); + }); + + const badge = container.querySelector('.user-message-item__sender-badge'); + expect(badge?.textContent).toBe('[Agent]'); + }); + + it('does not render a sender badge for plain user messages without metadata', () => { + act(() => { + root.render( + + + , + ); + }); + + expect(container.querySelector('.user-message-item__sender-badge')).toBeNull(); + }); }); diff --git a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx index d8b1a0a21c..991c3e50f3 100644 --- a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx +++ b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx @@ -192,6 +192,20 @@ export const UserMessageItem = React.memo( label: t('steering.statusPending'), } : null; + // Sender identity badge for forwarded agent messages (R-23). Only present + // when the backend attached sender metadata; historical messages without + // it render no badge at all (graceful degradation). + const senderBadge = useMemo(() => { + const meta = message?.metadata; + if (!meta?.senderSessionId) return null; + const role = typeof meta.senderRole === 'string' ? meta.senderRole : 'Agent'; + const depth = typeof meta.senderDepth === 'number' ? ` L${meta.senderDepth}` : ''; + const name = + typeof meta.senderName === 'string' && meta.senderName.trim() + ? ` ${meta.senderName.trim()}` + : ''; + return `[${role}${depth}]${name}`; + }, [message?.metadata]); const { displayText, reproductionSteps } = useMemo(() => { const reproductionRegex = /([\s\S]*?)<\/reproduction_steps\s*>?/g; @@ -538,7 +552,6 @@ export const UserMessageItem = React.memo( onCancel={cancelEdit} presentation={composerPresentation} workspacePath={currentSession?.workspacePath} - workspaceId={currentSession?.workspaceId} excludeSessionId={resolvedSessionId} /> ) : ( @@ -555,6 +568,9 @@ export const UserMessageItem = React.memo( : 'user-message-item__main-contents-bridge' } > + {senderBadge && ( + {senderBadge} + )} {isFailed ? (
({ subscribe: () => () => {}, }), }, + isSessionConfirmedDeleted: () => false, })); vi.mock('../../services/btwSessionPane', () => ({ diff --git a/src/web-ui/src/flow_chat/components/subagent/SubagentProjectionView.tsx b/src/web-ui/src/flow_chat/components/subagent/SubagentProjectionView.tsx index fb873f65b5..6662fafad6 100644 --- a/src/web-ui/src/flow_chat/components/subagent/SubagentProjectionView.tsx +++ b/src/web-ui/src/flow_chat/components/subagent/SubagentProjectionView.tsx @@ -7,7 +7,7 @@ import { FlowToolCard } from '../FlowToolCard'; import { taskCollapseStateManager } from '../../store/TaskCollapseStateManager'; import { SmoothHeightCollapse } from '../modern/SmoothHeightCollapse'; import { FLOWCHAT_COLLAPSE_DURATION_MS } from '../modern/flowChatCollapseMotion'; -import { FlowChatStore } from '../../store/FlowChatStore'; +import { FlowChatStore, isSessionConfirmedDeleted } from '../../store/FlowChatStore'; import { getSubagentProjectionState } from '../../utils/subagentProjection'; import { ensureBtwSessionAvailable } from '../../services/btwSessionPane'; import { RuntimeStatusSlot } from '../modern/RuntimeStatusSlot'; @@ -142,6 +142,7 @@ export const SubagentProjectionView: React.FC = ({ compactText = true, liveItemsMode = 'last-round', }) => { + const { t } = useTranslation('flow-chat'); const containerRef = useRef(null); const userScrolledUpRef = useRef(false); const lastScrollTopRef = useRef(0); @@ -208,7 +209,8 @@ export const SubagentProjectionView: React.FC = ({ previous?.turn === next.turn && previous?.round === next.round && previous?.items === next.items && - previous?.isRunning === next.isRunning + previous?.isRunning === next.isRunning && + previous?.executionStatus === next.executionStatus ) { return; } @@ -232,6 +234,16 @@ export const SubagentProjectionView: React.FC = ({ ? state.bySessionId.get(resolvedSubagentSessionId) : undefined )); + const linkedSession = resolvedSubagentSessionId + ? FlowChatStore.getInstance().getState().sessions.get(resolvedSubagentSessionId) + : undefined; + const linkedSessionMissing = Boolean( + resolvedSubagentSessionId && + !linkedSession && + projectionState?.executionStatus !== 'running' && + !runtimeStatus && + !liveItems.some(item => 'isStreaming' in item && item.isStreaming === true) + ); useEffect(() => { if (!resolvedSubagentSessionId || itemsProp !== undefined) { @@ -243,12 +255,15 @@ export const SubagentProjectionView: React.FC = ({ const session = state.sessions.get(resolvedSubagentSessionId); const ownerSessionId = parentSessionId ?? sessionId; + if (!session) { + // A child session fully missing from the store is treated as deleted; + // do not resurrect an empty shell that would open as a blank panel. + return; + } + const shouldEnsureSession = - !session || - ( - session.isHistorical && - (session.historyState === 'metadata-only' || session.historyState === 'failed') - ); + session.isHistorical && + (session.historyState === 'metadata-only' || session.historyState === 'failed'); if (!shouldEnsureSession) { return; @@ -327,7 +342,12 @@ export const SubagentProjectionView: React.FC = ({ const shouldRenderProjection = Boolean(resolvedSubagentSessionId) && - (items.length > 0 || projectionState?.isRunning === true || Boolean(runtimeStatus)); + // A confirmed-deleted subagent session must never render a projection + // shell (e.g. after the task-delete tool removed it via the event path). + !isSessionConfirmedDeleted(resolvedSubagentSessionId) && + (items.length > 0 + || projectionState?.executionStatus != null + || Boolean(runtimeStatus)); if (!shouldRenderProjection) { return null; @@ -359,6 +379,21 @@ export const SubagentProjectionView: React.FC = ({ item.id === lastVisibleItemId, ))} + {projectionState?.executionStatus && projectionState.executionStatus !== 'running' && ( +
+ {t(`subagent.status.${projectionState.executionStatus}`)} +
+ )} + {linkedSessionMissing && ( +
+ {t('subagent.deletedSession')} +
+ )}
diff --git a/src/web-ui/src/flow_chat/hooks/useThreadGoalController.ts b/src/web-ui/src/flow_chat/hooks/useThreadGoalController.ts index 7a964fe1b9..0b7cfbcf88 100644 --- a/src/web-ui/src/flow_chat/hooks/useThreadGoalController.ts +++ b/src/web-ui/src/flow_chat/hooks/useThreadGoalController.ts @@ -14,9 +14,11 @@ import { import { parseGoalCommand } from '../services/goalCommandParser'; import { fetchSessionThreadGoal, + fetchGoalChain, runGoalCommandSafely, runThreadGoalUiAction, saveThreadGoalObjective, + type GoalChainEntry, type ThreadGoalSnapshot, } from '../services/goalService'; @@ -24,6 +26,7 @@ const HISTORICAL_THREAD_GOAL_REFRESH_DELAY_MS = 350; export interface ThreadGoalController { goal: ThreadGoalSnapshot | null; + goalChain: GoalChainEntry[]; menuOpen: boolean; editOpen: boolean; editMode: 'create' | 'update'; @@ -43,6 +46,7 @@ export interface ThreadGoalController { saveEdit: (objective: string) => Promise; confirmResume: () => Promise; dismissResume: () => void; + loadGoalChain: () => Promise; } function readStoreGoal(sessionId: string | undefined): ThreadGoalSnapshot | null { @@ -122,6 +126,21 @@ export function useThreadGoalController( const goal = storeGoal; + const [goalChain, setGoalChain] = useState([]); + + const loadGoalChain = useCallback(async () => { + if (!session || isBtwSession) { + setGoalChain([]); + return; + } + try { + const chain = await fetchGoalChain(session); + setGoalChain(chain); + } catch { + // best-effort: keep whatever chain we had before + } + }, [session, isBtwSession]); + const titles = useMemo( () => ({ usageMessage: t('chatInput.goalUsage'), @@ -160,6 +179,16 @@ export function useThreadGoalController( void refreshGoal(); }, [session?.isHistorical, sessionId, isBtwSession, refreshGoal]); + useEffect(() => { + void loadGoalChain(); + }, [loadGoalChain]); + + // Reload goal chain when the store goal changes (e.g. after /goal set or edit). + useEffect(() => { + if (!sessionId || isBtwSession) return; + void loadGoalChain(); + }, [sessionId, isBtwSession, goal?.goalId, goal?.status, goal?.updatedAt, loadGoalChain]); + const goalId = goal?.goalId; const goalStatus = goal?.status; const goalUpdatedAt = goal?.updatedAt; @@ -310,6 +339,7 @@ export function useThreadGoalController( return useMemo( () => ({ goal, + goalChain, menuOpen, editOpen, editMode, @@ -328,6 +358,7 @@ export function useThreadGoalController( saveEdit, confirmResume, dismissResume, + loadGoalChain, }), [ availableActions, @@ -340,6 +371,8 @@ export function useThreadGoalController( editMode, editOpen, goal, + goalChain, + loadGoalChain, menuOpen, openEdit, openGoalEntry, diff --git a/src/web-ui/src/flow_chat/services/AgenticEventListener.ts b/src/web-ui/src/flow_chat/services/AgenticEventListener.ts index 7d358e1c48..d9f5db5523 100644 --- a/src/web-ui/src/flow_chat/services/AgenticEventListener.ts +++ b/src/web-ui/src/flow_chat/services/AgenticEventListener.ts @@ -13,6 +13,7 @@ import type { ToolEvent, AgenticEvent, SubagentSessionLinkedEvent, + SubagentTurnCompletedEvent, SessionTitleGeneratedEvent, SessionModelAutoMigratedEvent, SessionReasoningPresetAutoClearedEvent, @@ -24,6 +25,7 @@ import type { DeepReviewQueueStateChangedEvent, AcpContextUsageUpdatedEvent, OpenBuiltInBrowserEvent, + ThreadGoalUpdatedPayload, } from '@/infrastructure/api/service-api/AgentAPI'; import { createLogger } from '@/shared/utils/logger'; @@ -44,6 +46,7 @@ export interface AgenticEventCallbacks { onTextChunk?: (event: TextChunkEvent) => void; onToolEvent?: (event: ToolEvent) => void; onSubagentSessionLinked?: (event: SubagentSessionLinkedEvent) => void; + onSubagentTurnCompleted?: (event: SubagentTurnCompletedEvent) => void; onDeepReviewQueueStateChanged?: (event: DeepReviewQueueStateChangedEvent) => void; onDialogTurnCompleted?: (event: AgenticEvent) => void; onDialogTurnFailed?: (event: AgenticEvent) => void; @@ -53,7 +56,7 @@ export interface AgenticEventCallbacks { onContextCompressionStarted?: (event: AgenticEvent) => void; onContextCompressionCompleted?: (event: AgenticEvent) => void; onContextCompressionFailed?: (event: AgenticEvent) => void; - onThreadGoalUpdated?: (event: { sessionId: string; goal?: Record | null }) => void; + onThreadGoalUpdated?: (event: ThreadGoalUpdatedPayload) => void; onOpenBuiltInBrowser?: (event: OpenBuiltInBrowserEvent) => void; onSessionTitleGenerated?: (event: SessionTitleGeneratedEvent) => void; onSessionModelAutoMigrated?: (event: SessionModelAutoMigratedEvent) => void; @@ -70,8 +73,9 @@ export class AgenticEventListener { async startListening(callbacks: AgenticEventCallbacks): Promise { if (this.isListening) { - logger.warn('Event listener already running'); - return; + // UI-08: 重入时先卸载旧监听再注册新回调,避免多套监听叠加导致事件重复分发。 + logger.warn('Event listener already running; restarting with new callbacks'); + await this.stopListening(); } logger.info('Starting Agentic event listener'); @@ -172,6 +176,14 @@ export class AgenticEventListener { this.unlistenFunctions.push(unlisten); } + if (callbacks.onSubagentTurnCompleted) { + const unlisten = agentAPI.onSubagentTurnCompleted((event) => { + logger.debug('Subagent turn completed:', event); + callbacks.onSubagentTurnCompleted?.(event); + }); + this.unlistenFunctions.push(unlisten); + } + if (callbacks.onDeepReviewQueueStateChanged) { const unlisten = agentAPI.onDeepReviewQueueStateChanged((event) => { logger.debug('Deep Review queue state changed:', event); @@ -385,7 +397,7 @@ export class AgenticEventListener { break; case 'agentic://thread-goal-updated': callbacks.onThreadGoalUpdated?.( - payload as { sessionId: string; goal?: Record | null }, + payload as { sessionId: string; goal?: ThreadGoalUpdatedPayload['goal'] | null }, ); break; case 'agentic://open-built-in-browser': diff --git a/src/web-ui/src/flow_chat/services/FlowChatManager.ts b/src/web-ui/src/flow_chat/services/FlowChatManager.ts index 5c46ba7e86..d7ca5880c9 100644 --- a/src/web-ui/src/flow_chat/services/FlowChatManager.ts +++ b/src/web-ui/src/flow_chat/services/FlowChatManager.ts @@ -489,7 +489,7 @@ export class FlowChatManager { }, undefined, response.sessionName, - 128128, + 1048576, response.agentType, workspacePath, config.remoteConnectionId, @@ -795,6 +795,7 @@ export class FlowChatManager { id: todo.id, content: todo.content, status: todo.status, + dependencies: todo.dependencies, })); if (result.merge) { @@ -841,6 +842,7 @@ export class FlowChatManager { id: todo.id, content: todo.content, status: todo.status, + dependencies: todo.dependencies, })); if (context) { diff --git a/src/web-ui/src/flow_chat/services/btwSessionPane.ts b/src/web-ui/src/flow_chat/services/btwSessionPane.ts index 5634e6ba7d..e8f7ea98bb 100644 --- a/src/web-ui/src/flow_chat/services/btwSessionPane.ts +++ b/src/web-ui/src/flow_chat/services/btwSessionPane.ts @@ -1,14 +1,17 @@ import { i18nService } from '@/infrastructure/i18n'; import { createTab } from '@/shared/utils/tabUtils'; +import { createLogger } from '@/shared/utils/logger'; import type { PanelContent } from '@/app/components/panels/base/types'; import { useAgentCanvasStore } from '@/app/components/panels/content-canvas/stores'; import type { CanvasTab } from '@/app/components/panels/content-canvas/types'; -import { flowChatStore } from '../store/FlowChatStore'; -import { resolveSessionTitle } from '../utils/sessionTitle'; +import type { Session } from '../types/flow-chat'; +import { flowChatStore, isSessionConfirmedDeleted } from '../store/FlowChatStore'; import { flowChatManager } from './FlowChatManager'; export const BTW_SESSION_PANEL_TYPE = 'btw-session' as const; +const log = createLogger('btwSessionPane'); + export type BtwSessionViewKind = 'review-check'; export interface BtwSessionPanelData { @@ -51,13 +54,123 @@ type AgentCanvasState = ReturnType; export const getBtwSessionDuplicateKey = (childSessionId: string) => `btw-session-${childSessionId}`; +const BTW_PLACEHOLDER_TITLE_TEXT_KEYS = ['flow-chat:btw.threadLabel', 'flow-chat:btw.deletedThreadLabel'] as const; + +/** Timeout guard that stops watching a tab title if no real title ever arrives. */ +const BTW_TAB_TITLE_REFRESH_TIMEOUT_MS = 5 * 60 * 1000; + +const isBtwPlaceholderTitleText = (title: string | null | undefined): boolean => + Boolean( + title?.trim() && + BTW_PLACEHOLDER_TITLE_TEXT_KEYS.some(key => title.trim() === i18nService.t(key)), + ); + +/** + * Resolve the child session's real title, ignoring generic placeholder titles + * (for example a freshly created shell that has not been hydrated yet). + */ +const resolveBtwSessionTitleText = (session: Session | undefined): string | null => { + if (!session) { + return null; + } + const rawTitle = + session.titleSource === 'i18n' && session.titleI18nKey + ? i18nService.t(session.titleI18nKey, session.titleI18nParams) + : session.title; + const title = typeof rawTitle === 'string' ? rawTitle.trim() : ''; + if (!title || isBtwPlaceholderTitleText(title)) { + return null; + } + return title; +}; + const resolveBtwSessionTitle = (childSessionId: string): string => { const session = flowChatStore.getState().sessions.get(childSessionId); - const title = session - ? resolveSessionTitle(session, (key, options) => i18nService.t(key, options)) - : undefined; - if (title) return title; - return i18nService.t('flow-chat:btw.threadLabel'); + if (!session) { + return i18nService.t('flow-chat:btw.deletedThreadLabel'); + } + return resolveBtwSessionTitleText(session) || i18nService.t('flow-chat:btw.threadLabel'); +}; + +const activeTabTitleWatchers = new Set(); + +/** + * Keeps a btw-session tab title in sync with the child session: once the real + * session name arrives (history hydration metadata or title generation) the + * generic placeholder title is replaced, and a session that disappears before + * any real title arrived is marked as deleted. Explicit display titles win and + * are never overwritten (callers only subscribe when the title is a + * placeholder). + */ +const subscribeBtwSessionTabTitleRefresh = (params: { + duplicateCheckKey: string; + childSessionId: string; +}): void => { + const resolveRealTitle = (): string | null => { + const session = flowChatStore.getState().sessions.get(params.childSessionId); + return session ? resolveBtwSessionTitleText(session) : null; + }; + if (resolveRealTitle() || activeTabTitleWatchers.has(params.duplicateCheckKey)) { + return; + } + + let disposed = false; + let unsubscribe: (() => void) | null = null; + const cleanupTimer: { current?: ReturnType } = {}; + const dispose = (): void => { + if (disposed) { + return; + } + disposed = true; + activeTabTitleWatchers.delete(params.duplicateCheckKey); + if (cleanupTimer.current !== undefined) { + clearTimeout(cleanupTimer.current); + } + unsubscribe?.(); + }; + + activeTabTitleWatchers.add(params.duplicateCheckKey); + unsubscribe = flowChatStore.subscribe(() => { + if (disposed) { + return; + } + const canvasStore = useAgentCanvasStore.getState(); + const existing = canvasStore.findTabByMetadata({ duplicateCheckKey: params.duplicateCheckKey }); + if (!existing || !isBtwPlaceholderTitleText(existing.tab.title)) { + return; + } + + const session = flowChatStore.getState().sessions.get(params.childSessionId); + if (!session) { + // Session disappeared before a real title arrived; mark as deleted. + const deletedTitle = i18nService.t('flow-chat:btw.deletedThreadLabel'); + if (existing.tab.title !== deletedTitle) { + canvasStore.updateTabContent(existing.tab.id, existing.groupId, { + ...existing.tab.content, + title: deletedTitle, + }); + } + return; + } + + const realTitle = resolveRealTitle(); + if (!realTitle) { + return; + } + dispose(); + if (existing.tab.title !== realTitle) { + const content = existing.tab.content; + const data = content.data && typeof content.data === 'object' + ? { ...content.data, displayTitle: undefined } + : content.data; + canvasStore.updateTabContent(existing.tab.id, existing.groupId, { + ...content, + title: realTitle, + data, + }); + } + }); + cleanupTimer.current = setTimeout(dispose, BTW_TAB_TITLE_REFRESH_TIMEOUT_MS); }; const scheduleFrame = (callback: FrameRequestCallback): void => { @@ -156,14 +269,38 @@ export async function loadBtwSessionHistory(params: LoadBtwSessionHistoryParams) remoteSshHost: params.remoteSshHost, } : undefined; - if (location) { - await flowChatManager.hydrateSessionHistoryForDetail(params.childSessionId, location); - } else { - await flowChatManager.hydrateSessionHistoryForDetail(params.childSessionId); + const hydrate = (): Promise => { + if (location) { + return flowChatManager.hydrateSessionHistoryForDetail(params.childSessionId, location); + } + return flowChatManager.hydrateSessionHistoryForDetail(params.childSessionId); + }; + try { + await hydrate(); + } catch (error) { + // Automatic retry with the same parameters. If the second attempt also + // fails the error propagates (the store marks historyState 'failed'), so + // the panel can surface a visible retry entry instead of a silent empty + // conversation. + log.warn('Session history hydration failed, retrying once', { + childSessionId: params.childSessionId, + error, + }); + await hydrate(); } } export function ensureBtwSessionAvailable(params: EnsureBtwSessionAvailableParams): void { + // A session whose deletion was confirmed must not be re-created as a + // placeholder shell (nor hydrated) when its panel is requested again; the + // panel already renders the deleted-thread placeholder title. + if (isSessionConfirmedDeleted(params.childSessionId)) { + log.warn('ensureBtwSessionAvailable: ignoring confirmed deleted session', { + childSessionId: params.childSessionId, + }); + return; + } + const existingSession = flowChatStore.getState().sessions.get(params.childSessionId); const parentSession = flowChatStore.getState().sessions.get(params.parentSessionId); const resolvedWorkspacePath = params.workspacePath || parentSession?.workspacePath; @@ -187,7 +324,7 @@ export function ensureBtwSessionAvailable(params: EnsureBtwSessionAvailableParam if (!existingSession) { flowChatStore.addExternalSession( params.childSessionId, - params.sessionTitle || resolveBtwSessionTitle(params.childSessionId), + params.sessionTitle || i18nService.t('flow-chat:btw.threadLabel'), params.agentType || parentSession?.mode || 'agentic', resolvedWorkspacePath, { @@ -210,13 +347,21 @@ export function ensureBtwSessionAvailable(params: EnsureBtwSessionAvailableParam !sessionToHydrate.config?.modelName && !hasLoadedDialogTurns ); + // Relaxed: hydrate whenever a session exists with empty content and has not + // reached a renderable ('ready') or in-flight ('hydrating') state, so + // event-created placeholder shells (e.g. subagents) load automatically. + // 'failed' stays eligible here (open-panel retry); the panel itself leaves + // 'failed' for manual retry to avoid looping. + const sessionHasEmptyUnreadyContent = Boolean( + sessionToHydrate && + !hasLoadedDialogTurns && + sessionToHydrate.historyState !== 'ready' && + sessionToHydrate.historyState !== 'hydrating' + ); const shouldHydrate = !existingSession || shouldHydrateMissingSubagentModel || - Boolean( - sessionToHydrate?.isHistorical && - (sessionToHydrate.historyState === 'metadata-only' || sessionToHydrate.historyState === 'failed') - ); + sessionHasEmptyUnreadyContent; const workspacePath = resolvedWorkspacePath || sessionToHydrate?.workspacePath; if (!shouldHydrate || !workspacePath) { @@ -232,7 +377,14 @@ export function ensureBtwSessionAvailable(params: EnsureBtwSessionAvailableParam remoteSshHost: resolvedRemoteSshHost, } : {}), - }).catch(() => undefined); + }).catch(error => { + // Surface hydration failures in logs; the session panel also shows a + // visible retry entry once historyState becomes 'failed'. + log.warn('Failed to hydrate btw session history', { + childSessionId: params.childSessionId, + error, + }); + }); } export function openBtwSessionInAuxPane(params: { @@ -250,8 +402,9 @@ export function openBtwSessionInAuxPane(params: { includeInternal?: boolean; viewKind?: BtwSessionViewKind; }): void { - ensureBtwSessionAvailable(params); - + // Resolve the panel title before ensureBtwSessionAvailable may create an + // on-demand shell, so a missing (deleted) child session gets the deleted + // placeholder instead of the generic thread label. const content = buildBtwSessionPanelContent( params.childSessionId, params.parentSessionId, @@ -260,6 +413,8 @@ export function openBtwSessionInAuxPane(params: { params.sessionTitle, ); + ensureBtwSessionAvailable(params); + const duplicateCheckKey = content.metadata?.duplicateCheckKey; const canvasStore = useAgentCanvasStore.getState(); if (duplicateCheckKey) { @@ -271,6 +426,12 @@ export function openBtwSessionInAuxPane(params: { canvasStore.updateTabContent(existing.tab.id, existing.groupId, content); canvasStore.switchToTab(existing.tab.id, existing.groupId); clearSessionUnreadCompletionAfterRender(params.childSessionId); + if (!params.sessionTitle?.trim() || isBtwPlaceholderTitleText(params.sessionTitle)) { + subscribeBtwSessionTabTitleRefresh({ + duplicateCheckKey, + childSessionId: params.childSessionId, + }); + } return; } } @@ -289,6 +450,14 @@ export function openBtwSessionInAuxPane(params: { replaceExisting: false, mode: 'agent', }); + if (duplicateCheckKey) { + if (!params.sessionTitle?.trim() || isBtwPlaceholderTitleText(params.sessionTitle)) { + subscribeBtwSessionTabTitleRefresh({ + duplicateCheckKey, + childSessionId: params.childSessionId, + }); + } + } clearSessionUnreadCompletionAfterRender(params.childSessionId); } diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts index 5462ae2c1f..d4cf5505b8 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts @@ -9,7 +9,7 @@ import { } from './EventHandlerModule'; import { stateMachineManager } from '../../state-machine'; import { SessionExecutionEvent, SessionExecutionState } from '../../state-machine/types'; -import { FlowChatStore } from '../../store/FlowChatStore'; +import { FlowChatStore, isSessionConfirmedDeleted, markSessionsConfirmedDeleted } from '../../store/FlowChatStore'; import { notificationService } from '../../../shared/notification-system/services/NotificationService'; import type { DialogTurn, FlowToolItem, FlowUserSteeringItem, ModelRound, Session } from '../../types/flow-chat'; import type { FlowChatContext } from './types'; @@ -347,6 +347,66 @@ describe('subagent parent helpers', () => { ).toBe('Authentication boundary'); }); + it('does not resurrect a confirmed-deleted child session on SubagentSessionLinked', () => { + const task = makeTaskTool('task-deleted'); + FlowChatStore.getInstance().setState(() => ({ + sessions: new Map([[ + 'parent-session', + { + sessionId: 'parent-session', + title: 'Parent Session', + dialogTurns: [{ + id: 'parent-turn', + sessionId: 'parent-session', + userMessage: { id: 'user-1', content: 'Run', timestamp: 900 }, + modelRounds: [makeRound('round-1', [task])], + status: 'processing', + startTime: 900, + }], + status: 'idle', + config: { agentType: 'agentic' }, + createdAt: 800, + lastActiveAt: 1000, + error: null, + sessionKind: 'normal', + workspacePath: 'D:\\workspace\\repo', + } as Session, + ]]), + activeSessionId: 'parent-session', + })); + markSessionsConfirmedDeleted(['deleted-child-ghost']); + + __test_only__.handleSubagentSessionLinked( + { currentWorkspacePath: 'D:\\workspace\\repo' } as FlowChatContext, + { + sessionId: 'deleted-child-ghost', + parentSessionId: 'parent-session', + parentDialogTurnId: 'parent-turn', + parentToolCallId: 'task-deleted', + agentType: 'Executor', + }, + ); + + expect( + FlowChatStore.getInstance().getState().sessions.has('deleted-child-ghost'), + ).toBe(false); + }); + + it('does not create a placeholder shell for a confirmed-deleted session on DialogTurnStarted', () => { + markSessionsConfirmedDeleted(['deleted-turn-ghost']); + + __test_only__.handleDialogTurnStarted(createFlowChatContext(), { + sessionId: 'deleted-turn-ghost', + turnId: 'ghost-turn-1', + turnIndex: 0, + userInput: 'Ghost input', + userMessageMetadata: { kind: 'user_dialog' }, + }); + + expect(FlowChatStore.getInstance().getState().sessions.has('deleted-turn-ghost')) + .toBe(false); + }); + it('stores an absolute parent Turn index when linking from a partial restored tail', () => { const task = makeTaskTool('task-tail'); FlowChatStore.getInstance().setState(() => ({ @@ -858,6 +918,7 @@ function createFlowChatContext(): FlowChatContext { getBufferSize: vi.fn(() => 0), flushNow: vi.fn(), clear: vi.fn(), + add: vi.fn(), } as any, pendingTurnCompletions: new Map(), pendingHistoryLoads: new Map(), @@ -1131,6 +1192,110 @@ describe('handleDialogTurnComplete', () => { }); }); +describe('handleTextChunk', () => { + beforeEach(() => { + resetFlowChatStore(); + stateMachineManager.clear(); + }); + + afterEach(() => { + resetFlowChatStore(); + stateMachineManager.clear(); + }); + + it('creates an ACP flow session placeholder when a text chunk arrives for an unknown acp_ flow session', () => { + const sessionId = 'acp_codebuddy_7f0e1a2b-3c4d-4e5f-8a9b-0c1d2e3f4a5b'; + const turnId = 'turn-1'; + const context = createFlowChatContext(); + + __test_only__.handleTextChunk(context, { + sessionId, + turnId, + roundId: 'round-1', + text: 'hello from ACP', + } as any); + + const session = FlowChatStore.getInstance().getState().sessions.get(sessionId); + expect(session).toBeDefined(); + expect(session?.config?.agentType).toBe('acp:codebuddy'); + }); + + it('keeps dropping text chunks for a non-ACP session missing from the store', () => { + const sessionId = 'regular-session'; + const turnId = 'turn-1'; + const context = createFlowChatContext(); + + __test_only__.handleTextChunk(context, { + sessionId, + turnId, + roundId: 'round-1', + text: 'hello', + } as any); + + expect(FlowChatStore.getInstance().getState().sessions.has(sessionId)).toBe(false); + }); +}); + +describe('handleSessionDeleted', () => { + beforeEach(() => { + resetFlowChatStore(); + }); + + afterEach(() => { + resetFlowChatStore(); + }); + + it('marks the session confirmed-deleted even when the store cascade is empty', () => { + // The store never loaded the session (e.g. it was deleted while the tab + // was closed), so the cascade is empty. The id must still be recorded so + // a later refresh cannot resurrect it from residual disk metadata. + const sessionId = 'ghost-deleted-1'; + expect(isSessionConfirmedDeleted(sessionId)).toBe(false); + + __test_only__.handleSessionDeleted(createFlowChatContext(), { sessionId }); + + expect(isSessionConfirmedDeleted(sessionId)).toBe(true); + }); + + it('marks every cascade member and removes the sessions from the store', () => { + const parentId = 'parent-deleted-1'; + const childId = 'child-deleted-1'; + FlowChatStore.getInstance().setState(() => ({ + sessions: new Map([ + [parentId, { + sessionId: parentId, + title: 'Parent', + dialogTurns: [], + status: 'idle', + config: { agentType: 'agentic' }, + createdAt: 800, + lastActiveAt: 1000, + error: null, + } as Session], + [childId, { + sessionId: childId, + title: 'Child', + parentSessionId: parentId, + dialogTurns: [], + status: 'idle', + config: { agentType: 'agentic' }, + createdAt: 900, + lastActiveAt: 1000, + error: null, + } as Session], + ]), + activeSessionId: null, + })); + + __test_only__.handleSessionDeleted(createFlowChatContext(), { sessionId: parentId }); + + expect(isSessionConfirmedDeleted(parentId)).toBe(true); + expect(isSessionConfirmedDeleted(childId)).toBe(true); + expect(FlowChatStore.getInstance().getState().sessions.has(parentId)).toBe(false); + expect(FlowChatStore.getInstance().getState().sessions.has(childId)).toBe(false); + }); +}); + describe('handleCompressionCompleted', () => { beforeEach(() => { vi.restoreAllMocks(); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts index f39592b038..35dcf683b4 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts @@ -3,7 +3,7 @@ * Initializes event listeners and handles various Agentic events */ -import { FlowChatStore, mergeModelRoundAttemptDiagnostics } from '../../store/FlowChatStore'; +import { FlowChatStore, isSessionConfirmedDeleted, markSessionsConfirmedDeleted, mergeModelRoundAttemptDiagnostics } from '../../store/FlowChatStore'; import { stateMachineManager } from '../../state-machine'; import { SessionExecutionEvent, SessionExecutionState } from '../../state-machine/types'; import { agenticEventListener, type AgenticEventCallbacks } from '../AgenticEventListener'; @@ -26,6 +26,7 @@ import { resolveThreadGoalUserMessageDisplay } from '../../utils/threadGoalDispl import { cleanRemoteUserInput } from '../../utils/userInputText'; import { effectiveToolInvocation, getEffectiveToolName } from '../../utils/toolInvocationIdentity'; import { absoluteSessionTurnIndexForId } from '../../utils/flowChatTurnOrdinal'; +import { isAcpFlowSession } from '../../utils/acpSession'; import type { DeepReviewQueueStateChangedEvent, ImageAnalysisEvent, @@ -37,6 +38,7 @@ import type { SessionModelAutoMigratedEvent, SessionReasoningPresetAutoClearedEvent, SubagentSessionLinkedEvent, + SubagentTurnCompletedEvent, } from '@/infrastructure/api/service-api/AgentAPI'; import { MCPAPI } from '@/infrastructure/api/service-api/MCPAPI'; import { ACPClientAPI, type AcpPermissionRequestEvent } from '@/infrastructure/api/service-api/ACPClientAPI'; @@ -52,6 +54,9 @@ import { useBackgroundCommandActivityStore } from '../../store/backgroundCommand import { useBackgroundSubagentActivityStore } from '../../store/backgroundSubagentActivityStore'; import { createTab } from '@/shared/utils/tabUtils'; import { splitFilePathAndContent } from '@/shared/utils/partialJsonParser'; +import { systemAPI } from '@/infrastructure/api/service-api/SystemAPI'; +import { configManager } from '@/infrastructure/config/services/ConfigManager'; +import { i18nService } from '@/infrastructure/i18n'; const pendingImageAnalysisTurns = new Map(); import { @@ -72,12 +77,14 @@ import { processToolProgressInternal, handleToolExecutionProgress, handleToolTerminalReady, + cleanupPendingTerminalSessionIdsForTurn, } from './ToolEventModule'; import { handleAcpPermissionRequestForToolCard } from './AcpPermissionToolCardModule'; import { clearRuntimeStatus, scheduleModelResponseStatus, } from './RuntimeStatusModule'; +import { clearRuntimeStatusState } from '../../store/runtimeStatusStore'; import { requestPeerSessionRefresh } from './PeerSessionRefreshModule'; import { isPeerDeviceModeActive } from '@/infrastructure/peer-device/peerModeFlag'; import { @@ -88,6 +95,11 @@ import { const log = createLogger('EventHandlerModule'); const TURN_COMPLETION_QUIET_WINDOW_MS = 500; +const SUBAGENT_NOTIFY_BODY_MAX_LENGTH = 200; +const SUBAGENT_NOTIFY_SESSION_DEBOUNCE_MS = 3000; +const SUBAGENT_NOTIFY_GLOBAL_THROTTLE_MS = 2000; +const lastSubagentNotifyAt = new Map(); +let lastSubagentGlobalNotifyAt = 0; interface MCPInteractionRequestEvent { interactionId: string; @@ -163,6 +175,8 @@ export const __test_only__ = { handleDialogTurnFailed, handleSubagentSessionLinked, handleModelRoundStart, + handleSessionDeleted, + handleTextChunk, handleTokenUsageUpdate, handleCompressionCompleted, }; @@ -240,6 +254,77 @@ function recoverIdleLatestTurnDataEvent( return true; } +/** + * UI-04: ACP 直连投递会话在非流式状态(IDLE/ERROR)下收到更晚 turn 的数据事件时, + * 对齐 recoverIdleLatestTurnDataEvent 语义:更新 currentDialogTurnId 并(IDLE 时) + * START,使后续流式事件不再因 state_not_accepting_data / turn_id_mismatch 被丢弃。 + * 放宽点:recoverIdleLatestTurnDataEvent 要求 currentDialogTurnId 为空且 turn 为 + * 会话最后一个 turn;ACP 会话允许 currentDialogTurnId 已有值但目标 turn 更新。 + */ +function recoverAcpIdleTurnForDataEvent( + sessionId: string, + turnId: string, + currentState: SessionExecutionState, + currentDialogTurnId: string | null, +): boolean { + if (isStreamingExecutionState(currentState)) { + return false; + } + + const store = FlowChatStore.getInstance(); + const session = store.getState().sessions.get(sessionId); + if (!session || !isAcpFlowSession(session)) { + return false; + } + + const turnIndex = session.dialogTurns.findIndex((turn: DialogTurn) => turn.id === turnId); + if (turnIndex < 0) { + return false; + } + if (currentDialogTurnId) { + const currentIndex = session.dialogTurns.findIndex( + (turn: DialogTurn) => turn.id === currentDialogTurnId, + ); + if (currentIndex < 0 || turnIndex <= currentIndex) { + return false; + } + } + + const machine = stateMachineManager.get(sessionId); + const machineContext = machine?.getContext(); + if (machineContext) { + machineContext.currentDialogTurnId = turnId; + } + if (machine?.getCurrentState() === SessionExecutionState.IDLE) { + void stateMachineManager + .transition(sessionId, SessionExecutionEvent.START, { + taskId: sessionId, + dialogTurnId: turnId, + }) + .catch(error => { + log.error('State machine transition failed while recovering ACP idle data event', { + sessionId, + turnId, + error, + }); + }); + } + + log.info('ACP idle turn recovered', { + sessionId, + turnId, + prevState: currentState, + }); + + log.debug('Recovered ACP data event after non-streaming state', { + sessionId, + turnId, + eventName: 'data', + currentState, + }); + return true; +} + function handleDeepReviewQueueStateChanged(event: DeepReviewQueueStateChangedEvent): void { const store = FlowChatStore.getInstance(); const session = store.getState().sessions.get(event.sessionId); @@ -431,6 +516,16 @@ function ensureSubagentSession( return; } + // A session whose deletion was confirmed on the backend must not be + // resurrected as a placeholder shell by stale in-flight events. + if (isSessionConfirmedDeleted(subagentSessionId)) { + log.warn('SubagentSessionLinked: ignoring event for confirmed deleted session', { + subagentSessionId, + parentSessionId: parentInfo.sessionId, + }); + return; + } + const parentSession = store.getState().sessions.get(parentInfo.sessionId); const parentTurnIndex = parentSession ? absoluteSessionTurnIndexForId(parentSession, parentInfo.dialogTurnId) @@ -538,6 +633,148 @@ function handleSubagentSessionLinked( reconcileBackgroundSubagentSession(childSessionId); } +function resolveSubagentCompletionKind( + status: string | undefined, +): 'completed' | 'error' | 'interrupted' { + switch (status) { + case 'failed': + return 'error'; + case 'cancelled': + case 'partial_timeout': + return 'interrupted'; + default: + return 'completed'; + } +} + +function resolveSubagentNotifyTitle(sessionId: string, agentType: string | undefined): string { + const session = FlowChatStore.getInstance().getState().sessions.get(sessionId); + const sessionTitle = session?.title?.trim(); + if (sessionTitle) { + return sessionTitle; + } + return agentType?.trim() || sessionId; +} + +function compactTextForNotification(text: string, maxLength: number): string { + const compact = text.replace(/\s+/g, ' ').trim(); + if (compact.length <= maxLength) { + return compact; + } + return `${compact.slice(0, maxLength).trimEnd()}...`; +} + +async function notifySubagentTurnCompleted( + childSessionId: string, + parentSessionId: string, + agentType: string | undefined, + outputText: string | undefined, + status: string | undefined, +): Promise { + const now = Date.now(); + + // Debounce repeated completion events for the same subagent, and throttle + // bursts when several subagents finish around the same time. + const lastForSession = lastSubagentNotifyAt.get(childSessionId) ?? 0; + if (now - lastForSession < SUBAGENT_NOTIFY_SESSION_DEBOUNCE_MS) { + return; + } + if (now - lastSubagentGlobalNotifyAt < SUBAGENT_NOTIFY_GLOBAL_THROTTLE_MS) { + return; + } + lastSubagentNotifyAt.set(childSessionId, now); + lastSubagentGlobalNotifyAt = now; + + // Only notify when the parent conversation is not being watched right now. + const activeSessionId = FlowChatStore.getInstance().getState().activeSessionId; + if (activeSessionId === parentSessionId && isAppWindowFocused()) { + return; + } + + let notificationsEnabled = true; + try { + notificationsEnabled = await configManager.getConfig( + 'app.notifications.dialog_completion_notify', + ); + } catch (error) { + log.warn('Failed to read dialog_completion_notify config', error); + } + if (notificationsEnabled === false) { + return; + } + + const completionKind = resolveSubagentCompletionKind(status); + const trimmedOutput = outputText?.trim(); + const body = trimmedOutput + ? compactTextForNotification(trimmedOutput, SUBAGENT_NOTIFY_BODY_MAX_LENGTH) + : i18nService.t(`flow-chat:subagent.${completionKind}Notification`); + + await systemAPI.sendSystemNotification( + resolveSubagentNotifyTitle(childSessionId, agentType), + body, + ); +} + +function handleSubagentTurnCompleted( + context: FlowChatContext, + event: SubagentTurnCompletedEvent, +): void { + const childSessionId = event?.sessionId ?? (event as any)?.childSessionId; + const parentSessionId = event?.parentSessionId ?? (event as any)?.parent_session_id; + const parentDialogTurnId = + event?.parentDialogTurnId ?? (event as any)?.parent_dialog_turn_id; + const parentToolCallId = event?.parentToolCallId ?? (event as any)?.parent_tool_call_id; + const subagentDialogTurnId = + event?.subagentDialogTurnId ?? (event as any)?.subagent_dialog_turn_id; + const modelId = event?.modelId ?? (event as any)?.model_id; + const effectiveModelName = event?.effectiveModelName ?? (event as any)?.effective_model_name; + + if (childSessionId && parentSessionId && parentDialogTurnId && parentToolCallId) { + const parentInfo: SubagentParentInfo = { + sessionId: parentSessionId, + dialogTurnId: parentDialogTurnId, + toolCallId: parentToolCallId, + }; + attachSubagentSessionToParentTool(parentInfo, childSessionId, subagentDialogTurnId); + if (typeof modelId === 'string' && modelId.trim()) { + FlowChatStore.getInstance().updateSessionModelName(childSessionId, modelId.trim()); + } + } + + if (subagentDialogTurnId && parentSessionId && parentDialogTurnId && parentToolCallId) { + updateSubagentParentTaskModel( + context, + { + sessionId: parentSessionId, + dialogTurnId: parentDialogTurnId, + toolCallId: parentToolCallId, + }, + typeof modelId === 'string' && modelId.trim() ? modelId.trim() : undefined, + typeof effectiveModelName === 'string' && effectiveModelName.trim() + ? effectiveModelName.trim() + : '', + ); + } + + reconcileBackgroundSubagentSession(childSessionId); + + if (childSessionId && parentSessionId) { + const status = event?.status; + const outputText = event?.outputText ?? (event as any)?.output_text; + FlowChatStore.getInstance().markSessionUnreadCompletion( + childSessionId, + resolveSubagentCompletionKind(status), + ); + void notifySubagentTurnCompleted( + childSessionId, + parentSessionId, + event?.agentType ?? (event as any)?.agent_type, + outputText, + status, + ); + } +} + function getLinkedSubagentParentInfo(sessionId: string): SubagentParentInfo | undefined { const session = FlowChatStore.getInstance().getState().sessions.get(sessionId); if ( @@ -614,6 +851,83 @@ function updateSubagentParentTaskModel( debouncedSaveDialogTurn(context, parentInfo.sessionId, parentInfo.dialogTurnId, 800); } +/** + * UI-04: ACP 流会话 id 形状判定(对齐 session_message_tool.rs 的 + * acp_flow_client_id_from_session_id + looks_like_uuid): + * `acp__`,尾部段为 8-4-4-4-12 的 UUID 形状,client_id 非空。 + * 命中时返回 `acp:`,否则返回 null。占位会话据此带 agentType, + * 使 isAcpFlowSession / ensureDialogTurnForAcpDataEvent 对 ACP 委派回复生效。 + */ +const ACP_FLOW_SESSION_ID_UUID_RE = + /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; + +function acpAgentTypeFromFlowSessionId(sessionId: string): string | null { + if (!sessionId.startsWith('acp_')) { + return null; + } + const rest = sessionId.slice('acp_'.length); + const lastSeparatorIndex = rest.lastIndexOf('_'); + if (lastSeparatorIndex <= 0) { + return null; + } + const clientId = rest.slice(0, lastSeparatorIndex); + const uuidSegment = rest.slice(lastSeparatorIndex + 1); + if (!clientId || !ACP_FLOW_SESSION_ID_UUID_RE.test(uuidSegment)) { + return null; + } + return `acp:${clientId}`; +} + +/** + * UI-04: ACP 直连投递会话可能完全没有 dialog-turn-started / 状态机。 + * text-chunk / tool-event 到达时惰性建 turn 并补齐状态机,避免数据事件被丢弃。 + */ +function ensureDialogTurnForAcpDataEvent(sessionId: string, turnId: string): boolean { + if (!sessionId || !turnId) { + return false; + } + + const store = FlowChatStore.getInstance(); + const session = store.getState().sessions.get(sessionId); + if (!session || !isAcpFlowSession(session)) { + return false; + } + + const existing = session.dialogTurns.find(turn => turn.id === turnId); + if (!existing) { + const lazyTurn: DialogTurn = { + id: turnId, + sessionId, + kind: 'user_dialog', + userMessage: { + id: `user_acp_lazy_${Date.now()}`, + content: '', + timestamp: Date.now(), + }, + modelRounds: [], + status: 'pending', + startTime: Date.now(), + }; + store.addDialogTurn(sessionId, lazyTurn); + } + + const machine = stateMachineManager.getOrCreate(sessionId); + const ctx = machine.getContext(); + if (ctx.currentDialogTurnId !== turnId) { + ctx.currentDialogTurnId = turnId; + } + if (machine.getCurrentState() === SessionExecutionState.IDLE) { + void stateMachineManager.transition(sessionId, SessionExecutionEvent.START, { + taskId: sessionId, + dialogTurnId: turnId, + }).catch(error => { + log.error('State machine transition failed on lazy ACP turn start', { sessionId, error }); + }); + } + + return true; +} + /** * Event filtering mechanism: determines if an event should be processed */ @@ -630,6 +944,15 @@ export function shouldProcessEvent( const machine = stateMachineManager.get(sessionId); if (!machine) { if (eventType === 'data') { + // UI-04: ACP 直连投递会话在状态机缺失时,text-chunk / tool-event 到达先惰性建 + // turn(含状态机),放行处理;其余场景维持丢弃并记录。 + if ( + (eventName === 'TextChunk' || eventName === 'ToolEvent') && + turnId && + ensureDialogTurnForAcpDataEvent(sessionId, turnId) + ) { + return true; + } logDroppedDataEvent(eventName, sessionId, turnId, { reason: 'missing_state_machine' }); } return false; @@ -656,6 +979,16 @@ export function shouldProcessEvent( return true; } + // UI-04: ACP 直连投递会话在非流式状态(IDLE/ERROR)收到更晚 turn 的数据事件时, + // 对齐 recoverIdleLatestTurnDataEvent 语义恢复(更新 currentDialogTurnId + START), + // 使 ACP 委派回复在状态机就绪但 turn 已推进的场景不被丢弃。 + if ( + turnId && + recoverAcpIdleTurnForDataEvent(sessionId, turnId, currentState, context.currentDialogTurnId) + ) { + return true; + } + logDroppedDataEvent(eventName, sessionId, turnId, { reason: 'state_not_accepting_data', currentState, @@ -821,6 +1154,9 @@ export async function initializeEventListeners( onSessionModelAutoMigrated: (event) => { handleSessionModelAutoMigrated(event); }, + onSubagentTurnCompleted: (event) => { + handleSubagentTurnCompleted(context, event); + }, onSessionReasoningPresetAutoCleared: (event) => { handleSessionReasoningPresetAutoCleared(event); }, @@ -924,6 +1260,14 @@ function handleSessionCreated(context: FlowChatContext, event: any): void { const remoteConnectionId = extractEventRemoteConnectionId(event); const remoteSshHost = extractEventRemoteSshHost(event); + // Subagent relationship fields are optional: the backend is adding them to + // the session-created payload; until they arrive they degrade to undefined + // and the session is treated as a normal external session. + const parentSessionId = + (typeof event.parentSessionId === 'string' && event.parentSessionId) || undefined; + const subagentType = + (typeof event.subagentType === 'string' && event.subagentType) || undefined; + if (existing) return; store.addExternalSession( @@ -935,6 +1279,9 @@ function handleSessionCreated(context: FlowChatContext, event: any): void { projectWorkspacePath, executionTarget, workspaceId, + sessionKind: parentSessionId ? 'subagent' : undefined, + parentSessionId, + subagentType, }, remoteConnectionId, remoteSshHost @@ -1337,10 +1684,10 @@ function handleUserSteeringInjected(_context: FlowChatContext, event: any): void */ function handleSessionDeleted(context: FlowChatContext, event: any): void { const { sessionId } = event; - + const store = FlowChatStore.getInstance(); const removedSessionIds = store.getCascadeSessionIds(sessionId); - if (removedSessionIds.length === 0) return; + if (!sessionId) return; log.info('Remote session deleted', { sessionId }); removedSessionIds.forEach(id => { @@ -1350,8 +1697,30 @@ function handleSessionDeleted(context: FlowChatContext, event: any): void { context.processingManager.clearSessionStatus(id); cleanupSaveState(context, id); cleanupSessionBuffers(context, id); + // Drop transient runtime wait status so a stale event cannot re-render a + // deleted subagent's projection shell (same guard as the UI delete path). + clearRuntimeStatusState({ sessionId: id }); }); + // Backend-confirmed deletions must never be resurrected by stale events + // (same guard as the frontend UI delete path in FlowChatStore). Mark + // unconditionally: when the cascade is empty (the store never loaded the + // session, e.g. it was deleted while the tab was closed) the id must still + // be recorded so a later refresh cannot resurrect it from residual disk + // metadata or the backend deletion tombstone. + markSessionsConfirmedDeleted( + removedSessionIds.length > 0 ? removedSessionIds : [sessionId] + ); store.removeSession(sessionId); + + // Close any open btw-session panel tabs for the deleted sessions so the + // deleted thread placeholder does not linger in the canvas. Dynamic import + // keeps the module graph acyclic (btwSessionPane -> FlowChatManager -> index + // -> EventHandlerModule). + void import('../../services/btwSessionPane').then(({ closeBtwSessionInAuxPane }) => { + for (const id of removedSessionIds) { + closeBtwSessionInAuxPane(id); + } + }); } /** @@ -1563,6 +1932,15 @@ function handleDialogTurnStarted(context: FlowChatContext, event: any): void { const session = state.sessions.get(sessionId); if (!session) { + // A session whose deletion was confirmed on the backend must not be + // resurrected as a placeholder shell by stale in-flight events. + if (isSessionConfirmedDeleted(sessionId)) { + log.warn('DialogTurnStarted: ignoring event for confirmed deleted session', { + sessionId, + sessionsCount: state.sessions.size, + }); + return; + } // Hidden MiniApp agent runs (e.g. PPT Live) submit turns with // `surface: 'miniapp_agent'`. Register them as transient miniapp sessions // so they stay out of the session list and the agent companion bubbles. @@ -1570,7 +1948,8 @@ function handleDialogTurnStarted(context: FlowChatContext, event: any): void { const miniAppId = typeof userMessageMetadata?.appId === 'string' ? userMessageMetadata.appId : undefined; - log.warn('DialogTurnStarted: session not in store, creating placeholder', { sessionId, sessionsCount: state.sessions.size, isMiniAppAgentRun }); + const acpAgentType = acpAgentTypeFromFlowSessionId(sessionId); + log.warn('DialogTurnStarted: session not in store, creating placeholder', { sessionId, sessionsCount: state.sessions.size, isMiniAppAgentRun, acpAgentType }); store.addExternalSession( sessionId, isMiniAppAgentRun ? (miniAppId ? `MiniApp: ${miniAppId}` : 'MiniApp Agent') : 'Remote Session', @@ -1578,7 +1957,9 @@ function handleDialogTurnStarted(context: FlowChatContext, event: any): void { resolveExternalSessionWorkspacePath(context, event), isMiniAppAgentRun ? { sessionKind: 'miniapp', isTransient: true, agentBackedTransient: true } - : undefined, + : acpAgentType + ? { agentType: acpAgentType } + : undefined, extractEventRemoteConnectionId(event), extractEventRemoteSshHost(event) ); @@ -1750,13 +2131,34 @@ function handleDialogTurnStarted(context: FlowChatContext, event: any): void { */ function handleTextChunk(context: FlowChatContext, event: any): void { const { sessionId, turnId, roundId, text, contentType = 'text', isThinkingEnd = false } = event; + + // UI-05: ACP 流会话(acp__)text-chunk 可能先于 session-created / + // dialog-turn-started 到达(事件乱序 / 会话未 hydrate)。对齐 handleDialogTurnStarted + // 占位创建模式:可解析 ACP 流会话 id 则先占位创建 session(带 agentType),使 + // shouldProcessEvent / ensureDialogTurnForAcpDataEvent 惰性建 turn 生效;非 ACP 会话 + // / 无法解析则维持原丢弃路径(不扩大改动面)。 + if (sessionId && turnId && !FlowChatStore.getInstance().getState().sessions.has(sessionId)) { + const acpAgentType = acpAgentTypeFromFlowSessionId(sessionId); + if (acpAgentType) { + FlowChatStore.getInstance().addExternalSession( + sessionId, + 'Remote Session', + 'agentic', + resolveExternalSessionWorkspacePath(context, event), + { agentType: acpAgentType }, + extractEventRemoteConnectionId(event), + extractEventRemoteSshHost(event) + ); + } + } + if (!shouldProcessEvent(sessionId, turnId, 'data', 'TextChunk')) { return; } - + const store = FlowChatStore.getInstance(); const session = store.getState().sessions.get(sessionId); - + if (!session) { if (!context.contentBuffers.has(sessionId)) { log.debug('Session not found (text chunk event)', { sessionId }); @@ -1764,11 +2166,21 @@ function handleTextChunk(context: FlowChatContext, event: any): void { return; } - const dialogTurn = session.dialogTurns.find((turn: DialogTurn) => turn.id === turnId); + let dialogTurn = session.dialogTurns.find((turn: DialogTurn) => turn.id === turnId); if (!dialogTurn) { - requestPeerSessionRefresh(sessionId); - log.debug('Dialog turn not found', { turnId }); - return; + // UI-04: ACP 直连投递会话可能只有 text-chunk 而无 dialog-turn-started, + // 惰性建 turn(含状态机)后继续展示;其余会话维持原丢弃路径。 + if (turnId && ensureDialogTurnForAcpDataEvent(sessionId, turnId)) { + dialogTurn = FlowChatStore.getInstance() + .getState() + .sessions.get(sessionId) + ?.dialogTurns.find((turn: DialogTurn) => turn.id === turnId); + } + if (!dialogTurn) { + requestPeerSessionRefresh(sessionId); + log.debug('Dialog turn not found', { turnId }); + return; + } } clearRuntimeStatus(context, sessionId, turnId, { roundId }); @@ -2335,6 +2747,22 @@ function handleCompressionFailed(context: FlowChatContext, event: any): void { /** * Handle dialog turn completed event */ +// UI-05: 模型原生正常终止码('eos' / 'tool_calls')在后端可能被误报为 +// success=false。结合 hasFinalResponse 判断:只要该 turn 确实产出了最终回复, +// 就按正常收尾处理,而不是失败。口径与 turnCompletionNotice.NORMAL_FINISH_REASONS 对齐。 +const MODEL_NATIVE_NORMAL_FINISH_REASONS = new Set(['eos', 'tool_calls']); + +function isModelNativeNormalTermination( + finishReason?: string, + hasFinalResponse?: boolean, +): boolean { + if (typeof finishReason !== 'string') { + return false; + } + const reason = finishReason.trim(); + return MODEL_NATIVE_NORMAL_FINISH_REASONS.has(reason) && hasFinalResponse === true; +} + function buildUnsuccessfulCompletionError(finishReason?: string): string { if (finishReason === 'empty_round') { return 'Model returned an empty response after retrying. finish_reason=empty_round'; @@ -2401,7 +2829,9 @@ export function handleDialogTurnComplete( return; } - if (success === false) { + // UI-05: finishReason 归一化残余——'eos' / 'tool_calls' 等模型原生正常终止码 + // 若已产出最终回复(hasFinalResponse=true),不应被当作失败。 + if (success === false && !isModelNativeNormalTermination(finishReason, hasFinalResponse)) { handleDialogTurnFailed(context, { ...event, sessionId, @@ -2421,6 +2851,9 @@ export function handleDialogTurnComplete( } context.handledTerminalTurnEvents.add(terminalKey); + // UI-11: 终态清理该 turn 滞留的 pending terminal session 缓存。 + cleanupPendingTerminalSessionIdsForTurn(sessionId, turnId); + const machine = stateMachineManager.get(sessionId); if (machine) { const ctx = machine.getContext(); @@ -2486,6 +2919,9 @@ function handleDialogTurnFailed(context: FlowChatContext, event: any): void { return; } context.handledTerminalTurnEvents.add(terminalKey); + + // UI-11: 终态清理该 turn 滞留的 pending terminal session 缓存。 + cleanupPendingTerminalSessionIdsForTurn(sessionId, turnId); } log.error('Dialog turn failed', { sessionId, turnId, error, errorDetail }); @@ -2586,6 +3022,9 @@ function handleDialogTurnCancelled( return; } context.handledTerminalTurnEvents.add(terminalKey); + + // UI-11: 终态清理该 turn 滞留的 pending terminal session 缓存。 + cleanupPendingTerminalSessionIdsForTurn(sessionId, turnId); } log.info('Dialog turn cancelled', { sessionId, turnId }); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts index deeea61101..9b337ed645 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts @@ -460,7 +460,7 @@ describe('createChatSession', () => { }), undefined, expect.any(String), - 128128, + 1048576, 'agentic', '/source/repo', undefined, diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts index 8ad55c599f..1790a8e405 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts @@ -57,6 +57,7 @@ import { const log = createLogger('SessionModule'); const pendingSessionCreations = new Map>(); + const getHydrationLocationKey = ( location: SessionHistoryHydrationLocation | undefined, ): string => location?.workspacePath diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/TextChunkModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/TextChunkModule.ts index af751f2e65..a2f67bf27f 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/TextChunkModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/TextChunkModule.ts @@ -77,6 +77,32 @@ function findRound( return turn?.modelRounds.find(candidate => candidate.id === roundId); } +/** + * B1 惰性建 round 公共函数:ACP 直连投递等事件源可能跳过 model-round-started, + * round 缺失时先建 round(id=roundId)再追加内容,避免数据被静默丢弃。 + * text-chunk 与 tool-event 路径共用。addModelRound 按 roundId 去重,重复调用安全。 + */ +export function ensureModelRoundExists( + context: FlowChatContext, + sessionId: string, + turnId: string, + roundId: string +): void { + if (findRound(context, sessionId, turnId, roundId)) { + return; + } + const lazyModelRound: import('../../types/flow-chat').ModelRound = { + id: roundId, + index: 0, + items: [], + isStreaming: true, + isComplete: false, + status: 'streaming', + startTime: Date.now(), + }; + context.flowChatStore.addModelRound(sessionId, turnId, lazyModelRound); +} + /** * Process a normal text chunk without notifying the store. */ @@ -152,7 +178,13 @@ export function processNormalTextChunkInternal( attemptId, attemptIndex, }; - + + // B1 防御:ACP 直连投递等事件源可能跳过 model-round-started,round 缺失时 + // 先惰性建 round(id=roundId)再追加文本项,避免文本内容被静默丢弃。 + if (!round) { + ensureModelRoundExists(context, sessionId, turnId, roundId); + } + context.flowChatStore.addModelRoundItemSilent(sessionId, turnId, textItem, roundId); sessionActiveTextItems.set(streamKey, textItemId); } else { diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.ts index b68a0ccc85..f3314dd1c9 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.ts @@ -9,6 +9,7 @@ import { createLogger } from '@/shared/utils/logger'; import type { FlowChatContext, FlowToolItem, ToolEventOptions, DialogTurn } from './types'; import { immediateSaveDialogTurn } from './PersistenceModule'; import { applyPendingAcpPermissionForTool } from './AcpPermissionToolCardModule'; +import { ensureModelRoundExists } from './TextChunkModule'; import { normalizeParamsPartialFragment } from '../EventBatcher'; import { effectiveToolInvocation } from '../../utils/toolInvocationIdentity'; import type { @@ -89,7 +90,7 @@ export function processToolEvent( case 'Started': { flushPendingBatchedEvents(context); - handleStarted(store, sessionId, turnId, roundId, dialogTurn, toolEvent, attemptId, attemptIndex, options); + handleStarted(context, store, sessionId, turnId, roundId, dialogTurn, toolEvent, attemptId, attemptIndex, options); break; } @@ -389,14 +390,8 @@ function handleEarlyDetected( const targetRound = dialogTurn.modelRounds.find(round => round.id === roundId); if (!targetRound) { - log.error('Tool EarlyDetected event references missing round (backend bug)', { - sessionId, - turnId, - roundId, - toolId: toolEvent.tool_id, - toolName: toolEvent.tool_name, - }); - return; + // B1 防御:round 缺失时先惰性建 round,再追加 tool 项,避免工具事件被静默丢弃。 + ensureModelRoundExists(context, sessionId, turnId, roundId); } store.addModelRoundItem(sessionId, turnId, preparingToolItem, roundId); @@ -453,6 +448,7 @@ function handleWaiting( * Handle tool started event */ function handleStarted( + context: FlowChatContext, store: FlowChatStore, sessionId: string, turnId: string, @@ -506,13 +502,11 @@ function handleStarted( pendingTerminalSessionIds.delete(toolEvent.tool_id); applyPendingAcpPermissionForTool(store, toolEvent.tool_id); } else { - log.error('Tool Started event references missing round (backend bug)', { - sessionId, - turnId, - roundId, - toolId: toolEvent.tool_id, - toolName: toolEvent.tool_name - }); + // B1 防御:round 缺失时先惰性建 round,再追加 tool 项,避免工具事件被静默丢弃。 + ensureModelRoundExists(context, sessionId, turnId, roundId); + store.addModelRoundItem(sessionId, turnId, toolItem, roundId); + pendingTerminalSessionIds.delete(toolEvent.tool_id); + applyPendingAcpPermissionForTool(store, toolEvent.tool_id); } } } @@ -788,3 +782,32 @@ export function handleToolTerminalReady( terminalSessionId: terminal_session_id, }); } + +/** + * UI-11: turn 到达终态(completed / failed / cancelled)时清理模块级 + * pendingTerminalSessionIds 中属于该 turn 的滞留项——tool 尚未走到 Started + * 即被终态吞掉时,其 terminal_session_id 不再有机会消费,避免 Map 无限累积。 + */ +export function cleanupPendingTerminalSessionIdsForTurn( + sessionId: string, + turnId: string, +): void { + if (pendingTerminalSessionIds.size === 0) { + return; + } + + const store = FlowChatStore.getInstance(); + const session = store.getState().sessions.get(sessionId); + const turn = session?.dialogTurns.find(candidate => candidate.id === turnId); + if (!turn) { + return; + } + + for (const round of turn.modelRounds) { + for (const item of round.items) { + if (item.type === 'tool' && typeof item.id === 'string') { + pendingTerminalSessionIds.delete(item.id); + } + } + } +} diff --git a/src/web-ui/src/flow_chat/services/goalService.ts b/src/web-ui/src/flow_chat/services/goalService.ts index dfbecd51f8..5e272f67aa 100644 --- a/src/web-ui/src/flow_chat/services/goalService.ts +++ b/src/web-ui/src/flow_chat/services/goalService.ts @@ -9,6 +9,13 @@ import { sessionProjectWorkspacePath } from '../utils/sessionWorkspace'; export { isGoalSlashCommand, parseGoalCommand } from './goalCommandParser'; export type { GoalCommandAction } from './goalCommandParser'; +export interface GoalChainEntry { + sessionId: string; + sessionName: string; + goal: ThreadGoalSnapshot | null; + depth: number; +} + export interface ThreadGoalSnapshot { goalId?: string; objective: string; @@ -63,26 +70,33 @@ function mapGoal(goal: { }; } -const GOAL_KICKOFF_CONTENT_PREFIX = 'Continue working toward the thread goal:'; - +// UI-13: goal kickoff 去重改用结构化标记(metadata.threadGoalKickoff), +// 不再依赖英文文案前缀匹配(多语言下会失效)。 +// 局限:无 threadGoalKickoff 标记的 pending 项(标记引入前的历史队列)仅能靠 +// /goal 命令前缀兜底匹配;backend 注入的 kickoff 文案若无标记则不再在此处去重。 function isRedundantGoalKickoffPendingItem( displayMessage: string | undefined, - content: string + content: string, + userMessageMetadata?: Record, ): boolean { const display = displayMessage?.trim() ?? ''; if (/^\/goal\b/i.test(display)) { return true; } - return ( - content.startsWith(GOAL_KICKOFF_CONTENT_PREFIX) || - /^\/goal\b/i.test(content.trim()) - ); + if (userMessageMetadata?.threadGoalKickoff === true) { + return true; + } + return /^\/goal\b/i.test(content.trim()); } /** Drop legacy frontend kickoff rows; backend already steers via objective_updated. */ function clearRedundantGoalKickoffPendingItems(sessionId: string): void { for (const item of pendingQueueManager.list(sessionId)) { - if (isRedundantGoalKickoffPendingItem(item.displayMessage, item.content)) { + if (isRedundantGoalKickoffPendingItem( + item.displayMessage, + item.content, + item.userMessageMetadata, + )) { pendingQueueManager.remove(sessionId, item.id); } } @@ -93,6 +107,12 @@ function syncGoalToStore(sessionId: string, goal: ThreadGoalSnapshot | null): vo flowChatStore.setThreadGoal(sessionId, null); return; } + // UI-07: 单调 updatedAt 比较——只接受比 store 已记录的最新写入/清除更新的目标, + // 避免迟到的响应/事件把已清除的旧 goal 写回 UI(API 响应始终携带 updatedAt)。 + const lastSeenAt = flowChatStore.getState().sessions.get(sessionId)?.threadGoalUpdatedAt ?? 0; + if (lastSeenAt > 0 && goal.updatedAt != null && goal.updatedAt < lastSeenAt) { + return; + } flowChatStore.setThreadGoal(sessionId, { goalId: goal.goalId ?? `${sessionId}-goal`, objective: goal.objective, @@ -122,7 +142,10 @@ export async function fetchSessionThreadGoal( const base = await sessionRequestBase(session); const response = await agentAPI.getSessionThreadGoal(base); if (!response.goal) { - syncGoalToStore(session.sessionId, null); + // Read-only query: a null backend goal must NOT wipe a goal the user just + // set (still present in the store while backend propagation settles). + // Clearing is driven only by explicit clear semantics: + // runGoalCommand 'clear' / handleThreadGoalUpdated with goal=null. return null; } const snapshot = mapGoal(response.goal); @@ -322,6 +345,68 @@ export async function saveThreadGoalObjective( return snapshot; } +/** + * Walk up the parentSessionId chain from the given session to the root, + * fetch the thread goal for every ancestor, and return an ordered list + * from L0 (root) to the current session. + */ +export async function fetchGoalChain(session: Session): Promise { + const ancestors: Session[] = []; + const visited = new Set(); + let current: Session | undefined = session; + + while (current && !visited.has(current.sessionId)) { + visited.add(current.sessionId); + ancestors.push(current); + if (current.parentSessionId) { + current = flowChatStore.getState().sessions.get(current.parentSessionId); + } else { + break; + } + } + + // Reverse so the root (L0) comes first + ancestors.reverse(); + + const result: GoalChainEntry[] = []; + for (let i = 0; i < ancestors.length; i++) { + const s = ancestors[i]; + let goal: ThreadGoalSnapshot | null = null; + if (s.workspacePath) { + try { + goal = await fetchSessionThreadGoal(s); + } catch { + // best-effort: goal fetch failure shouldn't block the chain + } + } + if (!goal) { + // Fallback to the store's existing snapshot so a read-only miss (or a + // session without workspacePath) cannot flip the chip back to L0 while + // the user's goal is still active in the UI. + const stored = flowChatStore.getState().sessions.get(s.sessionId)?.threadGoal; + if (stored) { + goal = { + goalId: stored.goalId, + objective: stored.objective, + status: stored.status, + tokensUsed: stored.tokensUsed, + tokenBudget: stored.tokenBudget, + timeUsedSeconds: stored.timeUsedSeconds, + updatedAt: stored.updatedAt, + }; + } + } + result.push({ + sessionId: s.sessionId, + sessionName: s.title || `Session ${s.sessionId}`, + goal, + depth: i, + }); + } + + return result; +} + function resolveGoalCommandError(error: unknown, params: GoalCommandParams): string { if (!(error instanceof Error)) { return params.unknownErrorMessage; diff --git a/src/web-ui/src/flow_chat/services/openBtwSession.test.ts b/src/web-ui/src/flow_chat/services/openBtwSession.test.ts index b9c9a895d9..aa758e4fb4 100644 --- a/src/web-ui/src/flow_chat/services/openBtwSession.test.ts +++ b/src/web-ui/src/flow_chat/services/openBtwSession.test.ts @@ -88,6 +88,7 @@ vi.mock('../store/FlowChatStore', () => ({ sessions, activeSessionId, }), + subscribe: () => () => {}, addExternalSession: (...args: unknown[]) => mocks.addExternalSession(...args), updateSessionRelationship: (...args: unknown[]) => diff --git a/src/web-ui/src/flow_chat/services/threadGoalEventService.ts b/src/web-ui/src/flow_chat/services/threadGoalEventService.ts index 2913a03e2d..d9049a858f 100644 --- a/src/web-ui/src/flow_chat/services/threadGoalEventService.ts +++ b/src/web-ui/src/flow_chat/services/threadGoalEventService.ts @@ -36,6 +36,22 @@ function mapPayloadGoal( }; } +/** + * UI-07: monotonic updatedAt check. Once a session has a thread-goal clock + * (threadGoalUpdatedAt), only accept an incoming goal that provably carries a + * newer updatedAt. A missing timestamp cannot prove freshness, so a late + * thread-goal-updated event after an explicit clear must not resurrect the old + * goal (the store's own guard falls back to Date.now() for missing updatedAt, + * which would let a stale event through). + */ +function isGoalStaleForSession(sessionId: string, snapshot: ThreadGoalSnapshot): boolean { + const lastSeenAt = flowChatStore.getState().sessions.get(sessionId)?.threadGoalUpdatedAt ?? 0; + if (lastSeenAt <= 0) { + return false; + } + return snapshot.updatedAt == null || snapshot.updatedAt < lastSeenAt; +} + export function handleThreadGoalUpdated(payload: ThreadGoalUpdatedPayload): void { if (!payload.sessionId) return; @@ -53,6 +69,14 @@ export function handleThreadGoalUpdated(payload: ThreadGoalUpdatedPayload): void return; } + if (isGoalStaleForSession(payload.sessionId, snapshot)) { + log.debug('ThreadGoalUpdated ignored: goal is not newer than the last write/clear', { + sessionId: payload.sessionId, + goal: payload.goal, + }); + return; + } + flowChatStore.setThreadGoal(payload.sessionId, { goalId: snapshot.goalId ?? `${payload.sessionId}-goal`, objective: snapshot.objective, diff --git a/src/web-ui/src/flow_chat/services/usageReportService.test.ts b/src/web-ui/src/flow_chat/services/usageReportService.test.ts index 4f722e3eaa..6056052685 100644 --- a/src/web-ui/src/flow_chat/services/usageReportService.test.ts +++ b/src/web-ui/src/flow_chat/services/usageReportService.test.ts @@ -34,7 +34,7 @@ const createSession = (overrides: Partial = {}): Session => ({ error: null, isHistorical: false, todos: [], - maxContextTokens: 128128, + maxContextTokens: 1048576, mode: 'agentic', workspacePath: 'D:/workspace/BitFun', isTransient: false, diff --git a/src/web-ui/src/flow_chat/session-drivers/useComposerCapabilities.ts b/src/web-ui/src/flow_chat/session-drivers/useComposerCapabilities.ts index 1ca0c893b9..633c136123 100644 --- a/src/web-ui/src/flow_chat/session-drivers/useComposerCapabilities.ts +++ b/src/web-ui/src/flow_chat/session-drivers/useComposerCapabilities.ts @@ -13,6 +13,7 @@ import { useRuntimeStatusStore } from '../store/runtimeStatusStore'; import type { Session } from '../types/flow-chat'; import { resolveSessionDriverId, type SessionDriverId } from './resolve'; +import { isAcpFlowSession } from '../utils/acpSession'; export const DISPATCH_TRANSFER_ROUND_PREFIX = 'dispatch-transfer:'; @@ -87,7 +88,8 @@ export function useComposerCapabilities(input: ComposerCapabilityInput): Compose localSlashCommands: !dispatchTransport, ops: dispatchTransport ? DISPATCH_SLASH_OPS : LOCAL_SLASH_OPS, usageReport: true, - threadGoal: !displayAsChild && !dispatchTransport, + // UI-12: ACP 会话走 agent 协议自带 goal 编排,误显本地 threadGoal 入口。 + threadGoal: !displayAsChild && !dispatchTransport && !isAcpFlowSession(session), transferInFlight, submissionOptionsLocked, sessionScopedApproval: dispatchTransport, diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts index e75c0e6eaa..174bd4adfb 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts @@ -8,9 +8,11 @@ import { dispatchJobStore } from '@/features/dispatch/dispatchJobStore'; const apiMocks = vi.hoisted(() => ({ listSessions: vi.fn(), listSessionsPage: vi.fn(), + listDeletedSessionIds: vi.fn(), loadSessionTurns: vi.fn(), saveSessionTurn: vi.fn(), deleteSession: vi.fn(), + deleteSessionTree: vi.fn(), restoreSession: vi.fn(), restoreSessionView: vi.fn(), restoreSessionWithTurns: vi.fn(), @@ -45,12 +47,14 @@ const stateMachineManagerMock = vi.hoisted(() => ({ getOrCreate: vi.fn(), reset: vi.fn(), transition: vi.fn(async () => true), + subscribeGlobal: vi.fn(), })); vi.mock('@/infrastructure/api', () => ({ sessionAPI: { listSessions: apiMocks.listSessions, listSessionsPage: apiMocks.listSessionsPage, + listDeletedSessionIds: apiMocks.listDeletedSessionIds, loadSessionTurns: apiMocks.loadSessionTurns, saveSessionTurn: apiMocks.saveSessionTurn, }, @@ -60,6 +64,7 @@ vi.mock('@/infrastructure/api/service-api/SessionAPI', () => ({ sessionAPI: { listSessions: apiMocks.listSessions, listSessionsPage: apiMocks.listSessionsPage, + listDeletedSessionIds: apiMocks.listDeletedSessionIds, loadSessionTurns: apiMocks.loadSessionTurns, saveSessionTurn: apiMocks.saveSessionTurn, }, @@ -69,6 +74,7 @@ vi.mock('@/infrastructure/api/service-api/AgentAPI', () => ({ agentAPI: { cancelSession: apiMocks.cancelSession, deleteSession: apiMocks.deleteSession, + deleteSessionTree: apiMocks.deleteSessionTree, restoreSession: apiMocks.restoreSession, get restoreSessionView() { return apiMocks.restoreSessionView; @@ -156,7 +162,7 @@ const createSession = (overrides: Partial = {}): Session => ({ error: null, isHistorical: false, todos: [], - maxContextTokens: 128128, + maxContextTokens: 1048576, mode: 'agentic', workspacePath: 'D:/workspace/BitFun', isTransient: false, @@ -552,8 +558,8 @@ describe('FlowChatStore session removal active selection', () => { }); it('reuses pending delete intent when a concurrent local remove wins the race', async () => { - const deleteDeferred = createDeferred(); - apiMocks.deleteSession.mockImplementation(() => deleteDeferred.promise); + const deleteDeferred = createDeferred(); + apiMocks.deleteSessionTree.mockImplementation(() => deleteDeferred.promise); const keepSession = createSession({ sessionId: 'session-keep', title: 'Keep me', @@ -582,12 +588,69 @@ describe('FlowChatStore session removal active selection', () => { expect(removedSessionIds).toEqual(['session-remove']); expect(flowChatStore.getState().activeSessionId).toBeNull(); - deleteDeferred.resolve(); + deleteDeferred.resolve(['session-remove']); await deleting; expect(flowChatStore.getState().activeSessionId).toBeNull(); expect(Array.from(flowChatStore.getState().sessions.keys())).toEqual(['session-keep']); }); + + it('invalidates metadata request caches after a confirmed delete', async () => { + const session = createSession({ + sessionId: 'session-remove', + title: 'Remove me', + workspacePath: 'D:/workspace/BitFun', + }); + flowChatStore.setState(() => ({ + sessions: new Map([[session.sessionId, session]]), + })); + + apiMocks.deleteSessionTree.mockResolvedValueOnce(['session-remove']); + apiMocks.listDeletedSessionIds.mockResolvedValue([]); + apiMocks.listSessionsPage.mockResolvedValue({ + sessions: [ + { + sessionId: 'session-keep', + title: 'Saved session', + agentType: 'agentic', + modelName: 'auto', + createdAt: 10, + lastActiveAt: 20, + workspaceHostname: 'localhost', + }, + ], + totalTopLevelCount: 1, + loadedTopLevelCount: 1, + nextCursor: undefined, + hasMore: false, + }); + + await flowChatStore.loadSessionMetadataPage( + 'D:/workspace/BitFun', + 5, + undefined, + undefined, + undefined, + 'delete_invalidation_test' + ); + expect(apiMocks.listSessionsPage).toHaveBeenCalledTimes(1); + + await flowChatStore.deleteSession(session.sessionId, { nextActiveSessionId: null }); + expect(flowChatStore.getState().sessions.get(session.sessionId)).toBeUndefined(); + + // The same key as before must hit the backend again: the dedupe caches + // were invalidated by the confirmed delete, so the pre-deletion list + // cannot be served from cache ("deleted session still visible"). + await flowChatStore.loadSessionMetadataPage( + 'D:/workspace/BitFun', + 5, + undefined, + undefined, + undefined, + 'delete_invalidation_test' + ); + expect(apiMocks.listSessionsPage).toHaveBeenCalledTimes(2); + }); }); describe('FlowChatStore token usage', () => { @@ -2413,6 +2476,44 @@ describe('FlowChatStore historical session hydration state', () => { }); }); + it('filters tombstone-deleted sessions out of the paged metadata path', async () => { + apiMocks.listDeletedSessionIds.mockResolvedValueOnce(['tombstone-filtered-1']); + apiMocks.listSessionsPage.mockResolvedValueOnce({ + sessions: [ + { + sessionId: 'tombstone-filtered-1', + title: 'Deleted session', + agentType: 'agentic', + modelName: 'auto', + createdAt: 10, + lastActiveAt: 20, + workspaceHostname: 'localhost', + }, + ], + totalTopLevelCount: 1, + loadedTopLevelCount: 1, + nextCursor: undefined, + hasMore: false, + }); + + const page = await flowChatStore.loadSessionMetadataPage( + 'D:/workspace/BitFun', + 5, + undefined, + undefined, + undefined, + 'nav_initial' + ); + + expect(apiMocks.listDeletedSessionIds).toHaveBeenCalledWith( + 'D:/workspace/BitFun', + undefined, + undefined, + ); + expect(page.sessions).toHaveLength(1); + expect(flowChatStore.getState().sessions.get('tombstone-filtered-1')).toBeUndefined(); + }); + it('loads a paged metadata slice without requesting the full session list', async () => { apiMocks.listSessionsPage.mockResolvedValueOnce({ sessions: [ @@ -2448,7 +2549,7 @@ describe('FlowChatStore historical session hydration state', () => { cursor: undefined, remoteConnectionId: undefined, remoteSshHost: undefined, - }); + }, false); expect(page).toMatchObject({ totalTopLevelCount: 12, nextCursor: '5', diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index 05be069b2d..a827b61160 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -1,4 +1,4 @@ -/** +/** * Flow Chat global state store * Prevents state loss when components remount */ @@ -81,6 +81,7 @@ import { sessionMatchesWorkspace } from '../utils/workspaceScope'; import { resolveThreadGoalUserMessageDisplay } from '../utils/threadGoalDisplay'; import { cleanRemoteUserInput } from '../utils/userInputText'; import { useBackgroundSubagentActivityStore } from './backgroundSubagentActivityStore'; +import { clearRuntimeStatusState } from './runtimeStatusStore'; import { sessionComposerStore } from './sessionComposerStore'; import { recordHistorySessionDiagnosticEvent } from '../services/historySessionDiagnostics'; import { @@ -100,6 +101,112 @@ import { const log = createLogger('FlowChatStore'); +/** + * Session IDs whose deletion was confirmed by the backend. Stale in-flight + * events that still reference these IDs (a session deleted while processing, + * or a directory-level removal on disk) must not resurrect placeholder shells + * in the UI. Every creation path guards against them: event handler entry + * checks, the addExternalSession entry filter, and initializeFromDisk. + * + * The set is persisted to localStorage so a page refresh cannot resurrect a + * confirmed-deleted session from residual backend disk state. + */ +const CONFIRMED_DELETED_STORAGE_KEY = 'flowchat.confirmedDeletedSessionIds'; +const CONFIRMED_DELETED_MAX_ENTRIES = 500; + +const loadConfirmedDeletedSessionIds = (): Set => { + const loaded = new Set(); + try { + const raw = localStorage.getItem(CONFIRMED_DELETED_STORAGE_KEY); + if (!raw) { + return loaded; + } + const parsed: unknown = JSON.parse(raw); + if (Array.isArray(parsed)) { + for (const entry of parsed) { + if (typeof entry === 'string' && entry) { + loaded.add(entry); + } + } + } + } catch (error) { + log.warn('Failed to load confirmed deleted session ids from localStorage', error); + } + return loaded; +}; + +const trimConfirmedDeletedSessionIds = (ids: Set): void => { + // Keep at most CONFIRMED_DELETED_MAX_ENTRIES, dropping the oldest entries + // (Set iteration follows insertion order, so the head is the oldest). + while (ids.size > CONFIRMED_DELETED_MAX_ENTRIES) { + const oldest = ids.values().next().value; + if (oldest === undefined) break; + ids.delete(oldest); + } +}; + +const persistConfirmedDeletedSessionIds = (ids: ReadonlySet): void => { + try { + localStorage.setItem(CONFIRMED_DELETED_STORAGE_KEY, JSON.stringify(Array.from(ids))); + } catch (error) { + log.warn('Failed to persist confirmed deleted session ids to localStorage', error); + } +}; + +const confirmedDeletedSessionIds: Set = loadConfirmedDeletedSessionIds(); +trimConfirmedDeletedSessionIds(confirmedDeletedSessionIds); + +/** + * Merge deletions confirmed in another tab into the in-memory set. The merge + * is a union (never an overwrite) so deletions made in this tab are not lost + * when another tab writes its own set. + */ +const syncConfirmedDeletedSessionIdsFromStorage = (): void => { + const remoteIds = loadConfirmedDeletedSessionIds(); + if (remoteIds.size === 0) { + return; + } + let changed = false; + for (const sessionId of remoteIds) { + if (!confirmedDeletedSessionIds.has(sessionId)) { + confirmedDeletedSessionIds.add(sessionId); + changed = true; + } + } + if (changed) { + trimConfirmedDeletedSessionIds(confirmedDeletedSessionIds); + persistConfirmedDeletedSessionIds(confirmedDeletedSessionIds); + } +}; + +// Keep the set in sync across tabs: a deletion confirmed in another tab must +// also block resurrection here. The 'storage' event only fires in other tabs +// (never in the tab that wrote), so this cannot self-trigger. +if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') { + window.addEventListener('storage', (event: StorageEvent) => { + if (event.key !== CONFIRMED_DELETED_STORAGE_KEY || event.newValue === null) { + return; + } + syncConfirmedDeletedSessionIdsFromStorage(); + }); +} + +export const isSessionConfirmedDeleted = (sessionId: string | null | undefined): boolean => + Boolean(sessionId && confirmedDeletedSessionIds.has(sessionId)); + +/** + * Record session IDs whose deletion was confirmed (by the backend or by an + * explicit local removal) so stale in-flight events cannot resurrect them. + * The in-memory set and its localStorage mirror are both updated. + */ +export const markSessionsConfirmedDeleted = (sessionIds: Iterable): void => { + for (const sessionId of sessionIds) { + confirmedDeletedSessionIds.add(sessionId); + } + trimConfirmedDeletedSessionIds(confirmedDeletedSessionIds); + persistConfirmedDeletedSessionIds(confirmedDeletedSessionIds); +}; + function firstNonEmptyString(...values: unknown[]): string | undefined { for (const value of values) { if (typeof value === 'string' && value.trim()) { @@ -1460,6 +1567,52 @@ interface SelectorListener { hasLastValue: boolean; } +export interface SessionTreeNode { + sessionId: string; + sessionName: string; + agentType: string; + agentDisplayName: string; + depth: number; + status: 'running' | 'completed' | 'error' | 'cancelled'; + children: SessionTreeNode[]; + isAcpExternal: boolean; + externalProviderLabel?: string; + /** Default tool list for a SubAgent, fetched from the Agent registry. */ + tools?: string[]; + /** Number of dialog turns. */ + turnCount?: number; +} + +function sessionTreeNodeStatus(session: Session): SessionTreeNode['status'] { + if (session.status === 'error') return 'error'; + if (session.persistedStatus === 'completed') return 'completed'; + if (session.persistedStatus === 'archived') return 'completed'; + if (session.status === 'active') return 'running'; + return 'running'; +} + +const SUBAGENT_TOOLS: Record = { + 'Explore': ['Read', 'Grep', 'Glob', 'LS'], + 'FileFinder': ['Read', 'Grep', 'Glob', 'LS'], + 'GeneralPurpose': ['Read', 'Write', 'Edit', 'Grep', 'Glob', 'ExecCommand', 'Task'], + 'ResearchSpecialist': ['WebSearch', 'WebFetch', 'Read'], + 'CodeReview': ['Read', 'Grep', 'Glob', 'GetFileDiff'], + 'ReviewSecurity': ['Read', 'Grep', 'Glob', 'GetFileDiff'], + 'ReviewArchitecture': ['Read', 'Grep', 'Glob', 'GetFileDiff'], + 'ReviewBusinessLogic': ['Read', 'Grep', 'Glob', 'GetFileDiff'], + 'ReviewFrontend': ['Read', 'Grep', 'Glob', 'GetFileDiff'], + 'ReviewPerformance': ['Read', 'Grep', 'Glob', 'GetFileDiff'], + 'ReviewJudge': ['Read', 'Grep', 'Glob'], +}; + +function inferSessionTools(session: Session): string[] { + const type = session.subagentType || session.mode || ''; + if (SUBAGENT_TOOLS[type]) return SUBAGENT_TOOLS[type]; + if (type.startsWith('acp__')) return ['Read', 'Write', 'Edit', 'Grep', 'Glob', 'ExecCommand', 'Task', 'SessionControl']; + if (type.startsWith('Review')) return ['Read', 'Grep', 'Glob', 'GetFileDiff']; + return []; +} + export class FlowChatStore { private static instance: FlowChatStore; private state: FlowChatState; @@ -2234,6 +2387,7 @@ export class FlowChatStore { cursor?: string, remoteConnectionId?: string, remoteSshHost?: string, + includeHidden = false, ): string { return JSON.stringify([ workspacePath, @@ -2241,6 +2395,7 @@ export class FlowChatStore { remoteSshHost || '', cursor || '', limit, + includeHidden === true, ]); } @@ -3463,20 +3618,24 @@ export class FlowChatStore { const visited = new Set(); const orderedSessionIds: string[] = []; - const visit = (sessionId: string): void => { + const MAX_CASCADE_DEPTH = 256; + const visit = (sessionId: string, depth: number = 0): void => { if (visited.has(sessionId)) { return; } + if (depth > MAX_CASCADE_DEPTH) { + return; + } visited.add(sessionId); const childSessionIds = childSessionIdsByParent.get(sessionId) || []; childSessionIds.forEach(childSessionId => { - visit(childSessionId); + visit(childSessionId, depth + 1); }); orderedSessionIds.push(sessionId); }; - visit(rootSessionId); + visit(rootSessionId, 0); return orderedSessionIds; } @@ -3484,6 +3643,56 @@ export class FlowChatStore { return this.collectCascadeSessionIds(sessionId, this.state.sessions); } + public getSessionTree(sessionId: string): SessionTreeNode | null { + const sessions = this.state.sessions; + const rootSession = sessions.get(sessionId); + if (!rootSession) return null; + return this.buildSessionTreeNode(sessionId, sessions, 0); + } + + private buildSessionTreeNode( + sessionId: string, + sessions: Map, + depth: number, + ): SessionTreeNode { + const MAX_TREE_BUILD_DEPTH = 256; + if (depth > MAX_TREE_BUILD_DEPTH) { + const s = sessions.get(sessionId); + return { + sessionId, + sessionName: s?.title ?? sessionId, + agentType: s?.mode ?? 'unknown', + agentDisplayName: s?.subagentType ?? s?.mode ?? 'unknown', + depth, + status: 'running' as const, + children: [], + isAcpExternal: (s?.mode ?? '').startsWith('acp__'), + externalProviderLabel: s?.subagentType ?? undefined, + turnCount: s?.dialogTurns?.length ?? 0, + tools: s ? inferSessionTools(s) : undefined, + }; + } + + const session = sessions.get(sessionId)!; + const childIds = Array.from(sessions.values()) + .filter(s => s.parentSessionId === sessionId) + .map(s => s.sessionId); + + return { + sessionId: session.sessionId, + sessionName: session.title || session.sessionId, + agentType: session.mode || 'unknown', + agentDisplayName: session.subagentType || session.mode || 'unknown', + depth, + status: sessionTreeNodeStatus(session), + children: childIds.map(id => this.buildSessionTreeNode(id, sessions, depth + 1)), + isAcpExternal: (session.mode || '').startsWith('acp__'), + externalProviderLabel: session.subagentType ?? undefined, + turnCount: session.dialogTurns?.length ?? 0, + tools: inferSessionTools(session), + }; + } + public subscribe(listener: (state: FlowChatState) => void): () => void { this.listeners.add(listener); return () => { @@ -3569,7 +3778,7 @@ export class FlowChatStore { lastFinishedAt: undefined, error: null, historyState: 'new', - maxContextTokens: maxContextTokens || 128128, + maxContextTokens: maxContextTokens || 1048576, mode: mode || 'agentic', lastUserDialogMode: undefined, lastSubmittedMode: undefined, @@ -3582,6 +3791,7 @@ export class FlowChatStore { sessionKind: relationship.sessionKind, parentToolCallId: relationship.parentToolCallId, subagentType: relationship.subagentType, + depth: relationship.depth, btwThreads: [], btwOrigin: relationship.btwOrigin, isTransient: false, @@ -3613,6 +3823,9 @@ export class FlowChatStore { btwOrigin?: Session['btwOrigin']; parentToolCallId?: string; subagentType?: string; + /** ACP agent type (`acp:`) for placeholder sessions created from ACP flow session ids. */ + agentType?: string; + depth?: number; isTransient?: boolean; agentBackedTransient?: boolean; deepReviewRunManifest?: Session['deepReviewRunManifest']; @@ -3626,6 +3839,13 @@ export class FlowChatStore { remoteConnectionId?: string, remoteSshHost?: string ): void { + // A session whose deletion was confirmed must not be resurrected by stale + // in-flight events or panel rebuilds (same guard as initializeFromDisk). + if (isSessionConfirmedDeleted(sessionId)) { + log.warn('addExternalSession: ignoring confirmed deleted session', { sessionId }); + return; + } + import('../state-machine').then(({ stateMachineManager }) => { stateMachineManager.getOrCreate(sessionId); }); @@ -3645,20 +3865,22 @@ export class FlowChatStore { titleStatus: 'generated', dialogTurns: [], status: 'idle', - config: { - maxContextTokens: 128128, + +config: { + maxContextTokens: 1048576, autoCompact: true, enableTools: true, workspacePath, projectWorkspacePath: meta?.projectWorkspacePath, executionTarget: meta?.executionTarget, workspaceId: meta?.workspaceId, + agentType: meta?.agentType, } as any, createdAt: Date.now(), lastActiveAt: Date.now(), lastFinishedAt: undefined, error: null, - maxContextTokens: 128128, + maxContextTokens: 1048576, mode: mode || 'agentic', lastUserDialogMode: undefined, lastSubmittedMode: undefined, @@ -3673,6 +3895,7 @@ export class FlowChatStore { sessionKind: relationship.sessionKind, parentToolCallId: relationship.parentToolCallId, subagentType: relationship.subagentType, + depth: relationship.depth, btwThreads: [], btwOrigin: relationship.btwOrigin, deepReviewRunManifest: meta?.deepReviewRunManifest, @@ -3846,10 +4069,24 @@ export class FlowChatStore { return prev; } + // UI-07: 单调 updatedAt 比较。goal 清除后迟到的 thread-goal-updated 若携带 + // 更旧的 updatedAt,不得复活旧 goal。每次写入(含清除)都推进时钟。 + const now = Date.now(); + const lastSeenAt = session.threadGoalUpdatedAt ?? 0; + const incomingUpdatedAt = goal?.updatedAt ?? now; + if (goal && lastSeenAt > 0 && incomingUpdatedAt < lastSeenAt) { + return prev; + } + const nextThreadGoalUpdatedAt = Math.max( + lastSeenAt, + goal ? incomingUpdatedAt : now, + ); + const updatedSession = { ...session, threadGoal: goal ?? undefined, goalModeActive: active, + threadGoalUpdatedAt: nextThreadGoalUpdatedAt, lastActiveAt: Date.now(), }; @@ -4385,6 +4622,7 @@ export class FlowChatStore { updates.subagentType !== undefined ? updates.subagentType : session.subagentType, + depth: session.depth, }); const next: Session = { ...session, @@ -4392,6 +4630,7 @@ export class FlowChatStore { sessionKind: relationship.sessionKind, parentToolCallId: relationship.parentToolCallId, subagentType: relationship.subagentType, + depth: relationship.depth, btwOrigin: relationship.btwOrigin, }; @@ -4415,11 +4654,13 @@ export class FlowChatStore { sessionKind, parentSessionId: origin?.parentSessionId ?? session.parentSessionId, btwOrigin: { ...(session.btwOrigin || {}), ...(origin || {}) }, + depth: session.depth, }); const next: Session = { ...session, parentSessionId: relationship.parentSessionId, sessionKind: relationship.sessionKind, + depth: relationship.depth, btwOrigin: relationship.btwOrigin, }; @@ -4527,53 +4768,67 @@ export class FlowChatStore { } public async deleteSession(sessionId: string, options?: RemoveSessionOptions): Promise { - const sessionIdsToDelete = this.getCascadeSessionIds(sessionId); - if (sessionIdsToDelete.length === 0) { + if (!this.state.sessions.has(sessionId)) { return; } if (options) { this.pendingRemoveSessionOptions.set(sessionId, options); } - const { stateMachineManager } = await import('../state-machine'); - sessionIdsToDelete.forEach(id => { - stateMachineManager.delete(id); - }); - + let deletedSessionIds: string[]; try { const { agentAPI } = await import('@/infrastructure/api/service-api/AgentAPI'); - const deleteResults = await Promise.allSettled( - sessionIdsToDelete.map(async id => { - const sess = this.state.sessions.get(id); - const workspacePath = sess ? sessionProjectWorkspacePath(sess) : undefined; - if (!workspacePath) { - throw new Error(`Workspace path not found for session ${id}`); - } - - await agentAPI.deleteSession( - id, - workspacePath, - sess?.remoteConnectionId, - sess?.remoteSshHost - ); - }) - ); - - deleteResults.forEach((result, index) => { - if (result.status === 'rejected') { - log.error('Failed to delete session on backend', { - sessionId: sessionIdsToDelete[index], - error: result.reason, - }); + const sess = this.state.sessions.get(sessionId); + if (!sess) { + // A concurrent local remove already won the race (deleteSession started + // before removeSession took the session out of state); the pending + // delete intent is fulfilled below without a backend round-trip. + deletedSessionIds = []; + } else { + const workspacePath = sessionProjectWorkspacePath(sess); + if (!workspacePath) { + throw new Error(`Workspace path not found for session ${sessionId}`); } - }); + // Cascade deletion is owned by the backend; only the root session id is + // sent so pagination gaps in the local session map cannot leak disk state. + deletedSessionIds = await agentAPI.deleteSessionTree( + sessionId, + workspacePath, + sess.remoteConnectionId, + sess.remoteSshHost + ); + } } catch (error) { - log.error('Failed to delete session on backend', { sessionId, error }); + log.error('Failed to delete session tree on backend', { sessionId, error }); + throw error; } + const { stateMachineManager } = await import('../state-machine'); + deletedSessionIds.forEach(id => { + stateMachineManager.delete(id); + }); + const removedSessionIds = this.removeSession(sessionId, options); - sessionComposerStore.getState().removeDrafts(removedSessionIds); + const allRemovedIds = new Set([...removedSessionIds, ...deletedSessionIds]); + // Backend-confirmed deletions must never be resurrected by stale events. + markSessionsConfirmedDeleted(allRemovedIds); + // Close any open btw-session panel tabs for the deleted sessions so the + // deleted thread placeholder does not linger in the canvas. + const { closeBtwSessionInAuxPane } = await import('../services/btwSessionPane'); + for (const id of allRemovedIds) { + closeBtwSessionInAuxPane(id); + } + sessionComposerStore.getState().removeDrafts(Array.from(allRemovedIds)); this.pendingRemoveSessionOptions.delete(sessionId); + // Backend-confirmed deletions must not be hidden by the metadata request + // dedupe caches: an in-flight or recently-completed list/page request + // (METADATA_LIST_RECENT_DEDUPE_TTL_MS) keyed the same way would otherwise + // return the pre-deletion page on the next refresh, making the deleted + // session look like it is still there. Both caches are dropped so the + // next list/page request always re-reads from the backend (which now + // also filters the deletion tombstone). + this.metadataListRequests.clear(); + this.metadataPageRequests.clear(); } public removeSession(sessionId: string, options?: RemoveSessionOptions): string[] { @@ -4586,6 +4841,11 @@ export class FlowChatStore { this.pendingRemoveSessionOptions.delete(sessionId); this.clearRemovedSessionHistoryState(removedSessionIds, 'session-removed'); useBackgroundSubagentActivityStore.getState().removeSessions(removedSessionIds); + // Drop transient runtime wait status for every removed session so a stale + // event cannot re-render a deleted subagent's projection shell. + removedSessionIds.forEach(id => { + clearRuntimeStatusState({ sessionId: id }); + }); this.setState(prev => { const removedSessionIdSet = new Set(removedSessionIds); @@ -5427,11 +5687,18 @@ export class FlowChatStore { } public addModelRound(sessionId: string, dialogTurnId: string, modelRound: ModelRound): void { - this.updateDialogTurn(sessionId, dialogTurnId, turn => ({ - ...turn, - modelRounds: [...turn.modelRounds, synchronizeRoundAttempts(modelRound)], - status: 'processing' - })); + this.updateDialogTurn(sessionId, dialogTurnId, turn => { + // UI-03: 迟到的 model-round-started 与 B1 惰性建 round 可能命中同一 roundId, + // 按 roundId 去重,避免重复 round。 + if (turn.modelRounds.some(round => round.id === modelRound.id)) { + return turn; + } + return { + ...turn, + modelRounds: [...turn.modelRounds, synchronizeRoundAttempts(modelRound)], + status: 'processing' + }; + }); } public updateModelRound(sessionId: string, dialogTurnId: string, modelRoundId: string, updater: (round: ModelRound) => ModelRound): void { @@ -6341,6 +6608,14 @@ export class FlowChatStore { if (existingSession) { return; } + // A session whose deletion was confirmed (locally or on the backend) + // must not be resurrected by residual disk metadata on refresh. The + // tombstone registry is pre-warmed by the caller + // (`loadSessionMetadataPageUncached`) before this list is processed, + // mirroring the legacy `initializeFromDiskUncached` path. + if (isSessionConfirmedDeleted(metadata.sessionId)) { + return; + } // Skip archived sessions - they are managed in the settings page. if (metadata.status === 'archived') { return; @@ -6348,7 +6623,7 @@ export class FlowChatStore { stateMachineManager.getOrCreate(metadata.sessionId); - let maxContextTokens = 128128; + let maxContextTokens = 1048576; if (metadata.modelName) { const model = models.find((m: any) => m.name === metadata.modelName || m.id === metadata.modelName); if (model?.context_window) { @@ -6356,7 +6631,7 @@ export class FlowChatStore { } } - if (maxContextTokens === 128128) { + if (maxContextTokens === 1048576) { const primaryModelId = defaultModels?.primary; if (primaryModelId) { @@ -6434,6 +6709,7 @@ export class FlowChatStore { sessionKind: relationship.sessionKind, parentToolCallId: relationship.parentToolCallId, subagentType: relationship.subagentType, + depth: relationship.depth, btwThreads: [], btwOrigin: relationship.btwOrigin, hasUnreadCompletion: metadata.unreadCompletion, @@ -6468,7 +6744,8 @@ export class FlowChatStore { cursor?: string, remoteConnectionId?: string, remoteSshHost?: string, - traceSource = 'unknown' + traceSource = 'unknown', + includeHidden = false, ): Promise { const requestKey = this.getMetadataPageRequestKey( workspacePath, @@ -6476,6 +6753,7 @@ export class FlowChatStore { cursor, remoteConnectionId, remoteSshHost, + includeHidden, ); const existingRequest = this.metadataPageRequests.get(requestKey); const remote = isRemoteTraceContext(remoteConnectionId, remoteSshHost); @@ -6509,6 +6787,7 @@ export class FlowChatStore { remoteConnectionId, remoteSshHost, traceSource, + includeHidden, ); const request: MetadataPageRequest = { promise: loadPromise }; @@ -6543,7 +6822,8 @@ export class FlowChatStore { cursor?: string, remoteConnectionId?: string, remoteSshHost?: string, - traceSource = 'unknown' + traceSource = 'unknown', + includeHidden = false, ): Promise { const traceStartedAt = nowMs(); const remote = isRemoteTraceContext(remoteConnectionId, remoteSshHost); @@ -6575,6 +6855,30 @@ export class FlowChatStore { models: any[]; defaultModels: Record; }> | undefined; + // Pre-warm the confirmed-deleted registry from the backend deletion + // tombstone so sessions deleted while this client was not watching + // (for example while the tab was closed) are filtered by + // `isSessionConfirmedDeleted` during metadata processing below and + // cannot resurrect as ghosts from residual disk metadata. Runs in + // parallel with the page request and is awaited before the metadata + // list is processed, mirroring the legacy `initializeFromDiskUncached` + // pre-warm. A failed pre-warm only degrades to the event/UI guards. + const deletedSessionIdsPromise = (async () => { + try { + const ids = await sessionAPI.listDeletedSessionIds( + workspacePath, + remoteConnectionId, + remoteSshHost, + ); + return Array.isArray(ids) ? ids : []; + } catch (error) { + log.warn( + 'Failed to pre-warm confirmed deleted session ids from backend tombstone', + error, + ); + return [] as string[]; + } + })(); const pageRequestStartedAt = nowMs(); try { startupTrace.markPhase('session_metadata_page_request_start', { @@ -6583,13 +6887,16 @@ export class FlowChatStore { metadataListTraceId, command: 'list_persisted_sessions_page', }); - const pagePromise = sessionAPI.listSessionsPage({ - workspacePath, - limit, - cursor, - remoteConnectionId, - remoteSshHost, - }); + const pagePromise = sessionAPI.listSessionsPage( + { + workspacePath, + limit, + cursor, + remoteConnectionId, + remoteSshHost, + }, + includeHidden, + ); modelConfigPromise = this.loadSessionMetadataModelConfig(); page = await pagePromise; startupTrace.markPhase('session_metadata_page_request_end', { @@ -6619,7 +6926,12 @@ export class FlowChatStore { command: 'list_persisted_sessions', fallback: true, }); - const sessions = await sessionAPI.listSessions(workspacePath, remoteConnectionId, remoteSshHost); + const sessions = await sessionAPI.listSessions( + workspacePath, + remoteConnectionId, + remoteSshHost, + includeHidden, + ); startupTrace.markPhase('session_metadata_page_request_end', { remote, source: traceSource, @@ -6637,6 +6949,11 @@ export class FlowChatStore { }; } + const deletedSessionIds = await deletedSessionIdsPromise; + if (deletedSessionIds.length > 0) { + markSessionsConfirmedDeleted(deletedSessionIds); + } + await this.processPersistedSessionMetadataList( page.sessions, workspacePath, @@ -6693,6 +7010,24 @@ export class FlowChatStore { sessionCount, }); + // Pre-warm the confirmed-deleted registry from the backend deletion + // tombstone so sessions deleted while this client was not watching + // (for example while the tab was closed) are filtered by + // `isSessionConfirmedDeleted` below and cannot resurrect as ghosts + // from residual disk metadata. + try { + const deletedSessionIds = await sessionAPI.listDeletedSessionIds( + workspacePath, + remoteConnectionId, + remoteSshHost, + ); + if (deletedSessionIds.length > 0) { + markSessionsConfirmedDeleted(deletedSessionIds); + } + } catch (error) { + log.warn('Failed to pre-warm confirmed deleted session ids from backend tombstone', error); + } + const { stateMachineManager } = await import('../state-machine'); let models: any[] = []; @@ -6724,6 +7059,11 @@ export class FlowChatStore { if (existingSession) { return; } + // A session whose deletion was confirmed (locally or on the backend) + // must not be resurrected by residual disk metadata on refresh. + if (isSessionConfirmedDeleted(metadata.sessionId)) { + return; + } // Skip archived sessions - they are managed in the settings page if (metadata.status === 'archived') { return; @@ -6731,7 +7071,7 @@ export class FlowChatStore { stateMachineManager.getOrCreate(metadata.sessionId); - let maxContextTokens = 128128; + let maxContextTokens = 1048576; if (metadata.modelName) { const model = models.find((m: any) => m.name === metadata.modelName || m.id === metadata.modelName); if (model?.context_window) { @@ -6739,7 +7079,7 @@ export class FlowChatStore { } } - if (maxContextTokens === 128128) { + if (maxContextTokens === 1048576) { const primaryModelId = defaultModels?.primary; if (primaryModelId) { @@ -6814,6 +7154,7 @@ export class FlowChatStore { sessionKind: relationship.sessionKind, parentToolCallId: relationship.parentToolCallId, subagentType: relationship.subagentType, + depth: relationship.depth, btwThreads: [], btwOrigin: relationship.btwOrigin, hasUnreadCompletion: metadata.unreadCompletion, diff --git a/src/web-ui/src/flow_chat/tool-cards/CreatePlanDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/CreatePlanDisplay.tsx index 97fcbe8669..e160657cab 100644 --- a/src/web-ui/src/flow_chat/tool-cards/CreatePlanDisplay.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/CreatePlanDisplay.tsx @@ -352,7 +352,8 @@ export const PlanDisplay: React.FC = ({ const simpleTodos = latestPlanData.todos.map(t => ({ id: t.id, content: t.content, - status: t.status + status: t.status, + dependencies: t.dependencies, })); const message = `Implement the plan as specified, it is attached for your reference. Do NOT edit the plan file itself. To-do's from the plan have already been created. Do not create them again. Mark them as in_progress as you work, starting with the first one. Don't stop until you have completed all the to-dos. @@ -476,10 +477,11 @@ ${JSON.stringify(simpleTodos, null, 2)} {planData.todos && planData.todos.length > 0 && isTodosExpanded && (
- {todoRenderItems.map(({ todo, key }) => ( + {todoRenderItems.map(({ todo, key, depth }) => (
0 ? { paddingLeft: 12 + depth * 16 } : undefined} data-bf-component="create-plan-display" data-bf-part="todo" > diff --git a/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.scss b/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.scss index ade4241b94..f7af3a8427 100644 --- a/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.scss +++ b/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.scss @@ -239,6 +239,20 @@ background: rgba(var(--task-failed-badge-rgb), 0.15); } + .task-deleted-session-badge { + --task-deleted-session-badge-rgb: 107, 114, 128; + + display: inline-flex; + align-items: center; + padding: 0.1rem var(--bf-appearance-token-flowchat-inline-gap); + border-radius: 3px; + font-size: var(--bf-appearance-token-flowchat-font-size-xxs); + font-weight: 500; + flex-shrink: 0; + color: var(--bf-appearance-token-color-text-muted); + background: rgba(var(--task-deleted-session-badge-rgb), 0.15); + } + .task-review-outcome { display: inline-flex; align-items: center; diff --git a/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.test.tsx b/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.test.tsx index 49c8d5877a..e4f5472d14 100644 --- a/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.test.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.test.tsx @@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({ cancelSession: vi.fn(), notificationError: vi.fn(), flowChatListeners: new Set<() => void>(), + isSessionConfirmedDeleted: vi.fn(() => false), dynamicReviewTurn: { status: 'processing', startTime: 1000, @@ -138,8 +139,7 @@ vi.mock('../store/FlowChatStore', () => ({ return () => mocks.flowChatListeners.delete(listener); }, getState: () => ({ - sessions: new Map([ - ['parent-session', { + sessions: new Map([ ['parent-session', { sessionId: 'parent-session', workspacePath: 'D:\\workspace\\repo', remoteConnectionId: 'remote-1', @@ -159,6 +159,30 @@ vi.mock('../store/FlowChatStore', () => ({ config: { agentType: 'Explore', modelName: 'fast' }, dialogTurns: [], }], + ['code-review-session-1', { + sessionId: 'code-review-session-1', + mode: 'CodeReview', + config: { agentType: 'CodeReview', modelName: 'fast' }, + dialogTurns: [], + }], + ['legacy-review-security-session', { + sessionId: 'legacy-review-security-session', + mode: 'ReviewSecurity', + config: { agentType: 'ReviewSecurity', modelName: 'fast' }, + dialogTurns: [], + }], + ['legacy-review-judge-session', { + sessionId: 'legacy-review-judge-session', + mode: 'ReviewJudge', + config: { agentType: 'ReviewJudge', modelName: 'fast' }, + dialogTurns: [], + }], + ['custom-review-security-session', { + sessionId: 'custom-review-security-session', + mode: 'ReviewSecurity', + config: { agentType: 'ReviewSecurity', modelName: 'fast' }, + dialogTurns: [], + }], ['review-session-running', { sessionId: 'review-session-running', mode: 'CodeReview', @@ -237,6 +261,8 @@ vi.mock('../store/FlowChatStore', () => ({ ]), }), }, + isSessionConfirmedDeleted: (sessionId: string | null | undefined) => + mocks.isSessionConfirmedDeleted(sessionId), })); let JSDOMCtor: (new ( @@ -1555,4 +1581,37 @@ describeWithJsdom('TaskToolDisplay', () => { expect(container.querySelector('.base-tool-card.expanded')).toBeNull(); expect(taskCollapseStateManager.isCollapsed('task-tool-cancel')).toBe(true); }); + + it('renders the deleted placeholder when the linked subagent session is confirmed deleted', async () => { + mocks.isSessionConfirmedDeleted.mockReturnValue(true); + const toolItem: FlowToolItem = { + ...reviewTaskItem('completed', 'Explore', 'Investigate a removed subagent'), + subagentSessionId: 'subagent-session-1', + toolCall: { + id: 'task-call-1', + input: { + description: 'Investigate a removed subagent', + prompt: 'Explore the removed subagent path', + subagent_type: 'Explore', + }, + }, + }; + + await act(async () => { + root.render( + , + ); + }); + + // Even though `subagent-session-1` still exists in the store snapshot + // (stale entry), the confirmed-deleted registry must win and render the + // placeholder instead of the live title/rail. + expect(container.querySelector('.task-deleted-session-badge')).toBeTruthy(); + expect(container.querySelector('.task-header-rail__hit')).toBeNull(); + expect(mocks.isSessionConfirmedDeleted).toHaveBeenCalledWith('subagent-session-1'); + }); }); diff --git a/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.tsx index cc7bca2ed7..079dca162d 100644 --- a/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.tsx @@ -34,7 +34,7 @@ import { ToolTimeoutIndicator } from './ToolTimeoutIndicator'; import { getReviewerContextBySubagentId } from '@/shared/services/reviewTeamService'; import type { ReviewerContext } from '@/shared/services/reviewTeamService'; import { loadBtwSessionHistory, openBtwSessionInAuxPane } from '../services/btwSessionPane'; -import { flowChatStore } from '../store/FlowChatStore'; +import { flowChatStore, isSessionConfirmedDeleted } from '../store/FlowChatStore'; import { useSessionGoalModeActive } from '../hooks/useSessionGoalModeActive'; import { deriveSubagentExecutionStatus } from '../utils/subagentProjection'; import { deriveReviewTaskOutcome } from '../utils/reviewTaskOutcome'; @@ -177,7 +177,11 @@ function readLinkedSubagentSnapshot(sessionId: string): string { } const session = flowChatStore.getState().sessions.get(sessionId); const turn = session?.dialogTurns?.[session.dialogTurns.length - 1]; + // Include the confirmed-deleted flag so a session recorded as deleted + // (backend tombstone pre-warm or deletion event) renders the deleted + // placeholder even when a stale store entry still exists. return JSON.stringify([ + isSessionConfirmedDeleted(sessionId), session?.mode ?? '', session?.config?.agentType ?? '', session?.config?.modelName ?? '', @@ -534,6 +538,15 @@ export const TaskToolDisplay: React.FC = ({ const effectiveIsRunning = projectedSubagentStatus == null ? isRunning : projectedSubagentIsRunning; + const linkedSubagentSessionMissing = Boolean( + linkedSubagentSessionId && + ( + // A backend-confirmed deletion (deletion event or tombstone pre-warm) + // renders the deleted placeholder even when a stale store entry exists. + isSessionConfirmedDeleted(linkedSubagentSessionId) || + (!linkedSubagentSession && !effectiveIsRunning) + ), + ); const isFailed = !projectedSubagentIsRunning && ( displayStatus === 'error' || ( !isCancelledResult && @@ -805,6 +818,11 @@ export const TaskToolDisplay: React.FC = ({ {t(reviewOutcome.key)} )} + {linkedSubagentSessionMissing && ( + + {t('toolCards.taskTool.deletedSessionLabel')} + + )} {canStopSyncSubagent && (
- {!isCancelAction && ( + {!isCancelAction && !linkedSubagentSessionMissing && (