feat: initial release — ZeroClaw v0.1.0
- 22 AI providers (OpenRouter, Anthropic, OpenAI, Mistral, etc.) - 7 channels (CLI, Telegram, Discord, Slack, iMessage, Matrix, Webhook) - 5-step onboarding wizard with Project Context personalization - OpenClaw-aligned system prompt (SOUL.md, IDENTITY.md, USER.md, AGENTS.md, etc.) - SQLite memory backend with auto-save - Skills system with on-demand loading - Security: autonomy levels, command allowlists, cost limits - 532 tests passing, 0 clippy warnings
This commit is contained in:
commit
05cb353f7f
71 changed files with 15757 additions and 0 deletions
119
src/observability/log.rs
Normal file
119
src/observability/log.rs
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
use super::traits::{Observer, ObserverEvent, ObserverMetric};
|
||||
use tracing::info;
|
||||
|
||||
/// Log-based observer — uses tracing, zero external deps
|
||||
pub struct LogObserver;
|
||||
|
||||
impl LogObserver {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl Observer for LogObserver {
|
||||
fn record_event(&self, event: &ObserverEvent) {
|
||||
match event {
|
||||
ObserverEvent::AgentStart { provider, model } => {
|
||||
info!(provider = %provider, model = %model, "agent.start");
|
||||
}
|
||||
ObserverEvent::AgentEnd {
|
||||
duration,
|
||||
tokens_used,
|
||||
} => {
|
||||
let ms = u64::try_from(duration.as_millis()).unwrap_or(u64::MAX);
|
||||
info!(duration_ms = ms, tokens = ?tokens_used, "agent.end");
|
||||
}
|
||||
ObserverEvent::ToolCall {
|
||||
tool,
|
||||
duration,
|
||||
success,
|
||||
} => {
|
||||
let ms = u64::try_from(duration.as_millis()).unwrap_or(u64::MAX);
|
||||
info!(tool = %tool, duration_ms = ms, success = success, "tool.call");
|
||||
}
|
||||
ObserverEvent::ChannelMessage { channel, direction } => {
|
||||
info!(channel = %channel, direction = %direction, "channel.message");
|
||||
}
|
||||
ObserverEvent::HeartbeatTick => {
|
||||
info!("heartbeat.tick");
|
||||
}
|
||||
ObserverEvent::Error { component, message } => {
|
||||
info!(component = %component, error = %message, "error");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn record_metric(&self, metric: &ObserverMetric) {
|
||||
match metric {
|
||||
ObserverMetric::RequestLatency(d) => {
|
||||
let ms = u64::try_from(d.as_millis()).unwrap_or(u64::MAX);
|
||||
info!(latency_ms = ms, "metric.request_latency");
|
||||
}
|
||||
ObserverMetric::TokensUsed(t) => {
|
||||
info!(tokens = t, "metric.tokens_used");
|
||||
}
|
||||
ObserverMetric::ActiveSessions(s) => {
|
||||
info!(sessions = s, "metric.active_sessions");
|
||||
}
|
||||
ObserverMetric::QueueDepth(d) => {
|
||||
info!(depth = d, "metric.queue_depth");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
"log"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn log_observer_name() {
|
||||
assert_eq!(LogObserver::new().name(), "log");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log_observer_all_events_no_panic() {
|
||||
let obs = LogObserver::new();
|
||||
obs.record_event(&ObserverEvent::AgentStart {
|
||||
provider: "openrouter".into(),
|
||||
model: "claude-sonnet".into(),
|
||||
});
|
||||
obs.record_event(&ObserverEvent::AgentEnd {
|
||||
duration: Duration::from_millis(500),
|
||||
tokens_used: Some(100),
|
||||
});
|
||||
obs.record_event(&ObserverEvent::AgentEnd {
|
||||
duration: Duration::ZERO,
|
||||
tokens_used: None,
|
||||
});
|
||||
obs.record_event(&ObserverEvent::ToolCall {
|
||||
tool: "shell".into(),
|
||||
duration: Duration::from_millis(10),
|
||||
success: false,
|
||||
});
|
||||
obs.record_event(&ObserverEvent::ChannelMessage {
|
||||
channel: "telegram".into(),
|
||||
direction: "outbound".into(),
|
||||
});
|
||||
obs.record_event(&ObserverEvent::HeartbeatTick);
|
||||
obs.record_event(&ObserverEvent::Error {
|
||||
component: "provider".into(),
|
||||
message: "timeout".into(),
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log_observer_all_metrics_no_panic() {
|
||||
let obs = LogObserver::new();
|
||||
obs.record_metric(&ObserverMetric::RequestLatency(Duration::from_secs(2)));
|
||||
obs.record_metric(&ObserverMetric::TokensUsed(0));
|
||||
obs.record_metric(&ObserverMetric::TokensUsed(u64::MAX));
|
||||
obs.record_metric(&ObserverMetric::ActiveSessions(1));
|
||||
obs.record_metric(&ObserverMetric::QueueDepth(999));
|
||||
}
|
||||
}
|
||||
76
src/observability/mod.rs
Normal file
76
src/observability/mod.rs
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
pub mod log;
|
||||
pub mod multi;
|
||||
pub mod noop;
|
||||
pub mod traits;
|
||||
|
||||
pub use self::log::LogObserver;
|
||||
pub use noop::NoopObserver;
|
||||
pub use traits::{Observer, ObserverEvent};
|
||||
|
||||
use crate::config::ObservabilityConfig;
|
||||
|
||||
/// Factory: create the right observer from config
|
||||
pub fn create_observer(config: &ObservabilityConfig) -> Box<dyn Observer> {
|
||||
match config.backend.as_str() {
|
||||
"log" => Box::new(LogObserver::new()),
|
||||
"none" | "noop" => Box::new(NoopObserver),
|
||||
_ => {
|
||||
tracing::warn!(
|
||||
"Unknown observability backend '{}', falling back to noop",
|
||||
config.backend
|
||||
);
|
||||
Box::new(NoopObserver)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn factory_none_returns_noop() {
|
||||
let cfg = ObservabilityConfig {
|
||||
backend: "none".into(),
|
||||
};
|
||||
assert_eq!(create_observer(&cfg).name(), "noop");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_noop_returns_noop() {
|
||||
let cfg = ObservabilityConfig {
|
||||
backend: "noop".into(),
|
||||
};
|
||||
assert_eq!(create_observer(&cfg).name(), "noop");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_log_returns_log() {
|
||||
let cfg = ObservabilityConfig {
|
||||
backend: "log".into(),
|
||||
};
|
||||
assert_eq!(create_observer(&cfg).name(), "log");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_unknown_falls_back_to_noop() {
|
||||
let cfg = ObservabilityConfig {
|
||||
backend: "prometheus".into(),
|
||||
};
|
||||
assert_eq!(create_observer(&cfg).name(), "noop");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_empty_string_falls_back_to_noop() {
|
||||
let cfg = ObservabilityConfig { backend: "".into() };
|
||||
assert_eq!(create_observer(&cfg).name(), "noop");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_garbage_falls_back_to_noop() {
|
||||
let cfg = ObservabilityConfig {
|
||||
backend: "xyzzy_garbage_123".into(),
|
||||
};
|
||||
assert_eq!(create_observer(&cfg).name(), "noop");
|
||||
}
|
||||
}
|
||||
154
src/observability/multi.rs
Normal file
154
src/observability/multi.rs
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
use super::traits::{Observer, ObserverEvent, ObserverMetric};
|
||||
|
||||
/// Combine multiple observers — fan-out events to all backends
|
||||
pub struct MultiObserver {
|
||||
observers: Vec<Box<dyn Observer>>,
|
||||
}
|
||||
|
||||
impl MultiObserver {
|
||||
pub fn new(observers: Vec<Box<dyn Observer>>) -> Self {
|
||||
Self { observers }
|
||||
}
|
||||
}
|
||||
|
||||
impl Observer for MultiObserver {
|
||||
fn record_event(&self, event: &ObserverEvent) {
|
||||
for obs in &self.observers {
|
||||
obs.record_event(event);
|
||||
}
|
||||
}
|
||||
|
||||
fn record_metric(&self, metric: &ObserverMetric) {
|
||||
for obs in &self.observers {
|
||||
obs.record_metric(metric);
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&self) {
|
||||
for obs in &self.observers {
|
||||
obs.flush();
|
||||
}
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
"multi"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Test observer that counts calls
|
||||
struct CountingObserver {
|
||||
event_count: Arc<AtomicUsize>,
|
||||
metric_count: Arc<AtomicUsize>,
|
||||
flush_count: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl CountingObserver {
|
||||
fn new(
|
||||
event_count: Arc<AtomicUsize>,
|
||||
metric_count: Arc<AtomicUsize>,
|
||||
flush_count: Arc<AtomicUsize>,
|
||||
) -> Self {
|
||||
Self {
|
||||
event_count,
|
||||
metric_count,
|
||||
flush_count,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Observer for CountingObserver {
|
||||
fn record_event(&self, _event: &ObserverEvent) {
|
||||
self.event_count.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
fn record_metric(&self, _metric: &ObserverMetric) {
|
||||
self.metric_count.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
fn flush(&self) {
|
||||
self.flush_count.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
fn name(&self) -> &str {
|
||||
"counting"
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_name() {
|
||||
let m = MultiObserver::new(vec![]);
|
||||
assert_eq!(m.name(), "multi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_empty_no_panic() {
|
||||
let m = MultiObserver::new(vec![]);
|
||||
m.record_event(&ObserverEvent::HeartbeatTick);
|
||||
m.record_metric(&ObserverMetric::TokensUsed(10));
|
||||
m.flush();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_fans_out_events() {
|
||||
let ec1 = Arc::new(AtomicUsize::new(0));
|
||||
let mc1 = Arc::new(AtomicUsize::new(0));
|
||||
let fc1 = Arc::new(AtomicUsize::new(0));
|
||||
let ec2 = Arc::new(AtomicUsize::new(0));
|
||||
let mc2 = Arc::new(AtomicUsize::new(0));
|
||||
let fc2 = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let m = MultiObserver::new(vec![
|
||||
Box::new(CountingObserver::new(ec1.clone(), mc1.clone(), fc1.clone())),
|
||||
Box::new(CountingObserver::new(ec2.clone(), mc2.clone(), fc2.clone())),
|
||||
]);
|
||||
|
||||
m.record_event(&ObserverEvent::HeartbeatTick);
|
||||
m.record_event(&ObserverEvent::HeartbeatTick);
|
||||
m.record_event(&ObserverEvent::HeartbeatTick);
|
||||
|
||||
assert_eq!(ec1.load(Ordering::SeqCst), 3);
|
||||
assert_eq!(ec2.load(Ordering::SeqCst), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_fans_out_metrics() {
|
||||
let ec1 = Arc::new(AtomicUsize::new(0));
|
||||
let mc1 = Arc::new(AtomicUsize::new(0));
|
||||
let fc1 = Arc::new(AtomicUsize::new(0));
|
||||
let ec2 = Arc::new(AtomicUsize::new(0));
|
||||
let mc2 = Arc::new(AtomicUsize::new(0));
|
||||
let fc2 = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let m = MultiObserver::new(vec![
|
||||
Box::new(CountingObserver::new(ec1.clone(), mc1.clone(), fc1.clone())),
|
||||
Box::new(CountingObserver::new(ec2.clone(), mc2.clone(), fc2.clone())),
|
||||
]);
|
||||
|
||||
m.record_metric(&ObserverMetric::TokensUsed(100));
|
||||
m.record_metric(&ObserverMetric::RequestLatency(Duration::from_millis(5)));
|
||||
|
||||
assert_eq!(mc1.load(Ordering::SeqCst), 2);
|
||||
assert_eq!(mc2.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_fans_out_flush() {
|
||||
let ec = Arc::new(AtomicUsize::new(0));
|
||||
let mc = Arc::new(AtomicUsize::new(0));
|
||||
let fc1 = Arc::new(AtomicUsize::new(0));
|
||||
let fc2 = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let m = MultiObserver::new(vec![
|
||||
Box::new(CountingObserver::new(ec.clone(), mc.clone(), fc1.clone())),
|
||||
Box::new(CountingObserver::new(ec.clone(), mc.clone(), fc2.clone())),
|
||||
]);
|
||||
|
||||
m.flush();
|
||||
assert_eq!(fc1.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(fc2.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
}
|
||||
72
src/observability/noop.rs
Normal file
72
src/observability/noop.rs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
use super::traits::{Observer, ObserverEvent, ObserverMetric};
|
||||
|
||||
/// Zero-overhead observer — all methods compile to nothing
|
||||
pub struct NoopObserver;
|
||||
|
||||
impl Observer for NoopObserver {
|
||||
#[inline(always)]
|
||||
fn record_event(&self, _event: &ObserverEvent) {}
|
||||
|
||||
#[inline(always)]
|
||||
fn record_metric(&self, _metric: &ObserverMetric) {}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
"noop"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn noop_name() {
|
||||
assert_eq!(NoopObserver.name(), "noop");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noop_record_event_does_not_panic() {
|
||||
let obs = NoopObserver;
|
||||
obs.record_event(&ObserverEvent::HeartbeatTick);
|
||||
obs.record_event(&ObserverEvent::AgentStart {
|
||||
provider: "test".into(),
|
||||
model: "test".into(),
|
||||
});
|
||||
obs.record_event(&ObserverEvent::AgentEnd {
|
||||
duration: Duration::from_millis(100),
|
||||
tokens_used: Some(42),
|
||||
});
|
||||
obs.record_event(&ObserverEvent::AgentEnd {
|
||||
duration: Duration::ZERO,
|
||||
tokens_used: None,
|
||||
});
|
||||
obs.record_event(&ObserverEvent::ToolCall {
|
||||
tool: "shell".into(),
|
||||
duration: Duration::from_secs(1),
|
||||
success: true,
|
||||
});
|
||||
obs.record_event(&ObserverEvent::ChannelMessage {
|
||||
channel: "cli".into(),
|
||||
direction: "inbound".into(),
|
||||
});
|
||||
obs.record_event(&ObserverEvent::Error {
|
||||
component: "test".into(),
|
||||
message: "boom".into(),
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noop_record_metric_does_not_panic() {
|
||||
let obs = NoopObserver;
|
||||
obs.record_metric(&ObserverMetric::RequestLatency(Duration::from_millis(50)));
|
||||
obs.record_metric(&ObserverMetric::TokensUsed(1000));
|
||||
obs.record_metric(&ObserverMetric::ActiveSessions(5));
|
||||
obs.record_metric(&ObserverMetric::QueueDepth(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noop_flush_does_not_panic() {
|
||||
NoopObserver.flush();
|
||||
}
|
||||
}
|
||||
52
src/observability/traits.rs
Normal file
52
src/observability/traits.rs
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
use std::time::Duration;
|
||||
|
||||
/// Events the observer can record
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ObserverEvent {
|
||||
AgentStart {
|
||||
provider: String,
|
||||
model: String,
|
||||
},
|
||||
AgentEnd {
|
||||
duration: Duration,
|
||||
tokens_used: Option<u64>,
|
||||
},
|
||||
ToolCall {
|
||||
tool: String,
|
||||
duration: Duration,
|
||||
success: bool,
|
||||
},
|
||||
ChannelMessage {
|
||||
channel: String,
|
||||
direction: String,
|
||||
},
|
||||
HeartbeatTick,
|
||||
Error {
|
||||
component: String,
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Numeric metrics
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ObserverMetric {
|
||||
RequestLatency(Duration),
|
||||
TokensUsed(u64),
|
||||
ActiveSessions(u64),
|
||||
QueueDepth(u64),
|
||||
}
|
||||
|
||||
/// Core observability trait — implement for any backend
|
||||
pub trait Observer: Send + Sync {
|
||||
/// Record a discrete event
|
||||
fn record_event(&self, event: &ObserverEvent);
|
||||
|
||||
/// Record a numeric metric
|
||||
fn record_metric(&self, metric: &ObserverMetric);
|
||||
|
||||
/// Flush any buffered data (no-op for most backends)
|
||||
fn flush(&self) {}
|
||||
|
||||
/// Human-readable name of this observer
|
||||
fn name(&self) -> &str;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue