Skip to content
Draft
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,7 @@ Read the full vision in [docs/spacedrive.md](docs/spacedrive.md).
### Prerequisites

- **Rust** 1.85+ ([rustup](https://rustup.rs/))
- An LLM API key from any supported provider (Anthropic, OpenAI, OpenRouter, Z.ai, Groq, Together, Fireworks, DeepSeek, xAI, Mistral, or OpenCode Zen)
- An LLM API key from any supported provider (Anthropic, OpenAI, OpenRouter, Ollama Cloud, Z.ai, Groq, Together, Fireworks, DeepSeek, xAI, Mistral, or OpenCode Zen)

### Build and Run

Expand Down
12 changes: 9 additions & 3 deletions build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ fn main() {

// Skip if bun isn't installed or node_modules is missing (CI without frontend deps)
if !interface_dir.join("node_modules").exists() {
eprintln!("cargo:warning=interface/node_modules not found, skipping frontend build. Run `bun install` in interface/");
eprintln!(
"cargo:warning=interface/node_modules not found, skipping frontend build. Run `bun install` in interface/"
);
ensure_dist_dir();
return;
}
Expand All @@ -28,10 +30,14 @@ fn main() {
match status {
Ok(s) if s.success() => {}
Ok(s) => {
eprintln!("cargo:warning=frontend build exited with {s}, the binary will serve a stale or empty UI");
eprintln!(
"cargo:warning=frontend build exited with {s}, the binary will serve a stale or empty UI"
);
}
Err(e) => {
eprintln!("cargo:warning=failed to run `bun run build`: {e}. Install bun to build the frontend.");
eprintln!(
"cargo:warning=failed to run `bun run build`: {e}. Install bun to build the frontend."
);
ensure_dist_dir();
}
}
Expand Down
2 changes: 2 additions & 0 deletions docs/content/docs/(configuration)/config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ spacebot --config /path/to.toml # CLI override
anthropic_key = "env:ANTHROPIC_API_KEY"
openai_key = "env:OPENAI_API_KEY"
openrouter_key = "env:OPENROUTER_API_KEY"
ollama_key = "env:OLLAMA_API_KEY"

# --- Instance Defaults ---
# All agents inherit these. Individual agents can override any field.
Expand Down Expand Up @@ -168,6 +169,7 @@ Model names include the provider as a prefix:
| Anthropic | `anthropic/<model>` | `anthropic/claude-sonnet-4-20250514` |
| OpenAI | `openai/<model>` | `openai/gpt-4o` |
| OpenRouter | `openrouter/<provider>/<model>` | `openrouter/anthropic/claude-sonnet-4-20250514` |
| Ollama Cloud | `ollama/<model>` | `ollama/gpt-oss:20b` |

You can mix providers across process types. See [Routing](/docs/routing) for the full routing system.

Expand Down
2 changes: 1 addition & 1 deletion docs/content/docs/(deployment)/roadmap.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ The full message-in → LLM → response-out pipeline is wired end-to-end across
- **Config** — hierarchical TOML with `Config`, `AgentConfig`, `ResolvedAgentConfig`, `Binding`, `MessagingConfig`. File watcher with event filtering and content hash debounce for hot-reload.
- **Multi-agent** — per-agent database isolation, `Agent` struct bundles all dependencies
- **Database connections** — SQLite + LanceDB + redb per-agent, migrations for all tables
- **LLM**`SpacebotModel` implements Rig's `CompletionModel`, routes through `LlmManager` via HTTP with retries and fallback chains across 11 providers (Anthropic, OpenAI, OpenRouter, Z.ai, Groq, Together, Fireworks, DeepSeek, xAI, Mistral, OpenCode Zen)
- **LLM**`SpacebotModel` implements Rig's `CompletionModel`, routes through `LlmManager` via HTTP with retries and fallback chains across 12 providers (Anthropic, OpenAI, OpenRouter, Ollama Cloud, Z.ai, Groq, Together, Fireworks, DeepSeek, xAI, Mistral, OpenCode Zen)
- **Model routing**`RoutingConfig` with process-type defaults, task overrides, fallback chains
- **Memory** — full stack: types, SQLite store (CRUD + graph), LanceDB (embeddings + vector + FTS), fastembed, hybrid search (RRF fusion). `memory_type` filter wired end-to-end through SearchConfig. `total_cmp` for safe sorting.
- **Memory maintenance** — decay + prune implemented
Expand Down
1 change: 1 addition & 0 deletions interface/src/lib/providerIcons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export function ProviderIcon({ provider, className = "text-ink-faint", size = 24
anthropic: Anthropic,
openai: OpenAI,
openrouter: OpenRouter,
ollama: OpenRouter,
groq: Groq,
mistral: Mistral,
deepseek: DeepSeek,
Expand Down
7 changes: 7 additions & 0 deletions interface/src/routes/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,13 @@ const PROVIDERS = [
placeholder: "sk-...",
envVar: "OPENAI_API_KEY",
},
{
id: "ollama",
name: "Ollama Cloud",
description: "Hosted Ollama models via OpenAI-compatible API",
placeholder: "ollama_...",
envVar: "OLLAMA_API_KEY",
},
{
id: "zhipu",
name: "Z.ai (GLM)",
Expand Down
4 changes: 2 additions & 2 deletions src/agent.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
//! Agent processes: channels, branches, workers, compactor, cortex.

pub mod channel;
pub mod branch;
pub mod worker;
pub mod channel;
pub mod compactor;
pub mod cortex;
pub mod cortex_chat;
pub mod ingestion;
pub mod status;
pub mod worker;
42 changes: 27 additions & 15 deletions src/agent/branch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@

use crate::agent::compactor::estimate_history_tokens;
use crate::error::Result;
use crate::llm::routing::is_context_overflow_error;
use crate::llm::SpacebotModel;
use crate::{BranchId, ChannelId, ProcessId, ProcessType, AgentDeps, ProcessEvent};
use crate::hooks::SpacebotHook;
use crate::llm::SpacebotModel;
use crate::llm::routing::is_context_overflow_error;
use crate::{AgentDeps, BranchId, ChannelId, ProcessEvent, ProcessId, ProcessType};
use rig::agent::AgentBuilder;
use rig::completion::{CompletionModel, Prompt};
use rig::tool::server::ToolServerHandle;
Expand Down Expand Up @@ -44,8 +44,14 @@ impl Branch {
) -> Self {
let id = Uuid::new_v4();
let process_id = ProcessId::Branch(id);
let hook = SpacebotHook::new(deps.agent_id.clone(), process_id, ProcessType::Branch, Some(channel_id.clone()), deps.event_tx.clone());

let hook = SpacebotHook::new(
deps.agent_id.clone(),
process_id,
ProcessType::Branch,
Some(channel_id.clone()),
deps.event_tx.clone(),
);

Self {
id,
channel_id,
Expand All @@ -58,7 +64,7 @@ impl Branch {
max_turns,
}
}

/// Run the branch's LLM agent loop and return a conclusion.
///
/// Each branch has its own isolated ToolServer with `memory_save` and
Expand All @@ -70,7 +76,7 @@ impl Branch {
/// be large, making them susceptible to overflow on the first LLM call.
pub async fn run(mut self, prompt: impl Into<String>) -> Result<String> {
let prompt = prompt.into();

tracing::info!(
branch_id = %self.id,
channel_id = %self.channel_id,
Expand All @@ -97,15 +103,17 @@ impl Branch {
let mut overflow_retries = 0;

let conclusion = loop {
match agent.prompt(&current_prompt)
match agent
.prompt(&current_prompt)
.with_history(&mut self.history)
.with_hook(self.hook.clone())
.await
{
Ok(response) => break response,
Err(rig::completion::PromptError::MaxTurnsError { .. }) => {
let partial = extract_last_assistant_text(&self.history)
.unwrap_or_else(|| "Branch exhausted its turns without a final conclusion.".into());
let partial = extract_last_assistant_text(&self.history).unwrap_or_else(|| {
"Branch exhausted its turns without a final conclusion.".into()
});
tracing::warn!(branch_id = %self.id, "branch hit max turns, returning partial result");
break partial;
}
Expand Down Expand Up @@ -133,7 +141,8 @@ impl Branch {
"branch context overflow, compacting and retrying"
);
self.force_compact_history();
current_prompt = "Continue where you left off. Older context has been compacted.".into();
current_prompt =
"Continue where you left off. Older context has been compacted.".into();
}
Err(error) => {
tracing::error!(branch_id = %self.id, %error, "branch LLM call failed");
Expand All @@ -149,9 +158,9 @@ impl Branch {
channel_id: self.channel_id.clone(),
conclusion: conclusion.clone(),
});

tracing::info!(branch_id = %self.id, "branch completed");

Ok(conclusion)
}

Expand Down Expand Up @@ -192,7 +201,9 @@ impl Branch {
return;
}

let remove_count = ((total as f32 * fraction) as usize).max(1).min(total.saturating_sub(2));
let remove_count = ((total as f32 * fraction) as usize)
.max(1)
.min(total.saturating_sub(2));
self.history.drain(..remove_count);

let marker = format!(
Expand All @@ -207,7 +218,8 @@ impl Branch {
fn extract_last_assistant_text(history: &[rig::message::Message]) -> Option<String> {
for message in history.iter().rev() {
if let rig::message::Message::Assistant { content, .. } = message {
let texts: Vec<String> = content.iter()
let texts: Vec<String> = content
.iter()
.filter_map(|c| {
if let rig::message::AssistantContent::Text(t) = c {
Some(t.text.clone())
Expand Down
35 changes: 14 additions & 21 deletions src/agent/compactor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,7 @@ pub struct Compactor {

impl Compactor {
/// Create a new compactor for a channel.
pub fn new(
channel_id: ChannelId,
deps: AgentDeps,
history: Arc<RwLock<Vec<Message>>>,
) -> Self {
pub fn new(channel_id: ChannelId, deps: AgentDeps, history: Arc<RwLock<Vec<Message>>>) -> Self {
Self {
channel_id,
deps,
Expand Down Expand Up @@ -117,12 +113,7 @@ impl Compactor {
.expect("failed to render compactor prompt");

tokio::spawn(async move {
let result = run_compaction(
&deps,
&compactor_prompt,
&history,
fraction,
).await;
let result = run_compaction(&deps, &compactor_prompt, &history, fraction).await;

match result {
Ok(turns_compacted) => {
Expand Down Expand Up @@ -191,7 +182,9 @@ async fn run_compaction(
let (removed_messages, remove_count) = {
let mut hist = history.write().await;
let total = hist.len();
let remove_count = ((total as f32 * fraction) as usize).max(1).min(total.saturating_sub(2));
let remove_count = ((total as f32 * fraction) as usize)
.max(1)
.min(total.saturating_sub(2));
if remove_count == 0 {
return Ok(0);
}
Expand All @@ -205,12 +198,14 @@ async fn run_compaction(
// 3. Run the compaction LLM to produce summary + extracted memories
let routing = deps.runtime_config.routing.load();
let model_name = routing.resolve(ProcessType::Worker, None).to_string();
let model = SpacebotModel::make(&deps.llm_manager, &model_name)
.with_routing((**routing).clone());
let model =
SpacebotModel::make(&deps.llm_manager, &model_name).with_routing((**routing).clone());

// Give the compaction worker memory_save so it can directly persist memories
let tool_server: ToolServerHandle = ToolServer::new()
.tool(crate::tools::MemorySaveTool::new(deps.memory_search.clone()))
.tool(crate::tools::MemorySaveTool::new(
deps.memory_search.clone(),
))
.run();

let agent = AgentBuilder::new(model)
Expand All @@ -220,7 +215,8 @@ async fn run_compaction(
.build();

let mut compaction_history = Vec::new();
let response = agent.prompt(&transcript)
let response = agent
.prompt(&transcript)
.with_history(&mut compaction_history)
.await;

Expand Down Expand Up @@ -294,9 +290,7 @@ fn estimate_assistant_content_chars(content: &AssistantContent) -> usize {
AssistantContent::ToolCall(tc) => {
tc.function.name.len() + tc.function.arguments.to_string().len()
}
AssistantContent::Reasoning(r) => {
r.reasoning.iter().map(|s| s.len()).sum()
}
AssistantContent::Reasoning(r) => r.reasoning.iter().map(|s| s.len()).sum(),
AssistantContent::Image(_) => 500,
}
}
Expand Down Expand Up @@ -339,8 +333,7 @@ fn render_messages_as_transcript(messages: &[Message]) -> String {
AssistantContent::ToolCall(tc) => {
output.push_str(&format!(
"[Tool Call: {}({})]\n",
tc.function.name,
tc.function.arguments
tc.function.name, tc.function.arguments
));
}
_ => {}
Expand Down
Loading