From ccbaf90a2415394c16d67488445883e8b891762c Mon Sep 17 00:00:00 2001 From: pbranchu Date: Sat, 21 Mar 2026 12:45:47 +0000 Subject: [PATCH] Expose heartbeat default_timeout_secs in config.toml Add a [heartbeat] section to KernelConfig so users can tune the inactivity timeout that determines when agents are marked unresponsive. Reactive agents (hands) that sit idle between infrequent requests were getting marked as crashed after the hardcoded 180s default, causing the first request after idle to fail. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/openfang-kernel/src/heartbeat.rs | 11 +++++ crates/openfang-kernel/src/kernel.rs | 5 ++- crates/openfang-types/src/config.rs | 58 +++++++++++++++++++++++++ docs/configuration.md | 16 +++++++ 4 files changed, 89 insertions(+), 1 deletion(-) diff --git a/crates/openfang-kernel/src/heartbeat.rs b/crates/openfang-kernel/src/heartbeat.rs index f682157f..ee97eeae 100644 --- a/crates/openfang-kernel/src/heartbeat.rs +++ b/crates/openfang-kernel/src/heartbeat.rs @@ -332,6 +332,17 @@ mod tests { assert_eq!(summary.unresponsive_agents[0].name, "agent-2"); } + #[test] + fn test_heartbeat_config_custom_timeout() { + let config = HeartbeatConfig { + default_timeout_secs: 600, + ..HeartbeatConfig::default() + }; + assert_eq!(config.default_timeout_secs, 600); + assert_eq!(config.check_interval_secs, DEFAULT_CHECK_INTERVAL_SECS); + assert_eq!(config.max_recovery_attempts, DEFAULT_MAX_RECOVERY_ATTEMPTS); + } + #[test] fn test_recovery_tracker() { let tracker = RecoveryTracker::new(); diff --git a/crates/openfang-kernel/src/kernel.rs b/crates/openfang-kernel/src/kernel.rs index 816132fb..3d7fff89 100644 --- a/crates/openfang-kernel/src/kernel.rs +++ b/crates/openfang-kernel/src/kernel.rs @@ -4310,7 +4310,10 @@ impl OpenFangKernel { use crate::heartbeat::{check_agents, is_quiet_hours, HeartbeatConfig, RecoveryTracker}; let kernel = Arc::clone(self); - let config = HeartbeatConfig::default(); + let config = HeartbeatConfig { + default_timeout_secs: self.config.heartbeat.default_timeout_secs, + ..HeartbeatConfig::default() + }; let interval_secs = config.check_interval_secs; let recovery_tracker = RecoveryTracker::new(); diff --git a/crates/openfang-types/src/config.rs b/crates/openfang-types/src/config.rs index 71748eb0..e84ced94 100644 --- a/crates/openfang-types/src/config.rs +++ b/crates/openfang-types/src/config.rs @@ -1099,6 +1099,30 @@ pub struct KernelConfig { /// Defaults to `~/.openfang/workflows`. Set to empty string to disable. #[serde(default)] pub workflows_dir: Option, + /// Heartbeat monitor settings. + #[serde(default)] + pub heartbeat: HeartbeatSettings, +} + +/// Heartbeat monitor settings exposed in `[heartbeat]` config section. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HeartbeatSettings { + /// Seconds of inactivity before a reactive agent is marked as unresponsive. + /// Default: 180. Set higher to prevent idle hands from being marked as crashed. + #[serde(default = "default_heartbeat_timeout")] + pub default_timeout_secs: u64, +} + +fn default_heartbeat_timeout() -> u64 { + 180 +} + +impl Default for HeartbeatSettings { + fn default() -> Self { + Self { + default_timeout_secs: default_heartbeat_timeout(), + } + } } /// Dashboard authentication (username/password login). @@ -1308,6 +1332,7 @@ impl Default for KernelConfig { oauth: OAuthConfig::default(), auth: AuthConfig::default(), workflows_dir: None, + heartbeat: HeartbeatSettings::default(), } } } @@ -4005,4 +4030,37 @@ mod tests { let config: SlackConfig = toml::from_str("unfurl_links = true").unwrap(); assert!(config.unfurl_links); } + + #[test] + fn test_heartbeat_settings_default() { + let settings = HeartbeatSettings::default(); + assert_eq!(settings.default_timeout_secs, 180); + } + + #[test] + fn test_heartbeat_settings_deserialization() { + let toml_str = r#"default_timeout_secs = 600"#; + let settings: HeartbeatSettings = toml::from_str(toml_str).unwrap(); + assert_eq!(settings.default_timeout_secs, 600); + } + + #[test] + fn test_heartbeat_settings_omitted_uses_default() { + // When [heartbeat] section is omitted entirely, KernelConfig should use defaults + let toml_str = r#" + log_level = "debug" + "#; + let config: KernelConfig = toml::from_str(toml_str).unwrap(); + assert_eq!(config.heartbeat.default_timeout_secs, 180); + } + + #[test] + fn test_heartbeat_settings_in_kernel_config() { + let toml_str = r#" + [heartbeat] + default_timeout_secs = 300 + "#; + let config: KernelConfig = toml::from_str(toml_str).unwrap(); + assert_eq!(config.heartbeat.default_timeout_secs, 300); + } } diff --git a/docs/configuration.md b/docs/configuration.md index 5e1195ad..2fb27cf8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1467,6 +1467,22 @@ Configured in agent manifests via `ModelRoutingConfig`: | `simple_threshold` | `100` | Token count below which a query is classified as simple. | | `complex_threshold` | `500` | Token count above which a query is classified as complex. | +### Heartbeat Monitor + +Global heartbeat settings in `[heartbeat]`: + +```toml +[heartbeat] +# Seconds of inactivity before a reactive agent is marked as unresponsive. +# Increase this if you have hands that sit idle between infrequent requests. +# Default: 180 +default_timeout_secs = 300 +``` + +| Field | Default | Description | +|-------|---------|-------------| +| `default_timeout_secs` | `180` | Seconds of inactivity before marking an agent as unresponsive. Per-agent `heartbeat_interval_secs` in autonomous config overrides this. | + ### Autonomous Guardrails (per-agent manifest) Configured in agent manifests via `AutonomousConfig`: