feat(security): add safety heartbeat reinjection with cadence fixes
This commit is contained in:
parent
3341608d52
commit
a029c720a6
@ -289,6 +289,20 @@ pub(crate) struct NonCliApprovalContext {
|
||||
tokio::task_local! {
|
||||
static TOOL_LOOP_NON_CLI_APPROVAL_CONTEXT: Option<NonCliApprovalContext>;
|
||||
static LOOP_DETECTION_CONFIG: LoopDetectionConfig;
|
||||
static SAFETY_HEARTBEAT_CONFIG: Option<SafetyHeartbeatConfig>;
|
||||
}
|
||||
|
||||
/// 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<tokio::sync::mpsc::Sender<String>>,
|
||||
hooks: Option<&crate::hooks::HookRunner>,
|
||||
excluded_tools: &[String],
|
||||
safety_heartbeat: Option<SafetyHeartbeatConfig>,
|
||||
) -> Result<String> {
|
||||
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<String> = 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<String> {
|
||||
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<usize> = (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");
|
||||
|
||||
@ -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<crate::config::ModelRouteConfig>,
|
||||
approval_manager: Arc<ApprovalManager>,
|
||||
safety_heartbeat: Option<SafetyHeartbeatConfig>,
|
||||
}
|
||||
|
||||
#[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::<traits::ChannelMessage>(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::<traits::ChannelMessage>(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::<traits::ChannelMessage>(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(
|
||||
|
||||
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user