diff --git a/docs/telegram-login-desktop.md b/docs/telegram-login-desktop.md
index 1109f25e1..e8ff44907 100644
--- a/docs/telegram-login-desktop.md
+++ b/docs/telegram-login-desktop.md
@@ -91,13 +91,8 @@ const handleDeepLinkUrls = async (urls: string[] | null | undefined) => {
try {
const parsed = new URL(url);
-<<<<<<< HEAD
if (parsed.protocol !== 'openhuman:') return;
if (parsed.hostname !== 'auth') return;
-=======
- if (parsed.protocol !== "outsourced:") return;
- if (parsed.hostname !== "auth") return;
->>>>>>> fix/telegram-mcp
const token = parsed.searchParams.get("token");
if (!token) return;
diff --git a/src/openhuman/agent/agent.rs b/src/openhuman/agent/agent.rs
index b850bf207..c3fc1276d 100644
--- a/src/openhuman/agent/agent.rs
+++ b/src/openhuman/agent/agent.rs
@@ -405,10 +405,10 @@ impl Agent {
.tools(tools)
.memory(memory)
.tool_dispatcher(tool_dispatcher)
- .memory_loader(Box::new(DefaultMemoryLoader::new(
- 5,
- config.memory.min_relevance_score,
- )))
+ .memory_loader(Box::new(
+ DefaultMemoryLoader::new(5, config.memory.min_relevance_score)
+ .with_max_chars(config.agent.max_memory_context_chars),
+ ))
.prompt_builder(prompt_builder)
.config(config.agent.clone())
.model_name(model_name)
@@ -709,6 +709,7 @@ impl Agent {
} else {
None
},
+ system_prompt_cache_boundary: None,
},
&effective_model,
self.temperature,
@@ -951,6 +952,7 @@ mod tests {
return Ok(crate::openhuman::providers::ChatResponse {
text: Some("done".into()),
tool_calls: vec![],
+ usage: None,
});
}
Ok(guard.remove(0))
@@ -994,6 +996,7 @@ mod tests {
responses: Mutex::new(vec![crate::openhuman::providers::ChatResponse {
text: Some("hello".into()),
tool_calls: vec![],
+ usage: None,
}]),
});
@@ -1032,10 +1035,12 @@ mod tests {
name: "echo".into(),
arguments: "{}".into(),
}],
+ usage: None,
},
crate::openhuman::providers::ChatResponse {
text: Some("done".into()),
tool_calls: vec![],
+ usage: None,
},
]),
});
@@ -1078,10 +1083,12 @@ mod tests {
.into(),
),
tool_calls: vec![],
+ usage: None,
},
crate::openhuman::providers::ChatResponse {
text: Some("done".into()),
tool_calls: vec![],
+ usage: None,
},
]),
});
diff --git a/src/openhuman/agent/cost.rs b/src/openhuman/agent/cost.rs
new file mode 100644
index 000000000..42d6da1d2
--- /dev/null
+++ b/src/openhuman/agent/cost.rs
@@ -0,0 +1,192 @@
+//! Token cost tracking for agent loop budget enforcement.
+//!
+//! Tracks cumulative token usage across inference calls and enforces
+//! the `max_cost_per_day_cents` budget from the security policy.
+
+use crate::openhuman::providers::UsageInfo;
+use std::sync::atomic::{AtomicU64, Ordering};
+
+/// Per-token pricing in microdollars (millionths of a dollar).
+/// Default pricing is conservative; callers should provide model-specific rates.
+#[derive(Debug, Clone)]
+pub struct TokenPricing {
+ /// Cost per input token in microdollars.
+ pub input_token_microdollars: u64,
+ /// Cost per output token in microdollars.
+ pub output_token_microdollars: u64,
+}
+
+impl Default for TokenPricing {
+ fn default() -> Self {
+ // Conservative defaults (~$3/1M input, ~$15/1M output — Sonnet-class)
+ Self {
+ input_token_microdollars: 3,
+ output_token_microdollars: 15,
+ }
+ }
+}
+
+/// Thread-safe cumulative token and cost tracker.
+#[derive(Debug)]
+pub struct CostTracker {
+ total_input_tokens: AtomicU64,
+ total_output_tokens: AtomicU64,
+ total_cost_microdollars: AtomicU64,
+ pricing: TokenPricing,
+ /// Budget in microdollars (0 = unlimited).
+ budget_microdollars: u64,
+}
+
+impl CostTracker {
+ /// Create a new tracker with the given pricing and budget (in cents).
+ pub fn new(pricing: TokenPricing, budget_cents: u32) -> Self {
+ Self {
+ total_input_tokens: AtomicU64::new(0),
+ total_output_tokens: AtomicU64::new(0),
+ total_cost_microdollars: AtomicU64::new(0),
+ pricing,
+ budget_microdollars: budget_cents as u64 * 10_000, // cents → microdollars
+ }
+ }
+
+ /// Create a tracker with default pricing and a budget in cents.
+ pub fn with_budget_cents(budget_cents: u32) -> Self {
+ Self::new(TokenPricing::default(), budget_cents)
+ }
+
+ /// Record usage from a provider response.
+ pub fn record_usage(&self, usage: &UsageInfo) {
+ self.total_input_tokens
+ .fetch_add(usage.input_tokens, Ordering::Relaxed);
+ self.total_output_tokens
+ .fetch_add(usage.output_tokens, Ordering::Relaxed);
+
+ let cost = usage.input_tokens * self.pricing.input_token_microdollars
+ + usage.output_tokens * self.pricing.output_token_microdollars;
+ self.total_cost_microdollars
+ .fetch_add(cost, Ordering::Relaxed);
+
+ tracing::debug!(
+ input_tokens = usage.input_tokens,
+ output_tokens = usage.output_tokens,
+ cost_microdollars = cost,
+ total_cost_microdollars = self.total_cost_microdollars.load(Ordering::Relaxed),
+ "[cost_tracker] recorded usage"
+ );
+ }
+
+ /// Check whether the budget has been exceeded.
+ /// Returns `Ok(())` if within budget, or `Err` with spent/budget amounts.
+ pub fn check_budget(&self) -> Result<(), (u64, u64)> {
+ if self.budget_microdollars == 0 {
+ return Ok(()); // Unlimited
+ }
+ let spent = self.total_cost_microdollars.load(Ordering::Relaxed);
+ if spent > self.budget_microdollars {
+ Err((spent, self.budget_microdollars))
+ } else {
+ Ok(())
+ }
+ }
+
+ /// Get the total input tokens recorded.
+ pub fn total_input_tokens(&self) -> u64 {
+ self.total_input_tokens.load(Ordering::Relaxed)
+ }
+
+ /// Get the total output tokens recorded.
+ pub fn total_output_tokens(&self) -> u64 {
+ self.total_output_tokens.load(Ordering::Relaxed)
+ }
+
+ /// Get the total cost in microdollars.
+ pub fn total_cost_microdollars(&self) -> u64 {
+ self.total_cost_microdollars.load(Ordering::Relaxed)
+ }
+
+ /// Human-readable cost summary.
+ pub fn summary(&self) -> String {
+ let input = self.total_input_tokens.load(Ordering::Relaxed);
+ let output = self.total_output_tokens.load(Ordering::Relaxed);
+ let cost = self.total_cost_microdollars.load(Ordering::Relaxed);
+ let dollars = cost as f64 / 1_000_000.0;
+ format!("Tokens: {input} in / {output} out | Cost: ${dollars:.4}")
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn tracks_cumulative_usage() {
+ let tracker = CostTracker::with_budget_cents(1000);
+ tracker.record_usage(&UsageInfo {
+ input_tokens: 1000,
+ output_tokens: 500,
+ context_window: 0,
+ });
+ tracker.record_usage(&UsageInfo {
+ input_tokens: 2000,
+ output_tokens: 1000,
+ context_window: 0,
+ });
+
+ assert_eq!(tracker.total_input_tokens(), 3000);
+ assert_eq!(tracker.total_output_tokens(), 1500);
+ assert!(tracker.total_cost_microdollars() > 0);
+ }
+
+ #[test]
+ fn budget_enforcement() {
+ // Budget: 1 cent = 10,000 microdollars
+ let tracker = CostTracker::new(
+ TokenPricing {
+ input_token_microdollars: 100,
+ output_token_microdollars: 100,
+ },
+ 1, // 1 cent
+ );
+
+ // 50 input + 50 output = 100 tokens × 100 = 10,000 microdollars = 1 cent (at limit)
+ tracker.record_usage(&UsageInfo {
+ input_tokens: 50,
+ output_tokens: 50,
+ context_window: 0,
+ });
+ assert!(tracker.check_budget().is_ok());
+
+ // One more token pushes over budget
+ tracker.record_usage(&UsageInfo {
+ input_tokens: 1,
+ output_tokens: 0,
+ context_window: 0,
+ });
+ assert!(tracker.check_budget().is_err());
+ }
+
+ #[test]
+ fn unlimited_budget() {
+ let tracker = CostTracker::with_budget_cents(0);
+ tracker.record_usage(&UsageInfo {
+ input_tokens: 1_000_000,
+ output_tokens: 1_000_000,
+ context_window: 0,
+ });
+ assert!(tracker.check_budget().is_ok());
+ }
+
+ #[test]
+ fn summary_format() {
+ let tracker = CostTracker::with_budget_cents(100);
+ tracker.record_usage(&UsageInfo {
+ input_tokens: 1000,
+ output_tokens: 500,
+ context_window: 0,
+ });
+ let summary = tracker.summary();
+ assert!(summary.contains("1000 in"));
+ assert!(summary.contains("500 out"));
+ assert!(summary.contains("$"));
+ }
+}
diff --git a/src/openhuman/agent/dispatcher.rs b/src/openhuman/agent/dispatcher.rs
index 66db64590..5582a3546 100644
--- a/src/openhuman/agent/dispatcher.rs
+++ b/src/openhuman/agent/dispatcher.rs
@@ -255,6 +255,7 @@ mod tests {
.into(),
),
tool_calls: vec![],
+ usage: None,
};
let dispatcher = XmlToolDispatcher;
let (_, calls) = dispatcher.parse_response(&response);
@@ -271,6 +272,7 @@ mod tests {
name: "file_read".into(),
arguments: "{\"path\":\"a.txt\"}".into(),
}],
+ usage: None,
};
let dispatcher = NativeToolDispatcher;
let (_, calls) = dispatcher.parse_response(&response);
@@ -300,6 +302,7 @@ mod tests {
.into(),
),
tool_calls: vec![],
+ usage: None,
};
let dispatcher = NativeToolDispatcher;
let (text, calls) = dispatcher.parse_response(&response);
@@ -316,6 +319,7 @@ mod tests {
"Let me run this.\n{\"name\":\"shell\",\"arguments\":{\"command\":\"pwd\"}}".into(),
),
tool_calls: vec![],
+ usage: None,
};
let dispatcher = NativeToolDispatcher;
let (text, calls) = dispatcher.parse_response(&response);
diff --git a/src/openhuman/agent/error.rs b/src/openhuman/agent/error.rs
new file mode 100644
index 000000000..47e81222a
--- /dev/null
+++ b/src/openhuman/agent/error.rs
@@ -0,0 +1,158 @@
+//! Structured error types for the agent loop.
+//!
+//! Replaces generic `anyhow::bail!` with typed variants so callers can
+//! distinguish retryable errors from permanent failures and take appropriate
+//! recovery actions (e.g. triggering compaction on context-limit errors).
+
+use std::fmt;
+
+/// Structured error type for agent loop operations.
+#[derive(Debug)]
+pub enum AgentError {
+ /// The LLM provider returned an error.
+ ProviderError { message: String, retryable: bool },
+ /// Context window is exhausted and compaction cannot help.
+ ContextLimitExceeded { utilization_pct: u8 },
+ /// A tool execution failed.
+ ToolExecutionError { tool_name: String, message: String },
+ /// The daily cost budget has been exceeded.
+ CostBudgetExceeded {
+ spent_microdollars: u64,
+ budget_microdollars: u64,
+ },
+ /// The agent exceeded its maximum tool iterations.
+ MaxIterationsExceeded { max: usize },
+ /// History compaction failed.
+ CompactionFailed {
+ message: String,
+ consecutive_failures: u8,
+ },
+ /// Channel permission denied for a tool operation.
+ PermissionDenied {
+ tool_name: String,
+ required_level: String,
+ channel_max_level: String,
+ },
+ /// Generic/untyped error (escape hatch for migration).
+ Other(anyhow::Error),
+}
+
+impl fmt::Display for AgentError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::ProviderError { message, retryable } => {
+ write!(f, "Provider error (retryable={retryable}): {message}")
+ }
+ Self::ContextLimitExceeded { utilization_pct } => {
+ write!(
+ f,
+ "Context window exhausted ({utilization_pct}% utilized, compaction disabled)"
+ )
+ }
+ Self::ToolExecutionError { tool_name, message } => {
+ write!(f, "Tool execution error [{tool_name}]: {message}")
+ }
+ Self::CostBudgetExceeded {
+ spent_microdollars,
+ budget_microdollars,
+ } => {
+ let spent = *spent_microdollars as f64 / 1_000_000.0;
+ let budget = *budget_microdollars as f64 / 1_000_000.0;
+ write!(
+ f,
+ "Daily cost budget exceeded: spent ${spent:.4}, budget ${budget:.4}"
+ )
+ }
+ Self::MaxIterationsExceeded { max } => {
+ write!(f, "Agent exceeded maximum tool iterations ({max})")
+ }
+ Self::CompactionFailed {
+ message,
+ consecutive_failures,
+ } => {
+ write!(
+ f,
+ "Compaction failed ({consecutive_failures} consecutive): {message}"
+ )
+ }
+ Self::PermissionDenied {
+ tool_name,
+ required_level,
+ channel_max_level,
+ } => {
+ write!(
+ f,
+ "Permission denied for tool '{tool_name}': requires {required_level}, channel allows {channel_max_level}"
+ )
+ }
+ Self::Other(e) => write!(f, "{e}"),
+ }
+ }
+}
+
+impl std::error::Error for AgentError {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ match self {
+ Self::Other(e) => Some(e.as_ref()),
+ _ => None,
+ }
+ }
+}
+
+impl From for AgentError {
+ fn from(e: anyhow::Error) -> Self {
+ // Attempt to recover a typed AgentError that was wrapped in anyhow.
+ match e.downcast::() {
+ Ok(agent_err) => agent_err,
+ Err(other) => Self::Other(other),
+ }
+ }
+}
+
+/// Check if an error message indicates a context/prompt-too-long failure.
+pub fn is_context_limit_error(error_msg: &str) -> bool {
+ let lower = error_msg.to_lowercase();
+ lower.contains("prompt is too long")
+ || lower.contains("context_length_exceeded")
+ || lower.contains("maximum context length")
+ || lower.contains("prompt too long")
+ || lower.contains("token limit")
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn display_formatting() {
+ let err = AgentError::MaxIterationsExceeded { max: 10 };
+ assert_eq!(
+ err.to_string(),
+ "Agent exceeded maximum tool iterations (10)"
+ );
+
+ let err = AgentError::CostBudgetExceeded {
+ spent_microdollars: 5_500_000,
+ budget_microdollars: 5_000_000,
+ };
+ assert!(err.to_string().contains("5.5000"));
+ }
+
+ #[test]
+ fn context_limit_detection() {
+ assert!(is_context_limit_error("prompt is too long for model"));
+ assert!(is_context_limit_error("context_length_exceeded"));
+ assert!(!is_context_limit_error("rate limit exceeded"));
+ }
+
+ #[test]
+ fn permission_denied_display() {
+ let err = AgentError::PermissionDenied {
+ tool_name: "shell".into(),
+ required_level: "Execute".into(),
+ channel_max_level: "ReadOnly".into(),
+ };
+ assert!(err.to_string().contains("shell"));
+ assert!(err.to_string().contains("Execute"));
+ }
+}
diff --git a/src/openhuman/agent/events.rs b/src/openhuman/agent/events.rs
new file mode 100644
index 000000000..86a8c8231
--- /dev/null
+++ b/src/openhuman/agent/events.rs
@@ -0,0 +1,216 @@
+//! Typed event system for agent loop observability.
+//!
+//! Replaces the basic `ToolEventObserver` with a comprehensive `AgentEvent`
+//! enum broadcast via `tokio::sync::broadcast`. Multiple consumers (Socket.IO
+//! relay, logging, cost tracking) can subscribe to the same event stream.
+
+use crate::openhuman::providers::UsageInfo;
+
+/// Events emitted during agent loop execution.
+///
+/// Subscribers receive these via `tokio::sync::broadcast::Receiver`.
+#[derive(Debug, Clone)]
+pub enum AgentEvent {
+ /// An LLM inference call is about to be made.
+ InferenceStart {
+ iteration: usize,
+ message_count: usize,
+ },
+
+ /// An LLM inference call completed.
+ InferenceComplete {
+ iteration: usize,
+ has_tool_calls: bool,
+ usage: Option,
+ },
+
+ /// Tool calls were parsed from the LLM response.
+ ToolCallsParsed {
+ tool_names: Vec,
+ /// Full arguments per tool call (parallel with tool_names).
+ tool_arguments: Vec,
+ /// Optional tool_call_id per call (parallel with tool_names).
+ tool_call_ids: Vec