Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions src/apps/desktop/src/api/agentic_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<bitfun_core::agentic::agents::team_presets::LegionPreset>, 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::LegionPreset, String> {
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::*;
Expand Down
20 changes: 20 additions & 0 deletions src/apps/desktop/src/api/remote_workspace_policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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),
(
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 5 additions & 1 deletion src/crates/assembly/core/src/agentic/agents/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -35,7 +36,8 @@ pub use definitions::review::{
};
pub use definitions::shared::ReadonlySubagent;
pub use definitions::subagents::{
ComputerUseMode, ExploreAgent, FileFinderAgent, GeneralPurposeAgent, ResearchSpecialistAgent,
ComputerUseMode, ExploreAgent, FileFinderAgent, GeneralPurposeAgent,
ResearchSpecialistAgent,
};
use indexmap::IndexMap;
pub use prompt_builder::{
Expand Down Expand Up @@ -132,6 +134,8 @@ pub fn shared_coding_mode_tools() -> Vec<String> {
"ReviewPlatform".to_string(),
"ControlHub".to_string(),
"InitMiniApp".to_string(),
"SessionMessage".to_string(),
"SessionHistory".to_string(),
];
append_provider_group_tools(&mut tools, "core.canvas");
tools
Expand Down
359 changes: 103 additions & 256 deletions src/crates/assembly/core/src/agentic/agents/prompts/team_mode.md

Large diffs are not rendered by default.

135 changes: 135 additions & 0 deletions src/crates/assembly/core/src/agentic/agents/team_presets.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
//! Legion preset storage.
//!
//! Each preset is a JSON file under `<user-config>/legions/<id>.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<LegionNode>,
pub edges: Vec<LegionEdge>,
}

#[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<String>,
}

fn legions_dir() -> PathBuf {
get_path_manager_arc().user_config_dir().join(LEGIONS_SUBDIR)
}

fn preset_path(id: &str) -> Result<PathBuf, String> {
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<Vec<LegionPreset>, 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<LegionPreset, String> {
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}"))
}
Loading