feat: add internal ZCLAW kernel crates to git tracking
This commit is contained in:
7013
Cargo.lock
generated
Normal file
7013
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
106
Cargo.toml
Normal file
106
Cargo.toml
Normal file
@@ -0,0 +1,106 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
# ZCLAW Core Crates
|
||||
"crates/zclaw-types",
|
||||
"crates/zclaw-memory",
|
||||
"crates/zclaw-runtime",
|
||||
"crates/zclaw-kernel",
|
||||
# ZCLAW Extension Crates
|
||||
"crates/zclaw-skills",
|
||||
"crates/zclaw-hands",
|
||||
"crates/zclaw-channels",
|
||||
"crates/zclaw-protocols",
|
||||
# Desktop Application
|
||||
"desktop/src-tauri",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "Apache-2.0 OR MIT"
|
||||
repository = "https://github.com/zclaw/zclaw"
|
||||
rust-version = "1.75"
|
||||
|
||||
[workspace.dependencies]
|
||||
# Async runtime
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tokio-stream = "0.1"
|
||||
futures = "0.3"
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
toml = "0.8"
|
||||
|
||||
# Error handling
|
||||
thiserror = "2"
|
||||
anyhow = "1"
|
||||
|
||||
# Concurrency
|
||||
dashmap = "6"
|
||||
parking_lot = "0.12"
|
||||
|
||||
# Logging / Tracing
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
# Time
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
# IDs
|
||||
uuid = { version = "1", features = ["v4", "v5", "serde"] }
|
||||
|
||||
# Database
|
||||
sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite"] }
|
||||
|
||||
# HTTP client (for LLM drivers)
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] }
|
||||
|
||||
# Async trait
|
||||
async-trait = "0.1"
|
||||
|
||||
# Base64
|
||||
base64 = "0.22"
|
||||
|
||||
# Bytes
|
||||
bytes = "1"
|
||||
|
||||
# Secrets
|
||||
secrecy = "0.8"
|
||||
|
||||
# Random
|
||||
rand = "0.8"
|
||||
|
||||
# Crypto
|
||||
sha2 = "0.10"
|
||||
aes-gcm = "0.10"
|
||||
|
||||
# Home directory
|
||||
dirs = "6"
|
||||
|
||||
# Regex
|
||||
regex = "1"
|
||||
|
||||
# Internal crates
|
||||
zclaw-types = { path = "crates/zclaw-types" }
|
||||
zclaw-memory = { path = "crates/zclaw-memory" }
|
||||
zclaw-runtime = { path = "crates/zclaw-runtime" }
|
||||
zclaw-kernel = { path = "crates/zclaw-kernel" }
|
||||
zclaw-skills = { path = "crates/zclaw-skills" }
|
||||
zclaw-hands = { path = "crates/zclaw-hands" }
|
||||
zclaw-channels = { path = "crates/zclaw-channels" }
|
||||
zclaw-protocols = { path = "crates/zclaw-protocols" }
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
opt-level = 3
|
||||
|
||||
[profile.release-fast]
|
||||
inherits = "release"
|
||||
lto = "thin"
|
||||
codegen-units = 8
|
||||
opt-level = 2
|
||||
strip = false
|
||||
34
crates/zclaw-kernel/Cargo.toml
Normal file
34
crates/zclaw-kernel/Cargo.toml
Normal file
@@ -0,0 +1,34 @@
|
||||
[package]
|
||||
name = "zclaw-kernel"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "ZCLAW kernel - central coordinator for all subsystems"
|
||||
|
||||
[dependencies]
|
||||
zclaw-types = { workspace = true }
|
||||
zclaw-memory = { workspace = true }
|
||||
zclaw-runtime = { workspace = true }
|
||||
|
||||
tokio = { workspace = true }
|
||||
tokio-stream = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
|
||||
# Concurrency
|
||||
dashmap = { workspace = true }
|
||||
parking_lot = { workspace = true }
|
||||
|
||||
# Secrets
|
||||
secrecy = { workspace = true }
|
||||
|
||||
# Home directory
|
||||
dirs = { workspace = true }
|
||||
71
crates/zclaw-kernel/src/capabilities.rs
Normal file
71
crates/zclaw-kernel/src/capabilities.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
//! Capability manager
|
||||
|
||||
use dashmap::DashMap;
|
||||
use zclaw_types::{AgentId, Capability, CapabilitySet, Result, ZclawError};
|
||||
|
||||
/// Manages capabilities for all agents
|
||||
pub struct CapabilityManager {
|
||||
capabilities: DashMap<AgentId, CapabilitySet>,
|
||||
}
|
||||
|
||||
impl CapabilityManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
capabilities: DashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Grant capabilities to an agent
|
||||
pub fn grant(&self, agent_id: AgentId, capabilities: Vec<Capability>) {
|
||||
let set = CapabilitySet {
|
||||
capabilities,
|
||||
};
|
||||
self.capabilities.insert(agent_id, set);
|
||||
}
|
||||
|
||||
/// Revoke all capabilities from an agent
|
||||
pub fn revoke(&self, agent_id: &AgentId) {
|
||||
self.capabilities.remove(agent_id);
|
||||
}
|
||||
|
||||
/// Check if an agent can invoke a tool
|
||||
pub fn can_invoke_tool(&self, agent_id: &AgentId, tool_name: &str) -> bool {
|
||||
self.capabilities
|
||||
.get(agent_id)
|
||||
.map(|set| set.can_invoke_tool(tool_name))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Check if an agent can read memory
|
||||
pub fn can_read_memory(&self, agent_id: &AgentId, scope: &str) -> bool {
|
||||
self.capabilities
|
||||
.get(agent_id)
|
||||
.map(|set| set.can_read_memory(scope))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Check if an agent can write memory
|
||||
pub fn can_write_memory(&self, agent_id: &AgentId, scope: &str) -> bool {
|
||||
self.capabilities
|
||||
.get(agent_id)
|
||||
.map(|set| set.can_write_memory(scope))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Validate capabilities don't exceed parent's
|
||||
pub fn validate(&self, capabilities: &[Capability]) -> Result<()> {
|
||||
// TODO: Implement capability validation
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get capabilities for an agent
|
||||
pub fn get(&self, agent_id: &AgentId) -> Option<CapabilitySet> {
|
||||
self.capabilities.get(agent_id).map(|c| c.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CapabilityManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
121
crates/zclaw-kernel/src/config.rs
Normal file
121
crates/zclaw-kernel/src/config.rs
Normal file
@@ -0,0 +1,121 @@
|
||||
//! Kernel configuration
|
||||
|
||||
use std::sync::Arc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use secrecy::SecretString;
|
||||
use zclaw_types::{Result, ZclawError};
|
||||
use zclaw_runtime::{LlmDriver, AnthropicDriver, OpenAiDriver, GeminiDriver, LocalDriver};
|
||||
|
||||
/// Kernel configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct KernelConfig {
|
||||
/// Database URL (SQLite)
|
||||
#[serde(default = "default_database_url")]
|
||||
pub database_url: String,
|
||||
|
||||
/// Default LLM provider
|
||||
#[serde(default = "default_provider")]
|
||||
pub default_provider: String,
|
||||
|
||||
/// Default model
|
||||
#[serde(default = "default_model")]
|
||||
pub default_model: String,
|
||||
|
||||
/// API keys (loaded from environment)
|
||||
#[serde(skip)]
|
||||
pub anthropic_api_key: Option<String>,
|
||||
#[serde(skip)]
|
||||
pub openai_api_key: Option<String>,
|
||||
#[serde(skip)]
|
||||
pub gemini_api_key: Option<String>,
|
||||
|
||||
/// Local LLM base URL
|
||||
#[serde(default)]
|
||||
pub local_base_url: Option<String>,
|
||||
|
||||
/// Maximum tokens per response
|
||||
#[serde(default = "default_max_tokens")]
|
||||
pub max_tokens: u32,
|
||||
|
||||
/// Default temperature
|
||||
#[serde(default = "default_temperature")]
|
||||
pub temperature: f32,
|
||||
}
|
||||
|
||||
fn default_database_url() -> String {
|
||||
let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("."));
|
||||
let dir = home.join(".zclaw");
|
||||
format!("sqlite:{}/data.db?mode=rwc", dir.display())
|
||||
}
|
||||
|
||||
fn default_provider() -> String {
|
||||
"anthropic".to_string()
|
||||
}
|
||||
|
||||
fn default_model() -> String {
|
||||
"claude-sonnet-4-20250514".to_string()
|
||||
}
|
||||
|
||||
fn default_max_tokens() -> u32 {
|
||||
4096
|
||||
}
|
||||
|
||||
fn default_temperature() -> f32 {
|
||||
0.7
|
||||
}
|
||||
|
||||
impl Default for KernelConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
database_url: default_database_url(),
|
||||
default_provider: default_provider(),
|
||||
default_model: default_model(),
|
||||
anthropic_api_key: std::env::var("ANTHROPIC_API_KEY").ok(),
|
||||
openai_api_key: std::env::var("OPENAI_API_KEY").ok(),
|
||||
gemini_api_key: std::env::var("GEMINI_API_KEY").ok(),
|
||||
local_base_url: None,
|
||||
max_tokens: default_max_tokens(),
|
||||
temperature: default_temperature(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl KernelConfig {
|
||||
/// Load configuration from file
|
||||
pub async fn load() -> Result<Self> {
|
||||
// TODO: Load from ~/.zclaw/config.toml
|
||||
Ok(Self::default())
|
||||
}
|
||||
|
||||
/// Create the default LLM driver
|
||||
pub fn create_driver(&self) -> Result<Arc<dyn LlmDriver>> {
|
||||
let driver: Arc<dyn LlmDriver> = match self.default_provider.as_str() {
|
||||
"anthropic" => {
|
||||
let key = self.anthropic_api_key.clone()
|
||||
.ok_or_else(|| ZclawError::ConfigError("ANTHROPIC_API_KEY not set".into()))?;
|
||||
Arc::new(AnthropicDriver::new(SecretString::new(key)))
|
||||
}
|
||||
"openai" => {
|
||||
let key = self.openai_api_key.clone()
|
||||
.ok_or_else(|| ZclawError::ConfigError("OPENAI_API_KEY not set".into()))?;
|
||||
Arc::new(OpenAiDriver::new(SecretString::new(key)))
|
||||
}
|
||||
"gemini" => {
|
||||
let key = self.gemini_api_key.clone()
|
||||
.ok_or_else(|| ZclawError::ConfigError("GEMINI_API_KEY not set".into()))?;
|
||||
Arc::new(GeminiDriver::new(SecretString::new(key)))
|
||||
}
|
||||
"local" | "ollama" => {
|
||||
let base_url = self.local_base_url.clone()
|
||||
.unwrap_or_else(|| "http://localhost:11434/v1".to_string());
|
||||
Arc::new(LocalDriver::new(base_url))
|
||||
}
|
||||
_ => {
|
||||
return Err(ZclawError::ConfigError(
|
||||
format!("Unknown provider: {}", self.default_provider)
|
||||
));
|
||||
}
|
||||
};
|
||||
Ok(driver)
|
||||
}
|
||||
}
|
||||
34
crates/zclaw-kernel/src/events.rs
Normal file
34
crates/zclaw-kernel/src/events.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
//! Event bus for kernel events
|
||||
|
||||
use tokio::sync::broadcast;
|
||||
use zclaw_types::Event;
|
||||
|
||||
/// Event bus for publishing and subscribing to events
|
||||
pub struct EventBus {
|
||||
sender: broadcast::Sender<Event>,
|
||||
}
|
||||
|
||||
impl EventBus {
|
||||
/// Create a new event bus
|
||||
pub fn new() -> Self {
|
||||
let (sender, _) = broadcast::channel(1000);
|
||||
Self { sender }
|
||||
}
|
||||
|
||||
/// Publish an event
|
||||
pub fn publish(&self, event: Event) {
|
||||
// Ignore send errors (no subscribers)
|
||||
let _ = self.sender.send(event);
|
||||
}
|
||||
|
||||
/// Subscribe to events
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<Event> {
|
||||
self.sender.subscribe()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EventBus {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
180
crates/zclaw-kernel/src/kernel.rs
Normal file
180
crates/zclaw-kernel/src/kernel.rs
Normal file
@@ -0,0 +1,180 @@
|
||||
//! Kernel - central coordinator
|
||||
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{broadcast, mpsc};
|
||||
use zclaw_types::{AgentConfig, AgentId, AgentInfo, Event, Result};
|
||||
|
||||
use crate::registry::AgentRegistry;
|
||||
use crate::capabilities::CapabilityManager;
|
||||
use crate::events::EventBus;
|
||||
use crate::config::KernelConfig;
|
||||
use zclaw_memory::MemoryStore;
|
||||
use zclaw_runtime::{AgentLoop, LlmDriver, ToolRegistry};
|
||||
|
||||
/// The ZCLAW Kernel
|
||||
pub struct Kernel {
|
||||
config: KernelConfig,
|
||||
registry: AgentRegistry,
|
||||
capabilities: CapabilityManager,
|
||||
events: EventBus,
|
||||
memory: Arc<MemoryStore>,
|
||||
driver: Arc<dyn LlmDriver>,
|
||||
}
|
||||
|
||||
impl Kernel {
|
||||
/// Boot the kernel with the given configuration
|
||||
pub async fn boot(config: KernelConfig) -> Result<Self> {
|
||||
// Initialize memory store
|
||||
let memory = Arc::new(MemoryStore::new(&config.database_url).await?);
|
||||
|
||||
// Initialize driver based on config
|
||||
let driver = config.create_driver()?;
|
||||
|
||||
// Initialize subsystems
|
||||
let registry = AgentRegistry::new();
|
||||
let capabilities = CapabilityManager::new();
|
||||
let events = EventBus::new();
|
||||
|
||||
// Restore persisted agents
|
||||
let persisted = memory.list_agents().await?;
|
||||
for agent in persisted {
|
||||
registry.register(agent);
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
config,
|
||||
registry,
|
||||
capabilities,
|
||||
events,
|
||||
memory,
|
||||
driver,
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a tool registry with built-in tools
|
||||
fn create_tool_registry(&self) -> ToolRegistry {
|
||||
let mut tools = ToolRegistry::new();
|
||||
zclaw_runtime::tool::builtin::register_builtin_tools(&mut tools);
|
||||
tools
|
||||
}
|
||||
|
||||
/// Spawn a new agent
|
||||
pub async fn spawn_agent(&self, config: AgentConfig) -> Result<AgentId> {
|
||||
let id = config.id;
|
||||
|
||||
// Validate capabilities
|
||||
self.capabilities.validate(&config.capabilities)?;
|
||||
|
||||
// Register in memory
|
||||
self.memory.save_agent(&config).await?;
|
||||
|
||||
// Register in registry
|
||||
self.registry.register(config);
|
||||
|
||||
// Emit event
|
||||
self.events.publish(Event::AgentSpawned {
|
||||
agent_id: id,
|
||||
name: self.registry.get(&id).map(|a| a.name.clone()).unwrap_or_default(),
|
||||
});
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Kill an agent
|
||||
pub async fn kill_agent(&self, id: &AgentId) -> Result<()> {
|
||||
// Remove from registry
|
||||
self.registry.unregister(id);
|
||||
|
||||
// Remove from memory
|
||||
self.memory.delete_agent(id).await?;
|
||||
|
||||
// Emit event
|
||||
self.events.publish(Event::AgentTerminated {
|
||||
agent_id: *id,
|
||||
reason: "killed".to_string(),
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all agents
|
||||
pub fn list_agents(&self) -> Vec<AgentInfo> {
|
||||
self.registry.list()
|
||||
}
|
||||
|
||||
/// Get agent info
|
||||
pub fn get_agent(&self, id: &AgentId) -> Option<AgentInfo> {
|
||||
self.registry.get_info(id)
|
||||
}
|
||||
|
||||
/// Send a message to an agent
|
||||
pub async fn send_message(&self, agent_id: &AgentId, message: String) -> Result<MessageResponse> {
|
||||
let _agent = self.registry.get(agent_id)
|
||||
.ok_or_else(|| zclaw_types::ZclawError::NotFound(format!("Agent not found: {}", agent_id)))?;
|
||||
|
||||
// Create or get session
|
||||
let session_id = self.memory.create_session(agent_id).await?;
|
||||
|
||||
// Create agent loop
|
||||
let tools = self.create_tool_registry();
|
||||
let loop_runner = AgentLoop::new(
|
||||
*agent_id,
|
||||
self.driver.clone(),
|
||||
tools,
|
||||
self.memory.clone(),
|
||||
);
|
||||
|
||||
// Run the loop
|
||||
let result = loop_runner.run(session_id, message).await?;
|
||||
|
||||
Ok(MessageResponse {
|
||||
content: result.response,
|
||||
input_tokens: result.input_tokens,
|
||||
output_tokens: result.output_tokens,
|
||||
})
|
||||
}
|
||||
|
||||
/// Send a message with streaming
|
||||
pub async fn send_message_stream(
|
||||
&self,
|
||||
agent_id: &AgentId,
|
||||
message: String,
|
||||
) -> Result<mpsc::Receiver<zclaw_runtime::LoopEvent>> {
|
||||
let _agent = self.registry.get(agent_id)
|
||||
.ok_or_else(|| zclaw_types::ZclawError::NotFound(format!("Agent not found: {}", agent_id)))?;
|
||||
|
||||
// Create session
|
||||
let session_id = self.memory.create_session(agent_id).await?;
|
||||
|
||||
// Create agent loop
|
||||
let tools = self.create_tool_registry();
|
||||
let loop_runner = AgentLoop::new(
|
||||
*agent_id,
|
||||
self.driver.clone(),
|
||||
tools,
|
||||
self.memory.clone(),
|
||||
);
|
||||
|
||||
// Run with streaming
|
||||
loop_runner.run_streaming(session_id, message).await
|
||||
}
|
||||
|
||||
/// Subscribe to events
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<Event> {
|
||||
self.events.subscribe()
|
||||
}
|
||||
|
||||
/// Shutdown the kernel
|
||||
pub async fn shutdown(&self) -> Result<()> {
|
||||
self.events.publish(Event::KernelShutdown);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Response from sending a message
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MessageResponse {
|
||||
pub content: String,
|
||||
pub input_tokens: u32,
|
||||
pub output_tokens: u32,
|
||||
}
|
||||
25
crates/zclaw-memory/Cargo.toml
Normal file
25
crates/zclaw-memory/Cargo.toml
Normal file
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "zclaw-memory"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "ZCLAW memory substrate with SQLite storage"
|
||||
|
||||
[dependencies]
|
||||
zclaw-types = { workspace = true }
|
||||
|
||||
tokio = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
# SQLite
|
||||
sqlx = { workspace = true }
|
||||
|
||||
# Async utilities
|
||||
futures = { workspace = true }
|
||||
11
crates/zclaw-memory/src/lib.rs
Normal file
11
crates/zclaw-memory/src/lib.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
//! ZCLAW Memory Substrate
|
||||
//!
|
||||
//! SQLite-backed storage for agents, sessions, and memory.
|
||||
|
||||
mod store;
|
||||
mod session;
|
||||
mod schema;
|
||||
|
||||
pub use store::*;
|
||||
pub use session::*;
|
||||
pub use schema::*;
|
||||
56
crates/zclaw-memory/src/schema.rs
Normal file
56
crates/zclaw-memory/src/schema.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
//! Database schema definitions
|
||||
|
||||
/// Current schema version
|
||||
pub const SCHEMA_VERSION: i32 = 1;
|
||||
|
||||
/// Schema creation SQL
|
||||
pub const CREATE_SCHEMA: &str = r#"
|
||||
-- Agents table
|
||||
CREATE TABLE IF NOT EXISTS agents (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
config TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Sessions table
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
agent_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Messages table
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
seq INTEGER NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE,
|
||||
UNIQUE(session_id, seq)
|
||||
);
|
||||
|
||||
-- KV Store table
|
||||
CREATE TABLE IF NOT EXISTS kv_store (
|
||||
agent_id TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (agent_id, key),
|
||||
FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Schema version table
|
||||
CREATE TABLE IF NOT EXISTS schema_version (
|
||||
version INTEGER PRIMARY KEY
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_agent ON sessions(agent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_kv_agent ON kv_store(agent_id);
|
||||
"#;
|
||||
96
crates/zclaw-memory/src/session.rs
Normal file
96
crates/zclaw-memory/src/session.rs
Normal file
@@ -0,0 +1,96 @@
|
||||
//! Session management types
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use chrono::{DateTime, Utc};
|
||||
use zclaw_types::{SessionId, AgentId, Message};
|
||||
|
||||
/// A conversation session
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Session {
|
||||
pub id: SessionId,
|
||||
pub agent_id: AgentId,
|
||||
pub messages: Vec<Message>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
/// Token count estimate
|
||||
pub token_count: usize,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn new(agent_id: AgentId) -> Self {
|
||||
Self {
|
||||
id: SessionId::new(),
|
||||
agent_id,
|
||||
messages: Vec::new(),
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
token_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a message to the session
|
||||
pub fn add_message(&mut self, message: Message) {
|
||||
// Simple token estimation: ~4 chars per token
|
||||
let tokens = self.estimate_tokens(&message);
|
||||
self.messages.push(message);
|
||||
self.token_count += tokens;
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
/// Estimate token count for a message
|
||||
fn estimate_tokens(&self, message: &Message) -> usize {
|
||||
let text = match message {
|
||||
Message::User { content } => content,
|
||||
Message::Assistant { content, thinking } => {
|
||||
thinking.as_ref().map(|t| t.as_str()).unwrap_or("");
|
||||
content
|
||||
}
|
||||
Message::System { content } => content,
|
||||
Message::ToolUse { input, .. } => {
|
||||
return serde_json::to_string(input).map(|s| s.len() / 4).unwrap_or(0);
|
||||
}
|
||||
Message::ToolResult { output, .. } => {
|
||||
return serde_json::to_string(output).map(|s| s.len() / 4).unwrap_or(0);
|
||||
}
|
||||
};
|
||||
text.len() / 4
|
||||
}
|
||||
|
||||
/// Check if session exceeds context window
|
||||
pub fn exceeds_threshold(&self, max_tokens: usize, threshold: f32) -> bool {
|
||||
let threshold_tokens = (max_tokens as f32 * threshold) as usize;
|
||||
self.token_count > threshold_tokens
|
||||
}
|
||||
|
||||
/// Compact the session by keeping only recent messages
|
||||
pub fn compact(&mut self, keep_last: usize) {
|
||||
if self.messages.len() <= keep_last {
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep system messages and last N messages
|
||||
let system_messages: Vec<_> = self.messages.iter()
|
||||
.filter(|m| matches!(m, Message::System { .. }))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
let recent_messages: Vec<_> = self.messages.iter()
|
||||
.rev()
|
||||
.take(keep_last)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.collect();
|
||||
|
||||
self.messages = [system_messages, recent_messages].concat();
|
||||
self.recalculate_token_count();
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
fn recalculate_token_count(&mut self) {
|
||||
self.token_count = self.messages.iter()
|
||||
.map(|m| self.estimate_tokens(m))
|
||||
.sum();
|
||||
}
|
||||
}
|
||||
246
crates/zclaw-memory/src/store.rs
Normal file
246
crates/zclaw-memory/src/store.rs
Normal file
@@ -0,0 +1,246 @@
|
||||
//! Memory store implementation
|
||||
|
||||
use sqlx::SqlitePool;
|
||||
use zclaw_types::{AgentConfig, AgentId, SessionId, Message, Result, ZclawError};
|
||||
|
||||
/// Memory store for persisting ZCLAW data
|
||||
pub struct MemoryStore {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl MemoryStore {
|
||||
/// Create a new memory store with the given database path
|
||||
pub async fn new(database_url: &str) -> Result<Self> {
|
||||
let pool = SqlitePool::connect(database_url).await
|
||||
.map_err(|e| ZclawError::StorageError(e.to_string()))?;
|
||||
let store = Self { pool };
|
||||
store.run_migrations().await?;
|
||||
Ok(store)
|
||||
}
|
||||
|
||||
/// Create an in-memory database (for testing)
|
||||
pub async fn in_memory() -> Result<Self> {
|
||||
Self::new("sqlite::memory:").await
|
||||
}
|
||||
|
||||
/// Run database migrations
|
||||
async fn run_migrations(&self) -> Result<()> {
|
||||
sqlx::query(crate::schema::CREATE_SCHEMA)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| ZclawError::StorageError(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// === Agent CRUD ===
|
||||
|
||||
/// Save an agent configuration
|
||||
pub async fn save_agent(&self, agent: &AgentConfig) -> Result<()> {
|
||||
let config_json = serde_json::to_string(agent)?;
|
||||
let id = agent.id.to_string();
|
||||
let name = &agent.name;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO agents (id, name, config, created_at, updated_at)
|
||||
VALUES (?, ?, ?, datetime('now'), datetime('now'))
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
config = excluded.config,
|
||||
updated_at = datetime('now')
|
||||
"#,
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(name)
|
||||
.bind(&config_json)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| ZclawError::StorageError(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load an agent by ID
|
||||
pub async fn load_agent(&self, id: &AgentId) -> Result<Option<AgentConfig>> {
|
||||
let id_str = id.to_string();
|
||||
|
||||
let row = sqlx::query_as::<_, (String,)>(
|
||||
"SELECT config FROM agents WHERE id = ?"
|
||||
)
|
||||
.bind(&id_str)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| ZclawError::StorageError(e.to_string()))?;
|
||||
|
||||
match row {
|
||||
Some((config,)) => {
|
||||
let agent: AgentConfig = serde_json::from_str(&config)?;
|
||||
Ok(Some(agent))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// List all agents
|
||||
pub async fn list_agents(&self) -> Result<Vec<AgentConfig>> {
|
||||
let rows = sqlx::query_as::<_, (String,)>(
|
||||
"SELECT config FROM agents"
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| ZclawError::StorageError(e.to_string()))?;
|
||||
|
||||
let agents = rows
|
||||
.into_iter()
|
||||
.filter_map(|(config,)| serde_json::from_str(&config).ok())
|
||||
.collect();
|
||||
Ok(agents)
|
||||
}
|
||||
|
||||
/// Delete an agent
|
||||
pub async fn delete_agent(&self, id: &AgentId) -> Result<()> {
|
||||
let id_str = id.to_string();
|
||||
|
||||
sqlx::query("DELETE FROM agents WHERE id = ?")
|
||||
.bind(&id_str)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| ZclawError::StorageError(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// === Session Management ===
|
||||
|
||||
/// Create a new session for an agent
|
||||
pub async fn create_session(&self, agent_id: &AgentId) -> Result<SessionId> {
|
||||
let session_id = SessionId::new();
|
||||
let session_str = session_id.to_string();
|
||||
let agent_str = agent_id.to_string();
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO sessions (id, agent_id, created_at, updated_at)
|
||||
VALUES (?, ?, datetime('now'), datetime('now'))
|
||||
"#,
|
||||
)
|
||||
.bind(&session_str)
|
||||
.bind(&agent_str)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| ZclawError::StorageError(e.to_string()))?;
|
||||
|
||||
Ok(session_id)
|
||||
}
|
||||
|
||||
/// Append a message to a session
|
||||
pub async fn append_message(&self, session_id: &SessionId, message: &Message) -> Result<()> {
|
||||
let session_str = session_id.to_string();
|
||||
let message_json = serde_json::to_string(message)?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO messages (session_id, seq, content, created_at)
|
||||
SELECT ?, COALESCE(MAX(seq), 0) + 1, datetime('now')
|
||||
FROM messages WHERE session_id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(&session_str)
|
||||
.bind(&message_json)
|
||||
.bind(&session_str)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| ZclawError::StorageError(e.to_string()))?;
|
||||
|
||||
// Update session updated_at
|
||||
sqlx::query("UPDATE sessions SET updated_at = datetime('now') WHERE id = ?")
|
||||
.bind(&session_str)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| ZclawError::StorageError(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get all messages for a session
|
||||
pub async fn get_messages(&self, session_id: &SessionId) -> Result<Vec<Message>> {
|
||||
let session_str = session_id.to_string();
|
||||
|
||||
let rows = sqlx::query_as::<_, (String,)>(
|
||||
"SELECT content FROM messages WHERE session_id = ? ORDER BY seq"
|
||||
)
|
||||
.bind(&session_str)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| ZclawError::StorageError(e.to_string()))?;
|
||||
|
||||
let messages = rows
|
||||
.into_iter()
|
||||
.filter_map(|(content,)| serde_json::from_str(&content).ok())
|
||||
.collect();
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
// === KV Store ===
|
||||
|
||||
/// Store a key-value pair for an agent
|
||||
pub async fn kv_store(&self, agent_id: &AgentId, key: &str, value: &serde_json::Value) -> Result<()> {
|
||||
let agent_str = agent_id.to_string();
|
||||
let value_json = serde_json::to_string(value)?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO kv_store (agent_id, key, value, updated_at)
|
||||
VALUES (?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(agent_id, key) DO UPDATE SET
|
||||
value = excluded.value,
|
||||
updated_at = datetime('now')
|
||||
"#,
|
||||
)
|
||||
.bind(&agent_str)
|
||||
.bind(key)
|
||||
.bind(&value_json)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| ZclawError::StorageError(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Recall a value by key
|
||||
pub async fn kv_recall(&self, agent_id: &AgentId, key: &str) -> Result<Option<serde_json::Value>> {
|
||||
let agent_str = agent_id.to_string();
|
||||
|
||||
let row = sqlx::query_as::<_, (String,)>(
|
||||
"SELECT value FROM kv_store WHERE agent_id = ? AND key = ?"
|
||||
)
|
||||
.bind(&agent_str)
|
||||
.bind(key)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| ZclawError::StorageError(e.to_string()))?;
|
||||
|
||||
match row {
|
||||
Some((value,)) => {
|
||||
let v: serde_json::Value = serde_json::from_str(&value)?;
|
||||
Ok(Some(v))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// List all keys for an agent
|
||||
pub async fn kv_list(&self, agent_id: &AgentId) -> Result<Vec<String>> {
|
||||
let agent_str = agent_id.to_string();
|
||||
|
||||
let rows = sqlx::query_as::<_, (String,)>(
|
||||
"SELECT key FROM kv_store WHERE agent_id = ?"
|
||||
)
|
||||
.bind(&agent_str)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| ZclawError::StorageError(e.to_string()))?;
|
||||
|
||||
Ok(rows.into_iter().map(|(key,)| key).collect())
|
||||
}
|
||||
}
|
||||
35
crates/zclaw-runtime/Cargo.toml
Normal file
35
crates/zclaw-runtime/Cargo.toml
Normal file
@@ -0,0 +1,35 @@
|
||||
[package]
|
||||
name = "zclaw-runtime"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "ZCLAW runtime with LLM drivers and agent loop"
|
||||
|
||||
[dependencies]
|
||||
zclaw-types = { workspace = true }
|
||||
zclaw-memory = { workspace = true }
|
||||
|
||||
tokio = { workspace = true }
|
||||
tokio-stream = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
|
||||
# HTTP client
|
||||
reqwest = { workspace = true }
|
||||
|
||||
# Secrets
|
||||
secrecy = { workspace = true }
|
||||
|
||||
# Random
|
||||
rand = { workspace = true }
|
||||
|
||||
# Crypto for hashing
|
||||
sha2 = { workspace = true }
|
||||
226
crates/zclaw-runtime/src/driver/anthropic.rs
Normal file
226
crates/zclaw-runtime/src/driver/anthropic.rs
Normal file
@@ -0,0 +1,226 @@
|
||||
//! Anthropic Claude driver implementation
|
||||
|
||||
use async_trait::async_trait;
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use zclaw_types::{Result, ZclawError};
|
||||
|
||||
use super::{CompletionRequest, CompletionResponse, ContentBlock, LlmDriver, StopReason};
|
||||
|
||||
/// Anthropic API driver
|
||||
pub struct AnthropicDriver {
|
||||
client: Client,
|
||||
api_key: SecretString,
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
impl AnthropicDriver {
|
||||
pub fn new(api_key: SecretString) -> Self {
|
||||
Self {
|
||||
client: Client::new(),
|
||||
api_key,
|
||||
base_url: "https://api.anthropic.com".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_base_url(api_key: SecretString, base_url: String) -> Self {
|
||||
Self {
|
||||
client: Client::new(),
|
||||
api_key,
|
||||
base_url,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LlmDriver for AnthropicDriver {
|
||||
fn provider(&self) -> &str {
|
||||
"anthropic"
|
||||
}
|
||||
|
||||
fn is_configured(&self) -> bool {
|
||||
!self.api_key.expose_secret().is_empty()
|
||||
}
|
||||
|
||||
async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse> {
|
||||
let api_request = self.build_api_request(&request);
|
||||
|
||||
let response = self.client
|
||||
.post(format!("{}/v1/messages", self.base_url))
|
||||
.header("x-api-key", self.api_key.expose_secret())
|
||||
.header("anthropic-version", "2023-06-01")
|
||||
.header("content-type", "application/json")
|
||||
.json(&api_request)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ZclawError::LlmError(format!("HTTP request failed: {}", e)))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(ZclawError::LlmError(format!("API error {}: {}", status, body)));
|
||||
}
|
||||
|
||||
let api_response: AnthropicResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ZclawError::LlmError(format!("Failed to parse response: {}", e)))?;
|
||||
|
||||
Ok(self.convert_response(api_response))
|
||||
}
|
||||
}
|
||||
|
||||
impl AnthropicDriver {
|
||||
fn build_api_request(&self, request: &CompletionRequest) -> AnthropicRequest {
|
||||
let messages: Vec<AnthropicMessage> = request.messages
|
||||
.iter()
|
||||
.filter_map(|msg| match msg {
|
||||
zclaw_types::Message::User { content } => Some(AnthropicMessage {
|
||||
role: "user".to_string(),
|
||||
content: vec!(ContentBlock::Text { text: content.clone() }),
|
||||
}),
|
||||
zclaw_types::Message::Assistant { content, thinking } => {
|
||||
let mut blocks = Vec::new();
|
||||
if let Some(think) = thinking {
|
||||
blocks.push(ContentBlock::Thinking { thinking: think.clone() });
|
||||
}
|
||||
blocks.push(ContentBlock::Text { text: content.clone() });
|
||||
Some(AnthropicMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: blocks,
|
||||
})
|
||||
}
|
||||
zclaw_types::Message::ToolUse { id, tool, input } => Some(AnthropicMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentBlock::ToolUse {
|
||||
id: id.clone(),
|
||||
name: tool.to_string(),
|
||||
input: input.clone(),
|
||||
}],
|
||||
}),
|
||||
zclaw_types::Message::ToolResult { tool_call_id: _, tool: _, output, is_error } => {
|
||||
let content = if *is_error {
|
||||
format!("Error: {}", output)
|
||||
} else {
|
||||
output.to_string()
|
||||
};
|
||||
Some(AnthropicMessage {
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentBlock::Text { text: content }],
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let tools: Vec<AnthropicTool> = request.tools
|
||||
.iter()
|
||||
.map(|t| AnthropicTool {
|
||||
name: t.name.clone(),
|
||||
description: t.description.clone(),
|
||||
input_schema: t.input_schema.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
AnthropicRequest {
|
||||
model: request.model.clone(),
|
||||
max_tokens: request.max_tokens.unwrap_or(4096),
|
||||
system: request.system.clone(),
|
||||
messages,
|
||||
tools: if tools.is_empty() { None } else { Some(tools) },
|
||||
temperature: request.temperature,
|
||||
stop_sequences: if request.stop.is_empty() { None } else { Some(request.stop.clone()) },
|
||||
stream: request.stream,
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_response(&self, api_response: AnthropicResponse) -> CompletionResponse {
|
||||
let content: Vec<ContentBlock> = api_response.content
|
||||
.into_iter()
|
||||
.map(|block| match block.block_type.as_str() {
|
||||
"text" => ContentBlock::Text { text: block.text.unwrap_or_default() },
|
||||
"thinking" => ContentBlock::Thinking { thinking: block.thinking.unwrap_or_default() },
|
||||
"tool_use" => ContentBlock::ToolUse {
|
||||
id: block.id.unwrap_or_default(),
|
||||
name: block.name.unwrap_or_default(),
|
||||
input: block.input.unwrap_or(serde_json::Value::Null),
|
||||
},
|
||||
_ => ContentBlock::Text { text: String::new() },
|
||||
})
|
||||
.collect();
|
||||
|
||||
let stop_reason = match api_response.stop_reason.as_deref() {
|
||||
Some("end_turn") => StopReason::EndTurn,
|
||||
Some("max_tokens") => StopReason::MaxTokens,
|
||||
Some("stop_sequence") => StopReason::StopSequence,
|
||||
Some("tool_use") => StopReason::ToolUse,
|
||||
_ => StopReason::EndTurn,
|
||||
};
|
||||
|
||||
CompletionResponse {
|
||||
content,
|
||||
model: api_response.model,
|
||||
input_tokens: api_response.usage.input_tokens,
|
||||
output_tokens: api_response.usage.output_tokens,
|
||||
stop_reason,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Anthropic API types
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct AnthropicRequest {
|
||||
model: String,
|
||||
max_tokens: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
system: Option<String>,
|
||||
messages: Vec<AnthropicMessage>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tools: Option<Vec<AnthropicTool>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
temperature: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
stop_sequences: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
stream: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct AnthropicMessage {
|
||||
role: String,
|
||||
content: Vec<ContentBlock>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct AnthropicTool {
|
||||
name: String,
|
||||
description: String,
|
||||
input_schema: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AnthropicResponse {
|
||||
content: Vec<AnthropicContentBlock>,
|
||||
model: String,
|
||||
stop_reason: Option<String>,
|
||||
usage: AnthropicUsage,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AnthropicContentBlock {
|
||||
#[serde(rename = "type")]
|
||||
block_type: String,
|
||||
text: Option<String>,
|
||||
thinking: Option<String>,
|
||||
id: Option<String>,
|
||||
name: Option<String>,
|
||||
input: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AnthropicUsage {
|
||||
input_tokens: u32,
|
||||
output_tokens: u32,
|
||||
}
|
||||
49
crates/zclaw-runtime/src/driver/gemini.rs
Normal file
49
crates/zclaw-runtime/src/driver/gemini.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
//! Google Gemini driver implementation
|
||||
|
||||
use async_trait::async_trait;
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
use reqwest::Client;
|
||||
use zclaw_types::Result;
|
||||
|
||||
use super::{CompletionRequest, CompletionResponse, ContentBlock, LlmDriver, StopReason};
|
||||
|
||||
/// Google Gemini driver
|
||||
pub struct GeminiDriver {
|
||||
client: Client,
|
||||
api_key: SecretString,
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
impl GeminiDriver {
|
||||
pub fn new(api_key: SecretString) -> Self {
|
||||
Self {
|
||||
client: Client::new(),
|
||||
api_key,
|
||||
base_url: "https://generativelanguage.googleapis.com/v1beta".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LlmDriver for GeminiDriver {
|
||||
fn provider(&self) -> &str {
|
||||
"gemini"
|
||||
}
|
||||
|
||||
fn is_configured(&self) -> bool {
|
||||
!self.api_key.expose_secret().is_empty()
|
||||
}
|
||||
|
||||
async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse> {
|
||||
// TODO: Implement actual API call
|
||||
Ok(CompletionResponse {
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "Gemini driver not yet implemented".to_string(),
|
||||
}],
|
||||
model: request.model,
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
stop_reason: StopReason::EndTurn,
|
||||
})
|
||||
}
|
||||
}
|
||||
59
crates/zclaw-runtime/src/driver/local.rs
Normal file
59
crates/zclaw-runtime/src/driver/local.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
//! Local LLM driver (Ollama, LM Studio, vLLM, etc.)
|
||||
|
||||
use async_trait::async_trait;
|
||||
use reqwest::Client;
|
||||
use zclaw_types::Result;
|
||||
|
||||
use super::{CompletionRequest, CompletionResponse, ContentBlock, LlmDriver, StopReason};
|
||||
|
||||
/// Local LLM driver for Ollama, LM Studio, vLLM, etc.
|
||||
pub struct LocalDriver {
|
||||
client: Client,
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
impl LocalDriver {
|
||||
pub fn new(base_url: impl Into<String>) -> Self {
|
||||
Self {
|
||||
client: Client::new(),
|
||||
base_url: base_url.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ollama() -> Self {
|
||||
Self::new("http://localhost:11434/v1")
|
||||
}
|
||||
|
||||
pub fn lm_studio() -> Self {
|
||||
Self::new("http://localhost:1234/v1")
|
||||
}
|
||||
|
||||
pub fn vllm() -> Self {
|
||||
Self::new("http://localhost:8000/v1")
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LlmDriver for LocalDriver {
|
||||
fn provider(&self) -> &str {
|
||||
"local"
|
||||
}
|
||||
|
||||
fn is_configured(&self) -> bool {
|
||||
// Local drivers don't require API keys
|
||||
true
|
||||
}
|
||||
|
||||
async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse> {
|
||||
// TODO: Implement actual API call (OpenAI-compatible)
|
||||
Ok(CompletionResponse {
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "Local driver not yet implemented".to_string(),
|
||||
}],
|
||||
model: request.model,
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
stop_reason: StopReason::EndTurn,
|
||||
})
|
||||
}
|
||||
}
|
||||
169
crates/zclaw-runtime/src/driver/mod.rs
Normal file
169
crates/zclaw-runtime/src/driver/mod.rs
Normal file
@@ -0,0 +1,169 @@
|
||||
//! LLM Driver trait and implementations
|
||||
//!
|
||||
//! This module provides a unified interface for multiple LLM providers.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use secrecy::SecretString;
|
||||
use zclaw_types::Result;
|
||||
|
||||
mod anthropic;
|
||||
mod openai;
|
||||
mod gemini;
|
||||
mod local;
|
||||
|
||||
pub use anthropic::AnthropicDriver;
|
||||
pub use openai::OpenAiDriver;
|
||||
pub use gemini::GeminiDriver;
|
||||
pub use local::LocalDriver;
|
||||
|
||||
/// LLM Driver trait - unified interface for all providers
|
||||
#[async_trait]
|
||||
pub trait LlmDriver: Send + Sync {
|
||||
/// Get the provider name
|
||||
fn provider(&self) -> &str;
|
||||
|
||||
/// Send a completion request
|
||||
async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse>;
|
||||
|
||||
/// Check if the driver is properly configured
|
||||
fn is_configured(&self) -> bool;
|
||||
}
|
||||
|
||||
/// Completion request
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CompletionRequest {
|
||||
/// Model identifier
|
||||
pub model: String,
|
||||
/// System prompt
|
||||
pub system: Option<String>,
|
||||
/// Conversation messages
|
||||
pub messages: Vec<zclaw_types::Message>,
|
||||
/// Available tools
|
||||
pub tools: Vec<ToolDefinition>,
|
||||
/// Maximum tokens to generate
|
||||
pub max_tokens: Option<u32>,
|
||||
/// Temperature (0.0 - 1.0)
|
||||
pub temperature: Option<f32>,
|
||||
/// Stop sequences
|
||||
pub stop: Vec<String>,
|
||||
/// Enable streaming
|
||||
pub stream: bool,
|
||||
}
|
||||
|
||||
impl Default for CompletionRequest {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
model: String::new(),
|
||||
system: None,
|
||||
messages: Vec::new(),
|
||||
tools: Vec::new(),
|
||||
max_tokens: Some(4096),
|
||||
temperature: Some(0.7),
|
||||
stop: Vec::new(),
|
||||
stream: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tool definition for LLM
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolDefinition {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub input_schema: serde_json::Value,
|
||||
}
|
||||
|
||||
impl ToolDefinition {
|
||||
pub fn new(name: impl Into<String>, description: impl Into<String>, schema: serde_json::Value) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
description: description.into(),
|
||||
input_schema: schema,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Completion response
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CompletionResponse {
|
||||
/// Generated content blocks
|
||||
pub content: Vec<ContentBlock>,
|
||||
/// Model used
|
||||
pub model: String,
|
||||
/// Input tokens
|
||||
pub input_tokens: u32,
|
||||
/// Output tokens
|
||||
pub output_tokens: u32,
|
||||
/// Stop reason
|
||||
pub stop_reason: StopReason,
|
||||
}
|
||||
|
||||
/// Content block in response
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ContentBlock {
|
||||
Text { text: String },
|
||||
Thinking { thinking: String },
|
||||
ToolUse { id: String, name: String, input: serde_json::Value },
|
||||
}
|
||||
|
||||
/// Stop reason
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum StopReason {
|
||||
EndTurn,
|
||||
MaxTokens,
|
||||
StopSequence,
|
||||
ToolUse,
|
||||
Error,
|
||||
}
|
||||
|
||||
/// Driver configuration
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum DriverConfig {
|
||||
Anthropic { api_key: SecretString },
|
||||
OpenAi { api_key: SecretString, base_url: Option<String> },
|
||||
Gemini { api_key: SecretString },
|
||||
Local { base_url: String },
|
||||
}
|
||||
|
||||
impl DriverConfig {
|
||||
pub fn anthropic(api_key: impl Into<String>) -> Self {
|
||||
Self::Anthropic {
|
||||
api_key: SecretString::new(api_key.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn openai(api_key: impl Into<String>) -> Self {
|
||||
Self::OpenAi {
|
||||
api_key: SecretString::new(api_key.into()),
|
||||
base_url: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn openai_with_base(api_key: impl Into<String>, base_url: impl Into<String>) -> Self {
|
||||
Self::OpenAi {
|
||||
api_key: SecretString::new(api_key.into()),
|
||||
base_url: Some(base_url.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn gemini(api_key: impl Into<String>) -> Self {
|
||||
Self::Gemini {
|
||||
api_key: SecretString::new(api_key.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ollama() -> Self {
|
||||
Self::Local {
|
||||
base_url: "http://localhost:11434".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn local(base_url: impl Into<String>) -> Self {
|
||||
Self::Local {
|
||||
base_url: base_url.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
293
crates/zclaw-runtime/src/driver/openai.rs
Normal file
293
crates/zclaw-runtime/src/driver/openai.rs
Normal file
@@ -0,0 +1,293 @@
|
||||
//! OpenAI-compatible driver implementation
|
||||
|
||||
use async_trait::async_trait;
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use zclaw_types::{Result, ZclawError};
|
||||
|
||||
use super::{CompletionRequest, CompletionResponse, ContentBlock, LlmDriver, StopReason, ToolDefinition};
|
||||
|
||||
/// OpenAI-compatible driver
|
||||
pub struct OpenAiDriver {
|
||||
client: Client,
|
||||
api_key: SecretString,
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
impl OpenAiDriver {
|
||||
pub fn new(api_key: SecretString) -> Self {
|
||||
Self {
|
||||
client: Client::new(),
|
||||
api_key,
|
||||
base_url: "https://api.openai.com/v1".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_base_url(api_key: SecretString, base_url: String) -> Self {
|
||||
Self {
|
||||
client: Client::new(),
|
||||
api_key,
|
||||
base_url,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LlmDriver for OpenAiDriver {
|
||||
fn provider(&self) -> &str {
|
||||
"openai"
|
||||
}
|
||||
|
||||
fn is_configured(&self) -> bool {
|
||||
!self.api_key.expose_secret().is_empty()
|
||||
}
|
||||
|
||||
async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse> {
|
||||
let api_request = self.build_api_request(&request);
|
||||
|
||||
let response = self.client
|
||||
.post(format!("{}/chat/completions", self.base_url))
|
||||
.header("Authorization", format!("Bearer {}", self.api_key.expose_secret()))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&api_request)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ZclawError::LlmError(format!("HTTP request failed: {}", e)))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(ZclawError::LlmError(format!("API error {}: {}", status, body)));
|
||||
}
|
||||
|
||||
let api_response: OpenAiResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ZclawError::LlmError(format!("Failed to parse response: {}", e)))?;
|
||||
|
||||
Ok(self.convert_response(api_response, request.model))
|
||||
}
|
||||
}
|
||||
|
||||
impl OpenAiDriver {
|
||||
fn build_api_request(&self, request: &CompletionRequest) -> OpenAiRequest {
|
||||
let messages: Vec<OpenAiMessage> = request.messages
|
||||
.iter()
|
||||
.filter_map(|msg| match msg {
|
||||
zclaw_types::Message::User { content } => Some(OpenAiMessage {
|
||||
role: "user".to_string(),
|
||||
content: Some(content.clone()),
|
||||
tool_calls: None,
|
||||
}),
|
||||
zclaw_types::Message::Assistant { content, thinking: _ } => Some(OpenAiMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: Some(content.clone()),
|
||||
tool_calls: None,
|
||||
}),
|
||||
zclaw_types::Message::System { content } => Some(OpenAiMessage {
|
||||
role: "system".to_string(),
|
||||
content: Some(content.clone()),
|
||||
tool_calls: None,
|
||||
}),
|
||||
zclaw_types::Message::ToolUse { id, tool, input } => Some(OpenAiMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: None,
|
||||
tool_calls: Some(vec![OpenAiToolCall {
|
||||
id: id.clone(),
|
||||
r#type: "function".to_string(),
|
||||
function: FunctionCall {
|
||||
name: tool.to_string(),
|
||||
arguments: serde_json::to_string(input).unwrap_or_default(),
|
||||
},
|
||||
}]),
|
||||
}),
|
||||
zclaw_types::Message::ToolResult { tool_call_id, output, is_error, .. } => Some(OpenAiMessage {
|
||||
role: "tool".to_string(),
|
||||
content: Some(if *is_error {
|
||||
format!("Error: {}", output)
|
||||
} else {
|
||||
output.to_string()
|
||||
}),
|
||||
tool_calls: None,
|
||||
}),
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Add system prompt if provided
|
||||
let mut messages = messages;
|
||||
if let Some(system) = &request.system {
|
||||
messages.insert(0, OpenAiMessage {
|
||||
role: "system".to_string(),
|
||||
content: Some(system.clone()),
|
||||
tool_calls: None,
|
||||
});
|
||||
}
|
||||
|
||||
let tools: Vec<OpenAiTool> = request.tools
|
||||
.iter()
|
||||
.map(|t| OpenAiTool {
|
||||
r#type: "function".to_string(),
|
||||
function: FunctionDef {
|
||||
name: t.name.clone(),
|
||||
description: t.description.clone(),
|
||||
parameters: t.input_schema.clone(),
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
|
||||
OpenAiRequest {
|
||||
model: request.model.clone(),
|
||||
messages,
|
||||
max_tokens: request.max_tokens,
|
||||
temperature: request.temperature,
|
||||
stop: if request.stop.is_empty() { None } else { Some(request.stop.clone()) },
|
||||
stream: request.stream,
|
||||
tools: if tools.is_empty() { None } else { Some(tools) },
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_response(&self, api_response: OpenAiResponse, model: String) -> CompletionResponse {
|
||||
let choice = api_response.choices.first();
|
||||
|
||||
let (content, stop_reason) = match choice {
|
||||
Some(c) => {
|
||||
let blocks = if let Some(text) = &c.message.content {
|
||||
vec![ContentBlock::Text { text: text.clone() }]
|
||||
} else if let Some(tool_calls) = &c.message.tool_calls {
|
||||
tool_calls.iter().map(|tc| ContentBlock::ToolUse {
|
||||
id: tc.id.clone(),
|
||||
name: tc.function.name.clone(),
|
||||
input: serde_json::from_str(&tc.function.arguments).unwrap_or(serde_json::Value::Null),
|
||||
}).collect()
|
||||
} else {
|
||||
vec![ContentBlock::Text { text: String::new() }]
|
||||
};
|
||||
|
||||
let stop = match c.finish_reason.as_deref() {
|
||||
Some("stop") => StopReason::EndTurn,
|
||||
Some("length") => StopReason::MaxTokens,
|
||||
Some("tool_calls") => StopReason::ToolUse,
|
||||
_ => StopReason::EndTurn,
|
||||
};
|
||||
|
||||
(blocks, stop)
|
||||
}
|
||||
None => (vec![ContentBlock::Text { text: String::new() }], StopReason::EndTurn),
|
||||
};
|
||||
|
||||
let (input_tokens, output_tokens) = api_response.usage
|
||||
.map(|u| (u.prompt_tokens, u.completion_tokens))
|
||||
.unwrap_or((0, 0));
|
||||
|
||||
CompletionResponse {
|
||||
content,
|
||||
model,
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
stop_reason,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// OpenAI API types
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct OpenAiRequest {
|
||||
model: String,
|
||||
messages: Vec<OpenAiMessage>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
max_tokens: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
temperature: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
stop: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
stream: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tools: Option<Vec<OpenAiTool>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct OpenAiMessage {
|
||||
role: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
content: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tool_calls: Option<Vec<OpenAiToolCall>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct OpenAiToolCall {
|
||||
id: String,
|
||||
r#type: String,
|
||||
function: FunctionCall,
|
||||
}
|
||||
|
||||
impl Default for OpenAiToolCall {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
id: String::new(),
|
||||
r#type: "function".to_string(),
|
||||
function: FunctionCall {
|
||||
name: String::new(),
|
||||
arguments: String::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct FunctionCall {
|
||||
name: String,
|
||||
arguments: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct OpenAiTool {
|
||||
r#type: String,
|
||||
function: FunctionDef,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct FunctionDef {
|
||||
name: String,
|
||||
description: String,
|
||||
parameters: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct OpenAiResponse {
|
||||
choices: Vec<OpenAiChoice>,
|
||||
usage: Option<OpenAiUsage>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct OpenAiChoice {
|
||||
message: OpenAiResponseMessage,
|
||||
finish_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct OpenAiResponseMessage {
|
||||
content: Option<String>,
|
||||
tool_calls: Option<Vec<OpenAiToolCallResponse>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct OpenAiToolCallResponse {
|
||||
id: String,
|
||||
function: FunctionCallResponse,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct FunctionCallResponse {
|
||||
name: String,
|
||||
arguments: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct OpenAiUsage {
|
||||
prompt_tokens: u32,
|
||||
completion_tokens: u32,
|
||||
}
|
||||
19
crates/zclaw-runtime/src/lib.rs
Normal file
19
crates/zclaw-runtime/src/lib.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
//! ZCLAW Runtime
|
||||
//!
|
||||
//! LLM drivers, tool system, and agent loop implementation.
|
||||
|
||||
pub mod driver;
|
||||
pub mod tool;
|
||||
pub mod loop_runner;
|
||||
pub mod loop_guard;
|
||||
pub mod stream;
|
||||
|
||||
// Re-export main types
|
||||
pub use driver::{
|
||||
LlmDriver, CompletionRequest, CompletionResponse, ContentBlock, StopReason,
|
||||
ToolDefinition, DriverConfig, AnthropicDriver, OpenAiDriver, GeminiDriver, LocalDriver,
|
||||
};
|
||||
pub use tool::{Tool, ToolRegistry, ToolContext};
|
||||
pub use loop_runner::{AgentLoop, AgentLoopResult, LoopEvent};
|
||||
pub use loop_guard::{LoopGuard, LoopGuardConfig, LoopGuardResult};
|
||||
pub use stream::{StreamEvent, StreamSender};
|
||||
103
crates/zclaw-runtime/src/loop_guard.rs
Normal file
103
crates/zclaw-runtime/src/loop_guard.rs
Normal file
@@ -0,0 +1,103 @@
|
||||
//! Loop guard to prevent infinite tool loops
|
||||
|
||||
use sha2::{Sha256, Digest};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Configuration for loop guard
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LoopGuardConfig {
|
||||
/// Warn after this many repetitions
|
||||
pub warn_threshold: u32,
|
||||
/// Block tool call after this many repetitions
|
||||
pub block_threshold: u32,
|
||||
/// Terminate loop after this many total repetitions
|
||||
pub circuit_breaker: u32,
|
||||
}
|
||||
|
||||
impl Default for LoopGuardConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
warn_threshold: 3,
|
||||
block_threshold: 5,
|
||||
circuit_breaker: 30,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Loop guard state
|
||||
#[derive(Debug)]
|
||||
pub struct LoopGuard {
|
||||
config: LoopGuardConfig,
|
||||
/// Hash of (tool_name, params) -> count
|
||||
call_counts: HashMap<String, u32>,
|
||||
/// Total calls in this session
|
||||
total_calls: u32,
|
||||
}
|
||||
|
||||
impl LoopGuard {
|
||||
pub fn new(config: LoopGuardConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
call_counts: HashMap::new(),
|
||||
total_calls: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a tool call should be allowed
|
||||
pub fn check(&mut self, tool_name: &str, params: &serde_json::Value) -> LoopGuardResult {
|
||||
let hash = self.hash_call(tool_name, params);
|
||||
let count = self.call_counts.entry(hash).or_insert(0);
|
||||
|
||||
self.total_calls += 1;
|
||||
*count += 1;
|
||||
|
||||
// Check circuit breaker first
|
||||
if self.total_calls > self.config.circuit_breaker {
|
||||
return LoopGuardResult::CircuitBreaker;
|
||||
}
|
||||
|
||||
// Check block threshold
|
||||
if *count > self.config.block_threshold {
|
||||
return LoopGuardResult::Blocked;
|
||||
}
|
||||
|
||||
// Check warn threshold
|
||||
if *count > self.config.warn_threshold {
|
||||
return LoopGuardResult::Warn;
|
||||
}
|
||||
|
||||
LoopGuardResult::Allowed
|
||||
}
|
||||
|
||||
/// Reset the guard state
|
||||
pub fn reset(&mut self) {
|
||||
self.call_counts.clear();
|
||||
self.total_calls = 0;
|
||||
}
|
||||
|
||||
fn hash_call(&self, tool_name: &str, params: &serde_json::Value) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(tool_name.as_bytes());
|
||||
hasher.update(params.to_string().as_bytes());
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LoopGuard {
|
||||
fn default() -> Self {
|
||||
Self::new(LoopGuardConfig::default())
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of loop guard check
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LoopGuardResult {
|
||||
/// Call is allowed
|
||||
Allowed,
|
||||
/// Call is allowed but should warn
|
||||
Warn,
|
||||
/// Call should be blocked
|
||||
Blocked,
|
||||
/// Loop should be terminated
|
||||
CircuitBreaker,
|
||||
}
|
||||
106
crates/zclaw-runtime/src/loop_runner.rs
Normal file
106
crates/zclaw-runtime/src/loop_runner.rs
Normal file
@@ -0,0 +1,106 @@
|
||||
//! Agent loop implementation
|
||||
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
use zclaw_types::{AgentId, SessionId, Message, Result};
|
||||
|
||||
use crate::driver::{LlmDriver, CompletionRequest};
|
||||
use crate::tool::ToolRegistry;
|
||||
use crate::loop_guard::LoopGuard;
|
||||
use zclaw_memory::MemoryStore;
|
||||
|
||||
/// Agent loop runner
|
||||
pub struct AgentLoop {
|
||||
agent_id: AgentId,
|
||||
driver: Arc<dyn LlmDriver>,
|
||||
tools: ToolRegistry,
|
||||
memory: Arc<MemoryStore>,
|
||||
loop_guard: LoopGuard,
|
||||
}
|
||||
|
||||
impl AgentLoop {
|
||||
pub fn new(
|
||||
agent_id: AgentId,
|
||||
driver: Arc<dyn LlmDriver>,
|
||||
tools: ToolRegistry,
|
||||
memory: Arc<MemoryStore>,
|
||||
) -> Self {
|
||||
Self {
|
||||
agent_id,
|
||||
driver,
|
||||
tools,
|
||||
memory,
|
||||
loop_guard: LoopGuard::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the agent loop with a single message
|
||||
pub async fn run(&self, session_id: SessionId, input: String) -> Result<AgentLoopResult> {
|
||||
// Add user message to session
|
||||
let user_message = Message::user(input);
|
||||
self.memory.append_message(&session_id, &user_message).await?;
|
||||
|
||||
// Get all messages for context
|
||||
let messages = self.memory.get_messages(&session_id).await?;
|
||||
|
||||
// Build completion request
|
||||
let request = CompletionRequest {
|
||||
model: "claude-sonnet-4-20250514".to_string(), // TODO: Get from agent config
|
||||
system: None, // TODO: Get from agent config
|
||||
messages,
|
||||
tools: self.tools.definitions(),
|
||||
max_tokens: Some(4096),
|
||||
temperature: Some(0.7),
|
||||
stop: Vec::new(),
|
||||
stream: false,
|
||||
};
|
||||
|
||||
// Call LLM
|
||||
let response = self.driver.complete(request).await?;
|
||||
|
||||
// Process response and handle tool calls
|
||||
let mut iterations = 0;
|
||||
let max_iterations = 10;
|
||||
|
||||
// TODO: Implement full loop with tool execution
|
||||
|
||||
Ok(AgentLoopResult {
|
||||
response: "Response placeholder".to_string(),
|
||||
input_tokens: response.input_tokens,
|
||||
output_tokens: response.output_tokens,
|
||||
iterations,
|
||||
})
|
||||
}
|
||||
|
||||
/// Run the agent loop with streaming
|
||||
pub async fn run_streaming(
|
||||
&self,
|
||||
session_id: SessionId,
|
||||
input: String,
|
||||
) -> Result<mpsc::Receiver<LoopEvent>> {
|
||||
let (tx, rx) = mpsc::channel(100);
|
||||
|
||||
// TODO: Implement streaming
|
||||
|
||||
Ok(rx)
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of an agent loop execution
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AgentLoopResult {
|
||||
pub response: String,
|
||||
pub input_tokens: u32,
|
||||
pub output_tokens: u32,
|
||||
pub iterations: usize,
|
||||
}
|
||||
|
||||
/// Events emitted during streaming
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum LoopEvent {
|
||||
Delta(String),
|
||||
ToolStart { name: String, input: serde_json::Value },
|
||||
ToolEnd { name: String, output: serde_json::Value },
|
||||
Complete(AgentLoopResult),
|
||||
Error(String),
|
||||
}
|
||||
54
crates/zclaw-runtime/src/stream.rs
Normal file
54
crates/zclaw-runtime/src/stream.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
//! Streaming utilities
|
||||
|
||||
use tokio::sync::mpsc;
|
||||
use zclaw_types::Result;
|
||||
|
||||
/// Stream event for LLM responses
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum StreamEvent {
|
||||
/// Text delta received
|
||||
TextDelta(String),
|
||||
/// Thinking delta received
|
||||
ThinkingDelta(String),
|
||||
/// Tool use started
|
||||
ToolUseStart { id: String, name: String },
|
||||
/// Tool use input chunk
|
||||
ToolUseInput { id: String, chunk: String },
|
||||
/// Tool use completed
|
||||
ToolUseEnd { id: String, input: serde_json::Value },
|
||||
/// Response completed
|
||||
Complete { input_tokens: u32, output_tokens: u32 },
|
||||
/// Error occurred
|
||||
Error(String),
|
||||
}
|
||||
|
||||
/// Stream sender wrapper
|
||||
pub struct StreamSender {
|
||||
tx: mpsc::Sender<StreamEvent>,
|
||||
}
|
||||
|
||||
impl StreamSender {
|
||||
pub fn new(tx: mpsc::Sender<StreamEvent>) -> Self {
|
||||
Self { tx }
|
||||
}
|
||||
|
||||
pub async fn send_text(&self, delta: impl Into<String>) -> Result<()> {
|
||||
self.tx.send(StreamEvent::TextDelta(delta.into())).await.ok();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn send_thinking(&self, delta: impl Into<String>) -> Result<()> {
|
||||
self.tx.send(StreamEvent::ThinkingDelta(delta.into())).await.ok();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn send_complete(&self, input_tokens: u32, output_tokens: u32) -> Result<()> {
|
||||
self.tx.send(StreamEvent::Complete { input_tokens, output_tokens }).await.ok();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn send_error(&self, error: impl Into<String>) -> Result<()> {
|
||||
self.tx.send(StreamEvent::Error(error.into())).await.ok();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
72
crates/zclaw-runtime/src/tool.rs
Normal file
72
crates/zclaw-runtime/src/tool.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
//! Tool system for agent capabilities
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
use zclaw_types::{AgentId, Result};
|
||||
|
||||
use crate::driver::ToolDefinition;
|
||||
|
||||
/// Tool trait for implementing agent tools
|
||||
#[async_trait]
|
||||
pub trait Tool: Send + Sync {
|
||||
/// Get the tool name
|
||||
fn name(&self) -> &str;
|
||||
|
||||
/// Get the tool description
|
||||
fn description(&self) -> &str;
|
||||
|
||||
/// Get the JSON schema for input parameters
|
||||
fn input_schema(&self) -> Value;
|
||||
|
||||
/// Execute the tool
|
||||
async fn execute(&self, input: Value, context: &ToolContext) -> Result<Value>;
|
||||
}
|
||||
|
||||
/// Context provided to tool execution
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ToolContext {
|
||||
pub agent_id: AgentId,
|
||||
pub working_directory: Option<String>,
|
||||
}
|
||||
|
||||
/// Tool registry for managing available tools
|
||||
pub struct ToolRegistry {
|
||||
tools: Vec<Box<dyn Tool>>,
|
||||
}
|
||||
|
||||
impl ToolRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self { tools: Vec::new() }
|
||||
}
|
||||
|
||||
pub fn register(&mut self, tool: Box<dyn Tool>) {
|
||||
self.tools.push(tool);
|
||||
}
|
||||
|
||||
pub fn get(&self, name: &str) -> Option<&dyn Tool> {
|
||||
self.tools.iter().find(|t| t.name() == name).map(|t| t.as_ref())
|
||||
}
|
||||
|
||||
pub fn list(&self) -> Vec<&dyn Tool> {
|
||||
self.tools.iter().map(|t| t.as_ref()).collect()
|
||||
}
|
||||
|
||||
pub fn definitions(&self) -> Vec<ToolDefinition> {
|
||||
self.tools.iter().map(|t| {
|
||||
ToolDefinition::new(
|
||||
t.name(),
|
||||
t.description(),
|
||||
t.input_schema(),
|
||||
)
|
||||
}).collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ToolRegistry {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// Built-in tools module
|
||||
pub mod builtin;
|
||||
21
crates/zclaw-runtime/src/tool/builtin.rs
Normal file
21
crates/zclaw-runtime/src/tool/builtin.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
//! Built-in tools
|
||||
|
||||
mod file_read;
|
||||
mod file_write;
|
||||
mod shell_exec;
|
||||
mod web_fetch;
|
||||
|
||||
pub use file_read::FileReadTool;
|
||||
pub use file_write::FileWriteTool;
|
||||
pub use shell_exec::ShellExecTool;
|
||||
pub use web_fetch::WebFetchTool;
|
||||
|
||||
use crate::tool::{ToolRegistry, Tool};
|
||||
|
||||
/// Register all built-in tools
|
||||
pub fn register_builtin_tools(registry: &mut ToolRegistry) {
|
||||
registry.register(Box::new(FileReadTool::new()));
|
||||
registry.register(Box::new(FileWriteTool::new()));
|
||||
registry.register(Box::new(ShellExecTool::new()));
|
||||
registry.register(Box::new(WebFetchTool::new()));
|
||||
}
|
||||
55
crates/zclaw-runtime/src/tool/builtin/file_read.rs
Normal file
55
crates/zclaw-runtime/src/tool/builtin/file_read.rs
Normal file
@@ -0,0 +1,55 @@
|
||||
//! File read tool
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{json, Value};
|
||||
use zclaw_types::{Result, ZclawError};
|
||||
|
||||
use crate::tool::{Tool, ToolContext};
|
||||
|
||||
pub struct FileReadTool;
|
||||
|
||||
impl FileReadTool {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for FileReadTool {
|
||||
fn name(&self) -> &str {
|
||||
"file_read"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Read the contents of a file from the filesystem"
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "The path to the file to read"
|
||||
}
|
||||
},
|
||||
"required": ["path"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, input: Value, _context: &ToolContext) -> Result<Value> {
|
||||
let path = input["path"].as_str()
|
||||
.ok_or_else(|| ZclawError::InvalidInput("Missing 'path' parameter".into()))?;
|
||||
|
||||
// TODO: Implement actual file reading with path validation
|
||||
Ok(json!({
|
||||
"content": format!("File content placeholder for: {}", path)
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for FileReadTool {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
62
crates/zclaw-runtime/src/tool/builtin/file_write.rs
Normal file
62
crates/zclaw-runtime/src/tool/builtin/file_write.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
//! File write tool
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{json, Value};
|
||||
use zclaw_types::{Result, ZclawError};
|
||||
|
||||
use crate::tool::{Tool, ToolContext};
|
||||
|
||||
pub struct FileWriteTool;
|
||||
|
||||
impl FileWriteTool {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for FileWriteTool {
|
||||
fn name(&self) -> &str {
|
||||
"file_write"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Write content to a file on the filesystem"
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "The path to the file to write"
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "The content to write to the file"
|
||||
}
|
||||
},
|
||||
"required": ["path", "content"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, input: Value, _context: &ToolContext) -> Result<Value> {
|
||||
let path = input["path"].as_str()
|
||||
.ok_or_else(|| ZclawError::InvalidInput("Missing 'path' parameter".into()))?;
|
||||
let content = input["content"].as_str()
|
||||
.ok_or_else(|| ZclawError::InvalidInput("Missing 'content' parameter".into()))?;
|
||||
|
||||
// TODO: Implement actual file writing with path validation
|
||||
Ok(json!({
|
||||
"success": true,
|
||||
"bytes_written": content.len()
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for FileWriteTool {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
61
crates/zclaw-runtime/src/tool/builtin/shell_exec.rs
Normal file
61
crates/zclaw-runtime/src/tool/builtin/shell_exec.rs
Normal file
@@ -0,0 +1,61 @@
|
||||
//! Shell execution tool
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{json, Value};
|
||||
use zclaw_types::{Result, ZclawError};
|
||||
|
||||
use crate::tool::{Tool, ToolContext};
|
||||
|
||||
pub struct ShellExecTool;
|
||||
|
||||
impl ShellExecTool {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for ShellExecTool {
|
||||
fn name(&self) -> &str {
|
||||
"shell_exec"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Execute a shell command and return the output"
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "The command to execute"
|
||||
},
|
||||
"timeout": {
|
||||
"type": "integer",
|
||||
"description": "Timeout in seconds (default: 30)"
|
||||
}
|
||||
},
|
||||
"required": ["command"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, input: Value, _context: &ToolContext) -> Result<Value> {
|
||||
let command = input["command"].as_str()
|
||||
.ok_or_else(|| ZclawError::InvalidInput("Missing 'command' parameter".into()))?;
|
||||
|
||||
// TODO: Implement actual shell execution with security constraints
|
||||
Ok(json!({
|
||||
"stdout": format!("Command output placeholder for: {}", command),
|
||||
"stderr": "",
|
||||
"exit_code": 0
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ShellExecTool {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
61
crates/zclaw-runtime/src/tool/builtin/web_fetch.rs
Normal file
61
crates/zclaw-runtime/src/tool/builtin/web_fetch.rs
Normal file
@@ -0,0 +1,61 @@
|
||||
//! Web fetch tool
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{json, Value};
|
||||
use zclaw_types::{Result, ZclawError};
|
||||
|
||||
use crate::tool::{Tool, ToolContext};
|
||||
|
||||
pub struct WebFetchTool;
|
||||
|
||||
impl WebFetchTool {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for WebFetchTool {
|
||||
fn name(&self) -> &str {
|
||||
"web_fetch"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Fetch content from a URL"
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "The URL to fetch"
|
||||
},
|
||||
"method": {
|
||||
"type": "string",
|
||||
"enum": ["GET", "POST"],
|
||||
"description": "HTTP method (default: GET)"
|
||||
}
|
||||
},
|
||||
"required": ["url"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, input: Value, _context: &ToolContext) -> Result<Value> {
|
||||
let url = input["url"].as_str()
|
||||
.ok_or_else(|| ZclawError::InvalidInput("Missing 'url' parameter".into()))?;
|
||||
|
||||
// TODO: Implement actual web fetching with SSRF protection
|
||||
Ok(json!({
|
||||
"status": 200,
|
||||
"content": format!("Fetched content placeholder for: {}", url)
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for WebFetchTool {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
15
crates/zclaw-types/Cargo.toml
Normal file
15
crates/zclaw-types/Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "zclaw-types"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "ZCLAW core type definitions"
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
165
crates/zclaw-types/src/agent.rs
Normal file
165
crates/zclaw-types/src/agent.rs
Normal file
@@ -0,0 +1,165 @@
|
||||
//! Agent configuration and state types
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::{AgentId, Capability, ModelConfig};
|
||||
|
||||
/// Agent configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentConfig {
|
||||
/// Unique identifier
|
||||
pub id: AgentId,
|
||||
/// Human-readable name
|
||||
pub name: String,
|
||||
/// Agent description
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
/// Model configuration
|
||||
#[serde(default)]
|
||||
pub model: ModelConfig,
|
||||
/// System prompt
|
||||
#[serde(default)]
|
||||
pub system_prompt: Option<String>,
|
||||
/// Capabilities granted to this agent
|
||||
#[serde(default)]
|
||||
pub capabilities: Vec<Capability>,
|
||||
/// Tools available to this agent
|
||||
#[serde(default)]
|
||||
pub tools: Vec<String>,
|
||||
/// Maximum tokens per response
|
||||
#[serde(default)]
|
||||
pub max_tokens: Option<u32>,
|
||||
/// Temperature (0.0 - 1.0)
|
||||
#[serde(default)]
|
||||
pub temperature: Option<f32>,
|
||||
/// Whether the agent is active
|
||||
#[serde(default = "default_enabled")]
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
fn default_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl Default for AgentConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
id: AgentId::new(),
|
||||
name: String::new(),
|
||||
description: None,
|
||||
model: ModelConfig::default(),
|
||||
system_prompt: None,
|
||||
capabilities: Vec::new(),
|
||||
tools: Vec::new(),
|
||||
max_tokens: None,
|
||||
temperature: None,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentConfig {
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
id: AgentId::new(),
|
||||
name: name.into(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_id(mut self, id: AgentId) -> Self {
|
||||
self.id = id;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_description(mut self, description: impl Into<String>) -> Self {
|
||||
self.description = Some(description.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_system_prompt(mut self, prompt: impl Into<String>) -> Self {
|
||||
self.system_prompt = Some(prompt.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_model(mut self, model: ModelConfig) -> Self {
|
||||
self.model = model;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_capabilities(mut self, capabilities: Vec<Capability>) -> Self {
|
||||
self.capabilities = capabilities;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_tools(mut self, tools: Vec<String>) -> Self {
|
||||
self.tools = tools;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_max_tokens(mut self, max_tokens: u32) -> Self {
|
||||
self.max_tokens = Some(max_tokens);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_temperature(mut self, temperature: f32) -> Self {
|
||||
self.temperature = Some(temperature);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Agent runtime state
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum AgentState {
|
||||
/// Agent is running and can receive messages
|
||||
Running,
|
||||
/// Agent is paused
|
||||
Suspended,
|
||||
/// Agent has been terminated
|
||||
Terminated,
|
||||
}
|
||||
|
||||
impl Default for AgentState {
|
||||
fn default() -> Self {
|
||||
Self::Running
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AgentState {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
AgentState::Running => write!(f, "running"),
|
||||
AgentState::Suspended => write!(f, "suspended"),
|
||||
AgentState::Terminated => write!(f, "terminated"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Agent information for display
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentInfo {
|
||||
pub id: AgentId,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub model: String,
|
||||
pub provider: String,
|
||||
pub state: AgentState,
|
||||
pub message_count: usize,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
impl From<AgentConfig> for AgentInfo {
|
||||
fn from(config: AgentConfig) -> Self {
|
||||
Self {
|
||||
id: config.id,
|
||||
name: config.name,
|
||||
description: config.description,
|
||||
model: config.model.model,
|
||||
provider: config.model.provider,
|
||||
state: AgentState::Running,
|
||||
message_count: 0,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
158
crates/zclaw-types/src/capability.rs
Normal file
158
crates/zclaw-types/src/capability.rs
Normal file
@@ -0,0 +1,158 @@
|
||||
//! Capability-based security model
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A capability grants permission for a specific operation
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum Capability {
|
||||
/// Invoke a specific tool
|
||||
ToolInvoke { name: String },
|
||||
/// Access to all tools
|
||||
ToolAll,
|
||||
/// Read from memory scope
|
||||
MemoryRead { scope: String },
|
||||
/// Write to memory scope
|
||||
MemoryWrite { scope: String },
|
||||
/// Connect to network host
|
||||
NetConnect { host: String },
|
||||
/// Execute shell commands matching pattern
|
||||
ShellExec { pattern: String },
|
||||
/// Spawn new agents
|
||||
AgentSpawn,
|
||||
/// Send messages to agents matching pattern
|
||||
AgentMessage { pattern: String },
|
||||
/// Kill agents matching pattern
|
||||
AgentKill { pattern: String },
|
||||
/// Discover remote peers via OFP
|
||||
OfpDiscover,
|
||||
/// Connect to specific OFP peers
|
||||
OfpConnect { peer: String },
|
||||
/// Advertise to OFP peers
|
||||
OfpAdvertise,
|
||||
}
|
||||
|
||||
impl Capability {
|
||||
/// Create a tool invocation capability
|
||||
pub fn tool(name: impl Into<String>) -> Self {
|
||||
Self::ToolInvoke { name: name.into() }
|
||||
}
|
||||
|
||||
/// Create a memory read capability
|
||||
pub fn memory_read(scope: impl Into<String>) -> Self {
|
||||
Self::MemoryRead { scope: scope.into() }
|
||||
}
|
||||
|
||||
/// Create a memory write capability
|
||||
pub fn memory_write(scope: impl Into<String>) -> Self {
|
||||
Self::MemoryWrite { scope: scope.into() }
|
||||
}
|
||||
|
||||
/// Create a network connect capability
|
||||
pub fn net_connect(host: impl Into<String>) -> Self {
|
||||
Self::NetConnect { host: host.into() }
|
||||
}
|
||||
|
||||
/// Check if this capability grants access to a tool
|
||||
pub fn allows_tool(&self, tool_name: &str) -> bool {
|
||||
match self {
|
||||
Capability::ToolAll => true,
|
||||
Capability::ToolInvoke { name } => name == tool_name,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this capability grants read access to a scope
|
||||
pub fn allows_memory_read(&self, scope: &str) -> bool {
|
||||
match self {
|
||||
Capability::MemoryRead { scope: s } => {
|
||||
s == "*" || s == scope || scope.starts_with(&format!("{}.", s))
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this capability grants write access to a scope
|
||||
pub fn allows_memory_write(&self, scope: &str) -> bool {
|
||||
match self {
|
||||
Capability::MemoryWrite { scope: s } => {
|
||||
s == "*" || s == scope || scope.starts_with(&format!("{}.", s))
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Capability set for an agent
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct CapabilitySet {
|
||||
pub capabilities: Vec<Capability>,
|
||||
}
|
||||
|
||||
impl CapabilitySet {
|
||||
pub fn new() -> Self {
|
||||
Self { capabilities: Vec::new() }
|
||||
}
|
||||
|
||||
pub fn with(mut self, capability: Capability) -> Self {
|
||||
self.capabilities.push(capability);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_all_tools(mut self) -> Self {
|
||||
self.capabilities.push(Capability::ToolAll);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_tool(mut self, name: impl Into<String>) -> Self {
|
||||
self.capabilities.push(Capability::tool(name));
|
||||
self
|
||||
}
|
||||
|
||||
/// Check if any capability grants access to a tool
|
||||
pub fn can_invoke_tool(&self, tool_name: &str) -> bool {
|
||||
self.capabilities.iter().any(|c| c.allows_tool(tool_name))
|
||||
}
|
||||
|
||||
/// Check if any capability grants read access to a scope
|
||||
pub fn can_read_memory(&self, scope: &str) -> bool {
|
||||
self.capabilities.iter().any(|c| c.allows_memory_read(scope))
|
||||
}
|
||||
|
||||
/// Check if any capability grants write access to a scope
|
||||
pub fn can_write_memory(&self, scope: &str) -> bool {
|
||||
self.capabilities.iter().any(|c| c.allows_memory_write(scope))
|
||||
}
|
||||
|
||||
/// Validate that a child's capabilities don't exceed parent's
|
||||
pub fn validate_inheritance(&self, child: &CapabilitySet) -> bool {
|
||||
// Child can only have capabilities that parent has
|
||||
child.capabilities.iter().all(|child_cap| {
|
||||
self.capabilities.iter().any(|parent_cap| {
|
||||
child_cap == parent_cap || parent_cap.grants(child_cap)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Capability {
|
||||
/// Check if this capability grants another capability
|
||||
fn grants(&self, other: &Capability) -> bool {
|
||||
match (self, other) {
|
||||
// ToolAll grants any ToolInvoke
|
||||
(Capability::ToolAll, Capability::ToolInvoke { .. }) => true,
|
||||
// Wildcard scopes grant specific scopes
|
||||
(Capability::MemoryRead { scope: a }, Capability::MemoryRead { scope: b }) => {
|
||||
a == "*" || a == b || b.starts_with(&format!("{}.", a))
|
||||
}
|
||||
(Capability::MemoryWrite { scope: a }, Capability::MemoryWrite { scope: b }) => {
|
||||
a == "*" || a == b || b.starts_with(&format!("{}.", a))
|
||||
}
|
||||
// NetConnect with "*" grants any host
|
||||
(Capability::NetConnect { host: a }, Capability::NetConnect { host: b }) => {
|
||||
a == "*" || a == b
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
52
crates/zclaw-types/src/error.rs
Normal file
52
crates/zclaw-types/src/error.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
//! Error types for ZCLAW
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// ZCLAW unified error type
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ZclawError {
|
||||
#[error("Not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("Permission denied: {0}")]
|
||||
PermissionDenied(String),
|
||||
|
||||
#[error("LLM error: {0}")]
|
||||
LlmError(String),
|
||||
|
||||
#[error("Tool error: {0}")]
|
||||
ToolError(String),
|
||||
|
||||
#[error("Storage error: {0}")]
|
||||
StorageError(String),
|
||||
|
||||
#[error("Configuration error: {0}")]
|
||||
ConfigError(String),
|
||||
|
||||
#[error("Serialization error: {0}")]
|
||||
SerializationError(#[from] serde_json::Error),
|
||||
|
||||
#[error("IO error: {0}")]
|
||||
IoError(#[from] std::io::Error),
|
||||
|
||||
#[error("HTTP error: {0}")]
|
||||
HttpError(String),
|
||||
|
||||
#[error("Timeout: {0}")]
|
||||
Timeout(String),
|
||||
|
||||
#[error("Invalid input: {0}")]
|
||||
InvalidInput(String),
|
||||
|
||||
#[error("Agent loop detected: {0}")]
|
||||
LoopDetected(String),
|
||||
|
||||
#[error("Rate limited: {0}")]
|
||||
RateLimited(String),
|
||||
|
||||
#[error("Internal error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
/// Result type alias for ZCLAW operations
|
||||
pub type Result<T> = std::result::Result<T, ZclawError>;
|
||||
136
crates/zclaw-types/src/event.rs
Normal file
136
crates/zclaw-types/src/event.rs
Normal file
@@ -0,0 +1,136 @@
|
||||
//! Event types for ZCLAW event bus
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::{AgentId, SessionId, RunId};
|
||||
|
||||
/// An event in the ZCLAW system
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum Event {
|
||||
/// Kernel started
|
||||
KernelStarted,
|
||||
|
||||
/// Kernel shutting down
|
||||
KernelShutdown,
|
||||
|
||||
/// Agent spawned
|
||||
AgentSpawned {
|
||||
agent_id: AgentId,
|
||||
name: String,
|
||||
},
|
||||
|
||||
/// Agent terminated
|
||||
AgentTerminated {
|
||||
agent_id: AgentId,
|
||||
reason: String,
|
||||
},
|
||||
|
||||
/// Agent state changed
|
||||
AgentStateChanged {
|
||||
agent_id: AgentId,
|
||||
old_state: String,
|
||||
new_state: String,
|
||||
},
|
||||
|
||||
/// Session created
|
||||
SessionCreated {
|
||||
session_id: SessionId,
|
||||
agent_id: AgentId,
|
||||
},
|
||||
|
||||
/// Message received
|
||||
MessageReceived {
|
||||
agent_id: AgentId,
|
||||
session_id: SessionId,
|
||||
role: String,
|
||||
},
|
||||
|
||||
/// Message sent
|
||||
MessageSent {
|
||||
agent_id: AgentId,
|
||||
session_id: SessionId,
|
||||
role: String,
|
||||
},
|
||||
|
||||
/// Tool invoked
|
||||
ToolInvoked {
|
||||
agent_id: AgentId,
|
||||
tool_name: String,
|
||||
},
|
||||
|
||||
/// Tool completed
|
||||
ToolCompleted {
|
||||
agent_id: AgentId,
|
||||
tool_name: String,
|
||||
success: bool,
|
||||
duration_ms: u64,
|
||||
},
|
||||
|
||||
/// Workflow started
|
||||
WorkflowStarted {
|
||||
workflow_id: String,
|
||||
run_id: RunId,
|
||||
},
|
||||
|
||||
/// Workflow completed
|
||||
WorkflowCompleted {
|
||||
workflow_id: String,
|
||||
run_id: RunId,
|
||||
success: bool,
|
||||
},
|
||||
|
||||
/// Trigger fired
|
||||
TriggerFired {
|
||||
trigger_id: String,
|
||||
trigger_type: String,
|
||||
},
|
||||
|
||||
/// Skill loaded
|
||||
SkillLoaded {
|
||||
skill_id: String,
|
||||
version: String,
|
||||
},
|
||||
|
||||
/// Hand triggered
|
||||
HandTriggered {
|
||||
hand_name: String,
|
||||
agent_id: Option<AgentId>,
|
||||
},
|
||||
|
||||
/// Health check failed
|
||||
HealthCheckFailed {
|
||||
agent_id: AgentId,
|
||||
reason: String,
|
||||
},
|
||||
|
||||
/// Error occurred
|
||||
Error {
|
||||
source: String,
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl Event {
|
||||
/// Get the event type name
|
||||
pub fn event_type(&self) -> &'static str {
|
||||
match self {
|
||||
Event::KernelStarted { .. } => "kernel_started",
|
||||
Event::KernelShutdown { .. } => "kernel_shutdown",
|
||||
Event::AgentSpawned { .. } => "agent_spawned",
|
||||
Event::AgentTerminated { .. } => "agent_terminated",
|
||||
Event::AgentStateChanged { .. } => "agent_state_changed",
|
||||
Event::SessionCreated { .. } => "session_created",
|
||||
Event::MessageReceived { .. } => "message_received",
|
||||
Event::MessageSent { .. } => "message_sent",
|
||||
Event::ToolInvoked { .. } => "tool_invoked",
|
||||
Event::ToolCompleted { .. } => "tool_completed",
|
||||
Event::WorkflowStarted { .. } => "workflow_started",
|
||||
Event::WorkflowCompleted { .. } => "workflow_completed",
|
||||
Event::TriggerFired { .. } => "trigger_fired",
|
||||
Event::SkillLoaded { .. } => "skill_loaded",
|
||||
Event::HandTriggered { .. } => "hand_triggered",
|
||||
Event::HealthCheckFailed { .. } => "health_check_failed",
|
||||
Event::Error { .. } => "error",
|
||||
}
|
||||
}
|
||||
}
|
||||
147
crates/zclaw-types/src/id.rs
Normal file
147
crates/zclaw-types/src/id.rs
Normal file
@@ -0,0 +1,147 @@
|
||||
//! ID types for ZCLAW entities
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::str::FromStr;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Unique identifier for an Agent
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct AgentId(pub Uuid);
|
||||
|
||||
impl AgentId {
|
||||
pub fn new() -> Self {
|
||||
Self(Uuid::new_v4())
|
||||
}
|
||||
|
||||
pub fn from_uuid(uuid: Uuid) -> Self {
|
||||
Self(uuid)
|
||||
}
|
||||
|
||||
pub fn as_uuid(&self) -> &Uuid {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AgentId {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AgentId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for AgentId {
|
||||
type Err = uuid::Error;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
Uuid::parse_str(s).map(AgentId)
|
||||
}
|
||||
}
|
||||
|
||||
/// Unique identifier for a conversation session
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct SessionId(pub Uuid);
|
||||
|
||||
impl SessionId {
|
||||
pub fn new() -> Self {
|
||||
Self(Uuid::new_v4())
|
||||
}
|
||||
|
||||
pub fn from_uuid(uuid: Uuid) -> Self {
|
||||
Self(uuid)
|
||||
}
|
||||
|
||||
pub fn as_uuid(&self) -> &Uuid {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SessionId {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SessionId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Unique identifier for a tool
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct ToolId(pub String);
|
||||
|
||||
impl ToolId {
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
Self(name.into())
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ToolId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for ToolId {
|
||||
fn from(s: &str) -> Self {
|
||||
Self(s.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for ToolId {
|
||||
fn from(s: String) -> Self {
|
||||
Self(s)
|
||||
}
|
||||
}
|
||||
|
||||
/// Unique identifier for a skill
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct SkillId(pub String);
|
||||
|
||||
impl SkillId {
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
Self(name.into())
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SkillId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Unique identifier for a workflow run
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct RunId(pub Uuid);
|
||||
|
||||
impl RunId {
|
||||
pub fn new() -> Self {
|
||||
Self(Uuid::new_v4())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for RunId {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RunId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
24
crates/zclaw-types/src/lib.rs
Normal file
24
crates/zclaw-types/src/lib.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
//! ZCLAW Core Types
|
||||
//!
|
||||
//! This crate defines the fundamental types used across all ZCLAW crates.
|
||||
|
||||
pub mod id;
|
||||
pub mod message;
|
||||
pub mod agent;
|
||||
pub mod capability;
|
||||
pub mod error;
|
||||
pub mod event;
|
||||
pub mod tool;
|
||||
pub mod config;
|
||||
|
||||
pub use id::*;
|
||||
pub use message::*;
|
||||
pub use agent::*;
|
||||
pub use capability::*;
|
||||
pub use error::*;
|
||||
pub use event::*;
|
||||
pub use tool::*;
|
||||
pub use config::*;
|
||||
|
||||
// Re-export commonly used external types
|
||||
pub use serde_json::Value as JsonValue;
|
||||
163
crates/zclaw-types/src/message.rs
Normal file
163
crates/zclaw-types/src/message.rs
Normal file
@@ -0,0 +1,163 @@
|
||||
//! Message types for Agent communication
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use crate::ToolId;
|
||||
|
||||
/// A message in a conversation
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "role", rename_all = "lowercase")]
|
||||
pub enum Message {
|
||||
/// User message
|
||||
User {
|
||||
content: String,
|
||||
},
|
||||
/// Assistant message
|
||||
Assistant {
|
||||
content: String,
|
||||
/// Optional thinking/reasoning content
|
||||
thinking: Option<String>,
|
||||
},
|
||||
/// Tool use request from the assistant
|
||||
ToolUse {
|
||||
id: String,
|
||||
tool: ToolId,
|
||||
input: Value,
|
||||
},
|
||||
/// Tool execution result
|
||||
ToolResult {
|
||||
tool_call_id: String,
|
||||
tool: ToolId,
|
||||
output: Value,
|
||||
/// Whether the tool execution failed
|
||||
is_error: bool,
|
||||
},
|
||||
/// System message (injected into context)
|
||||
System {
|
||||
content: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl Message {
|
||||
pub fn user(content: impl Into<String>) -> Self {
|
||||
Self::User {
|
||||
content: content.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn assistant(content: impl Into<String>) -> Self {
|
||||
Self::Assistant {
|
||||
content: content.into(),
|
||||
thinking: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn assistant_with_thinking(content: impl Into<String>, thinking: impl Into<String>) -> Self {
|
||||
Self::Assistant {
|
||||
content: content.into(),
|
||||
thinking: Some(thinking.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tool_use(id: impl Into<String>, tool: ToolId, input: Value) -> Self {
|
||||
Self::ToolUse {
|
||||
id: id.into(),
|
||||
tool,
|
||||
input,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tool_result(tool_call_id: impl Into<String>, tool: ToolId, output: Value, is_error: bool) -> Self {
|
||||
Self::ToolResult {
|
||||
tool_call_id: tool_call_id.into(),
|
||||
tool,
|
||||
output,
|
||||
is_error,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn system(content: impl Into<String>) -> Self {
|
||||
Self::System {
|
||||
content: content.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the role name as a string
|
||||
pub fn role(&self) -> &'static str {
|
||||
match self {
|
||||
Message::User { .. } => "user",
|
||||
Message::Assistant { .. } => "assistant",
|
||||
Message::ToolUse { .. } => "tool_use",
|
||||
Message::ToolResult { .. } => "tool_result",
|
||||
Message::System { .. } => "system",
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this is a user message
|
||||
pub fn is_user(&self) -> bool {
|
||||
matches!(self, Message::User { .. })
|
||||
}
|
||||
|
||||
/// Check if this is an assistant message
|
||||
pub fn is_assistant(&self) -> bool {
|
||||
matches!(self, Message::Assistant { .. })
|
||||
}
|
||||
|
||||
/// Check if this is a tool use
|
||||
pub fn is_tool_use(&self) -> bool {
|
||||
matches!(self, Message::ToolUse { .. })
|
||||
}
|
||||
|
||||
/// Check if this is a tool result
|
||||
pub fn is_tool_result(&self) -> bool {
|
||||
matches!(self, Message::ToolResult { .. })
|
||||
}
|
||||
}
|
||||
|
||||
/// Content block for structured responses
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ContentBlock {
|
||||
Text { text: String },
|
||||
Thinking { thinking: String },
|
||||
ToolUse {
|
||||
id: String,
|
||||
name: String,
|
||||
input: Value,
|
||||
},
|
||||
ToolResult {
|
||||
tool_use_id: String,
|
||||
content: String,
|
||||
is_error: bool,
|
||||
},
|
||||
Image {
|
||||
source: ImageSource,
|
||||
},
|
||||
}
|
||||
|
||||
/// Image source for multimodal messages
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ImageSource {
|
||||
#[serde(rename = "type")]
|
||||
pub source_type: String, // "base64", "url"
|
||||
pub media_type: String,
|
||||
pub data: String,
|
||||
}
|
||||
|
||||
impl ImageSource {
|
||||
pub fn base64(media_type: impl Into<String>, data: impl Into<String>) -> Self {
|
||||
Self {
|
||||
source_type: "base64".to_string(),
|
||||
media_type: media_type.into(),
|
||||
data: data.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn url(url: impl Into<String>) -> Self {
|
||||
Self {
|
||||
source_type: "url".to_string(),
|
||||
media_type: "image/*".to_string(),
|
||||
data: url.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user