- src/tools/composio.rs: ComposioTool implementing Tool trait - list/execute/connect actions via Composio API (1000+ OAuth apps) - 60s timeout, proper error handling, JSON schema for LLM - 12 tests covering schema, validation, serde, error paths - src/security/secrets.rs: SecretStore for encrypted credential storage - XOR cipher with random 32-byte key stored in ~/.zeroclaw/.secret_key - enc: prefix for encrypted values, plaintext passthrough (backward compat) - Key file created with 0600 permissions (Unix) - 16 tests: roundtrip, unicode, long secrets, corrupt hex, permissions - src/config/schema.rs: ComposioConfig + SecretsConfig structs - Composio: enabled (default: false), api_key, entity_id - Secrets: encrypt (default: true) - Both with serde(default) for backward compatibility - 8 new config tests - src/onboard/wizard.rs: new Step 5 'Tool Mode & Security' - Sovereign (local only) vs Composio (managed OAuth) selection - Encrypted secret storage toggle (default: on) - 7-step wizard (was 6) - src/tools/mod.rs: all_tools() now accepts optional composio_key - src/agent/loop_.rs: wires Composio key from config into tool registry - README.md: Composio integration + encrypted secrets documentation 1017 tests, 0 clippy warnings, cargo fmt clean.
191 lines
6.5 KiB
Rust
191 lines
6.5 KiB
Rust
use crate::config::Config;
|
|
use crate::memory::{self, Memory, MemoryCategory};
|
|
use crate::observability::{self, Observer, ObserverEvent};
|
|
use crate::providers::{self, Provider};
|
|
use crate::runtime;
|
|
use crate::security::SecurityPolicy;
|
|
use crate::tools;
|
|
use anyhow::Result;
|
|
use std::fmt::Write;
|
|
use std::sync::Arc;
|
|
use std::time::Instant;
|
|
|
|
/// Build context preamble by searching memory for relevant entries
|
|
async fn build_context(mem: &dyn Memory, user_msg: &str) -> String {
|
|
let mut context = String::new();
|
|
|
|
// Pull relevant memories for this message
|
|
if let Ok(entries) = mem.recall(user_msg, 5).await {
|
|
if !entries.is_empty() {
|
|
context.push_str("[Memory context]\n");
|
|
for entry in &entries {
|
|
let _ = writeln!(context, "- {}: {}", entry.key, entry.content);
|
|
}
|
|
context.push('\n');
|
|
}
|
|
}
|
|
|
|
context
|
|
}
|
|
|
|
#[allow(clippy::too_many_lines)]
|
|
pub async fn run(
|
|
config: Config,
|
|
message: Option<String>,
|
|
provider_override: Option<String>,
|
|
model_override: Option<String>,
|
|
temperature: f64,
|
|
) -> Result<()> {
|
|
// ── Wire up agnostic subsystems ──────────────────────────────
|
|
let observer: Arc<dyn Observer> =
|
|
Arc::from(observability::create_observer(&config.observability));
|
|
let _runtime = runtime::create_runtime(&config.runtime);
|
|
let security = Arc::new(SecurityPolicy::from_config(
|
|
&config.autonomy,
|
|
&config.workspace_dir,
|
|
));
|
|
|
|
// ── Memory (the brain) ────────────────────────────────────────
|
|
let mem: Arc<dyn Memory> = Arc::from(memory::create_memory(
|
|
&config.memory,
|
|
&config.workspace_dir,
|
|
config.api_key.as_deref(),
|
|
)?);
|
|
tracing::info!(backend = mem.name(), "Memory initialized");
|
|
|
|
// ── Tools (including memory tools) ────────────────────────────
|
|
let composio_key = if config.composio.enabled {
|
|
config.composio.api_key.as_deref()
|
|
} else {
|
|
None
|
|
};
|
|
let _tools = tools::all_tools(security, mem.clone(), composio_key);
|
|
|
|
// ── Resolve provider ─────────────────────────────────────────
|
|
let provider_name = provider_override
|
|
.as_deref()
|
|
.or(config.default_provider.as_deref())
|
|
.unwrap_or("openrouter");
|
|
|
|
let model_name = model_override
|
|
.as_deref()
|
|
.or(config.default_model.as_deref())
|
|
.unwrap_or("anthropic/claude-sonnet-4-20250514");
|
|
|
|
let provider: Box<dyn Provider> =
|
|
providers::create_provider(provider_name, config.api_key.as_deref())?;
|
|
|
|
observer.record_event(&ObserverEvent::AgentStart {
|
|
provider: provider_name.to_string(),
|
|
model: model_name.to_string(),
|
|
});
|
|
|
|
// ── Build system prompt from workspace MD files (OpenClaw framework) ──
|
|
let skills = crate::skills::load_skills(&config.workspace_dir);
|
|
let tool_descs: Vec<(&str, &str)> = vec![
|
|
("shell", "Execute terminal commands"),
|
|
("file_read", "Read file contents"),
|
|
("file_write", "Write file contents"),
|
|
("memory_store", "Save to memory"),
|
|
("memory_recall", "Search memory"),
|
|
("memory_forget", "Delete a memory entry"),
|
|
];
|
|
let system_prompt = crate::channels::build_system_prompt(
|
|
&config.workspace_dir,
|
|
model_name,
|
|
&tool_descs,
|
|
&skills,
|
|
);
|
|
|
|
// ── Execute ──────────────────────────────────────────────────
|
|
let start = Instant::now();
|
|
|
|
if let Some(msg) = message {
|
|
// Auto-save user message to memory
|
|
if config.memory.auto_save {
|
|
let _ = mem
|
|
.store("user_msg", &msg, MemoryCategory::Conversation)
|
|
.await;
|
|
}
|
|
|
|
// Inject memory context into user message
|
|
let context = build_context(mem.as_ref(), &msg).await;
|
|
let enriched = if context.is_empty() {
|
|
msg.clone()
|
|
} else {
|
|
format!("{context}{msg}")
|
|
};
|
|
|
|
let response = provider
|
|
.chat_with_system(Some(&system_prompt), &enriched, model_name, temperature)
|
|
.await?;
|
|
println!("{response}");
|
|
|
|
// Auto-save assistant response to daily log
|
|
if config.memory.auto_save {
|
|
let summary = if response.len() > 100 {
|
|
format!("{}...", &response[..100])
|
|
} else {
|
|
response.clone()
|
|
};
|
|
let _ = mem
|
|
.store("assistant_resp", &summary, MemoryCategory::Daily)
|
|
.await;
|
|
}
|
|
} else {
|
|
println!("🦀 ZeroClaw Interactive Mode");
|
|
println!("Type /quit to exit.\n");
|
|
|
|
let (tx, mut rx) = tokio::sync::mpsc::channel(32);
|
|
let cli = crate::channels::CliChannel::new();
|
|
|
|
// Spawn listener
|
|
let listen_handle = tokio::spawn(async move {
|
|
let _ = crate::channels::Channel::listen(&cli, tx).await;
|
|
});
|
|
|
|
while let Some(msg) = rx.recv().await {
|
|
// Auto-save conversation turns
|
|
if config.memory.auto_save {
|
|
let _ = mem
|
|
.store("user_msg", &msg.content, MemoryCategory::Conversation)
|
|
.await;
|
|
}
|
|
|
|
// Inject memory context into user message
|
|
let context = build_context(mem.as_ref(), &msg.content).await;
|
|
let enriched = if context.is_empty() {
|
|
msg.content.clone()
|
|
} else {
|
|
format!("{context}{}", msg.content)
|
|
};
|
|
|
|
let response = provider
|
|
.chat_with_system(Some(&system_prompt), &enriched, model_name, temperature)
|
|
.await?;
|
|
println!("\n{response}\n");
|
|
|
|
if config.memory.auto_save {
|
|
let summary = if response.len() > 100 {
|
|
format!("{}...", &response[..100])
|
|
} else {
|
|
response.clone()
|
|
};
|
|
let _ = mem
|
|
.store("assistant_resp", &summary, MemoryCategory::Daily)
|
|
.await;
|
|
}
|
|
}
|
|
|
|
listen_handle.abort();
|
|
}
|
|
|
|
let duration = start.elapsed();
|
|
observer.record_event(&ObserverEvent::AgentEnd {
|
|
duration,
|
|
tokens_used: None,
|
|
});
|
|
|
|
Ok(())
|
|
}
|