Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
a5172dc
feat(observability): add instrumentation core with per-backend export…
claude Jul 26, 2026
6a65ba3
feat(observability): instrument the agent loop and wire backends at s…
claude Jul 26, 2026
ab11dce
feat(web): add combined Instrumentation settings page
claude Jul 26, 2026
ad6da5d
fix(observability): stop duplicating spans on immutable-span backends
claude Jul 26, 2026
a5cc79f
fix(observability): satisfy workspace clippy gate
claude Jul 26, 2026
2effb28
feat(observability): instrument the non-streaming agent loop
claude Jul 26, 2026
bf0f261
fix(config): allow expect in the instrumentation config test module
claude Jul 26, 2026
26f3454
fix(observability): give scores a real export path and de-flake the e…
claude Jul 26, 2026
0cb82fa
feat(observability): classify chat reactions as user-feedback scores
claude Jul 26, 2026
01c934e
feat(channels): correlate delivered replies with the trace that produ…
claude Jul 26, 2026
91e791b
feat(feedback): turn channel reactions into user-feedback scores
claude Jul 26, 2026
b20ef3f
feat(gateway): wire reaction feedback end to end
claude Jul 26, 2026
22f2f5f
feat(channels): emit reaction changes from Discord and Telegram
claude Jul 26, 2026
0681daf
feat(web): thumbs up/down feedback on assistant messages
claude Jul 26, 2026
3c32314
feat(observability): price token usage locally
claude Jul 26, 2026
3d5f81c
refactor: split four files back under the 1500-line limit
claude Jul 26, 2026
cd598d0
fix(feedback): close the gaps that made reaction scoring unreachable
penso Jul 27, 2026
d6f469c
fix(discord): link the activity-log embed to the reply's trace
penso Jul 27, 2026
9c07ec0
test(gateway): cover feedback.submit authorization through dispatch
penso Jul 27, 2026
adf631b
fix(feedback): attribute voice transcript and Slack native-stream rep…
penso Jul 27, 2026
0281c15
fix(feedback): attribute the logbook that follows a streamed reply
penso Jul 27, 2026
15f3459
docs(feedback): state the single-owner assumption behind the web scor…
penso Jul 27, 2026
406ea38
fix(feedback): attribute the logbook on every voice and streamed branch
penso Jul 27, 2026
01948d3
fix(observability): harden Langfuse v4 delivery
penso Jul 28, 2026
9365caa
test(web): stabilize instrumentation validation
penso Jul 28, 2026
f6c7eca
Merge remote-tracking branch 'origin/main' into claude/moltis-langfus…
penso Jul 28, 2026
5aa5b05
fix(scripts): classify targeted Rust test paths
penso Jul 28, 2026
ab25123
fix(observability): harden telemetry edge cases
penso Jul 28, 2026
d7b645d
refactor(observability): drop the unused local price table
penso Jul 29, 2026
6461777
fix(observability): make the exporter features real build toggles
penso Jul 29, 2026
c38e1e2
docs(instrumentation): say the settings page is read-only, register f…
penso Jul 29, 2026
138bd90
fix(scripts): stop local validation from passing without running tests
penso Jul 29, 2026
4082f4f
fix(e2e): make the RPC retry actually retry, and wait for the socket
penso Jul 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ default-members = [
"crates/node-host",
"crates/nostr",
"crates/oauth",
"crates/observability",
"crates/onboarding",
"crates/openclaw-import",
"crates/plugins",
Expand Down Expand Up @@ -106,6 +107,7 @@ members = [
"crates/node-host",
"crates/nostr",
"crates/oauth",
"crates/observability",
"crates/onboarding",
"crates/openclaw-import",
"crates/plugins",
Expand Down Expand Up @@ -212,7 +214,7 @@ sqlx = { features = ["migrate", "runtime-tokio", "sqlite"], version = "0.8" }
# HTTP client
reqwest = { features = ["form", "json", "multipart", "query", "socks", "stream"], version = "0.13" }
# UUID
uuid = { features = ["v4"], version = "1" }
uuid = { features = ["v4", "v5"], version = "1" }
# Async
async-stream = "0.3"
async-trait = "0.1"
Expand Down Expand Up @@ -401,6 +403,7 @@ moltis-network-filter = { default-features = false, path = "crates/network-fil
moltis-node-host = { path = "crates/node-host" }
moltis-nostr = { path = "crates/nostr" }
moltis-oauth = { path = "crates/oauth" }
moltis-observability = { default-features = false, path = "crates/observability" }
moltis-onboarding = { path = "crates/onboarding" }
moltis-openclaw-import = { path = "crates/openclaw-import" }
moltis-plugins = { path = "crates/plugins" }
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ Verify releases with `gh attestation verify <artifact> -R moltis-org/moltis` or
- **Safer Agent Editing** — Automatic checkpoints before built-in skill and memory mutations, restore tooling, session branching
- **Extensibility** — MCP servers (stdio + HTTP/SSE), skill system, 15 lifecycle hook events with circuit breaker, destructive command guard
- **Security** — Encryption-at-rest vault (XChaCha20-Poly1305 + Argon2id), password + passkey + API key auth, sandbox isolation, SSRF/CSWSH protection
- **Operations** — Cron scheduling, OpenTelemetry tracing, Prometheus metrics, cloud deploy (Fly.io, DigitalOcean), Tailscale integration, managed SSH deploy keys, host-pinned remote targets, live tool inventory in Settings, and CLI/web remote-exec doctor flows
- **Operations** — Cron scheduling, agent instrumentation (Langfuse for LLM traces, OTLP for Grafana/Datadog), Prometheus metrics, cloud deploy (Fly.io, DigitalOcean), Tailscale integration, managed SSH deploy keys, host-pinned remote targets, live tool inventory in Settings, and CLI/web remote-exec doctor flows

## How It Works

Expand Down
3 changes: 2 additions & 1 deletion crates/agents/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ async-stream = { workspace = true }
async-trait = { workspace = true }
futures = { workspace = true }
include_dir = { workspace = true }
moltis-common = { workspace = true }
moltis-common = { workspace = true }
moltis-observability = { workspace = true }
moltis-config = { workspace = true }
moltis-sessions = { workspace = true }
moltis-skills = { workspace = true }
Expand Down
26 changes: 23 additions & 3 deletions crates/agents/src/model/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ pub use moltis_config::schema::{AgentToolControls, ReasoningEffort, ToolChoice};

mod types;
pub use types::{
CompletionResponse, MAX_CAPTURED_PROVIDER_RAW_EVENTS, ModelMetadata, TOOL_CALL_METADATA_KEYS,
ToolCall, ToolCallArgumentDiagnostic, ToolCallArgumentSource, Usage,
CompletionResponse, InputTokenAccounting, MAX_CAPTURED_PROVIDER_RAW_EVENTS, ModelMetadata,
TOOL_CALL_METADATA_KEYS, ToolCall, ToolCallArgumentDiagnostic, ToolCallArgumentSource, Usage,
push_capped_provider_raw_event,
};

Expand All @@ -20,7 +20,9 @@ pub use convert::{
};

mod stream;
pub use stream::{LlmProvider, StreamEvent};
pub use stream::{
LlmProvider, ProviderAttemptEvent, ProviderIdentity, StreamEvent, TrackedStreamEvent,
};

#[cfg(test)]
fn document_absolute_path_from_media_ref(media_ref: &str) -> String {
Expand Down Expand Up @@ -242,6 +244,24 @@ mod tests {
assert_eq!(total.cache_write_tokens, 44);
}

#[test]
fn usage_normalizes_inclusive_input_into_exclusive_buckets() {
let usage = Usage::from_input_tokens(InputTokenAccounting::Inclusive, 1_000, 50, 800, 20);

assert_eq!(usage.input_tokens, 180);
assert_eq!(usage.cache_read_tokens, 800);
assert_eq!(usage.cache_write_tokens, 20);
}

#[test]
fn usage_preserves_anthropic_exclusive_input() {
let usage = Usage::from_input_tokens(InputTokenAccounting::Exclusive, 180, 50, 800, 20);

assert_eq!(usage.input_tokens, 180);
assert_eq!(usage.cache_read_tokens, 800);
assert_eq!(usage.cache_write_tokens, 20);
}

// ── to_openai_value ──────────────────────────────────────────────

#[test]
Expand Down
72 changes: 72 additions & 0 deletions crates/agents/src/model/stream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,40 @@ use super::{
types::{CompletionResponse, ModelMetadata, Usage},
};

/// Concrete provider and model selected for one completion attempt.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderIdentity {
pub provider: String,
pub model: String,
}

impl ProviderIdentity {
#[must_use]
pub fn new(provider: impl Into<String>, model: impl Into<String>) -> Self {
Self {
provider: provider.into(),
model: model.into(),
}
}
}

/// Lifecycle events for attempts made by a provider wrapper.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProviderAttemptEvent {
Started(ProviderIdentity),
Failed {
identity: ProviderIdentity,
error: String,
},
}

/// Event returned by the runner-facing tracked streaming API.
#[derive(Debug, Clone)]
pub enum TrackedStreamEvent {
Attempt(ProviderAttemptEvent),
Event(StreamEvent),
}

// ── Stream events ───────────────────────────────────────────────────────────

/// Events emitted during streaming LLM completion.
Expand Down Expand Up @@ -72,6 +106,26 @@ pub trait LlmProvider: Send + Sync {
self.complete(messages, tools).await
}

/// Complete while reporting each concrete provider attempt.
async fn complete_with_options_tracked(
&self,
messages: &[ChatMessage],
tools: &[serde_json::Value],
options: &AgentToolControls,
on_attempt: &mut (dyn FnMut(ProviderAttemptEvent) + Send),
) -> anyhow::Result<CompletionResponse> {
let identity = ProviderIdentity::new(self.name(), self.id());
on_attempt(ProviderAttemptEvent::Started(identity.clone()));
let result = self.complete_with_options(messages, tools, options).await;
if let Err(error) = &result {
on_attempt(ProviderAttemptEvent::Failed {
identity,
error: error.to_string(),
});
}
result
}

/// Whether this provider supports tool/function calling.
/// Defaults to false; providers that handle the `tools` parameter
/// in `complete()` should override this to return true.
Expand Down Expand Up @@ -134,6 +188,24 @@ pub trait LlmProvider: Send + Sync {
self.stream_with_tools(messages, tools)
}

/// Stream while reporting the concrete provider/model selected for the attempt.
fn stream_with_tools_and_options_tracked(
&self,
messages: Vec<ChatMessage>,
tools: Vec<serde_json::Value>,
options: AgentToolControls,
) -> Pin<Box<dyn Stream<Item = TrackedStreamEvent> + Send + '_>> {
let attempt = TrackedStreamEvent::Attempt(ProviderAttemptEvent::Started(
ProviderIdentity::new(self.name(), self.id()),
));
Box::pin(
tokio_stream::once(attempt).chain(
self.stream_with_tools_and_options(messages, tools, options)
.map(TrackedStreamEvent::Event),
),
)
}

/// Configured reasoning effort for this provider instance, if any.
///
/// Providers that support extended thinking (Anthropic, OpenAI o-series)
Expand Down
35 changes: 35 additions & 0 deletions crates/agents/src/model/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,48 @@ pub const TOOL_CALL_METADATA_KEYS: &[&str] = &["thought_signature"];

#[derive(Debug, Clone, Default)]
pub struct Usage {
/// Fresh input tokens, excluding cache reads and cache writes.
pub input_tokens: u32,
pub output_tokens: u32,
pub cache_read_tokens: u32,
pub cache_write_tokens: u32,
}

/// Provider-reported input-token accounting semantics.
///
/// OpenAI-style APIs report cached tokens as part of their input total, while
/// Anthropic reports fresh input, cache reads, and cache writes separately.
/// Provider adapters must identify which contract they received so `Usage`
/// always exposes mutually exclusive buckets.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InputTokenAccounting {
Inclusive,
Exclusive,
}

impl Usage {
#[must_use]
pub const fn from_input_tokens(
accounting: InputTokenAccounting,
reported_input_tokens: u32,
output_tokens: u32,
cache_read_tokens: u32,
cache_write_tokens: u32,
) -> Self {
let input_tokens = match accounting {
InputTokenAccounting::Inclusive => reported_input_tokens
.saturating_sub(cache_read_tokens)
.saturating_sub(cache_write_tokens),
InputTokenAccounting::Exclusive => reported_input_tokens,
};
Self {
input_tokens,
output_tokens,
cache_read_tokens,
cache_write_tokens,
}
}

#[must_use]
pub fn saturating_add(&self, other: &Self) -> Self {
Self {
Expand Down
Loading