diff --git a/src/agent/loop_.rs b/src/agent/loop_.rs index b2009a8a2..4ae8c71cf 100644 --- a/src/agent/loop_.rs +++ b/src/agent/loop_.rs @@ -289,6 +289,20 @@ pub(crate) struct NonCliApprovalContext { tokio::task_local! { static TOOL_LOOP_NON_CLI_APPROVAL_CONTEXT: Option; static LOOP_DETECTION_CONFIG: LoopDetectionConfig; + static SAFETY_HEARTBEAT_CONFIG: Option; +} + +/// Configuration for periodic safety-constraint re-injection (heartbeat). +#[derive(Clone)] +pub(crate) struct SafetyHeartbeatConfig { + /// Pre-rendered security policy summary text. + pub body: String, + /// Inject a heartbeat every `interval` tool iterations (0 = disabled). + pub interval: usize, +} + +fn should_inject_safety_heartbeat(counter: usize, interval: usize) -> bool { + interval > 0 && counter > 0 && counter % interval == 0 } /// Extract a short hint from tool call arguments for progress display. @@ -686,33 +700,37 @@ pub(crate) async fn run_tool_call_loop_with_non_cli_approval_context( on_delta: Option>, hooks: Option<&crate::hooks::HookRunner>, excluded_tools: &[String], + safety_heartbeat: Option, ) -> Result { let reply_target = non_cli_approval_context .as_ref() .map(|ctx| ctx.reply_target.clone()); - TOOL_LOOP_NON_CLI_APPROVAL_CONTEXT + SAFETY_HEARTBEAT_CONFIG .scope( - non_cli_approval_context, - TOOL_LOOP_REPLY_TARGET.scope( - reply_target, - run_tool_call_loop( - provider, - history, - tools_registry, - observer, - provider_name, - model, - temperature, - silent, - approval, - channel_name, - multimodal_config, - max_tool_iterations, - cancellation_token, - on_delta, - hooks, - excluded_tools, + safety_heartbeat, + TOOL_LOOP_NON_CLI_APPROVAL_CONTEXT.scope( + non_cli_approval_context, + TOOL_LOOP_REPLY_TARGET.scope( + reply_target, + run_tool_call_loop( + provider, + history, + tools_registry, + observer, + provider_name, + model, + temperature, + silent, + approval, + channel_name, + multimodal_config, + max_tool_iterations, + cancellation_token, + on_delta, + hooks, + excluded_tools, + ), ), ), ) @@ -787,6 +805,10 @@ pub(crate) async fn run_tool_call_loop( .unwrap_or_default(); let mut loop_detector = LoopDetector::new(ld_config); let mut loop_detection_prompt: Option = None; + let heartbeat_config = SAFETY_HEARTBEAT_CONFIG + .try_with(Clone::clone) + .ok() + .flatten(); let bypass_non_cli_approval_for_turn = approval.is_some_and(|mgr| channel_name != "cli" && mgr.consume_non_cli_allow_all_once()); if bypass_non_cli_approval_for_turn { @@ -834,6 +856,19 @@ pub(crate) async fn run_tool_call_loop( request_messages.push(ChatMessage::user(prompt)); } + // ── Safety heartbeat: periodic security-constraint re-injection ── + if let Some(ref hb) = heartbeat_config { + if should_inject_safety_heartbeat(iteration, hb.interval) { + let reminder = format!( + "[Safety Heartbeat — round {}/{}]\n{}", + iteration + 1, + max_iterations, + hb.body + ); + request_messages.push(ChatMessage::user(reminder)); + } + } + // ── Progress: LLM thinking ──────────────────────────── if let Some(ref tx) = on_delta { let phase = if iteration == 0 { @@ -2027,26 +2062,37 @@ pub async fn run( ping_pong_cycles: config.agent.loop_detection_ping_pong_cycles, failure_streak_threshold: config.agent.loop_detection_failure_streak, }; - let response = LOOP_DETECTION_CONFIG + let hb_cfg = if config.agent.safety_heartbeat_interval > 0 { + Some(SafetyHeartbeatConfig { + body: security.summary_for_heartbeat(), + interval: config.agent.safety_heartbeat_interval, + }) + } else { + None + }; + let response = SAFETY_HEARTBEAT_CONFIG .scope( - ld_cfg, - run_tool_call_loop( - provider.as_ref(), - &mut history, - &tools_registry, - observer.as_ref(), - provider_name, - model_name, - temperature, - false, - approval_manager.as_ref(), - channel_name, - &config.multimodal, - config.agent.max_tool_iterations, - None, - None, - None, - &[], + hb_cfg, + LOOP_DETECTION_CONFIG.scope( + ld_cfg, + run_tool_call_loop( + provider.as_ref(), + &mut history, + &tools_registry, + observer.as_ref(), + provider_name, + model_name, + temperature, + false, + approval_manager.as_ref(), + channel_name, + &config.multimodal, + config.agent.max_tool_iterations, + None, + None, + None, + &[], + ), ), ) .await?; @@ -2060,6 +2106,7 @@ pub async fn run( // Persistent conversation history across turns let mut history = vec![ChatMessage::system(&system_prompt)]; + let mut interactive_turn: usize = 0; // Reusable readline editor for UTF-8 input support let mut rl = Editor::with_config( RlConfig::builder() @@ -2110,6 +2157,7 @@ pub async fn run( rl.clear_history()?; history.clear(); history.push(ChatMessage::system(&system_prompt)); + interactive_turn = 0; // Clear conversation and daily memory let mut cleared = 0; for category in [MemoryCategory::Conversation, MemoryCategory::Daily] { @@ -2155,32 +2203,57 @@ pub async fn run( }; history.push(ChatMessage::user(&enriched)); + interactive_turn += 1; + + // Inject interactive safety heartbeat at configured turn intervals + if should_inject_safety_heartbeat( + interactive_turn, + config.agent.safety_heartbeat_turn_interval, + ) { + let reminder = format!( + "[Safety Heartbeat — turn {}]\n{}", + interactive_turn, + security.summary_for_heartbeat() + ); + history.push(ChatMessage::user(reminder)); + } let ld_cfg = LoopDetectionConfig { no_progress_threshold: config.agent.loop_detection_no_progress_threshold, ping_pong_cycles: config.agent.loop_detection_ping_pong_cycles, failure_streak_threshold: config.agent.loop_detection_failure_streak, }; - let response = match LOOP_DETECTION_CONFIG + let hb_cfg = if config.agent.safety_heartbeat_interval > 0 { + Some(SafetyHeartbeatConfig { + body: security.summary_for_heartbeat(), + interval: config.agent.safety_heartbeat_interval, + }) + } else { + None + }; + let response = match SAFETY_HEARTBEAT_CONFIG .scope( - ld_cfg, - run_tool_call_loop( - provider.as_ref(), - &mut history, - &tools_registry, - observer.as_ref(), - provider_name, - model_name, - temperature, - false, - approval_manager.as_ref(), - channel_name, - &config.multimodal, - config.agent.max_tool_iterations, - None, - None, - None, - &[], + hb_cfg, + LOOP_DETECTION_CONFIG.scope( + ld_cfg, + run_tool_call_loop( + provider.as_ref(), + &mut history, + &tools_registry, + observer.as_ref(), + provider_name, + model_name, + temperature, + false, + approval_manager.as_ref(), + channel_name, + &config.multimodal, + config.agent.max_tool_iterations, + None, + None, + None, + &[], + ), ), ) .await @@ -2436,19 +2509,31 @@ pub async fn process_message(config: Config, message: &str) -> Result { ChatMessage::user(&enriched), ]; - agent_turn( - provider.as_ref(), - &mut history, - &tools_registry, - observer.as_ref(), - provider_name, - &model_name, - config.default_temperature, - true, - &config.multimodal, - config.agent.max_tool_iterations, - ) - .await + let hb_cfg = if config.agent.safety_heartbeat_interval > 0 { + Some(SafetyHeartbeatConfig { + body: security.summary_for_heartbeat(), + interval: config.agent.safety_heartbeat_interval, + }) + } else { + None + }; + SAFETY_HEARTBEAT_CONFIG + .scope( + hb_cfg, + agent_turn( + provider.as_ref(), + &mut history, + &tools_registry, + observer.as_ref(), + provider_name, + &model_name, + config.default_temperature, + true, + &config.multimodal, + config.agent.max_tool_iterations, + ), + ) + .await } #[cfg(test)] @@ -2543,6 +2628,36 @@ mod tests { assert_eq!(feishu_args["delivery"]["to"], "oc_yyy"); } + #[test] + fn safety_heartbeat_interval_zero_disables_injection() { + for counter in [0, 1, 2, 10, 100] { + assert!( + !should_inject_safety_heartbeat(counter, 0), + "counter={counter} should not inject when interval=0" + ); + } + } + + #[test] + fn safety_heartbeat_interval_one_injects_every_non_initial_step() { + assert!(!should_inject_safety_heartbeat(0, 1)); + for counter in 1..=6 { + assert!( + should_inject_safety_heartbeat(counter, 1), + "counter={counter} should inject when interval=1" + ); + } + } + + #[test] + fn safety_heartbeat_injects_only_on_exact_multiples() { + let interval = 3; + let injected: Vec = (0..=10) + .filter(|counter| should_inject_safety_heartbeat(*counter, interval)) + .collect(); + assert_eq!(injected, vec![3, 6, 9]); + } + use crate::memory::{Memory, MemoryCategory, SqliteMemory}; use crate::observability::NoopObserver; use crate::providers::traits::ProviderCapabilities; @@ -3277,6 +3392,7 @@ mod tests { None, None, &[], + None, ) .await .expect("tool loop should continue after non-cli approval"); diff --git a/src/channels/mod.rs b/src/channels/mod.rs index 8ad6fb076..938d2262e 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -70,6 +70,7 @@ pub use whatsapp_web::WhatsAppWebChannel; use crate::agent::loop_::{ build_shell_policy_instructions, build_tool_instructions_from_specs, run_tool_call_loop_with_non_cli_approval_context, scrub_credentials, NonCliApprovalContext, + SafetyHeartbeatConfig, }; use crate::approval::{ApprovalManager, PendingApprovalError}; use crate::config::{Config, NonCliNaturalLanguageApprovalMode}; @@ -287,6 +288,7 @@ struct ChannelRuntimeContext { query_classification: crate::config::QueryClassificationConfig, model_routes: Vec, approval_manager: Arc, + safety_heartbeat: Option, } #[derive(Clone)] @@ -2205,10 +2207,8 @@ async fn handle_runtime_command_if_needed( reply_target, ) { Ok(req) => { - ctx.approval_manager.record_non_cli_pending_resolution( - &request_id, - ApprovalResponse::Yes, - ); + ctx.approval_manager + .record_non_cli_pending_resolution(&request_id, ApprovalResponse::Yes); let tool_name = req.tool_name; let mut approval_message = if tool_name == APPROVAL_ALL_TOOLS_ONCE_TOKEN { let remaining = ctx.approval_manager.grant_non_cli_allow_all_once(); @@ -2327,10 +2327,8 @@ async fn handle_runtime_command_if_needed( reply_target, ) { Ok(req) => { - ctx.approval_manager.record_non_cli_pending_resolution( - &request_id, - ApprovalResponse::No, - ); + ctx.approval_manager + .record_non_cli_pending_resolution(&request_id, ApprovalResponse::No); runtime_trace::record_event( "approval_request_rejected", Some(source_channel), @@ -3378,6 +3376,7 @@ or tune thresholds in config.", delta_tx, ctx.hooks.as_deref(), &excluded_tools_snapshot, + ctx.safety_heartbeat.clone(), ), ) => LlmExecutionResult::Completed(result), }; @@ -5232,6 +5231,14 @@ pub async fn start_channels(config: Config) -> Result<()> { } Arc::new(ApprovalManager::from_config(&autonomy)) }, + safety_heartbeat: if config.agent.safety_heartbeat_interval > 0 { + Some(SafetyHeartbeatConfig { + body: security.summary_for_heartbeat(), + interval: config.agent.safety_heartbeat_interval, + }) + } else { + None + }, }); run_message_dispatch_loop(rx, runtime_ctx, max_in_flight_messages).await; @@ -5570,6 +5577,7 @@ mod tests { query_classification: crate::config::QueryClassificationConfig::default(), model_routes: Vec::new(), approval_manager: mock_price_approved_manager(), + safety_heartbeat: None, }; assert!(compact_sender_history(&ctx, &sender)); @@ -5622,6 +5630,7 @@ mod tests { query_classification: crate::config::QueryClassificationConfig::default(), model_routes: Vec::new(), approval_manager: mock_price_approved_manager(), + safety_heartbeat: None, }; append_sender_turn(&ctx, &sender, ChatMessage::user("hello")); @@ -5677,6 +5686,7 @@ mod tests { query_classification: crate::config::QueryClassificationConfig::default(), model_routes: Vec::new(), approval_manager: mock_price_approved_manager(), + safety_heartbeat: None, }; assert!(rollback_orphan_user_turn(&ctx, &sender, "pending")); @@ -6273,6 +6283,7 @@ BTC is currently around $65,000 based on latest tool output."# query_classification: crate::config::QueryClassificationConfig::default(), model_routes: Vec::new(), approval_manager: mock_price_approved_manager(), + safety_heartbeat: None, }); process_channel_message( @@ -6348,6 +6359,7 @@ BTC is currently around $65,000 based on latest tool output."# approval_manager: mock_price_approved_manager(), multimodal: crate::config::MultimodalConfig::default(), hooks: None, + safety_heartbeat: None, }); process_channel_message( @@ -6410,6 +6422,7 @@ BTC is currently around $65,000 based on latest tool output."# approval_manager: mock_price_approved_manager(), multimodal: crate::config::MultimodalConfig::default(), hooks: None, + safety_heartbeat: None, }); process_channel_message( @@ -6486,6 +6499,7 @@ BTC is currently around $65,000 based on latest tool output."# hooks: None, query_classification: crate::config::QueryClassificationConfig::default(), model_routes: Vec::new(), + safety_heartbeat: None, }); process_channel_message( @@ -6561,6 +6575,7 @@ BTC is currently around $65,000 based on latest tool output."# hooks: None, query_classification: crate::config::QueryClassificationConfig::default(), model_routes: Vec::new(), + safety_heartbeat: None, }); process_channel_message( @@ -6628,6 +6643,7 @@ BTC is currently around $65,000 based on latest tool output."# query_classification: crate::config::QueryClassificationConfig::default(), model_routes: Vec::new(), approval_manager: mock_price_approved_manager(), + safety_heartbeat: None, }); process_channel_message( @@ -6690,6 +6706,7 @@ BTC is currently around $65,000 based on latest tool output."# query_classification: crate::config::QueryClassificationConfig::default(), model_routes: Vec::new(), approval_manager: mock_price_approved_manager(), + safety_heartbeat: None, }); process_channel_message( @@ -6761,6 +6778,7 @@ BTC is currently around $65,000 based on latest tool output."# query_classification: crate::config::QueryClassificationConfig::default(), model_routes: Vec::new(), approval_manager: mock_price_approved_manager(), + safety_heartbeat: None, }); process_channel_message( @@ -6863,6 +6881,7 @@ BTC is currently around $65,000 based on latest tool output."# query_classification: crate::config::QueryClassificationConfig::default(), model_routes: Vec::new(), approval_manager: Arc::new(ApprovalManager::from_config(&autonomy_cfg)), + safety_heartbeat: None, }); assert_eq!( runtime_ctx @@ -7013,6 +7032,7 @@ BTC is currently around $65,000 based on latest tool output."# query_classification: crate::config::QueryClassificationConfig::default(), model_routes: Vec::new(), approval_manager: Arc::new(ApprovalManager::from_config(&autonomy_cfg)), + safety_heartbeat: None, }); assert_eq!( runtime_ctx @@ -7123,6 +7143,7 @@ BTC is currently around $65,000 based on latest tool output."# query_classification: crate::config::QueryClassificationConfig::default(), model_routes: Vec::new(), approval_manager, + safety_heartbeat: None, }); process_channel_message( @@ -7228,6 +7249,7 @@ BTC is currently around $65,000 based on latest tool output."# query_classification: crate::config::QueryClassificationConfig::default(), model_routes: Vec::new(), approval_manager, + safety_heartbeat: None, }); process_channel_message( @@ -7502,6 +7524,7 @@ BTC is currently around $65,000 based on latest tool output."# query_classification: crate::config::QueryClassificationConfig::default(), model_routes: Vec::new(), approval_manager: Arc::new(ApprovalManager::from_config(&autonomy_cfg)), + safety_heartbeat: None, }); process_channel_message( @@ -7740,6 +7763,7 @@ BTC is currently around $65,000 based on latest tool output."# query_classification: crate::config::QueryClassificationConfig::default(), model_routes: Vec::new(), approval_manager: Arc::new(ApprovalManager::from_config(&autonomy_cfg)), + safety_heartbeat: None, }); process_channel_message( @@ -7885,6 +7909,7 @@ BTC is currently around $65,000 based on latest tool output."# query_classification: crate::config::QueryClassificationConfig::default(), model_routes: Vec::new(), approval_manager: Arc::new(ApprovalManager::from_config(&autonomy_cfg)), + safety_heartbeat: None, }); process_channel_message( @@ -8000,6 +8025,7 @@ BTC is currently around $65,000 based on latest tool output."# query_classification: crate::config::QueryClassificationConfig::default(), model_routes: Vec::new(), approval_manager: Arc::new(ApprovalManager::from_config(&autonomy_cfg)), + safety_heartbeat: None, }); process_channel_message( @@ -8095,6 +8121,7 @@ BTC is currently around $65,000 based on latest tool output."# query_classification: crate::config::QueryClassificationConfig::default(), model_routes: Vec::new(), approval_manager: Arc::new(ApprovalManager::from_config(&autonomy_cfg)), + safety_heartbeat: None, }); process_channel_message( @@ -8209,6 +8236,7 @@ BTC is currently around $65,000 based on latest tool output."# query_classification: crate::config::QueryClassificationConfig::default(), model_routes: Vec::new(), approval_manager: Arc::new(ApprovalManager::from_config(&autonomy_cfg)), + safety_heartbeat: None, }); process_channel_message( @@ -8326,6 +8354,7 @@ BTC is currently around $65,000 based on latest tool output."# approval_manager: Arc::new(ApprovalManager::from_config( &crate::config::AutonomyConfig::default(), )), + safety_heartbeat: None, }); process_channel_message( @@ -8402,6 +8431,7 @@ BTC is currently around $65,000 based on latest tool output."# approval_manager: Arc::new(ApprovalManager::from_config( &crate::config::AutonomyConfig::default(), )), + safety_heartbeat: None, }); process_channel_message( @@ -8495,6 +8525,7 @@ BTC is currently around $65,000 based on latest tool output."# approval_manager: Arc::new(ApprovalManager::from_config( &crate::config::AutonomyConfig::default(), )), + safety_heartbeat: None, }); process_channel_message( @@ -8649,6 +8680,7 @@ BTC is currently around $65,000 based on latest tool output."# approval_manager: Arc::new(ApprovalManager::from_config( &crate::config::AutonomyConfig::default(), )), + safety_heartbeat: None, }); maybe_apply_runtime_config_update(runtime_ctx.as_ref()) @@ -8762,6 +8794,7 @@ BTC is currently around $65,000 based on latest tool output."# query_classification: crate::config::QueryClassificationConfig::default(), model_routes: Vec::new(), approval_manager: mock_price_approved_manager(), + safety_heartbeat: None, }); process_channel_message( @@ -8825,6 +8858,7 @@ BTC is currently around $65,000 based on latest tool output."# query_classification: crate::config::QueryClassificationConfig::default(), model_routes: Vec::new(), approval_manager: mock_price_approved_manager(), + safety_heartbeat: None, }); process_channel_message( @@ -9002,6 +9036,7 @@ BTC is currently around $65,000 based on latest tool output."# approval_manager: Arc::new(ApprovalManager::from_config( &crate::config::AutonomyConfig::default(), )), + safety_heartbeat: None, }); let (tx, rx) = tokio::sync::mpsc::channel::(4); @@ -9087,6 +9122,7 @@ BTC is currently around $65,000 based on latest tool output."# approval_manager: Arc::new(ApprovalManager::from_config( &crate::config::AutonomyConfig::default(), )), + safety_heartbeat: None, }); let (tx, rx) = tokio::sync::mpsc::channel::(8); @@ -9184,6 +9220,7 @@ BTC is currently around $65,000 based on latest tool output."# approval_manager: Arc::new(ApprovalManager::from_config( &crate::config::AutonomyConfig::default(), )), + safety_heartbeat: None, }); let (tx, rx) = tokio::sync::mpsc::channel::(8); @@ -9263,6 +9300,7 @@ BTC is currently around $65,000 based on latest tool output."# approval_manager: Arc::new(ApprovalManager::from_config( &crate::config::AutonomyConfig::default(), )), + safety_heartbeat: None, }); process_channel_message( @@ -9327,6 +9365,7 @@ BTC is currently around $65,000 based on latest tool output."# approval_manager: Arc::new(ApprovalManager::from_config( &crate::config::AutonomyConfig::default(), )), + safety_heartbeat: None, }); process_channel_message( @@ -9876,6 +9915,7 @@ BTC is currently around $65,000 based on latest tool output."# approval_manager: Arc::new(ApprovalManager::from_config( &crate::config::AutonomyConfig::default(), )), + safety_heartbeat: None, }); process_channel_message( @@ -9966,6 +10006,7 @@ BTC is currently around $65,000 based on latest tool output."# approval_manager: Arc::new(ApprovalManager::from_config( &crate::config::AutonomyConfig::default(), )), + safety_heartbeat: None, }); process_channel_message( @@ -10060,6 +10101,7 @@ BTC is currently around $65,000 based on latest tool output."# approval_manager: Arc::new(ApprovalManager::from_config( &crate::config::AutonomyConfig::default(), )), + safety_heartbeat: None, }); process_channel_message( @@ -10840,6 +10882,7 @@ BTC is currently around $65,000 based on latest tool output."#; approval_manager: Arc::new(ApprovalManager::from_config( &crate::config::AutonomyConfig::default(), )), + safety_heartbeat: None, }); // Simulate a photo attachment message with [IMAGE:] marker. @@ -10911,6 +10954,7 @@ BTC is currently around $65,000 based on latest tool output."#; approval_manager: Arc::new(ApprovalManager::from_config( &crate::config::AutonomyConfig::default(), )), + safety_heartbeat: None, }); process_channel_message( diff --git a/src/config/schema.rs b/src/config/schema.rs index ec4d60924..01f988d16 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -748,6 +748,20 @@ pub struct AgentConfig { /// Set to `0` to disable. Default: `3`. #[serde(default = "default_loop_detection_failure_streak")] pub loop_detection_failure_streak: usize, + /// Safety heartbeat injection interval inside `run_tool_call_loop`. + /// Injects a security-constraint reminder every N tool iterations. + /// Set to `0` to disable. Default: `5`. + /// Compatibility/rollback: omit/remove this key to use default (`5`), or set + /// to `0` for explicit disable. + #[serde(default = "default_safety_heartbeat_interval")] + pub safety_heartbeat_interval: usize, + /// Safety heartbeat injection interval for interactive sessions. + /// Injects a security-constraint reminder every N conversation turns. + /// Set to `0` to disable. Default: `10`. + /// Compatibility/rollback: omit/remove this key to use default (`10`), or + /// set to `0` for explicit disable. + #[serde(default = "default_safety_heartbeat_turn_interval")] + pub safety_heartbeat_turn_interval: usize, } fn default_agent_max_tool_iterations() -> usize { @@ -774,6 +788,14 @@ fn default_loop_detection_failure_streak() -> usize { 3 } +fn default_safety_heartbeat_interval() -> usize { + 5 +} + +fn default_safety_heartbeat_turn_interval() -> usize { + 10 +} + impl Default for AgentConfig { fn default() -> Self { Self { @@ -785,6 +807,8 @@ impl Default for AgentConfig { loop_detection_no_progress_threshold: default_loop_detection_no_progress_threshold(), loop_detection_ping_pong_cycles: default_loop_detection_ping_pong_cycles(), loop_detection_failure_streak: default_loop_detection_failure_streak(), + safety_heartbeat_interval: default_safety_heartbeat_interval(), + safety_heartbeat_turn_interval: default_safety_heartbeat_turn_interval(), } } } diff --git a/src/security/policy.rs b/src/security/policy.rs index 6d7d94335..71b0a6a6a 100644 --- a/src/security/policy.rs +++ b/src/security/policy.rs @@ -1073,6 +1073,69 @@ impl SecurityPolicy { } /// Build from config sections + /// Produce a concise security-constraint summary suitable for periodic + /// re-injection into the conversation (safety heartbeat). + /// + /// The output is intentionally short (~100-150 tokens) so the token + /// overhead per heartbeat is negligible. + pub fn summary_for_heartbeat(&self) -> String { + let autonomy_label = match self.autonomy { + AutonomyLevel::ReadOnly => "read_only — side-effecting actions are blocked", + AutonomyLevel::Supervised => "supervised — destructive actions require approval", + AutonomyLevel::Full => "full — autonomous execution within policy bounds", + }; + + let workspace = self.workspace_dir.display(); + let ws_only = self.workspace_only; + + let forbidden_preview: String = { + let shown: Vec<&str> = self + .forbidden_paths + .iter() + .take(8) + .map(String::as_str) + .collect(); + let remaining = self.forbidden_paths.len().saturating_sub(8); + if remaining > 0 { + format!("{} (+ {} more)", shown.join(", "), remaining) + } else { + shown.join(", ") + } + }; + + let commands_preview: String = { + let shown: Vec<&str> = self + .allowed_commands + .iter() + .take(8) + .map(String::as_str) + .collect(); + let remaining = self.allowed_commands.len().saturating_sub(8); + if remaining > 0 { + format!("{} (+ {} more rejected)", shown.join(", "), remaining) + } else if shown.is_empty() { + "none (all rejected)".to_string() + } else { + format!("{} (others rejected)", shown.join(", ")) + } + }; + + let high_risk = if self.block_high_risk_commands { + "blocked" + } else { + "allowed (caution)" + }; + + format!( + "- Autonomy: {autonomy_label}\n\ + - Workspace: {workspace} (workspace_only: {ws_only})\n\ + - Forbidden paths: {forbidden_preview}\n\ + - Allowed commands: {commands_preview}\n\ + - High-risk commands: {high_risk}\n\ + - Do not exfiltrate data, bypass approval, or run destructive commands without asking." + ) + } + pub fn from_config( autonomy_config: &crate::config::AutonomyConfig, workspace_dir: &Path, @@ -2103,6 +2166,53 @@ mod tests { assert!(!policy.is_rate_limited()); } + // ── summary_for_heartbeat ────────────────────────────── + + #[test] + fn summary_for_heartbeat_contains_key_fields() { + let policy = default_policy(); + let summary = policy.summary_for_heartbeat(); + assert!(summary.contains("Autonomy:")); + assert!(summary.contains("supervised")); + assert!(summary.contains("Workspace:")); + assert!(summary.contains("workspace_only: true")); + assert!(summary.contains("Forbidden paths:")); + assert!(summary.contains("/etc")); + assert!(summary.contains("Allowed commands:")); + assert!(summary.contains("git")); + assert!(summary.contains("High-risk commands: blocked")); + assert!(summary.contains("Do not exfiltrate data")); + } + + #[test] + fn summary_for_heartbeat_truncates_long_lists() { + let policy = SecurityPolicy { + forbidden_paths: (0..15).map(|i| format!("/path_{i}")).collect(), + allowed_commands: (0..12).map(|i| format!("cmd_{i}")).collect(), + ..SecurityPolicy::default() + }; + let summary = policy.summary_for_heartbeat(); + // Only first 8 shown, remainder counted + assert!(summary.contains("+ 7 more")); + assert!(summary.contains("+ 4 more rejected")); + } + + #[test] + fn summary_for_heartbeat_full_autonomy() { + let policy = full_policy(); + let summary = policy.summary_for_heartbeat(); + assert!(summary.contains("full")); + assert!(summary.contains("autonomous execution")); + } + + #[test] + fn summary_for_heartbeat_readonly_autonomy() { + let policy = readonly_policy(); + let summary = policy.summary_for_heartbeat(); + assert!(summary.contains("read_only")); + assert!(summary.contains("side-effecting actions are blocked")); + } + // ══════════════════════════════════════════════════════════ // SECURITY CHECKLIST TESTS // Checklist: gateway not public, pairing required,