diff --git a/src/apps/cli/src/daemon/service.rs b/src/apps/cli/src/daemon/service.rs index cae9471086..7a143b74ca 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(target_os = "linux")] fn run_command(program: &str, args: &[&str]) -> Result { std::process::Command::new(program) .args(args) @@ -92,6 +93,7 @@ fn run_command(program: &str, args: &[&str]) -> Result { .with_context(|| format!("run `{program} {}`", args.join(" "))) } +#[cfg(target_os = "linux")] fn ensure_success(program: &str, args: &[&str]) -> Result<()> { let output = run_command(program, args)?; if output.status.success() { 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/browser_api.rs b/src/apps/desktop/src/api/browser_api.rs index dc8a520316..f8d9205884 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); window .add_child( 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/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index a15751550b..e72e2879bf 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -924,6 +924,11 @@ pub async fn run() { api::agentic_api::generate_session_title, api::agentic_api::get_available_modes, api::agentic_api::get_default_review_team_definition, + api::agentic_api::list_legion_presets, + api::agentic_api::get_legion_preset, + api::agentic_api::create_legion_preset, + api::agentic_api::update_legion_preset, + api::agentic_api::delete_legion_preset, api::btw_api::btw_ask_stream, api::btw_api::btw_cancel, api::editor_ai_api::editor_ai_stream, diff --git a/src/crates/adapters/ai-adapters/src/providers/openai/common.rs b/src/crates/adapters/ai-adapters/src/providers/openai/common.rs index 69f696d987..53162e7dcb 100644 --- a/src/crates/adapters/ai-adapters/src/providers/openai/common.rs +++ b/src/crates/adapters/ai-adapters/src/providers/openai/common.rs @@ -376,7 +376,28 @@ pub(crate) fn convert_tools_flat( tools: Option>, ) -> Option> { tools.map(|defs| { - defs.into_iter() + let mut seen = std::collections::HashSet::new(); + let mut dupes = Vec::new(); + let filtered: Vec = defs + .into_iter() + .filter(|tool| { + if !seen.insert(tool.name.clone()) { + dupes.push(tool.name.clone()); + false + } else { + true + } + }) + .collect(); + if !dupes.is_empty() { + log::error!( + target: "ai::openai_stream_request", + "DUPLICATE TOOL NAMES detected and deduplicated: {:?}", + dupes + ); + } + filtered + .into_iter() .map(|tool| { serde_json::json!({ "type": "function", diff --git a/src/crates/adapters/ai-adapters/src/providers/openai/message_converter.rs b/src/crates/adapters/ai-adapters/src/providers/openai/message_converter.rs index 3ec33276ea..11348c919b 100644 --- a/src/crates/adapters/ai-adapters/src/providers/openai/message_converter.rs +++ b/src/crates/adapters/ai-adapters/src/providers/openai/message_converter.rs @@ -406,7 +406,27 @@ impl OpenAIMessageConverter { pub fn convert_tools(tools: Option>) -> Option> { tools.map(|tool_defs| { - tool_defs + let mut seen = std::collections::HashSet::new(); + let mut dupes = Vec::new(); + let filtered: Vec = tool_defs + .into_iter() + .filter(|tool| { + if !seen.insert(tool.name.clone()) { + dupes.push(tool.name.clone()); + false + } else { + true + } + }) + .collect(); + if !dupes.is_empty() { + log::error!( + target: "ai::openai_stream_request", + "DUPLICATE TOOL NAMES detected and deduplicated: {:?}", + dupes + ); + } + filtered .into_iter() .map(|tool| { json!({ 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 75d45dd75a..9c880f5245 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 @@ -40,6 +40,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..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/acp_agent.md b/src/crates/assembly/core/src/agentic/agents/prompts/acp_agent.md new file mode 100644 index 0000000000..08d0629d7b --- /dev/null +++ b/src/crates/assembly/core/src/agentic/agents/prompts/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/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/registry/mod.rs b/src/crates/assembly/core/src/agentic/agents/registry/mod.rs index 0820c567fb..30bea4d86f 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/mod.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/mod.rs @@ -127,6 +127,11 @@ impl AgentRegistry { .and_then(|entries| entries.get(agent_type).cloned()) } + /// Remove an agent entry by id (e.g. when an ACP client is disabled/removed). + pub fn unregister_agent(&self, id: &str) { + self.write_agents().remove(id); + } + /// Get a agent by ID (searches all categories including hidden) pub fn get_agent( &self, 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 0597e576f6..18f84e964b 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/query.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/query.rs @@ -165,6 +165,23 @@ impl AgentRegistry { result } + /// Return ids of all agents visible for session creation (modes + subagents). + pub async fn get_agent_ids_for_session_creation(&self) -> Vec { + self.ensure_user_custom_agents_loaded().await; + let map = self.read_agents(); + let mut ids: Vec = map + .values() + .filter(|e| { + matches!(e.category, AgentCategory::Mode | AgentCategory::SubAgent) + }) + .map(|e| e.agent.id().to_string()) + .collect(); + drop(map); + 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/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/execution/execution_engine.rs b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs index b093786503..fffa7462ac 100644 --- a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs +++ b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs @@ -35,6 +35,8 @@ use crate::agentic::tools::product_runtime::{ use crate::agentic::tools::{ resolve_tool_manifest, tool_context_runtime, ResolvedToolManifest, ToolRuntimeRestrictions, }; +use crate::agentic::tools::post_call_hooks::run_stop_hooks_for_round; +use bitfun_agent_runtime::post_call_hooks::ToolCallSummary; use crate::agentic::WorkspaceBinding; use crate::infrastructure::ai::get_global_ai_client_factory; use crate::service::config::get_global_config_service; @@ -53,8 +55,8 @@ use bitfun_core_types::SessionModelBindingPolicy; use log::{debug, error, info, trace, warn}; use sha2::{Digest, Sha256}; use std::collections::{HashMap, HashSet}; -use std::path::Path; use std::sync::Arc; +use std::path::Path; use tokio_util::sync::CancellationToken; use tool_runtime::context::PrimaryModelFacts; @@ -3321,6 +3323,184 @@ impl ExecutionEngine { total_tools += round_result.tool_calls.len(); + // ── Stop hook: B01 提示蜂 + C01 审查蜂 ── + { + let tool_calls: Vec = round_result + .tool_calls + .iter() + .map(|tc| { + let preview = serde_json::to_string(&tc.arguments) + .unwrap_or_default(); + let preview = if preview.len() > 256 { + format!("{}...", &preview[..253]) + } else { + preview + }; + ToolCallSummary { + tool_name: tc.tool_name.clone(), + is_error: tc.is_error, + input_preview: preview, + } + }) + .collect(); + + let assistant_text = match &round_result.assistant_message.content { + MessageContent::Text(t) => t.as_str(), + MessageContent::Multimodal { text, .. } => text.as_str(), + MessageContent::Mixed { text, .. } => text.as_str(), + MessageContent::ToolResult { .. } => "", + }; + + // Collect file reads/edits from the FILE_READ_TRACKER + // For now, extract from round_result tool calls + let file_reads: Vec = round_result + .tool_calls + .iter() + .filter(|tc| matches!(tc.tool_name.as_str(), "Read" | "read_file")) + .filter_map(|tc| { + tc.arguments + .get("file_path") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + }) + .collect(); + + let file_edits: Vec = round_result + .tool_calls + .iter() + .filter(|tc| { + matches!( + tc.tool_name.as_str(), + "Edit" | "Write" | "edit_file" | "write_file" + ) + }) + .filter_map(|tc| { + tc.arguments + .get("file_path") + .or_else(|| tc.arguments.get("path")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + }) + .collect(); + + let aggregated = run_stop_hooks_for_round( + &self.session_manager, + &context.session_id, + &context.dialog_turn_id, + round_index as u32, + tool_calls.clone(), + assistant_text, + file_reads.clone(), + file_edits.clone(), + round_result.has_more_rounds, + ); + + // 审查蜂提醒模式:Abort 已在 post_call_hooks 降级为 WARN, + // 此处仅注入提醒上下文,不拦截任何操作 + if aggregated.is_abort() { + if let Some(abort) = &aggregated.abort { + match abort { + bitfun_agent_runtime::post_call_hooks::HookResult::Abort { + reason, + fix_instruction, + .. + } => { + let remind = format!( + "[审查蜂提醒] {reason}\n\n建议: {fix_instruction}" + ); + let msg = + Message::system(render_system_reminder(&remind)); + messages.push(msg); + warn!( + "[Stop Hook] Round {}: Reminder — {}", + round_index, reason + ); + } + _ => {} + } + } + } + + // Inject B01 additional contexts — replaces previous review messages + // to prevent unbounded context growth and spurious compression triggers. + // Bee-review messages are identified by the review-prefix inside a + // block. + messages.retain(|m| { + match &m.content { + crate::agentic::core::message::MessageContent::Text(text) => { + let trimmed = text.trim(); + !(trimmed.contains("[书记官]") && trimmed.contains("")) + && !(trimmed.contains("[提示蜂]") && trimmed.contains("")) + && !(trimmed.contains("[审查员]") && trimmed.contains("")) + } + _ => true, + } + }); + for ctx_msg in &aggregated.additional_contexts { + let msg = Message::system(render_system_reminder(ctx_msg)); + messages.push(msg); + } + + // ── 蜂群审查(cc-haha模式:stop hook → fast LLM → 注入结果)── + if !tool_calls.is_empty() { + let tl: Vec = tool_calls.iter() + .map(|tc| format!("{}{}", tc.tool_name, if tc.is_error { "(FAIL)" } else { "" })) + .collect(); + let ts: String = assistant_text.chars().take(400).collect(); + let fr2: Vec = file_reads.iter().take(5).cloned().collect(); + let fe2: Vec = file_edits.iter().take(5).cloned().collect(); + + let review_prompt = format!( + "你是蜂群审查员(书记官+纪律委员+提示蜂)。审查Agent本轮:\n\ + 工具:[{}] 读取:[{}] 编辑:[{}] 动作:{}\n\n\ + ## 书记官(上下文守护)\n\ + - 上下文压缩了?提取压缩前的关键决策/进度/用户纠正 → CTX:<要点>\n\ + - 轮次>20或token告急?预摘要关键状态 → CTX:<摘要>\n\ + - Agent忘记重要信息(重复提问/忽略决策)?→ CTX:<提醒>\n\n\ + ## 纪律委员(行为审查·仅提醒不拦截)\n\ + - 编辑前未Read?→ WARN:未读即改-<文件>\n\ + - 删除了LionHeart库文件?→ WARN:受保护路径-禁止删除\n\ + - LionHeart库读写操作正常,删除操作警告\n\ + - 同工具连续失败?→ WARN:策略死循环\n\ + - PS+中文+JSON?→ WARN:PS编码风险\n\ + - 全部工具失败?→ WARN:全部失败\n\ + - 改完不验证?→ WARN:未验证\n\n\ + ## 提示蜂(技能推荐)\n\ + - MiniApp相关但没加载miniapp-dev?→ SKILL:miniapp-dev\n\ + - 编码但没加载Pair-Programming?→ SKILL:Pair Programming\n\ + - 调研但没加载agent-reach?→ SKILL:agent-reach\n\n\ + 输出(每行一个):PASS / WARN:<问题> / CTX:<内容> / SKILL:<名称>\n\ + 禁止输出ABORT——审查蜂只有提醒权限,不拦截任何操作。\n\ + 一切正常只回复PASS。", + tl.join(","), fr2.join(","), fe2.join(","), ts + ); + + let sid2 = context.session_id.clone(); + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().expect("bee-review"); + rt.block_on(async move { + if let Ok(factory) = crate::infrastructure::ai::get_global_ai_client_factory().await { + if let Ok(client) = factory.get_client_resolved("primary").await { + let msgs = vec![bitfun_core_types::Message::user(review_prompt)]; + if let Ok(mut stream) = client.send_message_stream(msgs, None, None).await { + use futures::StreamExt; + let mut result = String::new(); + while let Some(Ok(chunk)) = stream.stream.next().await { + if let Some(t) = chunk.text { result.push_str(&t); } + } + let trimmed = result.trim().to_string(); + if !trimmed.is_empty() && trimmed != "PASS" { + crate::agentic::tools::post_call_hooks::push_review_result(&sid2, trimmed); + } + } + } + } + }); + }); + } + + } + // Track partial recovery reason from the last round if round_result.partial_recovery_reason.is_some() { last_partial_recovery_reason = round_result.partial_recovery_reason.clone(); 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/implementations/session_control_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs index be53cad988..b3c3f126bc 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 @@ -276,7 +276,7 @@ Arguments: } fn default_exposure(&self) -> ToolExposure { - ToolExposure::Deferred + ToolExposure::Direct } fn input_schema(&self) -> Value { @@ -302,8 +302,7 @@ Arguments: }, "agent_type": { "type": "string", - "enum": ["agentic", "Plan", "Cowork"], - "description": "Optional agent type when creating a session. Defaults to agentic." + "description": "Optional agent type when creating a session. Defaults to agentic. Available agent types are listed at runtime." } }, "required": ["action"], @@ -311,6 +310,23 @@ Arguments: }) } + async fn input_schema_for_model(&self) -> Value { + let mut schema = self.input_schema(); + if let Some(props) = schema.get_mut("properties") { + if let Some(obj) = props.get_mut("agent_type").and_then(|v| v.as_object_mut()) { + let registry = crate::agentic::agents::get_agent_registry(); + let ids = registry.get_agent_ids_for_session_creation().await; + let id_strs: Vec<&str> = ids.iter().map(|s| s.as_str()).collect(); + obj.insert("enum".to_string(), json!(id_strs)); + obj.insert( + "description".to_string(), + json!("Optional agent type when creating a session. Defaults to agentic."), + ); + } + } + schema + } + fn is_readonly(&self) -> bool { false } 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 67295625b6..f629de9302 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 @@ -106,7 +106,7 @@ Examples: } fn default_exposure(&self) -> ToolExposure { - ToolExposure::Deferred + ToolExposure::Direct } fn input_schema(&self) -> Value { 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 550926116e..7538e723a6 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 @@ -242,22 +242,11 @@ impl SessionMessageTool { } #[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, -} +struct SessionMessageAgentType(String); impl SessionMessageAgentType { - fn as_str(&self) -> &'static str { - match self { - Self::Agentic => "agentic", - Self::Plan => "Plan", - Self::Cowork => "Cowork", - } + fn as_str(&self) -> &str { + &self.0 } } @@ -298,7 +287,7 @@ Allowed agent types when creating a session: } fn default_exposure(&self) -> ToolExposure { - ToolExposure::Deferred + ToolExposure::Direct } fn input_schema(&self) -> Value { @@ -323,8 +312,7 @@ Allowed agent types when creating a session: }, "agent_type": { "type": "string", - "enum": ["agentic", "Plan", "Cowork"], - "description": "Required when session_id is omitted. Not allowed when sending to an existing session." + "description": "Required when session_id is omitted. Not allowed when sending to an existing session. Available agent types are listed at runtime." } }, "required": ["message"], @@ -332,6 +320,23 @@ Allowed agent types when creating a session: }) } + async fn input_schema_for_model(&self) -> Value { + let mut schema = self.input_schema(); + if let Some(props) = schema.get_mut("properties") { + if let Some(obj) = props.get_mut("agent_type").and_then(|v| v.as_object_mut()) { + let registry = crate::agentic::agents::get_agent_registry(); + let ids = registry.get_agent_ids_for_session_creation().await; + let id_strs: Vec<&str> = ids.iter().map(|s| s.as_str()).collect(); + obj.insert("enum".to_string(), json!(id_strs)); + obj.insert( + "description".to_string(), + json!("Required when session_id is omitted. Not allowed when sending to an existing session."), + ); + } + } + schema + } + fn is_readonly(&self) -> bool { false } 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..5aa8fbf9ad 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,103 @@ 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:") { + // 审查蜂无拦截权限,ABORT 降级为 WARN + result.additional_contexts.push(format!("[审查员] 提醒: {}", &trimmed[6..].trim())); + } 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/product_runtime/materialization.rs b/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs index 77474acefb..cd360d3100 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 @@ -67,6 +67,7 @@ impl StaticToolProviderFactory for ProductConcreteToolFactory { "ControlHub" => Some(Arc::new(ControlHubTool::new())), "ComputerUse" => Some(Arc::new(ComputerUseTool::new())), "Playbook" => Some(Arc::new(PlaybookTool::new())), + "LegionControl" => Some(Arc::new(LegionControlTool::new())), _ => None, } } diff --git a/src/crates/assembly/core/src/agentic/tools/registry.rs b/src/crates/assembly/core/src/agentic/tools/registry.rs index 8cbab904ab..19918a0513 100644 --- a/src/crates/assembly/core/src/agentic/tools/registry.rs +++ b/src/crates/assembly/core/src/agentic/tools/registry.rs @@ -486,6 +486,7 @@ mod tests { "SessionMessage", "SessionHistory", "Cron", + "LegionControl", "WebSearch", "WebFetch", "ListMCPResources", @@ -658,9 +659,6 @@ mod tests { vec![ "CreatePlan", "GetFileDiff", - "SessionControl", - "SessionMessage", - "SessionHistory", "Cron", "WebSearch", "WebFetch", 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..5663d6b5d8 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,17 @@ 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, .. + } = hook_result + { + log::warn!( + "[ToolGuard] Hook suggested abort for tool '{}': {reason} — ignored (reminder-only mode)", + tool_name + ); + } } result diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin.rs b/src/crates/contracts/product-domains/src/miniapp/builtin.rs index 156212f468..a65ecb54ce 100644 --- a/src/crates/contracts/product-domains/src/miniapp/builtin.rs +++ b/src/crates/contracts/product-domains/src/miniapp/builtin.rs @@ -159,6 +159,16 @@ pub const BUILTIN_APPS: &[BuiltinMiniAppBundle] = &[ worker_js: include_str!("builtin/assets/ppt-live/worker.js"), esm_dependencies_json: include_str!("builtin/assets/ppt-live/esm_dependencies.json"), }, + BuiltinMiniAppBundle { + id: "builtin-bee-colony-dag", + version: 1, + meta_json: include_str!("builtin/assets/bee-colony-dag/meta.json"), + html: include_str!("builtin/assets/bee-colony-dag/index.html"), + css: include_str!("builtin/assets/bee-colony-dag/style.css"), + ui_js: include_str!("builtin/assets/bee-colony-dag/ui.js"), + worker_js: "", + esm_dependencies_json: "[]", + }, ]; pub fn builtin_content_hash(app: &BuiltinMiniAppBundle) -> String { diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bee-colony-dag/index.html b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bee-colony-dag/index.html new file mode 100644 index 0000000000..ce4ec25fff --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bee-colony-dag/index.html @@ -0,0 +1,24 @@ + + + + + + 蜂群架构监控 + + + +
+
+

蜂群架构监控

+ 待命 +
+
+ +
+
+ 轮询中... +
+
+ + + diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bee-colony-dag/meta.json b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bee-colony-dag/meta.json new file mode 100644 index 0000000000..885f23d0c8 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bee-colony-dag/meta.json @@ -0,0 +1,35 @@ +{ + "id": "builtin-bee-colony-dag", + "name": "蜂群架构监控", + "description": "蜂群八角色 DAG 架构图,实时监控各节点执行状态。Agent 推送状态更新,节点颜色实时变化。", + "icon": "GitBranch", + "category": "tool", + "tags": ["蜂群", "DAG", "监控", "Agent"], + "version": 1, + "created_at": 0, + "updated_at": 0, + "permissions": { + "fs": { + "read": ["{appdata}"], + "write": ["{appdata}"] + }, + "shell": { "allow": [] }, + "net": { "allow": [] }, + "node": { "enabled": false } + }, + "ai_context": null, + "i18n": { + "locales": { + "zh-CN": { + "name": "蜂群架构监控", + "description": "蜂群八角色 DAG 架构图,实时监控各节点执行状态。", + "tags": ["蜂群", "DAG", "监控", "Agent"] + }, + "en-US": { + "name": "Bee Colony Monitor", + "description": "Bee Colony 8-role DAG architecture with real-time node status monitoring.", + "tags": ["bee-colony", "DAG", "monitor", "Agent"] + } + } + } +} diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bee-colony-dag/style.css b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bee-colony-dag/style.css new file mode 100644 index 0000000000..1416becce9 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bee-colony-dag/style.css @@ -0,0 +1,163 @@ +*, *::before, *::after { box-sizing: border-box; } +body, html { margin: 0; padding: 0; height: 100%; overflow: hidden; } + +:root { + /* Surfaces */ + --dag-bg: var(--bitfun-bg, #121214); + --dag-surface: var(--bitfun-bg-secondary, #1c1c1f); + --dag-surface-2: var(--bitfun-element-bg, rgba(255,255,255,0.06)); + --dag-border: var(--bitfun-border-subtle, rgba(255,255,255,0.10)); + + /* Text */ + --dag-text: var(--bitfun-text, #e8e8e8); + --dag-text-soft: var(--bitfun-text-secondary, #b0b0b0); + --dag-text-mute: var(--bitfun-text-muted, #858585); + + /* Semantic */ + --dag-accent: var(--bitfun-accent, #60a5fa); + --dag-success: var(--bitfun-success, #34d399); + --dag-error: var(--bitfun-error, #ef4444); + + /* Node status */ + --dag-idle: #3f3f46; + --dag-running: #60a5fa; + --dag-done: #34d399; + --dag-failed: #ef4444; + + --dag-radius: var(--bitfun-radius, 8px); + --dag-font: var(--bitfun-font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif); +} + +/* ---- Layout ---- */ +.bee-dag { + display: flex; + flex-direction: column; + height: 100vh; + background: var(--dag-bg); + color: var(--dag-text); + font-family: var(--dag-font); + font-size: 13px; + user-select: none; +} + +/* ---- Top bar ---- */ +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 16px; + background: var(--dag-surface); + border-bottom: 1px solid var(--dag-border); + flex-shrink: 0; +} + +.topbar__title { + margin: 0; + font-size: 15px; + font-weight: 600; + color: var(--dag-text); + letter-spacing: 0.02em; +} + +.topbar__badge { + display: inline-flex; + align-items: center; + padding: 3px 10px; + border-radius: 10px; + font-size: 11px; + font-weight: 500; + background: var(--dag-surface-2); + color: var(--dag-text-soft); + transition: background 0.3s, color 0.3s; +} + +.topbar__badge--running { + background: rgba(96,165,250,0.16); + color: var(--dag-running); +} + +.topbar__badge--done { + background: rgba(52,211,153,0.14); + color: var(--dag-done); +} + +.topbar__badge--failed { + background: rgba(239,68,68,0.14); + color: var(--dag-error); +} + +/* ---- Canvas ---- */ +.canvas-wrap { + flex: 1; + overflow: hidden; + position: relative; +} + +.dag-svg { + width: 100%; + height: 100%; + display: block; +} + +/* ---- Bottom bar ---- */ +.bottombar { + display: flex; + align-items: center; + justify-content: center; + padding: 6px 16px; + background: var(--dag-surface); + border-top: 1px solid var(--dag-border); + flex-shrink: 0; +} + +.bottombar__hint { + font-size: 11px; + color: var(--dag-text-mute); + letter-spacing: 0.03em; +} + +/* ---- SVG node animations ---- */ +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.55; } +} + +.node--running .dag-node-rect { + animation: pulse 1.2s ease-in-out infinite; +} + +/* ---- SVG elements ---- */ +.dag-edge { + stroke: rgba(255,255,255,0.16); + stroke-width: 1.6; +} + +.dag-edge--gate { + stroke: rgba(239,68,68,0.40); + stroke-dasharray: 6 3; +} + +.dag-node-rect { + transition: fill 0.35s ease; +} + +.dag-node-text { + fill: var(--dag-text); + font-family: var(--dag-font); + font-size: 13px; + font-weight: 600; +} + +.dag-node-sub { + fill: var(--dag-text-mute); + font-family: var(--dag-font); + font-size: 10px; + font-weight: 400; +} + +.dag-gate-dash { + fill: none; + stroke: rgba(239,68,68,0.45); + stroke-width: 1.8; + stroke-dasharray: 5 3; +} diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bee-colony-dag/ui.js b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bee-colony-dag/ui.js new file mode 100644 index 0000000000..3d7b0d4d68 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bee-colony-dag/ui.js @@ -0,0 +1,328 @@ +// Bee Colony DAG Monitor — main logic +// Renders an 8-role vertical DAG with real-time status polling via app.storage. +// Vanilla JS, no frameworks. SVG rendered via createElementNS. + +const SVG_NS = "http://www.w3.org/2000/svg"; + +// ── Role definitions ────────────────────────────────────────── +const ROLE_DEFS = [ + { id: "cmd", role: "\u6307\u6325\u5b98", tree: "\u6811 1-3 P\u2192C\u2192D", gate: false }, + { id: "sec", role: "\u79d8\u4e66 B01", tree: "L3 \u5e93\u68c0\u7d22", gate: false }, + { id: "pm", role: "\u4ea7\u54c1\u7ecf\u7406", tree: "R-ID \u9700\u6c42\u5b9a\u4e49", gate: false }, + { id: "plan", role: "\u89c4\u5212\u5e08", tree: "\u6811 4-6 P\u2192D\u2192C", gate: false }, + { id: "exec", role: "\u6267\u884c\u8005", tree: "\u6811 7-10 O\u2192O\u2192D\u2192A", gate: false }, + { id: "review", role: "\u5ba1\u67e5\u8005", tree: "\u529f\u80fd\u00b7\u5b89\u5168\u00b7Debug", gate: true }, + { id: "accept", role: "\u9a8c\u6536\u8005", tree: "R-ID \u9010\u9879\u95ed\u5408", gate: true }, + { id: "opt", role: "\u4f18\u5316\u8005", tree: "\u590d\u76d8\u00b7\u5f52\u6863\u00b7\u77e5\u8bc6\u5e93", gate: false }, +]; + +// ── Layout constants ────────────────────────────────────────── +const NODE_W = 180; +const NODE_H = 52; +const NODE_RX = 8; +const V_GAP = 88; // center-to-center vertical spacing +const PAD_X = 60; +const PAD_TOP = 30; +const PAD_BOT = 30; + +// ── State ────────────────────────────────────────────────────── +const state = { + layout: null, // { viewW, viewH, positions: [{x,y}] } + nodes: {}, // { cmd: {status, detail}, ... } + lastHash: "", +}; + +// ── Simple hash ──────────────────────────────────────────────── +function simpleHash(str) { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const c = str.charCodeAt(i); + hash = ((hash << 5) - hash) + c; + hash |= 0; + } + return String(hash); +} + +// ── Compute layout ───────────────────────────────────────────── +function computeLayout(totalW, totalH) { + const cx = totalW / 2; + const positions = ROLE_DEFS.map((_, i) => ({ + x: cx - NODE_W / 2, + y: PAD_TOP + i * V_GAP, + })); + + const viewH = PAD_TOP + ROLE_DEFS.length * V_GAP + PAD_BOT; + + return { + viewW: totalW, + viewH: viewH, + positions: positions, + }; +} + +// ── Status helpers ───────────────────────────────────────────── +function getStatus(nodeId) { + const n = state.nodes[nodeId]; + return n && n.status ? n.status : "idle"; +} + +function getFillColor(status) { + switch (status) { + case "running": return "var(--dag-running)"; + case "done": return "var(--dag-done)"; + case "failed": return "var(--dag-failed)"; + default: return "var(--dag-idle)"; + } +} + +// ── Render ───────────────────────────────────────────────────── +function render() { + const svg = document.getElementById("dag-svg"); + if (!svg) return; + + // Measure container + const wrap = svg.parentElement; + const w = wrap.clientWidth || 800; + const h = wrap.clientHeight || 600; + + const layout = computeLayout(w, h); + state.layout = layout; + + svg.setAttribute("viewBox", "0 0 " + layout.viewW + " " + layout.viewH); + svg.setAttribute("preserveAspectRatio", "xMidYMid meet"); + + // Clear + while (svg.firstChild) svg.removeChild(svg.firstChild); + + // Defs: arrow marker + const defs = createSvgEl("defs"); + const marker = createSvgEl("marker", { + id: "arrow", + viewBox: "0 0 10 10", + refX: "5", + refY: "10", + markerWidth: "6", + markerHeight: "6", + orient: "auto", + }); + const arrowPath = createSvgEl("path", { + d: "M 0 0 L 5 10 L 10 0", + fill: "rgba(255,255,255,0.16)", + }); + marker.appendChild(arrowPath); + defs.appendChild(marker); + + // Gate edge marker + const markerGate = createSvgEl("marker", { + id: "arrow-gate", + viewBox: "0 0 10 10", + refX: "5", + refY: "10", + markerWidth: "6", + markerHeight: "6", + orient: "auto", + }); + const arrowPathGate = createSvgEl("path", { + d: "M 0 0 L 5 10 L 10 0", + fill: "rgba(239,68,68,0.40)", + }); + markerGate.appendChild(arrowPathGate); + defs.appendChild(markerGate); + svg.appendChild(defs); + + // ── Edges (behind nodes) ── + for (let i = 0; i < ROLE_DEFS.length - 1; i++) { + const src = layout.positions[i]; + const dst = layout.positions[i + 1]; + const isGate = ROLE_DEFS[i + 1].gate || ROLE_DEFS[i].gate; + + const x1 = src.x + NODE_W / 2; + const y1 = src.y + NODE_H; + const x2 = dst.x + NODE_W / 2; + const y2 = dst.y; + + const line = createSvgEl("line", { + x1: x1, y1: y1, + x2: x2, y2: y2, + class: "dag-edge" + (isGate ? " dag-edge--gate" : ""), + "marker-end": isGate ? "url(#arrow-gate)" : "url(#arrow)", + }); + svg.appendChild(line); + } + + // ── Nodes ── + for (let i = 0; i < ROLE_DEFS.length; i++) { + const def = ROLE_DEFS[i]; + const pos = layout.positions[i]; + const status = getStatus(def.id); + + const g = createSvgEl("g", { + class: "dag-node" + (status === "running" ? " node--running" : ""), + }); + + // Node rect + const rect = createSvgEl("rect", { + x: pos.x, + y: pos.y, + width: NODE_W, + height: NODE_H, + rx: NODE_RX, + ry: NODE_RX, + class: "dag-node-rect", + fill: getFillColor(status), + stroke: "rgba(255,255,255,0.08)", + "stroke-width": "1", + }); + g.appendChild(rect); + + // Gate dashed overlay + if (def.gate) { + const dash = createSvgEl("rect", { + x: pos.x + 1.5, + y: pos.y + 1.5, + width: NODE_W - 3, + height: NODE_H - 3, + rx: NODE_RX - 1, + ry: NODE_RX - 1, + class: "dag-gate-dash", + }); + g.appendChild(dash); + } + + // Role name + const textRole = createSvgEl("text", { + x: pos.x + NODE_W / 2, + y: pos.y + 21, + "text-anchor": "middle", + class: "dag-node-text", + }); + textRole.textContent = def.role; + g.appendChild(textRole); + + // Tree number + const textTree = createSvgEl("text", { + x: pos.x + NODE_W / 2, + y: pos.y + 38, + "text-anchor": "middle", + class: "dag-node-sub", + }); + textTree.textContent = def.tree; + g.appendChild(textTree); + + svg.appendChild(g); + } +} + +// ── SVG element helper ───────────────────────────────────────── +function createSvgEl(tag, attrs) { + const el = document.createElementNS(SVG_NS, tag); + if (attrs) { + for (const [k, v] of Object.entries(attrs)) { + el.setAttribute(k, String(v)); + } + } + return el; +} + +// ── Badge update ─────────────────────────────────────────────── +function updateBadge(raw) { + const badge = document.getElementById("status-badge"); + if (!badge) return; + + const nodes = raw && raw.nodes ? raw.nodes : {}; + const vals = Object.values(nodes); + const statuses = vals.map(function (n) { return n.status || "idle"; }); + + let label = "\u5f85\u547d"; + let cls = ""; + + if (statuses.length > 0) { + const hasFailed = statuses.indexOf("failed") !== -1; + const hasRunning = statuses.indexOf("running") !== -1; + const allDone = statuses.every(function (s) { return s === "done"; }); + const allIdle = statuses.every(function (s) { return s === "idle"; }); + + if (hasFailed) { + label = "\u5f02\u5e38"; + cls = "topbar__badge--failed"; + } else if (hasRunning) { + label = "\u6267\u884c\u4e2d"; + cls = "topbar__badge--running"; + } else if (allDone) { + label = "\u5b8c\u6210"; + cls = "topbar__badge--done"; + } else if (allIdle) { + label = "\u5f85\u547d"; + cls = ""; + } + } + + badge.textContent = label; + badge.className = "topbar__badge " + cls; +} + +// ── Polling ──────────────────────────────────────────────────── +async function pollState() { + try { + const raw = await app.storage.get("bee-colony-state"); + if (!raw) return; + + const hash = simpleHash(JSON.stringify(raw)); + if (hash === state.lastHash) return; + state.lastHash = hash; + + // Update node statuses + if (raw.nodes) { + for (const [id, nodeState] of Object.entries(raw.nodes)) { + state.nodes[id] = nodeState; + } + } + + render(); + updateBadge(raw); + } catch (_e) { + // Ignore polling errors — the MiniApp may be running + // in a context where app.storage is not yet available. + } +} + +// ── Init ─────────────────────────────────────────────────────── +async function init() { + // Load any previously persisted state + try { + const saved = await app.storage.get("bee-colony-state"); + if (saved && saved.nodes) { + for (const [id, nodeState] of Object.entries(saved.nodes)) { + state.nodes[id] = nodeState; + } + } + updateBadge(saved); + } catch (_e) { + // No saved state yet + } + + // Initial render to show the DAG structure + render(); + + // Start polling + setInterval(pollState, 500); + pollState(); + + // Update poll hint + const hint = document.getElementById("poll-hint"); + if (hint) { + hint.textContent = "\u8f6e\u8be2\u4e2d (500ms)"; + } + + // Handle resize + window.addEventListener("resize", function () { + render(); + }); +} + +// ── Boot ─────────────────────────────────────────────────────── +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); +} else { + init(); +} 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..fbb963afb2 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,122 @@ 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 (提醒模式,不拦截) + let c01 = executor.behavior_guard(ctx); + if c01.is_abort() { + if let HookResult::Abort { + reason, + fix_instruction, + .. + } = c01 + { + aggregated.additional_contexts.push(format!( + "[C01 审查蜂] 提醒: {reason} — {fix_instruction}" + )); + } + } + + 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/post_call_hook_execution_contracts.rs b/src/crates/execution/agent-runtime/tests/post_call_hook_execution_contracts.rs index 48dfa0b84b..39ef9554f9 100644 --- a/src/crates/execution/agent-runtime/tests/post_call_hook_execution_contracts.rs +++ b/src/crates/execution/agent-runtime/tests/post_call_hook_execution_contracts.rs @@ -1,6 +1,6 @@ use bitfun_agent_runtime::post_call_hooks::{ resolve_deep_review_shared_context_tool_use, run_successful_tool_post_call_hooks, - DeepReviewSharedContextToolUseFacts, SuccessfulToolPostCallHookExecutor, + DeepReviewSharedContextToolUseFacts, HookResult, SuccessfulToolPostCallHookExecutor, }; use serde_json::{json, Value}; use std::collections::HashMap; @@ -21,6 +21,15 @@ impl SuccessfulToolPostCallHookExecutor<&str> for RecordingExecutor { self.calls .push((tool_name.to_string(), input.clone(), (*context).to_string())); } + + fn behavior_guard( + &mut self, + _tool_name: &str, + _input: &Value, + _context: &&str, + ) -> HookResult { + HookResult::Continue + } } #[test] 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", diff --git a/src/crates/interfaces/acp/src/client/manager.rs b/src/crates/interfaces/acp/src/client/manager.rs index 169e2258b6..3764fa141b 100644 --- a/src/crates/interfaces/acp/src/client/manager.rs +++ b/src/crates/interfaces/acp/src/client/manager.rs @@ -1507,6 +1507,34 @@ 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(); + for (client_id, config) in configs.iter().filter(|(_, c)| c.enabled) { + let agent_id = + bitfun_core::agentic::agents::AcpAgent::agent_id_for( + client_id, + ); + // Clean up any previous registration for this client + agent_registry.unregister_agent(&agent_id); + 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( diff --git a/src/crates/services/services-integrations/src/mcp/protocol/client_info.rs b/src/crates/services/services-integrations/src/mcp/protocol/client_info.rs index 157ca7d3c1..6abed44a1e 100644 --- a/src/crates/services/services-integrations/src/mcp/protocol/client_info.rs +++ b/src/crates/services/services-integrations/src/mcp/protocol/client_info.rs @@ -8,8 +8,6 @@ pub fn create_mcp_client_info( ) -> ClientInfo { ClientInfo::new( ClientCapabilities::builder() - .enable_roots() - .enable_sampling() .enable_elicitation() .build(), Implementation::new(client_name, client_version), diff --git a/src/crates/services/services-integrations/src/mcp/protocol/transport_remote.rs b/src/crates/services/services-integrations/src/mcp/protocol/transport_remote.rs index 77e984a27e..faa0a13bec 100644 --- a/src/crates/services/services-integrations/src/mcp/protocol/transport_remote.rs +++ b/src/crates/services/services-integrations/src/mcp/protocol/transport_remote.rs @@ -202,7 +202,7 @@ impl StreamableHttpClient for BitFunStreamableHttpClient { } } - let event_stream = SseStream::from_byte_stream(response.bytes_stream()).boxed(); + let event_stream = SseStream::from_bytes_stream(response.bytes_stream()).boxed(); Ok(event_stream) } @@ -303,7 +303,7 @@ impl StreamableHttpClient for BitFunStreamableHttpClient { match content_type.as_deref() { Some(ct) if ct.as_bytes().starts_with(EVENT_STREAM_MIME_TYPE.as_bytes()) => { - let event_stream = SseStream::from_byte_stream(response.bytes_stream()).boxed(); + let event_stream = SseStream::from_bytes_stream(response.bytes_stream()).boxed(); Ok(StreamableHttpPostResponse::Sse(event_stream, session_id)) } Some(ct) if ct.as_bytes().starts_with(JSON_MIME_TYPE.as_bytes()) => { diff --git a/src/web-ui/src/app/layout/AppLayout.tsx b/src/web-ui/src/app/layout/AppLayout.tsx index 81e5091126..3c953f9ff9 100644 --- a/src/web-ui/src/app/layout/AppLayout.tsx +++ b/src/web-ui/src/app/layout/AppLayout.tsx @@ -51,6 +51,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 })) ); @@ -748,6 +751,9 @@ const AppLayout: React.FC = ({ className = '' }) => { )} + + + {pendingAcpSessionClients.length > 0 && (
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..2b1122e940 --- /dev/null +++ b/src/web-ui/src/app/layout/BeeColonyMonitor.scss @@ -0,0 +1,102 @@ +// BeeColonyMonitor — floating panel for bee-colony-dag MiniApp +$panel-width: 380px; +$panel-height: 560px; +$trigger-offset-bottom: 80px; +$trigger-offset-right: 20px; + +.bee-monitor { + position: fixed; + z-index: 700; + pointer-events: none; + &--open { pointer-events: auto; } +} + +.bee-monitor__button { + position: fixed; + bottom: $trigger-offset-bottom; + right: $trigger-offset-right; + width: 44px; + height: 44px; + border-radius: 50%; + border: 1px solid var(--bitfun-border-subtle); + background: var(--bitfun-bg-secondary); + color: var(--bitfun-text-secondary); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.30); + transition: background 0.2s, color 0.2s, transform 0.2s; + pointer-events: auto; + z-index: 701; + &:hover { + background: var(--bitfun-element-hover); + color: var(--bitfun-accent); + transform: scale(1.08); + } + .bee-monitor--open & { opacity: 0; pointer-events: none; transform: scale(0.8); } +} + +.bee-monitor__backdrop { + position: fixed; + inset: 0; + background: transparent; + z-index: 698; +} + +.bee-monitor__panel { + position: fixed; + bottom: 136px; + right: $trigger-offset-right; + width: $panel-width; + height: $panel-height; + background: var(--bitfun-bg); + border: 1px solid var(--bitfun-border-subtle); + border-radius: 10px; + box-shadow: 0 8px 40px rgba(0, 0, 0, 0.45); + display: flex; + flex-direction: column; + z-index: 699; + overflow: hidden; + transform: translateY(16px) scale(0.96); + opacity: 0; + pointer-events: none; + transition: transform 0.22s cubic-bezier(0.22, 0.61, 0.36, 1), opacity 0.18s ease; + &--open { transform: translateY(0) scale(1); opacity: 1; pointer-events: auto; } + &--maximized { + top: 40px; left: 40px; right: 40px; bottom: 40px; + width: auto; height: auto; + } +} + +.bee-monitor__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + border-bottom: 1px solid var(--bitfun-border-subtle); + flex-shrink: 0; + background: var(--bitfun-bg-secondary); +} +.bee-monitor__title { + font-size: 13px; font-weight: 600; + color: var(--bitfun-text); +} +.bee-monitor__header-actions { display: flex; gap: 4px; } +.bee-monitor__header-btn { + width: 28px; height: 28px; border-radius: 6px; + border: none; background: transparent; + color: var(--bitfun-text-secondary); cursor: pointer; + display: flex; align-items: center; justify-content: center; + &:hover { background: var(--bitfun-element-hover); color: var(--bitfun-text); } + &--close:hover { background: rgba(239,68,68,0.15); color: var(--bitfun-error); } +} + +.bee-monitor__body { flex: 1; min-height: 0; display: flex; flex-direction: column; overflow: hidden; } +.bee-monitor__loading, .bee-monitor__error { + flex: 1; display: flex; flex-direction: column; + align-items: center; justify-content: center; gap: 8px; + padding: 24px; text-align: center; + color: var(--bitfun-text-muted); font-size: 13px; + small { font-size: 11px; opacity: 0.7; } +} \ No newline at end of file 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..ff1f90ce68 --- /dev/null +++ b/src/web-ui/src/app/layout/BeeColonyMonitor.tsx @@ -0,0 +1,147 @@ +/** + * 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 } from 'react'; +import { GitBranch, X, Minimize2, Maximize2 } from 'lucide-react'; +import { miniAppAPI } from '@/infrastructure/api/service-api/MiniAppAPI'; +import type { MiniApp } from '@/infrastructure/api/service-api/MiniAppAPI'; +import { useTheme } from '@/infrastructure/theme/hooks/useTheme'; +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 { themeType } = useTheme(); + 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); + + // 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('MiniApp not compiled'); + 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]); + + // Load app when panel opens + useEffect(() => { + if (isOpen && !app && !loading) { + void loadApp(); + } + }, [isOpen, app, loading, 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 */} +
+ 蜂群架构监控 +
+ + +
+
+ + {/* Body */} +
+ {loading && ( +
加载中...
+ )} + {error && !app && ( +
+

蜂群 MiniApp 未就绪

+ {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 936ff58ca8..57a076325c 100644 --- a/src/web-ui/src/app/scenes/agents/AgentsScene.tsx +++ b/src/web-ui/src/app/scenes/agents/AgentsScene.tsx @@ -39,7 +39,8 @@ import './AgentsView.scss'; import './AgentsScene.scss'; import { useGallerySceneAutoRefresh } from '@/app/hooks/useGallerySceneAutoRefresh'; import { - CORE_AGENT_IDS, + ACP_CORE_AGENT_PREFIX, + buildCoreAgentIds, isAgentInOverviewZone, isLocallyManageableSubagent, } from './agentVisibility'; @@ -288,26 +289,50 @@ const AgentsHomeView: React.FC = () => { }; }, []); - const coreAgentMeta = useMemo((): Record => ({ - agentic: { - role: t('coreAgentsZone.modes.agentic.role'), - ...CORE_AGENT_ACCENTS.agentic, - }, - Cowork: { - role: t('coreAgentsZone.modes.cowork.role'), - ...CORE_AGENT_ACCENTS.Cowork, - }, - ComputerUse: { - role: t('coreAgentsZone.modes.computerUse.role'), - ...CORE_AGENT_ACCENTS.ComputerUse, - }, - }), [t]); + const coreAgentMeta = useMemo((): Record => { + const meta: Record = { + agentic: { + role: t('coreAgentsZone.modes.agentic.role'), + ...CORE_AGENT_ACCENTS.agentic, + }, + Cowork: { + role: t('coreAgentsZone.modes.cowork.role'), + ...CORE_AGENT_ACCENTS.Cowork, + }, + ComputerUse: { + role: t('coreAgentsZone.modes.computerUse.role'), + ...CORE_AGENT_ACCENTS.ComputerUse, + }, + }; + // Append ACP external agent meta (use a neutral accent). + for (const agent of allAgents) { + if (agent.id.startsWith(ACP_CORE_AGENT_PREFIX) && !meta[agent.id]) { + meta[agent.id] = { + role: t('coreAgentsZone.modes.acpExternal.role', agent.name), + ...DEFAULT_CORE_AGENT_ACCENT, + }; + } + } + return meta; + }, [t, allAgents]); + + const acpEnabledIds = useMemo( + () => allAgents + .filter((a) => a.id.startsWith(ACP_CORE_AGENT_PREFIX)) + .map((a) => a.id.slice(ACP_CORE_AGENT_PREFIX.length)), + [allAgents], + ); - const coreAgents = useMemo(() => allAgents.filter((agent) => CORE_AGENT_IDS.has(agent.id)), [allAgents]); + const effectiveCoreAgentIds = useMemo(() => buildCoreAgentIds(acpEnabledIds), [acpEnabledIds]); + + const coreAgents = useMemo( + () => allAgents.filter((agent) => effectiveCoreAgentIds.has(agent.id)), + [allAgents, effectiveCoreAgentIds], + ); const visibleAgents = useMemo( - () => filteredAgents.filter((agent) => isAgentInOverviewZone(agent, hiddenAgentIds)), - [filteredAgents, hiddenAgentIds], + () => filteredAgents.filter((agent) => isAgentInOverviewZone(agent, hiddenAgentIds, effectiveCoreAgentIds)), + [filteredAgents, hiddenAgentIds, effectiveCoreAgentIds], ); const scrollToZone = useCallback((targetId: string) => { diff --git a/src/web-ui/src/app/scenes/agents/agentVisibility.ts b/src/web-ui/src/app/scenes/agents/agentVisibility.ts index e5fe4b460f..bf77e5b704 100644 --- a/src/web-ui/src/app/scenes/agents/agentVisibility.ts +++ b/src/web-ui/src/app/scenes/agents/agentVisibility.ts @@ -18,15 +18,31 @@ export const HIDDEN_AGENT_IDS = new Set([ ...FALLBACK_REVIEW_HIDDEN_AGENT_IDS, ]); +/** Prefix used by ACP external agents (e.g. acp__codex, acp__Claude_Code). */ +export const ACP_CORE_AGENT_PREFIX = 'acp__'; + /** Core mode agents shown in the top zone only; excluded from overview zone list and counts. */ -export const CORE_AGENT_IDS = new Set(['agentic', 'Cowork', 'ComputerUse']); +const STATIC_CORE_AGENT_IDS = new Set(['agentic', 'Cowork', 'ComputerUse']); + +/** Build the effective core-agent id set including enabled ACP external agents. */ +export function buildCoreAgentIds(acpEnabledIds: string[]): Set { + const ids = new Set(STATIC_CORE_AGENT_IDS); + for (const acpId of acpEnabledIds) { + ids.add(`${ACP_CORE_AGENT_PREFIX}${acpId}`); + } + return ids; +} + +/** Legacy static set kept for backward-compat callers that don't inject ACP ids. */ +export const CORE_AGENT_IDS = STATIC_CORE_AGENT_IDS; /** Agents that appear in the bottom overview grid (same pool as filter chip counts). */ export function isAgentInOverviewZone( agent: { id: string }, hiddenAgentIds: ReadonlySet = HIDDEN_AGENT_IDS, + coreAgentIds: ReadonlySet = CORE_AGENT_IDS, ): boolean { - return !hiddenAgentIds.has(agent.id) && !CORE_AGENT_IDS.has(agent.id); + return !hiddenAgentIds.has(agent.id) && !coreAgentIds.has(agent.id); } /** External subagents are visible in the overview but managed by their source adapter. */ diff --git a/src/web-ui/src/app/scenes/agents/components/CreateLegionPage.tsx b/src/web-ui/src/app/scenes/agents/components/CreateLegionPage.tsx new file mode 100644 index 0000000000..5e4b32c7c3 --- /dev/null +++ b/src/web-ui/src/app/scenes/agents/components/CreateLegionPage.tsx @@ -0,0 +1,177 @@ +import React, { useCallback, useState } from 'react'; +import { ArrowLeft, GitBranch, Network } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { Button, IconButton } from '@/component-library'; +import { useNotification } from '@/shared/notification-system'; +import PATTERNS, { + type LegionPatternNode, + type LegionPatternEdge, +} from '../data/orchestration-patterns'; +import { LegionPresetAPI } from '@/infrastructure/api/service-api/LegionPresetAPI'; +import '../AgentsView.scss'; + +interface CreateLegionPageProps { + onBack: () => void; +} + +const CreateLegionPage: React.FC = ({ onBack }) => { + const { t } = useTranslation('scenes/agents'); + const { success: notifySuccess, error: notifyError } = useNotification(); + const [selectedPatternId, setSelectedPatternId] = useState(PATTERNS[0]?.id ?? ''); + const [saving, setSaving] = useState(false); + + const selectedPattern = PATTERNS.find((p) => p.id === selectedPatternId) ?? null; + + const handleSelectPattern = useCallback((id: string) => { + setSelectedPatternId(id); + }, []); + + const handleSave = useCallback(async () => { + if (!selectedPattern || saving) return; + setSaving(true); + try { + await LegionPresetAPI.createPreset({ + id: selectedPattern.id, + name: selectedPattern.name, + description: selectedPattern.description, + nodes: selectedPattern.nodes.map((n) => ({ + id: n.id, + agent: n.agent, + role: n.role, + prompt: n.prompt, + gate: n.gate, + })), + edges: selectedPattern.edges.map((e) => ({ + from: e.from, + to: e.to, + condition: e.condition, + })), + }); + notifySuccess(`Legion preset "${selectedPattern.name}" saved`); + onBack(); + } catch (err) { + notifyError(`Failed to save legion preset: ${err}`); + } finally { + setSaving(false); + } + }, [selectedPattern, saving, onBack, notifySuccess, notifyError]); + + const renderNodeList = (nodes: LegionPatternNode[]) => ( +
+ {nodes.map((node, i) => ( +
+ {i + 1} +
+ {node.role} + {node.agent} +
+ {node.gate ? {t('legionPattern.gate')} : null} +
+ ))} +
+ ); + + const renderEdgeList = (edges: LegionPatternEdge[], nodes: LegionPatternNode[]) => ( +
+ {edges.map((edge) => { + const fromNode = nodes.find((n) => n.id === edge.from); + const toNode = nodes.find((n) => n.id === edge.to); + return ( +
${edge.to}`} className="legion-edge-item"> + {fromNode?.role ?? edge.from} + + {toNode?.role ?? edge.to} + {edge.condition ? ( + [{edge.condition}] + ) : null} +
+ ); + })} +
+ ); + + return ( +
+
+ + + +

+ {selectedPattern ? selectedPattern.name : t('legionPattern.choosePattern')} +

+
+ + {/* Pattern selector */} +
+

{t('legionPattern.orchestrationPatterns')}

+
+ {PATTERNS.map((pattern) => ( +
handleSelectPattern(pattern.id)} + role="button" + tabIndex={0} + aria-pressed={pattern.id === selectedPatternId} + onKeyDown={(e) => e.key === 'Enter' && handleSelectPattern(pattern.id)} + data-testid="legion-pattern-option" + data-pattern-id={pattern.id} + > + + {pattern.name} +
+ ))} +
+
+ + {selectedPattern ? ( + <> + {/* Summary */} +
+

{t('legionPattern.overview')}

+

{selectedPattern.description}

+
+ {t('legionPattern.complexity', { level: selectedPattern.complexityLevel })} + {t('legionPattern.nodesCount', { count: selectedPattern.nodes.length })} + {t('legionPattern.edgesCount', { count: selectedPattern.edges.length })} +
+
+ + {/* Nodes */} +
+

+ {t('legionPattern.nodes', { count: selectedPattern.nodes.length })} +

+ {renderNodeList(selectedPattern.nodes)} +
+ + {/* Edges */} +
+

+ {t('legionPattern.edges', { count: selectedPattern.edges.length })} +

+ {selectedPattern.edges.length > 0 + ? renderEdgeList(selectedPattern.edges, selectedPattern.nodes) + :

{t('legionPattern.noEdges')}

} +
+ + {/* Actions */} +
+ + +
+ + ) : null} +
+ ); +}; + +export default CreateLegionPage; diff --git a/src/web-ui/src/app/scenes/agents/components/LegionCard.scss b/src/web-ui/src/app/scenes/agents/components/LegionCard.scss new file mode 100644 index 0000000000..925e15e3ea --- /dev/null +++ b/src/web-ui/src/app/scenes/agents/components/LegionCard.scss @@ -0,0 +1,99 @@ +.legion-card { + cursor: pointer; + display: flex; + flex-direction: column; + gap: 10px; + padding: 16px; + border-radius: 10px; + background: var(--element-bg-soft); + border: 1px solid var(--border-subtle); + transition: border-color 0.15s, box-shadow 0.15s; + + &:hover { + border-color: var(--border-accent); + box-shadow: 0 2px 8px rgb(0 0 0 / 0.06); + } + + &__header { + display: flex; + align-items: flex-start; + gap: 10px; + } + + &__icon-area { + flex-shrink: 0; + } + + &__icon { + width: 36px; + height: 36px; + border-radius: 8px; + background: var(--color-overlay-white-12); + color: var(--color-accent-500); + display: flex; + align-items: center; + justify-content: center; + } + + &__header-info { + flex: 1; + min-width: 0; + } + + &__title-row { + display: flex; + align-items: center; + gap: 6px; + } + + &__name { + font-size: 14px; + font-weight: 600; + color: var(--color-text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &__badges { + display: flex; + gap: 4px; + flex-shrink: 0; + } + + &__body { + flex: 1; + } + + &__desc { + font-size: 12px; + line-height: 1.5; + color: var(--color-text-secondary); + margin: 0; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + + &__footer { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 6px; + } + + &__meta { + display: flex; + gap: 10px; + } + + &__meta-item { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11px; + color: var(--color-text-muted); + } +} 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..3b8c53d9c8 --- /dev/null +++ b/src/web-ui/src/app/scenes/agents/components/LegionCard.tsx @@ -0,0 +1,83 @@ +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} + > +
+
+
+ +
+
+
+
+ {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/scenes/agents/hooks/useAgentsList.ts b/src/web-ui/src/app/scenes/agents/hooks/useAgentsList.ts index f09846b3e8..b0d882caaa 100644 --- a/src/web-ui/src/app/scenes/agents/hooks/useAgentsList.ts +++ b/src/web-ui/src/app/scenes/agents/hooks/useAgentsList.ts @@ -16,10 +16,11 @@ import { useNotification } from '@/shared/notification-system'; import type { DynamicToolInfo } from '@/shared/types/agent-api'; import type { AgentWithCapabilities } from '../agentsStore'; import { enrichCapabilities } from '../utils'; -import { HIDDEN_AGENT_IDS, isAgentInOverviewZone } from '../agentVisibility'; +import { HIDDEN_AGENT_IDS, isAgentInOverviewZone, ACP_CORE_AGENT_PREFIX } from '../agentVisibility'; import { useCurrentWorkspace } from '@/infrastructure/contexts/WorkspaceContext'; import { loadDefaultReviewTeamDefinition } from '@/shared/services/reviewTeamService'; import { globalEventBus } from '@/infrastructure/event-bus'; +import { ACPClientAPI, type AcpClientInfo } from '@/infrastructure/api/service-api/ACPClientAPI'; export type FilterLevel = 'all' | 'builtin' | 'user' | 'project' | 'external'; export type FilterType = 'all' | 'mode' | 'subagent'; @@ -203,6 +204,14 @@ export function useAgentsList({ ]).catch((): Record => ({})), ]); + // Fetch enabled ACP external agents for core zone metadata enrichment. + let acpClients: AcpClientInfo[] = []; + try { + acpClients = (await ACPClientAPI.getClients()).filter((c) => c.enabled); + } catch { + // ACP not initialized — no external agents to show. + } + const profileMap = buildProfileMap(modes); const profileEntries = Object.values(profileMap); @@ -275,7 +284,34 @@ export function useAgentsList({ }); }); - setAllAgents([...modeAgents, ...subAgents]); + const modeAgentIds = new Set(modeAgents.map((a) => a.id)); + const acpAgents: AgentWithCapabilities[] = acpClients + .filter((client) => !modeAgentIds.has(`${ACP_CORE_AGENT_PREFIX}${client.id}`)) + .map((client): AgentWithCapabilities => { + const agentId = `${ACP_CORE_AGENT_PREFIX}${client.id}`; + const displayName = client.name || client.id; + const acpToolName = `${ACP_CORE_AGENT_PREFIX}${client.id}__prompt`; + const defaultTools = [acpToolName, 'Read', 'Grep', 'Glob', 'LS']; + return enrichCapabilities({ + key: `acp::${client.id}`, + id: agentId, + name: displayName, + description: client.command + ? `External ACP agent (${client.command}${client.args.length ? ' ' + client.args.join(' ') : ''})` + : `External ACP agent — ${client.status}`, + isReadonly: client.readonly, + isReview: false, + toolCount: defaultTools.length, + defaultTools, + defaultEnabled: client.enabled, + effectiveEnabled: client.enabled, + source: 'builtin' as AgentSource, + capabilities: [], + agentKind: 'subagent' as const, + }); + }); + + setAllAgents([...modeAgents, ...subAgents, ...acpAgents]); setAvailableTools(tools); setConfiguredModels(models); setModeProfiles(profileMap); diff --git a/src/web-ui/src/infrastructure/api/service-api/LegionPresetAPI.ts b/src/web-ui/src/infrastructure/api/service-api/LegionPresetAPI.ts new file mode 100644 index 0000000000..dff346d9cd --- /dev/null +++ b/src/web-ui/src/infrastructure/api/service-api/LegionPresetAPI.ts @@ -0,0 +1,45 @@ +import { api } from './ApiClient'; + +export interface LegionPresetNode { + id: string; + agent: string; + role: string; + prompt: string; + gate?: boolean; +} + +export interface LegionPresetEdge { + from: string; + to: string; + condition?: string; +} + +export interface LegionPreset { + id: string; + name: string; + description: string; + nodes: LegionPresetNode[]; + edges: LegionPresetEdge[]; +} + +export const LegionPresetAPI = { + async listPresets(): Promise { + return api.invoke('list_legion_presets'); + }, + + async getPreset(id: string): Promise { + return api.invoke('get_legion_preset', { id }); + }, + + async createPreset(preset: LegionPreset): Promise { + return api.invoke('create_legion_preset', { preset }); + }, + + async updatePreset(preset: LegionPreset): Promise { + return api.invoke('update_legion_preset', { preset }); + }, + + async deletePreset(id: string): Promise { + return api.invoke('delete_legion_preset', { id }); + }, +}; diff --git a/src/web-ui/src/locales/en-US/scenes/agents.json b/src/web-ui/src/locales/en-US/scenes/agents.json index ccce7c6233..8a575bfd5c 100644 --- a/src/web-ui/src/locales/en-US/scenes/agents.json +++ b/src/web-ui/src/locales/en-US/scenes/agents.json @@ -42,6 +42,9 @@ }, "computerUse": { "role": "Desktop automation agent" + }, + "acpExternal": { + "role": "ACP Agent" } } }, @@ -285,5 +288,33 @@ "Debug": "Debug mode: systematically diagnose and fix errors in code", "Claw": "Claw mode: extract and integrate information from external sources", "Team": "Team mode: coordinate multiple agents to collaboratively complete complex tasks" + }, + "legionPattern": { + "back": "Back", + "choosePattern": "Choose a Pattern", + "orchestrationPatterns": "Orchestration Patterns", + "overview": "Overview", + "complexity": "Complexity: L{{level}}", + "nodesCount": "{{count}} nodes", + "edgesCount": "{{count}} edges", + "noEdges": "No edges (self-contained agent)", + "usePattern": "Use Pattern", + "gate": "GATE", + "nodes": "Nodes ({{count}})", + "edges": "Edges ({{count}})", + "meta": { + "nodes": "nodes", + "edges": "edges", + "gate": "gate" + }, + "complexityLabel": { + "l1": "L1", + "l2l3": "L2-L3", + "l3": "L3", + "l4": "L4", + "l5l6": "L5-L6", + "l6": "L6", + "l7": "L7" + } } } diff --git a/src/web-ui/src/locales/zh-CN/scenes/agents.json b/src/web-ui/src/locales/zh-CN/scenes/agents.json index 0aa2eacffd..01a0e18f32 100644 --- a/src/web-ui/src/locales/zh-CN/scenes/agents.json +++ b/src/web-ui/src/locales/zh-CN/scenes/agents.json @@ -42,6 +42,9 @@ }, "computerUse": { "role": "电脑操作智能体" + }, + "acpExternal": { + "role": "ACP智能体" } } }, @@ -285,5 +288,33 @@ "Debug": "调试模式:系统性地诊断和修复代码中的错误", "Claw": "抓取模式:从外部源提取和整合信息", "Team": "团队模式:协调多个智能体协同完成复杂任务" + }, + "legionPattern": { + "back": "返回", + "choosePattern": "选择模式", + "orchestrationPatterns": "编排模式", + "overview": "概览", + "complexity": "复杂度:L{{level}}", + "nodesCount": "{{count}} 个节点", + "edgesCount": "{{count}} 条边", + "noEdges": "无边关系(独立智能体)", + "usePattern": "使用此模式", + "gate": "关卡", + "nodes": "节点({{count}})", + "edges": "边({{count}})", + "meta": { + "nodes": "节点", + "edges": "边", + "gate": "关卡" + }, + "complexityLabel": { + "l1": "L1", + "l2l3": "L2-L3", + "l3": "L3", + "l4": "L4", + "l5l6": "L5-L6", + "l6": "L6", + "l7": "L7" + } } } diff --git a/src/web-ui/src/locales/zh-TW/scenes/agents.json b/src/web-ui/src/locales/zh-TW/scenes/agents.json index 4657b59018..921b4ec8c9 100644 --- a/src/web-ui/src/locales/zh-TW/scenes/agents.json +++ b/src/web-ui/src/locales/zh-TW/scenes/agents.json @@ -42,6 +42,9 @@ }, "computerUse": { "role": "電腦操作智能體" + }, + "acpExternal": { + "role": "ACP智能體" } } }, @@ -285,5 +288,33 @@ "Debug": "除錯模式:系統性地診斷和修復程式碼中的錯誤", "Claw": "抓取模式:從外部來源提取和整合資訊", "Team": "團隊模式:協調多個智慧體協同完成複雜任務" + }, + "legionPattern": { + "back": "返回", + "choosePattern": "選擇模式", + "orchestrationPatterns": "編排模式", + "overview": "概覽", + "complexity": "複雜度:L{{level}}", + "nodesCount": "{{count}} 個節點", + "edgesCount": "{{count}} 條邊", + "noEdges": "無邊關係(獨立智慧體)", + "usePattern": "使用此模式", + "gate": "關卡", + "nodes": "節點({{count}})", + "edges": "邊({{count}})", + "meta": { + "nodes": "節點", + "edges": "邊", + "gate": "關卡" + }, + "complexityLabel": { + "l1": "L1", + "l2l3": "L2-L3", + "l3": "L3", + "l4": "L4", + "l5l6": "L5-L6", + "l6": "L6", + "l7": "L7" + } } }