feat: enhance safety filters for shell commands by normalizing ANSI-C quoting

This commit is contained in:
asepharyana
2026-07-13 04:10:08 +07:00
parent a080957c26
commit d09e440e7e
14 changed files with 383 additions and 85 deletions
+5 -2
View File
@@ -514,7 +514,7 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
.filter_map(|m| m.content.as_deref())
.map(|c| c.len())
.sum();
let token_estimate = total_chars / 4;
let token_estimate = total_chars / 3;
rt.messages = crate::app::runtime::shortsend::shape_messages(&rt.messages, token_estimate, max_wire_tokens, true, None);
state.push_toast(Toast::new(ToastKind::Success, "Conversation history compacted.".to_string()));
state.dirty = true;
@@ -706,7 +706,10 @@ fn spawn_turn(state: &AppStateRest) {
let abort_flag = state.abort_flag.clone();
abort_flag.store(false, std::sync::atomic::Ordering::SeqCst);
*in_flight_flag.lock().unwrap() = true;
*in_flight_flag.lock().unwrap_or_else(|e| {
tracing::error!("[spawn_turn] in_flight_flag mutex poisoned: {}", e);
e.into_inner()
}) = true;
let events_q = turn_events.clone();
+42 -19
View File
@@ -7,15 +7,24 @@ use crate::dto::chat::message::ChatMessage;
/// sending to the LLM.
///
/// Flow: trigger based on token estimate. If `token_estimate` exceeds
/// `MAX_WIRE_TOKENS * 0.8`, we shape. We also apply hysteresis so it doesn't
/// flutter.
/// the threshold, we shape. When `prev_shaped` is true, the threshold is
/// raised (95%) to avoid fluttering — compaction only re-triggers when
/// the context is genuinely full again. When `prev_shaped` is false, the
/// threshold is lower (85%) so compaction starts proactively.
///
/// Why: hysteresis prevents repeated compaction on every turn when the
/// token count hovers near the boundary.
///
/// Return: `true` if shaping should be applied.
pub fn should_shape(token_estimate: usize, max_wire_tokens: usize, prev_shaped: bool) -> bool {
let threshold = if prev_shaped {
(max_wire_tokens as f32 * 0.85) as usize
// Higher threshold when already shaped — defer re-shaping until
// the buffer is genuinely full again (95%).
(max_wire_tokens as f32 * 0.95) as usize
} else {
(max_wire_tokens as f32 * 0.90) as usize
// Lower threshold when not yet shaped — trigger shaping sooner
// (85%) to avoid hitting the context window limit.
(max_wire_tokens as f32 * 0.85) as usize
};
token_estimate >= threshold
}
@@ -59,7 +68,10 @@ pub fn shape_messages(
// Iterate backwards from the most recent to oldest
for m in msgs_to_eval.into_iter().rev() {
let text = m.content.as_deref().unwrap_or("");
let msg_tokens = text.len() / 4;
// Estimate tokens: ~1 token per 3 bytes for mixed content (code,
// prose, multi-byte). Conservative enough to stay under provider
// limits while avoiding premature compaction.
let msg_tokens = text.len() / 3;
if current_tokens + msg_tokens <= target_tokens {
current_tokens += msg_tokens;
@@ -80,22 +92,33 @@ pub fn shape_messages(
if !dropped_msgs.is_empty() {
let mut summary_text = "[prior conversation compacted]".to_string();
if let Some(llm) = client {
let prompt = format!(
"Summarize the following dropped conversation history briefly. Focus on main goals, decisions made, and files modified, so the context is preserved for future turns. Keep it concise.\n\nHistory:\n{}",
dropped_msgs.iter()
.map(|m| format!("[{}]: {}", if m.role == crate::dto::chat::message::Role::User { "User" } else { "Assistant" }, m.content.as_deref().unwrap_or("")))
.collect::<Vec<_>>()
.join("\n\n")
);
let req_msgs = vec![ChatMessage::user(prompt)];
if let Ok(resp) = llm.chat_with_tools_non_streaming(&req_msgs, None) {
if let Some(content) = resp.0.content {
summary_text = format!("[Summary of compacted prior conversation:\n{}\n]", content);
if let Some(llm) = client {
let prompt = format!(
"Summarize the following dropped conversation history briefly. Focus on main goals, decisions made, and files modified, so the context is preserved for future turns. Keep it concise.\n\nHistory:\n{}",
dropped_msgs.iter()
.map(|m| format!("[{}]: {}", if m.role == crate::dto::chat::message::Role::User { "User" } else { "Assistant" }, m.content.as_deref().unwrap_or("")))
.collect::<Vec<_>>()
.join("\n\n")
);
let req_msgs = vec![ChatMessage::user(prompt)];
match llm.chat_with_tools_non_streaming(&req_msgs, None) {
Ok(resp) => {
if let Some(content) = resp.0.content {
summary_text = format!("[Summary of compacted prior conversation:\n{}\n]", content);
}
}
Err(e) => {
tracing::warn!(
"[shortsend] LLM summarization failed: {}. \
Prior conversation history is lost — no summary available. \
This means the model will lose context about earlier parts of \
the conversation.",
e,
);
}
}
}
}
result.push(ChatMessage::system(summary_text));
}
+20 -5
View File
@@ -65,10 +65,11 @@ impl SseParser {
events.extend(self.flush_event());
} else if let Some(ty) = line.strip_prefix("event: ") {
self.event_type = Some(ty.trim().to_string());
} else if let Some(data) = line.strip_prefix("data: ") {
self.data_lines.push(data.to_string());
} else if line.starts_with("data:") {
self.data_lines.push(String::new());
} else if let Some(data) = line.strip_prefix("data:") {
// Handle both "data: {...}" (with space) and "data:{...}"
// (without space). Some providers omit the trailing space.
let data = data.trim_start().to_string();
self.data_lines.push(data);
}
}
events
@@ -119,7 +120,21 @@ impl SseParser {
tracing::warn!("[stream] total_tokens missing in usage chunk");
prompt_tokens + completion_tokens
});
return vec![StreamEvent::Usage { prompt_tokens, completion_tokens, total_tokens }];
// Only emit Usage as a standalone event if this chunk
// contains nothing else (no choices, no delta). Some
// non-standard providers may bundle usage WITH content
// in the same chunk; emitting both prevents content loss.
let has_other_content = value.get("choices")
.and_then(|c| c.as_array())
.map(|arr| arr.iter().any(|ch| {
ch.get("delta").and_then(|d| d.get("content")).is_some()
|| ch.get("delta").and_then(|d| d.get("reasoning_content")).is_some()
|| ch.get("delta").and_then(|d| d.get("tool_calls")).is_some()
}))
.unwrap_or(false);
if !has_other_content {
return vec![StreamEvent::Usage { prompt_tokens, completion_tokens, total_tokens }];
}
}
}
match event_type.as_str() {
+9 -1
View File
@@ -118,7 +118,15 @@ impl StreamedTurn {
.filter(|tc| !tc.name.is_empty())
.map(|tc| {
let args_value: serde_json::Value = serde_json::from_str(&tc.arguments)
.unwrap_or(serde_json::Value::String(tc.arguments.clone()));
.unwrap_or_else(|e| {
tracing::warn!(
"[stream] tool call '{}' has invalid JSON arguments: {} — \
arguments will be double-stringified, which may cause \
tool execution to fail",
tc.name, e,
);
serde_json::Value::String(tc.arguments.clone())
});
ToolCall {
id: tc.id.clone(),
type_: "function".to_string(),