feat(desktop): embed BoosAPI Desktop client as a subproject

Vendor the rebranded BoosAPI Desktop client (Tauri + Vue) into metapi as
a monorepo subdirectory under desktop/. Source only — node_modules,
dist, Cargo target and the upstream .git history are excluded.

desktop/ keeps its own independent Cargo workspace (desktop/src-tauri
with crates/*); it is not part of the backend boosapi crate, so a
`cargo check` at the metapi root still compiles only the backend. Build
the desktop app from desktop/src-tauri per desktop/README.md.

Note: app icons are still placeholder assets pending the final BoosAPI
logo. The updater signing keys live outside the repo and are not
included.
This commit is contained in:
boos
2026-06-22 11:32:57 +08:00
parent d9c78ecc73
commit e0a5f57793
2305 changed files with 449355 additions and 0 deletions
@@ -0,0 +1,408 @@
use crate::api::ApiClient;
use crate::costtracker::CostTracker;
use crate::hooks::HookConfig;
use crate::mcp::{self, McpClient};
use crate::tools::ToolRegistry;
use crate::types::*;
use serde_json::Value;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
/// Configuration options for creating an agent.
pub struct AgentOptions {
/// LLM model ID (default: claude-sonnet-4-6-20250514).
pub model: Option<String>,
/// API key for authentication.
pub api_key: Option<String>,
/// Custom API base URL.
pub base_url: Option<String>,
/// Custom LLM provider (bypasses built-in ApiClient creation).
pub provider: Option<Arc<dyn crate::LLMProvider>>,
/// Working directory for tool execution.
pub cwd: Option<String>,
/// Custom system prompt (replaces default).
pub system_prompt: Option<String>,
/// Additional text appended to the default system prompt.
pub append_system_prompt: Option<String>,
/// Skills context summary for system prompt injection.
pub skills_summary: Option<String>,
/// Maximum number of agentic turns (default: 100).
pub max_turns: Option<u32>,
/// Maximum budget in USD.
pub max_budget_usd: Option<f64>,
/// Permission mode for tool access control.
pub permission_mode: Option<PermissionMode>,
/// Tool names to allow (whitelist).
pub allowed_tools: Option<Vec<String>>,
/// Tool names to deny (blacklist).
pub disallowed_tools: Option<Vec<String>>,
/// Custom permission callback.
pub can_use_tool: Option<CanUseToolFn>,
/// Callback for user interaction (AskUserQuestion tool).
pub ask_fn: Option<crate::tools::askuser::AskUserFn>,
/// Custom tools to register.
pub custom_tools: Vec<Arc<dyn Tool>>,
/// MCP server configurations.
pub mcp_servers: HashMap<String, McpServerConfig>,
/// Subagent definitions.
pub agents: HashMap<String, SubagentDefinition>,
/// Extended thinking configuration.
pub thinking: Option<ThinkingConfig>,
/// Maximum output tokens per response.
pub max_tokens: Option<u64>,
/// Structured output JSON schema.
pub json_schema: Option<Value>,
/// Hook configuration.
pub hooks: Option<HookConfig>,
/// Cancellation token used to stop the running agent loop and tools.
pub abort_signal: Option<CancellationToken>,
/// Custom HTTP headers for API requests.
pub custom_headers: HashMap<String, String>,
/// Resume the most recent session in the current working directory.
pub continue_session: Option<bool>,
/// Resume a specific session by ID.
pub resume: Option<String>,
/// Fork instead of continuing a session.
pub fork_session: Option<bool>,
/// Control whether the session is persisted to disk.
pub persist_session: Option<bool>,
/// Explicit session ID to use (instead of auto-generating one).
pub session_id: Option<String>,
/// Effort level for reasoning: "low", "medium", "high", "max".
pub effort: Option<String>,
/// Fallback model if primary is unavailable.
pub fallback_model: Option<String>,
/// Sandbox configuration for restricting tool execution.
pub sandbox: Option<SandboxSettings>,
/// Enable debug mode.
pub debug: Option<bool>,
/// Additional working directories.
pub additional_directories: Option<Vec<String>>,
}
impl Default for AgentOptions {
fn default() -> Self {
Self {
model: None,
api_key: None,
base_url: None,
provider: None,
cwd: None,
system_prompt: None,
append_system_prompt: None,
skills_summary: None,
max_turns: None,
max_budget_usd: None,
permission_mode: None,
allowed_tools: None,
disallowed_tools: None,
can_use_tool: None,
ask_fn: None,
custom_tools: Vec::new(),
mcp_servers: HashMap::new(),
agents: HashMap::new(),
thinking: None,
max_tokens: None,
json_schema: None,
hooks: None,
abort_signal: None,
custom_headers: HashMap::new(),
continue_session: None,
resume: None,
fork_session: None,
persist_session: None,
session_id: None,
effort: None,
fallback_model: None,
sandbox: None,
debug: None,
additional_directories: None,
}
}
}
/// Subagent definition.
#[derive(Debug, Clone)]
pub struct SubagentDefinition {
pub description: String,
pub instructions: Option<String>,
pub tools: Option<Vec<String>>,
pub model: Option<String>,
}
/// The main Agent struct that orchestrates the agentic loop.
pub struct Agent {
pub(crate) api_client: ApiClient,
pub(crate) registry: ToolRegistry,
/// Conversation messages.
pub messages: Vec<Message>,
/// Working directory.
pub cwd: String,
/// Custom system prompt.
pub system_prompt: Option<String>,
/// Additional system prompt text.
pub append_system_prompt: Option<String>,
/// Skills context summary.
pub skills_summary: Option<String>,
/// Maximum agentic turns.
pub max_turns: u32,
/// Maximum budget in USD.
pub max_budget_usd: Option<f64>,
pub(crate) cost_tracker: CostTracker,
pub(crate) mcp_client: Arc<McpClient>,
/// Subagent definitions.
pub agents: HashMap<String, SubagentDefinition>,
/// Extended thinking configuration.
pub thinking: Option<ThinkingConfig>,
/// Maximum output tokens.
pub max_tokens: Option<u64>,
/// Structured output JSON schema.
pub json_schema: Option<Value>,
/// Hook configuration.
pub hooks: HookConfig,
pub(crate) can_use_tool: Option<CanUseToolFn>,
pub(crate) session_id: String,
pub(crate) abort_signal: CancellationToken,
}
impl Agent {
/// Create a new agent with the given options.
pub async fn new(options: AgentOptions) -> Result<Self, String> {
let cwd = options.cwd.unwrap_or_else(|| {
std::env::current_dir()
.unwrap()
.to_string_lossy()
.to_string()
});
let api_client = if let Some(provider) = options.provider {
ApiClient::with_provider(provider, options.model)
} else {
ApiClient::new(options.api_key, options.base_url, options.model)
};
let mut registry = ToolRegistry::default_registry();
// Replace default AskUserTool with configured one if ask_fn is provided
if let Some(ask_fn) = options.ask_fn {
registry.register_replace(Arc::new(crate::tools::askuser::AskUserTool::new(ask_fn)));
}
// Register custom tools
for tool in options.custom_tools {
registry.register(tool);
}
// Apply allowed/disallowed tool filters
if let Some(allowed) = &options.allowed_tools {
let allowed_refs: Vec<&str> = allowed.iter().map(|s| s.as_str()).collect();
registry.retain(&allowed_refs);
}
if let Some(disallowed) = &options.disallowed_tools {
let disallowed_refs: Vec<&str> = disallowed.iter().map(|s| s.as_str()).collect();
registry.remove(&disallowed_refs);
}
// Connect MCP servers
let mcp_client = Arc::new(McpClient::new());
for (name, config) in &options.mcp_servers {
match mcp_client.connect(name, config.clone()).await {
Ok(tools) => {
let mcp_tools = mcp::create_mcp_tools(name, &tools, mcp_client.clone());
for tool in mcp_tools {
registry.register(tool);
}
}
Err(e) => {
tracing::warn!("Failed to connect MCP server '{}': {}", name, e);
}
}
}
let session_id = uuid::Uuid::new_v4().to_string();
Ok(Self {
api_client,
registry,
messages: Vec::new(),
cwd,
system_prompt: options.system_prompt,
append_system_prompt: options.append_system_prompt,
skills_summary: options.skills_summary,
max_turns: options.max_turns.unwrap_or(100),
max_budget_usd: options.max_budget_usd,
cost_tracker: CostTracker::new(),
mcp_client,
agents: options.agents,
thinking: options.thinking,
max_tokens: options.max_tokens,
json_schema: options.json_schema,
hooks: options.hooks.unwrap_or_default(),
can_use_tool: options.can_use_tool,
session_id,
abort_signal: options.abort_signal.unwrap_or_default(),
})
}
/// Execute a query and return a channel of streaming SDK messages.
pub async fn query(
&mut self,
prompt: &str,
) -> (mpsc::Receiver<SDKMessage>, tokio::task::JoinHandle<()>) {
let (tx, rx) = mpsc::channel(100);
// Add user message
let user_msg = crate::utils::messages::create_user_message(prompt);
self.messages.push(user_msg);
// Clone what we need for the async loop
let messages = self.messages.clone();
let api_client = self.api_client.clone();
let cwd = self.cwd.clone();
let system_prompt = self.system_prompt.clone();
let append_system_prompt = self.append_system_prompt.clone();
let skills_summary = self.skills_summary.clone();
let max_turns = self.max_turns;
let max_budget_usd = self.max_budget_usd;
let cost_tracker = self.cost_tracker.clone();
let thinking = self.thinking.clone();
let max_tokens = self.max_tokens;
let registry = std::sync::Arc::new(self.registry_snapshot());
let can_use_tool = self.can_use_tool.clone();
let abort_signal = self.abort_signal.clone();
let handle = tokio::spawn(async move {
let result: Result<Vec<Message>, String> = super::r#loop::run_loop(
api_client,
messages,
registry,
&cwd,
system_prompt.as_deref(),
append_system_prompt.as_deref(),
skills_summary.as_deref(),
max_turns,
max_budget_usd,
&cost_tracker,
thinking,
max_tokens,
can_use_tool.as_ref(),
abort_signal,
tx.clone(),
)
.await;
if let Err(e) = result {
let _ = tx
.send(SDKMessage::Error {
message: e.to_string(),
})
.await;
}
});
(rx, handle)
}
/// Blocking query that returns a complete QueryResult.
pub async fn prompt(&mut self, text: &str) -> Result<QueryResult, String> {
let start = std::time::Instant::now();
let (mut rx, handle) = self.query(text).await;
let mut result_text = String::new();
let mut usage = Usage::default();
let mut num_turns = 0;
let mut cost_usd = 0.0;
let mut all_messages = Vec::new();
while let Some(msg) = rx.recv().await {
match msg {
SDKMessage::Result {
text,
usage: u,
num_turns: t,
cost_usd: c,
messages,
..
} => {
result_text = text;
usage = u;
num_turns = t;
cost_usd = c;
all_messages = messages;
}
SDKMessage::Assistant { message, .. } => {
let text = crate::types::extract_text(&message);
if !text.is_empty() {
result_text = text;
}
all_messages.push(message);
}
SDKMessage::Error { message } => {
return Err(message);
}
_ => {}
}
}
let _ = handle.await;
let duration_ms = start.elapsed().as_millis() as u64;
// Update agent's message history
self.messages = all_messages.clone();
Ok(QueryResult {
text: result_text,
usage,
num_turns,
cost_usd,
duration_ms,
messages: all_messages,
})
}
/// Get current conversation messages.
pub fn get_messages(&self) -> &[Message] {
&self.messages
}
/// Clear conversation history.
pub fn clear(&mut self) {
self.messages.clear();
}
/// Set the model.
pub fn set_model(&mut self, model: &str) {
self.api_client.set_model(model.to_string());
}
/// Get the current model.
pub fn model(&self) -> &str {
self.api_client.model()
}
/// Get the cost tracker.
pub fn cost_tracker(&self) -> &CostTracker {
&self.cost_tracker
}
/// Get session ID.
pub fn session_id(&self) -> &str {
&self.session_id
}
/// Close the agent and clean up resources.
pub async fn close(&self) {
self.mcp_client.close_all().await;
}
/// Create a snapshot of the current registry for use in async context.
fn registry_snapshot(&self) -> ToolRegistry {
let mut snapshot = ToolRegistry::new();
for tool in self.registry.all() {
snapshot.register(tool);
}
snapshot
}
}
@@ -0,0 +1,238 @@
use crate::api::ApiClient;
use crate::context;
use crate::costtracker::CostTracker;
use crate::tools::{self, ToolRegistry};
use crate::types::*;
use crate::utils::{compact, messages as msg_utils, retry};
use std::sync::Arc;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
/// Run the main agentic loop.
pub(crate) async fn run_loop(
api_client: ApiClient,
mut messages: Vec<Message>,
registry: Arc<ToolRegistry>,
cwd: &str,
system_prompt: Option<&str>,
append_system_prompt: Option<&str>,
skills_summary: Option<&str>,
max_turns: u32,
max_budget_usd: Option<f64>,
cost_tracker: &CostTracker,
thinking: Option<ThinkingConfig>,
max_tokens: Option<u64>,
can_use_tool: Option<&CanUseToolFn>,
abort_signal: CancellationToken,
tx: mpsc::Sender<SDKMessage>,
) -> Result<Vec<Message>, String> {
let tool_context = ToolUseContext::with_abort(cwd.to_string(), abort_signal);
// Build system prompt blocks
let system_blocks =
context::build_system_blocks(cwd, system_prompt, append_system_prompt, skills_summary);
// Build tool definitions for API
let api_tools: Vec<ApiToolParam> = registry
.all()
.iter()
.map(|tool| {
let schema = tool.input_schema();
ApiToolParam {
name: tool.name().to_string(),
description: tool.description().to_string(),
input_schema: serde_json::to_value(&schema).unwrap_or_default(),
}
})
.collect();
let retry_config = retry::RetryConfig::default();
let mut num_turns: u32 = 0;
let mut total_usage = Usage::default();
// Send system message
let _ = tx
.send(SDKMessage::System {
message: format!(
"Agent started with {} tools ({})",
api_tools.len(),
match api_client.api_type() {
crate::api::ApiType::AnthropicMessages => "anthropic",
crate::api::ApiType::OpenAICompletions => "openai",
}
),
})
.await;
loop {
if tool_context.abort_signal.is_cancelled() {
return Err("Agent cancelled".to_string());
}
// Check turn limit
num_turns += 1;
if num_turns > max_turns {
let _ = tx
.send(SDKMessage::Progress {
message: format!("Reached max turns ({})", max_turns),
})
.await;
break;
}
// Check budget
if let Some(budget) = max_budget_usd {
let current_cost = cost_tracker.total_cost().await;
if current_cost >= budget {
let _ = tx
.send(SDKMessage::Progress {
message: format!("Budget exceeded: ${:.4} >= ${:.4}", current_cost, budget),
})
.await;
break;
}
}
// Check if auto-compaction is needed
if compact::should_auto_compact(&messages, api_client.model()) {
messages = compact::micro_compact_messages(&messages);
}
// Normalize messages
let normalized = msg_utils::normalize_messages(&messages);
// Call the API via provider abstraction with retry
let api_client_ref = &api_client;
let system_blocks_ref = &system_blocks;
let api_tools_ref = &api_tools;
let thinking_ref = &thinking;
let start = std::time::Instant::now();
let response = tokio::select! {
response = retry::with_retry(&retry_config, || async {
api_client_ref
.create_message(
&normalized,
Some(system_blocks_ref.clone()),
Some(api_tools_ref.clone()),
max_tokens,
thinking_ref.clone(),
Some(tx.clone()),
)
.await
}) => response,
_ = tool_context.abort_signal.cancelled() => {
return Err("Agent cancelled".to_string());
}
};
let provider_response = match response {
Ok(r) => r,
Err(e) => {
if retry::is_auth_error(&e) {
return Err(format!("Authentication error: {}. Check your API key.", e));
}
return Err(format!("API error: {}", e));
}
};
let api_duration = start.elapsed().as_millis() as u64;
cost_tracker.add_api_duration(api_duration).await;
cost_tracker
.add_usage(api_client.model(), &provider_response.usage)
.await;
let usage = &provider_response.usage;
let assistant_msg = provider_response.message;
let stop_reason = provider_response.stop_reason;
// Accumulate usage
total_usage.input_tokens += usage.input_tokens;
total_usage.output_tokens += usage.output_tokens;
total_usage.cache_creation_input_tokens += usage.cache_creation_input_tokens;
total_usage.cache_read_input_tokens += usage.cache_read_input_tokens;
// Send assistant message event
let _ = tx
.send(SDKMessage::Assistant {
message: assistant_msg.clone(),
usage: Some(usage.clone()),
})
.await;
messages.push(assistant_msg.clone());
// Check for tool use
let tool_uses = extract_tool_uses(&assistant_msg);
if tool_uses.is_empty() {
// No tool calls - we're done (end_turn or max_tokens)
break;
}
// Execute tools
let tool_start = std::time::Instant::now();
let results = tokio::select! {
results = tools::execute_tools(
&assistant_msg,
&registry,
&tool_context,
can_use_tool,
Some(tx.clone()),
) => results,
_ = tool_context.abort_signal.cancelled() => {
return Err("Agent cancelled".to_string());
}
};
let tool_duration = tool_start.elapsed().as_millis() as u64;
cost_tracker.add_tool_duration(tool_duration).await;
// Send tool result events
for (id, name, result) in &results {
let content_text = result.get_text();
let _ = tx
.send(SDKMessage::ToolResult {
tool_use_id: id.clone(),
tool_name: name.clone(),
content: content_text,
is_error: result.is_error,
})
.await;
}
// Build tool results message and add to conversation
let tool_results_msg = tools::build_tool_results_message(&results);
messages.push(tool_results_msg);
// Check stop reason
if stop_reason.as_deref() == Some("end_turn") && tool_uses.is_empty() {
break;
}
}
// Calculate final cost
let total_cost = cost_tracker.total_cost().await;
// Extract final text
let final_text = messages
.iter()
.rev()
.find(|m| m.role == MessageRole::Assistant)
.map(|m| extract_text(m))
.unwrap_or_default();
// Send result event
let _ = tx
.send(SDKMessage::Result {
text: final_text,
usage: total_usage,
num_turns,
cost_usd: total_cost,
duration_ms: 0,
messages: messages.clone(),
})
.await;
Ok(messages)
}
@@ -0,0 +1,4 @@
mod agent;
pub(crate) mod r#loop;
pub use agent::*;