fix: add channel message timeouts, Telegram fallback, and fix identity/observer tests

Closes #184
This commit is contained in:
Argenis 2026-02-15 12:31:40 -05:00 committed by GitHub
parent be6474b815
commit dca95cac7a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 89 additions and 30 deletions

View file

@ -24,15 +24,17 @@ use crate::config::Config;
use crate::memory::{self, Memory};
use crate::providers::{self, Provider};
use crate::util::truncate_with_ellipsis;
use crate::identity;
use anyhow::Result;
use std::sync::Arc;
use std::time::Duration;
use std::time::{Duration, Instant};
/// Maximum characters per injected workspace file (matches `OpenClaw` default).
const BOOTSTRAP_MAX_CHARS: usize = 20_000;
const DEFAULT_CHANNEL_INITIAL_BACKOFF_SECS: u64 = 2;
const DEFAULT_CHANNEL_MAX_BACKOFF_SECS: u64 = 60;
const CHANNEL_MESSAGE_TIMEOUT_SECS: u64 = 90;
fn spawn_supervised_listener(
ch: Arc<dyn Channel>,
@ -187,11 +189,11 @@ pub fn build_system_prompt(
// Check if AIEOS identity is configured
if let Some(config) = identity_config {
if crate::identity::is_aieos_configured(config) {
if identity::is_aieos_configured(config) {
// Load AIEOS identity
match crate::identity::load_aieos_identity(config, workspace_dir) {
match identity::load_aieos_identity(config, workspace_dir) {
Ok(Some(aieos_identity)) => {
let aieos_prompt = crate::identity::aieos_to_system_prompt(&aieos_identity);
let aieos_prompt = identity::aieos_to_system_prompt(&aieos_identity);
if !aieos_prompt.is_empty() {
prompt.push_str(&aieos_prompt);
prompt.push_str("\n\n");
@ -684,13 +686,20 @@ pub async fn start_channels(config: Config) -> Result<()> {
}
// Call the LLM with system prompt (identity + soul + tools)
match provider
.chat_with_system(Some(&system_prompt), &msg.content, &model, temperature)
.await
{
Ok(response) => {
println!(" ⏳ Processing message...");
let started_at = Instant::now();
let llm_result = tokio::time::timeout(
Duration::from_secs(CHANNEL_MESSAGE_TIMEOUT_SECS),
provider.chat_with_system(Some(&system_prompt), &msg.content, &model, temperature),
)
.await;
match llm_result {
Ok(Ok(response)) => {
println!(
" 🤖 Reply: {}",
" 🤖 Reply ({}ms): {}",
started_at.elapsed().as_millis(),
truncate_with_ellipsis(&response, 80)
);
// Find the channel that sent this message and reply
@ -703,8 +712,11 @@ pub async fn start_channels(config: Config) -> Result<()> {
}
}
}
Err(e) => {
eprintln!(" ❌ LLM error: {e}");
Ok(Err(e)) => {
eprintln!(
" ❌ LLM error after {}ms: {e}",
started_at.elapsed().as_millis()
);
for ch in &channels {
if ch.name() == msg.channel {
let _ = ch.send(&format!("⚠️ Error: {e}"), &msg.sender).await;
@ -712,6 +724,28 @@ pub async fn start_channels(config: Config) -> Result<()> {
}
}
}
Err(_) => {
let timeout_msg = format!(
"LLM response timed out after {}s",
CHANNEL_MESSAGE_TIMEOUT_SECS
);
eprintln!(
" ❌ {} (elapsed: {}ms)",
timeout_msg,
started_at.elapsed().as_millis()
);
for ch in &channels {
if ch.name() == msg.channel {
let _ = ch
.send(
"⚠️ Request timed out while waiting for the model. Please try again.",
&msg.sender,
)
.await;
break;
}
}
}
}
}
@ -1045,9 +1079,9 @@ mod tests {
let ws = make_workspace();
let prompt = build_system_prompt(ws.path(), "model", &[], &[], Some(&config));
// Should fall back to OpenClaw format
// Should fall back to OpenClaw format when AIEOS file is not found
// (Error is logged to stderr with filename, not included in prompt)
assert!(prompt.contains("### SOUL.md"));
assert!(prompt.contains("[File not found: nonexistent.json]"));
}
#[test]

View file

@ -370,26 +370,52 @@ impl Channel for TelegramChannel {
}
async fn send(&self, message: &str, chat_id: &str) -> anyhow::Result<()> {
let body = serde_json::json!({
let markdown_body = serde_json::json!({
"chat_id": chat_id,
"text": message,
"parse_mode": "Markdown"
});
let resp = self
let markdown_resp = self
.client
.post(self.api_url("sendMessage"))
.json(&body)
.json(&markdown_body)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let err = resp
.text()
.await
.unwrap_or_else(|e| format!("<failed to read response body: {e}>"));
anyhow::bail!("Telegram sendMessage failed ({status}): {err}");
if markdown_resp.status().is_success() {
return Ok(());
}
let markdown_status = markdown_resp.status();
let markdown_err = markdown_resp.text().await.unwrap_or_default();
tracing::warn!(
status = ?markdown_status,
"Telegram sendMessage with Markdown failed; retrying without parse_mode"
);
// Retry without parse_mode as a compatibility fallback.
let plain_body = serde_json::json!({
"chat_id": chat_id,
"text": message,
});
let plain_resp = self
.client
.post(self.api_url("sendMessage"))
.json(&plain_body)
.send()
.await?;
if !plain_resp.status().is_success() {
let plain_status = plain_resp.status();
let plain_err = plain_resp.text().await.unwrap_or_default();
anyhow::bail!(
"Telegram sendMessage failed (markdown {}: {}; plain {}: {})",
markdown_status,
markdown_err,
plain_status,
plain_err
);
}
Ok(())

View file

@ -13,7 +13,7 @@ use std::path::Path;
///
/// This follows the AIEOS schema for defining AI agent identity, personality,
/// and behavior. See https://aieos.org for the full specification.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AieosIdentity {
/// Core identity: names, bio, origin, residence
#[serde(default)]
@ -580,6 +580,7 @@ mod tests {
first: Some("Nova".into()),
last: Some("AI".into()),
nickname: Some("Nov".into()),
full: Some("Nova AI".into()),
}),
bio: Some("A helpful assistant.".into()),
origin: Some("Silicon Valley".into()),

View file

@ -22,6 +22,7 @@ mod doctor;
mod gateway;
mod health;
mod heartbeat;
mod identity;
mod integrations;
mod memory;
mod migration;

View file

@ -37,7 +37,7 @@ pub enum ObserverMetric {
}
/// Core observability trait — implement for any backend
pub trait Observer: Send + Sync {
pub trait Observer: Send + Sync + 'static {
/// Record a discrete event
fn record_event(&self, event: &ObserverEvent);
@ -52,9 +52,6 @@ pub trait Observer: Send + Sync {
/// Downcast to `Any` for backend-specific operations
fn as_any(&self) -> &dyn std::any::Any where Self: Sized {
// Default implementation returns a placeholder that will fail on downcast.
// Implementors should override this to return `self`.
struct Placeholder;
std::any::TypeId::of::<Placeholder>()
self
}
}