From 085fe84fb3b12f4dec4e0362fbce8befdb783999 Mon Sep 17 00:00:00 2001 From: BitFun Research Date: Sat, 18 Jul 2026 15:01:38 +0800 Subject: [PATCH 1/2] feat: add LegionControl DAG topology execution engine with presets and commander prompt --- src/apps/desktop/src/api/agentic_api.rs | 27 + .../src/api/remote_workspace_policy.rs | 20 + .../agents/definitions/subagents/acp_agent.rs | 77 +++ .../agents/definitions/subagents/mod.rs | 2 + .../assembly/core/src/agentic/agents/mod.rs | 6 +- .../src/agentic/agents/prompts/team_mode.md | 359 +++-------- .../core/src/agentic/agents/team_presets.rs | 135 ++++ .../implementations/legion_control_tool.rs | 588 ++++++++++++++++++ .../src/agentic/tools/implementations/mod.rs | 2 + .../core/src/agentic/tools/post_call_hooks.rs | 165 ++++- .../src/agentic/tools/tool_context_runtime.rs | 22 +- .../agent-runtime/src/post_call_hooks.rs | 165 ++++- .../agent-runtime/src/session_control.rs | 19 +- .../tests/session_control_contracts.rs | 2 +- .../execution/tool-provider-groups/src/lib.rs | 2 +- 15 files changed, 1307 insertions(+), 284 deletions(-) create mode 100644 src/crates/assembly/core/src/agentic/agents/definitions/subagents/acp_agent.rs create mode 100644 src/crates/assembly/core/src/agentic/agents/team_presets.rs create mode 100644 src/crates/assembly/core/src/agentic/tools/implementations/legion_control_tool.rs diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index 8209bedf4c..b67cfa448a 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -2497,6 +2497,33 @@ fn system_time_to_unix_secs(time: std::time::SystemTime) -> u64 { } } +// ── Legion preset commands ────────────────────────────────────────── + +#[tauri::command] +pub async fn list_legion_presets() -> Result, String> { + bitfun_core::agentic::agents::team_presets::list_presets() +} + +#[tauri::command] +pub async fn get_legion_preset(id: String) -> Result { + bitfun_core::agentic::agents::team_presets::get_preset(&id) +} + +#[tauri::command] +pub async fn create_legion_preset(preset: bitfun_core::agentic::agents::team_presets::LegionPreset) -> Result<(), String> { + bitfun_core::agentic::agents::team_presets::create_preset(&preset) +} + +#[tauri::command] +pub async fn update_legion_preset(preset: bitfun_core::agentic::agents::team_presets::LegionPreset) -> Result<(), String> { + bitfun_core::agentic::agents::team_presets::update_preset(&preset) +} + +#[tauri::command] +pub async fn delete_legion_preset(id: String) -> Result<(), String> { + bitfun_core::agentic::agents::team_presets::delete_preset(&id) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index 5d72932902..73a6f889d2 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -278,6 +278,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = ), ("create_directory", RemoteWorkspacePolicy::LegacyUnaudited), ("create_file", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "create_legion_preset", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), ("create_miniapp", RemoteWorkspacePolicy::LegacyUnaudited), ("create_session", RemoteWorkspacePolicy::LegacyUnaudited), ("create_subagent", RemoteWorkspacePolicy::LegacyUnaudited), @@ -305,6 +309,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = ), ("delete_directory", RemoteWorkspacePolicy::LegacyUnaudited), ("delete_file", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "delete_legion_preset", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), ("delete_mcp_server", RemoteWorkspacePolicy::LegacyUnaudited), ("delete_miniapp", RemoteWorkspacePolicy::LegacyUnaudited), ( @@ -477,6 +485,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "get_latest_insights", RemoteWorkspacePolicy::LegacyUnaudited, ), + ( + "get_legion_preset", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), ("get_mcp_prompt", RemoteWorkspacePolicy::LegacyUnaudited), ( "get_mcp_remote_oauth_session", @@ -727,6 +739,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "list_directory_files", RemoteWorkspacePolicy::LegacyUnaudited, ), + ( + "list_legion_presets", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), ( "list_manageable_subagents", RemoteWorkspacePolicy::RemoteRouted, @@ -1527,6 +1543,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = RemoteWorkspacePolicy::WorkspaceAgnostic, ), ("update_cron_job", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "update_legion_preset", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), ( "update_custom_agent", RemoteWorkspacePolicy::LegacyUnaudited, 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..5203ed90a6 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/agents/definitions/subagents/acp_agent.rs @@ -0,0 +1,77 @@ +//! 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::{Agent, UserContextPolicy}; +use async_trait::async_trait; + +/// 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); + Self { + // ACP prompt tool is registered in the global tool registry by + // register_configured_tools() — do NOT add it to default_tools + // here, or the tool name will appear twice in the model manifest. + default_tools: vec![ + "Read".to_string(), + "Grep".to_string(), + "Glob".to_string(), + "LS".to_string(), + ], + agent_id, + display_name, + } + } + + /// The agent registry id: `acp__` + pub fn agent_id_for(client_id: &str) -> String { + format!("acp__{client_id}") + } +} + +#[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 + } +} 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 ca52676f59..4fe0d41ead 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; @@ -35,7 +36,8 @@ pub use definitions::review::{ }; 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::{ @@ -132,6 +134,8 @@ pub fn shared_coding_mode_tools() -> Vec { "ReviewPlatform".to_string(), "ControlHub".to_string(), "InitMiniApp".to_string(), + "SessionMessage".to_string(), + "SessionHistory".to_string(), ]; append_provider_group_tools(&mut tools, "core.canvas"); tools diff --git a/src/crates/assembly/core/src/agentic/agents/prompts/team_mode.md b/src/crates/assembly/core/src/agentic/agents/prompts/team_mode.md index 30b1ce5b4e..0be0119c22 100644 --- a/src/crates/assembly/core/src/agentic/agents/prompts/team_mode.md +++ b/src/crates/assembly/core/src/agentic/agents/prompts/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/src/agentic/agents/team_presets.rs b/src/crates/assembly/core/src/agentic/agents/team_presets.rs new file mode 100644 index 0000000000..7401f08518 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/agents/team_presets.rs @@ -0,0 +1,135 @@ +//! 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, + pub role: String, + 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().map_or(false, |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/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..0515606be3 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/tools/implementations/legion_control_tool.rs @@ -0,0 +1,588 @@ +//! LegionControl tool — load and instantiate legion templates. +//! +//! Reads `/legions/.json`, topologically sorts nodes by +//! edges, creates each node via `SessionControl` path, and returns the +//! created session list. + +use crate::agentic::coordination::{get_global_coordinator, get_global_scheduler}; +use crate::agentic::tools::framework::{ + Tool, ToolExposure, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, +}; +use crate::infrastructure::get_path_manager_arc; +use crate::service_agent_runtime::CoreServiceAgentRuntime; +use crate::util::errors::{BitFunError, BitFunResult}; +use async_trait::async_trait; +use bitfun_runtime_ports::{ + AgentDialogTurnRequest, AgentSessionCreateRequest, AgentSessionDeleteRequest, + DialogSubmissionPolicy, DialogTriggerSource, +}; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::path::PathBuf; + +/// JSON format of a legion template file. +#[derive(Debug, Deserialize)] +struct LegionTemplate { + id: String, + name: String, + #[serde(default)] + nodes: Vec, + #[serde(default)] + edges: Vec, +} + +#[derive(Debug, Deserialize, Clone)] +#[allow(dead_code)] +struct LegionNode { + id: String, + agent: String, + #[serde(default)] + role: String, + #[serde(default)] + prompt: String, +} + +#[derive(Debug, Deserialize)] +struct LegionEdge { + from: String, + to: String, + #[serde(default)] + condition: String, +} + +#[derive(Debug, Deserialize)] +struct LegionControlInput { + action: String, + #[serde(default)] + legion_id: String, + #[serde(default)] + workspace: Option, + /// When true, send each first-layer node its prompt as the initial task message. + #[serde(default)] + send_initial_message: bool, +} + +pub struct LegionControlTool; + +impl LegionControlTool { + pub fn new() -> Self { + Self + } + + fn config_dir(&self) -> PathBuf { + get_path_manager_arc() + .user_config_dir() + .join("legions") + } + + fn legion_path(&self, legion_id: &str) -> PathBuf { + self.config_dir().join(format!("{}.json", legion_id)) + } +} + +#[async_trait] +impl Tool for LegionControlTool { + fn name(&self) -> &str { + "LegionControl" + } + + async fn description(&self) -> BitFunResult { + Ok(concat!( + "Load and instantiate a legion template.\n\n", + "Actions:\n", + "- load: Read a legion template from the user config directory, ", + "topologically sort nodes by edges, create each node as a new agent session ", + "via SessionControl, and return the created session list.\n", + "- list: List available legion templates.\n\n", + "Parameters:\n", + "- action: \"load\" or \"list\"\n", + "- legion_id: template id (required for load)\n", + "- workspace: override workspace path (optional, defaults to current workspace)", + ) + .to_string()) + } + + fn short_description(&self) -> String { + "Load and instantiate legion templates with automatic topology-sorted node creation." + .to_string() + } + + fn default_exposure(&self) -> ToolExposure { + ToolExposure::Direct + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["load", "list"] + }, + "legion_id": { + "type": "string", + "description": "Legion template id (required for load action)" + }, + "workspace": { + "type": "string", + "description": "Override workspace path" + } + }, + "required": ["action"], + "additionalProperties": false + }) + } + + fn is_readonly(&self) -> bool { + false + } + + async fn validate_input( + &self, + input: &Value, + _context: Option<&ToolUseContext>, + ) -> ValidationResult { + match serde_json::from_value::(input.clone()) { + Ok(params) => { + if params.action == "load" && params.legion_id.is_empty() { + return ValidationResult { + result: false, + message: Some("legion_id is required for load action".to_string()), + error_code: None, + meta: None, + }; + } + ValidationResult::default() + } + Err(e) => ValidationResult { + result: false, + message: Some(format!("Invalid input: {}", e)), + error_code: None, + meta: None, + }, + } + } + + fn render_tool_use_message(&self, input: &Value, _options: &ToolRenderOptions) -> String { + let action = input + .get("action") + .and_then(Value::as_str) + .unwrap_or("?"); + let legion_id = input + .get("legion_id") + .and_then(Value::as_str) + .unwrap_or("?"); + format!("LegionControl {} {}", action, legion_id) + } + + 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 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)?; + + match params.action.as_str() { + "list" => self.list_legions().await, + "load" => { + self.load_and_instantiate(¶ms, context, &runtime) + .await + } + _ => Err(BitFunError::tool(format!( + "Unknown action: {}. Supported: load, list", + params.action + ))), + } + } +} + +impl LegionControlTool { + async fn list_legions(&self) -> BitFunResult> { + let dir = self.config_dir(); + let mut names: Vec = Vec::new(); + + if let Ok(entries) = std::fs::read_dir(&dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().is_some_and(|ext| ext == "json") { + if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) { + names.push(stem.to_string()); + } + } + } + } + + let result_text = if names.is_empty() { + format!( + "No legion templates found in {}", + dir.display() + ) + } else { + let mut lines = vec!["Available legion templates:".to_string()]; + for name in &names { + lines.push(format!("- {}", name)); + } + lines.join("\n") + }; + + Ok(vec![ToolResult::Result { + data: json!({ "legions": names }), + result_for_assistant: Some(result_text), + image_attachments: None, + }]) + } + + async fn load_and_instantiate( + &self, + params: &LegionControlInput, + context: &ToolUseContext, + runtime: &bitfun_agent_runtime::sdk::AgentRuntime, + ) -> BitFunResult> { + let path = self.legion_path(¶ms.legion_id); + let content = std::fs::read_to_string(&path).map_err(|e| { + BitFunError::tool(format!( + "Failed to read legion template {}: {}", + path.display(), + e + )) + })?; + + let template: LegionTemplate = serde_json::from_str(&content).map_err(|e| { + BitFunError::tool(format!("Invalid legion template: {}", e)) + })?; + + if template.nodes.is_empty() { + return Err(BitFunError::tool("Legion template has no nodes".to_string())); + } + + // Determine workspace + let workspace = params + .workspace + .clone() + .or_else(|| context.workspace_root().map(|p| p.to_string_lossy().to_string())) + .ok_or_else(|| BitFunError::tool("workspace is required".to_string()))?; + + // Topological sort + let sorted_ids = topological_sort(&template.nodes, &template.edges)?; + + // Build node lookup + let node_map: HashMap<&str, &LegionNode> = + template.nodes.iter().map(|n| (n.id.as_str(), n)).collect(); + + // Create sessions in topological order with rollback on failure + let mut created_sessions: Vec = Vec::new(); + let mut created_session_ids: Vec<(String, String)> = Vec::new(); // (session_id, workspace) + let mut session_lines: Vec = vec![format!( + "## Legion: {} ({})", + template.name, template.id + )]; + session_lines.push(format!("Workspace: {}", workspace)); + session_lines.push(String::new()); + + // Batch create: group independent nodes per topological layer + let layers = build_topological_layers(&sorted_ids, &template.edges); + let mut creation_result: BitFunResult<()> = Ok(()); + 'creation: for (layer_idx, layer) in layers.iter().enumerate() { + if layer.len() > 1 { + session_lines.push(format!( + "**Layer {} ({} parallel nodes):**", + layer_idx + 1, + layer.len() + )); + } + + for node_id in layer { + let node = match node_map.get(node_id.as_str()) { + Some(n) => n, + None => continue, + }; + + let session_name = if node.role.is_empty() { + format!("{}-{}", template.name, node.id) + } else { + format!("{}-{}", template.name, node.role) + }; + + let session = match runtime + .create_session(AgentSessionCreateRequest { + session_name, + agent_type: node.agent.clone(), + workspace_path: Some(workspace.clone()), + remote_connection_id: None, + remote_ssh_host: None, + metadata: { + let mut meta = serde_json::Map::new(); + meta.insert( + "legionId".to_string(), + json!(template.id), + ); + meta.insert( + "legionNodeId".to_string(), + json!(node.id), + ); + meta.insert( + "legionRole".to_string(), + json!(node.role), + ); + meta + }, + }) + .await + { + Ok(session) => session, + Err(error) => { + creation_result = Err(BitFunError::tool( + CoreServiceAgentRuntime::runtime_error_message(error), + )); + break 'creation; + } + }; + + created_session_ids + .push((session.session_id.clone(), workspace.clone())); + + let entry = json!({ + "node_id": node.id, + "role": node.role, + "agent_type": node.agent, + "session_id": session.session_id, + "session_name": session.session_name, + }); + created_sessions.push(entry); + + session_lines.push(format!( + "- **{}** ({}) → session `{}` (agent: {})", + node.role, node.id, session.session_id, node.agent + )); + } + + if layer.len() > 1 { + session_lines.push(String::new()); + } + } + + // Rollback on failure: delete all successfully created sessions + if creation_result.is_err() { + let error_msg = creation_result.unwrap_err(); + let mut delete_errors: Vec = Vec::new(); + for (sid, ws) in &created_session_ids { + if let Err(e) = runtime + .delete_session(AgentSessionDeleteRequest { + workspace_path: ws.clone(), + session_id: sid.clone(), + remote_connection_id: None, + remote_ssh_host: None, + }) + .await + { + delete_errors.push(format!(" {}: {}", sid, e)); + } + } + let mut msg = format!( + "Failed to create all legion sessions. {} Created {} session(s) which were cleaned up.", + error_msg, + created_session_ids.len() + ); + if !delete_errors.is_empty() { + msg.push_str(&format!( + "\nNote: {} session(s) could not be cleaned up:\n{}", + delete_errors.len(), + delete_errors.join("\n") + )); + } + return Err(BitFunError::tool(msg)); + } + + // Append edge structure + if !template.edges.is_empty() { + session_lines.push(String::from("### Edges")); + for edge in &template.edges { + let cond = if edge.condition.is_empty() { + String::new() + } else { + format!(" [condition: {}]", edge.condition) + }; + session_lines.push(format!("- {} → {}{}", edge.from, edge.to, cond)); + } + } + + // Auto-send initial task messages to first-layer nodes + if params.send_initial_message && !layers.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 dialog_runtime = CoreServiceAgentRuntime::agent_runtime_with_dialog_turns( + coordinator.clone(), + scheduler, + ) + .map_err(BitFunError::tool)?; + + let first_layer = &layers[0]; + session_lines.push(String::new()); + session_lines.push(format!( + "Sent initial tasks to {} first-layer node(s)", + first_layer.len() + )); + + for node_id in first_layer { + let node = match node_map.get(node_id.as_str()) { + Some(n) => n, + None => continue, + }; + let entry = created_sessions + .iter() + .find(|e| e["node_id"].as_str() == Some(node.id.as_str())); + let session_id = match entry.and_then(|e| e["session_id"].as_str()) { + Some(sid) => sid.to_string(), + None => continue, + }; + + let task_message = if node.prompt.is_empty() { + format!("Execute your role: {}", node.role) + } else { + node.prompt.clone() + }; + + let _ = dialog_runtime + .submit_dialog_turn(AgentDialogTurnRequest { + session_id, + message: task_message.clone(), + original_message: None, + turn_id: None, + agent_type: node.agent.clone(), + workspace_path: Some(workspace.clone()), + remote_connection_id: None, + remote_ssh_host: None, + policy: DialogSubmissionPolicy::for_source(DialogTriggerSource::AgentSession), + reply_route: None, + prepended_reminders: vec![], + attachments: vec![], + metadata: serde_json::Map::new(), + }) + .await; + + session_lines.push(format!( + "- Sent to **{}**: {}", + node.role, + if node.prompt.len() > 80 { + format!("{}...", &node.prompt[..77]) + } else { + node.prompt.clone() + } + )); + } + } + + Ok(vec![ToolResult::Result { + data: json!({ + "legion_id": template.id, + "legion_name": template.name, + "nodes_created": created_sessions.len(), + "sessions": created_sessions, + }), + result_for_assistant: Some(session_lines.join("\n")), + image_attachments: None, + }]) + } +} + +/// Topological sort: nodes with no incoming edges first. +fn topological_sort( + nodes: &[LegionNode], + edges: &[LegionEdge], +) -> BitFunResult> { + let mut in_degree: HashMap<&str, usize> = HashMap::new(); + let mut adjacency: HashMap<&str, Vec<&str>> = HashMap::new(); + + for node in nodes { + in_degree.entry(node.id.as_str()).or_insert(0); + adjacency.entry(node.id.as_str()).or_default(); + } + + for edge in edges { + // Skip conditional edges (fail/retry) — they are runtime routing, + // not compile-time dependencies for the DAG. + if !edge.condition.is_empty() { + continue; + } + adjacency + .entry(edge.from.as_str()) + .or_default() + .push(edge.to.as_str()); + *in_degree.entry(edge.to.as_str()).or_insert(0) += 1; + } + + let mut queue: VecDeque<&str> = in_degree + .iter() + .filter(|(_, °)| deg == 0) + .map(|(&id, _)| id) + .collect(); + + let mut sorted: Vec = Vec::new(); + while let Some(node) = queue.pop_front() { + sorted.push(node.to_string()); + if let Some(neighbors) = adjacency.get(node) { + for &neighbor in neighbors { + if let Some(deg) = in_degree.get_mut(neighbor) { + *deg -= 1; + if *deg == 0 { + queue.push_back(neighbor); + } + } + } + } + } + + if sorted.len() != nodes.len() { + let node_ids: HashSet<&str> = nodes.iter().map(|n| n.id.as_str()).collect(); + let sorted_set: HashSet<&str> = sorted.iter().map(|s| s.as_str()).collect(); + let missing: Vec<_> = node_ids.difference(&sorted_set).collect(); + return Err(BitFunError::tool(format!( + "Cyclic dependency detected in legion edges. Unresolved nodes: {:?}", + missing + ))); + } + + Ok(sorted) +} + +/// Group topologically sorted nodes into layers where each layer can execute in parallel. +fn build_topological_layers( + sorted_ids: &[String], + edges: &[LegionEdge], +) -> Vec> { + let mut layers: Vec> = Vec::new(); + let mut assigned: HashMap<&str, usize> = HashMap::new(); + + for id in sorted_ids { + // Place node one layer after all its predecessors. + // Nodes with no predecessors land on layer 0. + let max_pred_layer = edges + .iter() + .filter(|e| e.to == *id && e.condition.is_empty()) + .filter_map(|e| assigned.get(e.from.as_str())) + .max() + .copied(); + + let layer = match max_pred_layer { + Some(max_layer) => max_layer + 1, + None => 0, + }; + while layers.len() <= layer { + layers.push(Vec::new()); + } + layers[layer].push(id.clone()); + assigned.insert(id.as_str(), layer); + } + + layers.retain(|l| !l.is_empty()); + layers +} 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 81453cb82c..79d39ed797 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs @@ -26,6 +26,7 @@ pub mod get_time_tool; pub mod git_tool; pub mod glob_tool; pub mod grep_tool; +pub mod legion_control_tool; pub mod ls_tool; pub mod mcp_tools; pub mod miniapp_init_tool; @@ -67,6 +68,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 ls_tool::LSTool; pub use mcp_tools::{ GetMCPPromptTool, ListMCPPromptsTool, ListMCPResourcesTool, ReadMCPResourceTool, diff --git a/src/crates/assembly/core/src/agentic/tools/post_call_hooks.rs b/src/crates/assembly/core/src/agentic/tools/post_call_hooks.rs index db411435d2..89d906b5b0 100644 --- a/src/crates/assembly/core/src/agentic/tools/post_call_hooks.rs +++ b/src/crates/assembly/core/src/agentic/tools/post_call_hooks.rs @@ -3,13 +3,79 @@ //! The tool framework stays generic and calls this module after successful //! tool execution. Domain-specific hooks must keep their own gating inside the //! owning domain module. +//! +//! ## Hook Architecture (inspired by cc-haha / Claude Code hook system) +//! +//! cc-haha has 24 hook events + pre-tool / post-tool + exit-code protocol. +//! BitFun's current hook is post-call only, so we use session-level state +//! tracking to enforce cross-call invariants: +//! +//! - FILE_READ_TRACKER: records files read per session → Edit/Write/Delete +//! without prior Read yields Abort. +//! - STALE_TRACKER: repeated same-tool calls → Abort at threshold 3. +//! - LIONHEART_PATH_GUARD: Delete on LionHeart library paths → Abort. +//! +//! Future: pre-tool hooks (cc-haha `executePreToolHooks`) would let us block +//! before execution instead of aborting after the fact. use crate::agentic::deep_review::tool_measurement; +use crate::agentic::session::session_manager::SessionManager; use crate::agentic::tools::tool_context_runtime::ToolUseContext; use bitfun_agent_runtime::post_call_hooks::{ - run_successful_tool_post_call_hooks, SuccessfulToolPostCallHookExecutor, + run_stop_hooks, run_successful_tool_post_call_hooks, HookResult, StopHookAggregatedResult, + StopHookContext, StopHookExecutor, SuccessfulToolPostCallHookExecutor, ToolCallSummary, }; use serde_json::Value; +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, Mutex}; + +// ── File Read Tracking (data collection for B01/C01 context) ──── + +/// Tracks which files have been read per session. +/// +/// When a Read call succeeds, the file path is recorded here. +/// When an Edit/Write/Delete call fires, we check whether the target +/// file was previously read in this session. If not → Abort. +static FILE_READ_TRACKER: std::sync::LazyLock>>> = + std::sync::LazyLock::new(|| Mutex::new(HashMap::new())); + +/// Normalize a file path for consistent lookup in FILE_READ_TRACKER. +fn normalize_path(path: &str) -> String { + path.replace('\\', "/").trim_end_matches('/').to_lowercase() +} + +/// Record that a file was successfully read in this session. +fn record_file_read(session_id: &str, file_path: &str) { + if let Ok(mut tracker) = FILE_READ_TRACKER.lock() { + tracker + .entry(session_id.to_string()) + .or_default() + .insert(normalize_path(file_path)); + } +} + +/// Remove the file-read tracking entry for a given session. +#[allow(dead_code)] +pub(crate) fn remove_file_read_tracker_for_session(session_id: &str) { + if let Ok(mut tracker) = FILE_READ_TRACKER.lock() { + tracker.remove(session_id); + } +} + +// ── Bee-Review Buffer (cc-haha pattern: LLM result → inject next round) ── + +use std::sync::LazyLock; +static REVIEW_BUFFER: LazyLock>>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +/// Push a review result for a session (called from execution_engine spawn). +pub(crate) fn push_review_result(session_id: &str, result: String) { + if let Ok(mut buf) = REVIEW_BUFFER.lock() { + buf.entry(session_id.to_string()).or_default().push(result); + } +} + +// ── Hook Executor ─────────────────────────────────────────────── struct CorePostCallHookExecutor; @@ -22,13 +88,106 @@ impl SuccessfulToolPostCallHookExecutor for CorePostCallHookExec ) { tool_measurement::maybe_record_shared_context_tool_use(tool_name, input, context); } + + fn behavior_guard( + &mut self, + tool_name: &str, + input: &Value, + context: &ToolUseContext, + ) -> HookResult { + let session_id = match &context.session_id { + Some(id) => id.as_str(), + None => return HookResult::Continue, + }; + + // ── Track file reads (data collection only, no enforcement) ── + if matches!(tool_name, "Read" | "read_file") { + if let Some(file_path) = input.get("file_path").and_then(Value::as_str) { + record_file_read(session_id, file_path); + } + } + + // All enforcement moved to B01+C01 async agent review. + HookResult::Continue + } +} + +impl StopHookExecutor for CorePostCallHookExecutor { + /// B01+C01 review is handled by async agent sessions spawned in + /// execution_engine via std::thread + coordinator.start_dialog_turn. + /// The stop hook is a pure trigger point — no synchronous checks. + fn context_guard(&mut self, _ctx: &StopHookContext) -> HookResult { + HookResult::Continue + } + + fn behavior_guard(&mut self, _ctx: &StopHookContext) -> HookResult { + HookResult::Continue + } } pub(crate) fn record_successful_tool_call( tool_name: &str, input: &Value, context: &ToolUseContext, -) { +) -> HookResult { + let mut executor = CorePostCallHookExecutor; + run_successful_tool_post_call_hooks(tool_name, input, context, &mut executor) +} + +/// Stop hook — injects self-review reminder at round boundaries. +/// Matches the goal system pattern: no separate sessions, no context pushing, +/// just a reminder injected for the main agent to self-review. +pub(crate) fn run_stop_hooks_for_round( + _session_manager: &Arc, + session_id: &str, + turn_id: &str, + round_index: u32, + tool_calls: Vec, + assistant_text: &str, + file_reads: Vec, + file_edits: Vec, + round_has_more: bool, +) -> StopHookAggregatedResult { + let ctx = StopHookContext { + session_id: session_id.to_string(), + turn_id: turn_id.to_string(), + round_index, + tool_calls, + assistant_text_summary: assistant_text.to_string(), + file_reads, + file_edits, + round_has_more, + }; let mut executor = CorePostCallHookExecutor; - run_successful_tool_post_call_hooks(tool_name, input, context, &mut executor); + let mut result = run_stop_hooks(&ctx, &mut executor); + + // ── Bee-review: drain pending LLM results (cc-haha pattern) ── + if let Ok(mut buf) = REVIEW_BUFFER.lock() { + if let Some(results) = buf.remove(ctx.session_id.as_str()) { + for r in results { + let trimmed = r.trim(); + if trimmed.is_empty() || trimmed == "PASS" { + continue; + } + if trimmed.starts_with("ABORT:") || trimmed.starts_with("ABORT:") { + result.abort = Some(HookResult::Abort { + reason: format!("[审查员] {}", trimmed), + fix_instruction: "按审查员建议修正后继续。".to_string(), + max_retries: 1, + }); + } else if trimmed.starts_with("CTX:") || trimmed.starts_with("CTX:") { + result.additional_contexts.push(format!("[书记官] 上下文恢复: {}", &trimmed[4..].trim())); + } else if trimmed.starts_with("SKILL:") || trimmed.starts_with("SKILL:") { + result.additional_contexts.push(format!("[提示蜂] 推荐加载: {}", &trimmed[6..].trim())); + } else if trimmed.starts_with("WARN:") || trimmed.starts_with("WARN:") { + result.additional_contexts.push(format!("[审查员] 警告: {}", &trimmed[5..].trim())); + } else { + result.additional_contexts.push(format!("[审查员] {}", trimmed)); + } + } + } + } + + result } + 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 7c993eb69c..9244c9b5df 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 @@ -208,7 +208,27 @@ pub(crate) async fn call_with_tool_runtime_hooks( }; if result.is_ok() { - post_call_hooks::record_successful_tool_call(tool_name, input, context); + let hook_result = post_call_hooks::record_successful_tool_call(tool_name, input, context); + if let bitfun_agent_runtime::post_call_hooks::HookResult::Abort { + reason, + fix_instruction, + .. + } = hook_result + { + let interception = serde_json::json!({ + "intercepted": true, + "reason": reason, + "fix_instruction": fix_instruction, + }); + return Ok(vec![ToolResult::Result { + data: interception, + result_for_assistant: Some(format!( + "[ToolGuard Intercepted] Result for tool {} was rejected.\nReason: {}\nFix: {}", + tool_name, reason, fix_instruction + )), + image_attachments: None, + }]); + } } result diff --git a/src/crates/execution/agent-runtime/src/post_call_hooks.rs b/src/crates/execution/agent-runtime/src/post_call_hooks.rs index a8ae2ac199..1cf230fbb6 100644 --- a/src/crates/execution/agent-runtime/src/post_call_hooks.rs +++ b/src/crates/execution/agent-runtime/src/post_call_hooks.rs @@ -1,20 +1,43 @@ -//! Portable post-call hook routing decisions. +//! Portable post-call and lifecycle hook routing decisions. +//! +//! Hooks are organized into two tiers: +//! +//! - **Per-tool hooks** (`SuccessfulToolPostCall`, `BehaviorGuard`): +//! fire after each successful tool call. Fine-grained, single-operation scope. +//! +//! - **Turn-level hooks** (`Stop`): +//! fire after each dialog round completes. Whole-round scope — can inspect +//! the cumulative effect of multiple tool calls in a single round. +//! +//! Inspired by cc-haha's Stop hook (used by `/goal` to evaluate progress +//! after every assistant turn) and the LionBuddy V10 supervisor chain +//! (Plan→Do→Check→Act with peer review after each stage). use serde_json::Value; use std::collections::{HashMap, HashSet}; use std::path::Path; -/// Hook categories that concrete runtime integrations may execute after a -/// successful tool call. +/// Hook categories that concrete runtime integrations may execute. +/// +/// `SuccessfulToolPostCall` / `BehaviorGuard` fire per-tool. +/// `Stop` fires per-round (after the assistant message and all tool +/// results for a round are collected). #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[non_exhaustive] pub enum RuntimeHookKind { SuccessfulToolPostCall, DeepReviewSharedContextToolUse, + BehaviorGuard, + /// Fires after each dialog round completes (assistant message + tool results). + /// Carries round-level context including all tool calls in the round. + Stop, } -pub const fn successful_tool_post_call_hooks() -> [RuntimeHookKind; 1] { - [RuntimeHookKind::DeepReviewSharedContextToolUse] +pub const fn successful_tool_post_call_hooks() -> [RuntimeHookKind; 2] { + [ + RuntimeHookKind::DeepReviewSharedContextToolUse, + RuntimeHookKind::BehaviorGuard, + ] } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -26,6 +49,22 @@ pub enum RuntimeHookErrorPolicy { RecordWarning, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HookResult { + Continue, + Abort { + reason: String, + fix_instruction: String, + max_retries: u32, + }, +} + +impl HookResult { + pub fn is_abort(&self) -> bool { + matches!(self, HookResult::Abort { .. }) + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct RuntimeHookPlan { id: String, @@ -151,6 +190,15 @@ pub trait SuccessfulToolPostCallHookExecutor { input: &Value, context: &C, ); + + fn behavior_guard( + &mut self, + _tool_name: &str, + _input: &Value, + _context: &C, + ) -> HookResult { + HookResult::Continue + } } pub fn run_successful_tool_post_call_hooks( @@ -158,7 +206,8 @@ pub fn run_successful_tool_post_call_hooks( input: &Value, context: &C, executor: &mut E, -) where +) -> HookResult +where E: SuccessfulToolPostCallHookExecutor, { for hook in successful_tool_post_call_hooks() { @@ -166,9 +215,113 @@ pub fn run_successful_tool_post_call_hooks( RuntimeHookKind::DeepReviewSharedContextToolUse => { executor.record_deep_review_shared_context_tool_use(tool_name, input, context); } + RuntimeHookKind::BehaviorGuard => { + let result = executor.behavior_guard(tool_name, input, context); + if result.is_abort() { + return result; + } + } RuntimeHookKind::SuccessfulToolPostCall => {} + RuntimeHookKind::Stop => { + // Stop hooks are handled by run_stop_hooks() at the round level, + // not by the per-tool post-call dispatch. + } } } + HookResult::Continue +} + +// ── Stop (round-level) hooks ──────────────────────────────────── + +/// Summary of a single tool call within a round, for Stop hook inspection. +#[derive(Debug, Clone)] +pub struct ToolCallSummary { + pub tool_name: String, + pub is_error: bool, + /// First 256 chars of serialized tool input, for hook inspection. + pub input_preview: String, +} + +/// Context passed to Stop hooks after each dialog round completes. +#[derive(Debug, Clone)] +pub struct StopHookContext { + pub session_id: String, + pub turn_id: String, + pub round_index: u32, + pub tool_calls: Vec, + pub assistant_text_summary: String, + pub file_reads: Vec, + pub file_edits: Vec, + pub round_has_more: bool, +} + +/// Executor for Stop (round-level) hooks. +/// +/// Implementations provide two handlers that mirror the B01/C01 dual-bee +/// pattern from LionBuddy V10: +/// +/// - `context_guard` (B01 提示蜂): checks whether the agent had sufficient +/// context to make good decisions. May inject supplemental knowledge. +/// - `behavior_guard` (C01 审查蜂): checks whether the agent violated any +/// iron rules during the round. +pub trait StopHookExecutor { + /// B01 提示蜂 — context completeness check. + fn context_guard(&mut self, _ctx: &StopHookContext) -> HookResult { + HookResult::Continue + } + + /// C01 审查蜂 — iron-rule violation check. + fn behavior_guard(&mut self, _ctx: &StopHookContext) -> HookResult { + HookResult::Continue + } +} + +/// Aggregate result from running all Stop hook handlers. +/// +/// Collects the first Abort (if any) and all additional context strings. +#[derive(Debug, Clone, Default)] +pub struct StopHookAggregatedResult { + pub abort: Option, + pub additional_contexts: Vec, +} + +impl StopHookAggregatedResult { + pub fn is_abort(&self) -> bool { + self.abort.as_ref().is_some_and(|r| r.is_abort()) + } +} + +/// Run B01 context_guard followed by C01 behavior_guard for a round. +/// +/// Returns the aggregated result. If behavior_guard returns Abort, the +/// caller should inject the abort message into the next round. +pub fn run_stop_hooks( + ctx: &StopHookContext, + executor: &mut E, +) -> StopHookAggregatedResult { + let mut aggregated = StopHookAggregatedResult::default(); + + // B01 提示蜂: context completeness check (informational, non-blocking) + match executor.context_guard(ctx) { + HookResult::Continue => {} + HookResult::Abort { + reason, + fix_instruction, + .. + } => { + aggregated.additional_contexts.push(format!( + "[B01 提示蜂] 上下文不足: {reason} — {fix_instruction}" + )); + } + } + + // C01 审查蜂: iron-rule violation check (blocking on violation) + let c01 = executor.behavior_guard(ctx); + if c01.is_abort() { + aggregated.abort = Some(c01); + } + + aggregated } #[derive(Debug, Clone, Copy)] diff --git a/src/crates/execution/agent-runtime/src/session_control.rs b/src/crates/execution/agent-runtime/src/session_control.rs index bf0e4e44b7..532d518b96 100644 --- a/src/crates/execution/agent-runtime/src/session_control.rs +++ b/src/crates/execution/agent-runtime/src/session_control.rs @@ -24,23 +24,12 @@ impl SessionControlAction { } } -#[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, -} +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SessionControlAgentType(pub String); impl SessionControlAgentType { - pub const fn as_str(&self) -> &'static str { - match self { - Self::Agentic => "agentic", - Self::Plan => "Plan", - Self::Cowork => "Cowork", - } + pub fn as_str(&self) -> &str { + &self.0 } } diff --git a/src/crates/execution/agent-runtime/tests/session_control_contracts.rs b/src/crates/execution/agent-runtime/tests/session_control_contracts.rs index 57ebaf23b5..bdb698f99e 100644 --- a/src/crates/execution/agent-runtime/tests/session_control_contracts.rs +++ b/src/crates/execution/agent-runtime/tests/session_control_contracts.rs @@ -51,7 +51,7 @@ fn rejects_current_session_mutation_when_context_matches() { fn validates_create_requires_workspace_and_creator_session() { let mut input = base_input(SessionControlAction::Create); input.workspace = Some(std::env::temp_dir().to_string_lossy().to_string()); - input.agent_type = Some(SessionControlAgentType::Plan); + input.agent_type = Some(SessionControlAgentType("Plan".to_string())); let missing_creator = validate_session_control_input(&input, SessionControlValidationContext::default()); diff --git a/src/crates/execution/tool-provider-groups/src/lib.rs b/src/crates/execution/tool-provider-groups/src/lib.rs index 09c0adfce0..2851e2ff78 100644 --- a/src/crates/execution/tool-provider-groups/src/lib.rs +++ b/src/crates/execution/tool-provider-groups/src/lib.rs @@ -162,7 +162,7 @@ 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", "SessionMessage", "SessionHistory", "Cron", "LegionControl"], }, ToolProviderGroupPlan { provider_id: "core.integration", From 5912fbf9053a5b52f77fdc09b1479256e153c0e3 Mon Sep 17 00:00:00 2001 From: BitFun Research Date: Sat, 18 Jul 2026 15:02:58 +0800 Subject: [PATCH 2/2] fix: remove ACP agent references from legion-only PR --- .../agents/definitions/subagents/acp_agent.rs | 77 ------------------- .../agents/definitions/subagents/mod.rs | 2 - .../assembly/core/src/agentic/agents/mod.rs | 2 +- 3 files changed, 1 insertion(+), 80 deletions(-) delete mode 100644 src/crates/assembly/core/src/agentic/agents/definitions/subagents/acp_agent.rs 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 deleted file mode 100644 index 5203ed90a6..0000000000 --- a/src/crates/assembly/core/src/agentic/agents/definitions/subagents/acp_agent.rs +++ /dev/null @@ -1,77 +0,0 @@ -//! 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::{Agent, UserContextPolicy}; -use async_trait::async_trait; - -/// 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); - Self { - // ACP prompt tool is registered in the global tool registry by - // register_configured_tools() — do NOT add it to default_tools - // here, or the tool name will appear twice in the model manifest. - default_tools: vec![ - "Read".to_string(), - "Grep".to_string(), - "Glob".to_string(), - "LS".to_string(), - ], - agent_id, - display_name, - } - } - - /// The agent registry id: `acp__` - pub fn agent_id_for(client_id: &str) -> String { - format!("acp__{client_id}") - } -} - -#[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 - } -} 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 3b18284957..37309c8e3f 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,11 +1,9 @@ -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 4fe0d41ead..a6b0b25ca8 100644 --- a/src/crates/assembly/core/src/agentic/agents/mod.rs +++ b/src/crates/assembly/core/src/agentic/agents/mod.rs @@ -36,7 +36,7 @@ pub use definitions::review::{ }; pub use definitions::shared::ReadonlySubagent; pub use definitions::subagents::{ - AcpAgent, ComputerUseMode, ExploreAgent, FileFinderAgent, GeneralPurposeAgent, + ComputerUseMode, ExploreAgent, FileFinderAgent, GeneralPurposeAgent, ResearchSpecialistAgent, }; use indexmap::IndexMap;