mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-30 16:19:21 +00:00
Merge pull request #1132 from nearai/staging-promote/e805ec61-23059634819
chore: promote staging to main (2026-03-13 16:09 UTC)
This commit is contained in:
@@ -152,6 +152,30 @@ pub async fn run_agentic_loop(
|
||||
// Call LLM
|
||||
let output = delegate.call_llm(reasoning, reason_ctx, iteration).await?;
|
||||
|
||||
match &output.result {
|
||||
RespondResult::Text(text) => {
|
||||
tracing::debug!(
|
||||
iteration,
|
||||
len = text.len(),
|
||||
has_suggestions = text.contains("<suggestions>"),
|
||||
response = %text,
|
||||
"LLM text response"
|
||||
);
|
||||
}
|
||||
RespondResult::ToolCalls {
|
||||
tool_calls,
|
||||
content,
|
||||
} => {
|
||||
let names: Vec<&str> = tool_calls.iter().map(|tc| tc.name.as_str()).collect();
|
||||
tracing::debug!(
|
||||
iteration,
|
||||
tools = ?names,
|
||||
has_content = content.is_some(),
|
||||
"LLM tool_calls response"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
match output.result {
|
||||
RespondResult::Text(text) => {
|
||||
// Tool intent nudge: if the LLM says "let me search..." without
|
||||
|
||||
@@ -1057,6 +1057,54 @@ fn strip_internal_tool_call_text(text: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract `<suggestions>["...","..."]</suggestions>` from a response string.
|
||||
///
|
||||
/// Returns `(cleaned_text, suggestions)`. The `<suggestions>` block is stripped
|
||||
/// from the text regardless of whether the JSON inside parses successfully.
|
||||
/// Only the **last** `<suggestions>` block is used (closest to end of response).
|
||||
/// Blocks inside markdown code fences are ignored.
|
||||
pub(crate) fn extract_suggestions(text: &str) -> (String, Vec<String>) {
|
||||
use regex::Regex;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
static RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"(?s)<suggestions>\s*(.*?)\s*</suggestions>").expect("valid regex") // safety: constant pattern
|
||||
});
|
||||
|
||||
// Find the position of the last closing code fence to avoid matching inside code blocks
|
||||
let last_code_fence = text.rfind("```").unwrap_or(0);
|
||||
|
||||
// Find all matches, take the last one that's after the last code fence
|
||||
let mut best_match: Option<regex::Match<'_>> = None;
|
||||
let mut best_capture: Option<String> = None;
|
||||
for caps in RE.captures_iter(text) {
|
||||
if let (Some(full), Some(inner)) = (caps.get(0), caps.get(1))
|
||||
&& full.start() >= last_code_fence
|
||||
{
|
||||
best_match = Some(full);
|
||||
best_capture = Some(inner.as_str().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let Some(full) = best_match else {
|
||||
return (text.to_string(), Vec::new());
|
||||
};
|
||||
|
||||
let cleaned = format!("{}{}", &text[..full.start()], &text[full.end()..]); // safety: regex match boundaries are valid UTF-8
|
||||
let cleaned = cleaned.trim().to_string();
|
||||
|
||||
// Parse the JSON array
|
||||
let suggestions = best_capture
|
||||
.and_then(|json| serde_json::from_str::<Vec<String>>(&json).ok())
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|s| !s.trim().is_empty() && s.len() <= 80)
|
||||
.take(3)
|
||||
.collect();
|
||||
|
||||
(cleaned, suggestions)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
@@ -2203,6 +2251,55 @@ mod tests {
|
||||
assert_eq!(result, input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_suggestions_basic() {
|
||||
let input = "Here is my answer.\n<suggestions>[\"Check logs\", \"Deploy\"]</suggestions>";
|
||||
let (text, suggestions) = super::extract_suggestions(input);
|
||||
assert_eq!(text, "Here is my answer."); // safety: test
|
||||
assert_eq!(suggestions, vec!["Check logs", "Deploy"]); // safety: test
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_suggestions_no_tag() {
|
||||
let input = "Just a plain response.";
|
||||
let (text, suggestions) = super::extract_suggestions(input);
|
||||
assert_eq!(text, "Just a plain response."); // safety: test
|
||||
assert!(suggestions.is_empty()); // safety: test
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_suggestions_malformed_json() {
|
||||
let input = "Answer.\n<suggestions>not json</suggestions>";
|
||||
let (text, suggestions) = super::extract_suggestions(input);
|
||||
assert_eq!(text, "Answer."); // safety: test
|
||||
assert!(suggestions.is_empty()); // safety: test
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_suggestions_inside_code_fence() {
|
||||
let input = "```\n<suggestions>[\"foo\"]</suggestions>\n```";
|
||||
let (text, suggestions) = super::extract_suggestions(input);
|
||||
// The tag is inside a code fence, so it should not be extracted
|
||||
assert_eq!(text, input); // safety: test
|
||||
assert!(suggestions.is_empty()); // safety: test
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_suggestions_after_code_fence() {
|
||||
let input = "```\ncode\n```\nAnswer.\n<suggestions>[\"foo\"]</suggestions>";
|
||||
let (text, suggestions) = super::extract_suggestions(input);
|
||||
assert_eq!(text, "```\ncode\n```\nAnswer."); // safety: test
|
||||
assert_eq!(suggestions, vec!["foo"]); // safety: test
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_suggestions_filters_long() {
|
||||
let long = "x".repeat(81);
|
||||
let input = format!("Answer.\n<suggestions>[\"{}\", \"ok\"]</suggestions>", long);
|
||||
let (_, suggestions) = super::extract_suggestions(&input);
|
||||
assert_eq!(suggestions, vec!["ok"]); // safety: test
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_error_format_includes_tool_name() {
|
||||
// Regression test for issue #487: tool errors sent to the LLM should
|
||||
|
||||
+198
-1
@@ -538,11 +538,174 @@ pub fn next_cron_fire(
|
||||
}
|
||||
}
|
||||
|
||||
/// Describe common routine cron patterns in plain English.
|
||||
///
|
||||
/// Falls back to `cron: <raw>` for malformed or complex expressions.
|
||||
pub fn describe_cron(schedule: &str, timezone: Option<&str>) -> String {
|
||||
fn fallback(raw: &str) -> String {
|
||||
if raw.trim().is_empty() {
|
||||
"cron: (empty)".to_string()
|
||||
} else {
|
||||
format!("cron: {}", raw.trim())
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_u8_token(token: &str) -> Option<u8> {
|
||||
token.parse::<u8>().ok()
|
||||
}
|
||||
|
||||
fn parse_step(token: &str) -> Option<u8> {
|
||||
token
|
||||
.strip_prefix("*/")
|
||||
.and_then(parse_u8_token)
|
||||
.filter(|n| *n > 0)
|
||||
}
|
||||
|
||||
fn weekday_name(dow: &str) -> Option<&'static str> {
|
||||
let normalized = dow.trim().to_ascii_uppercase();
|
||||
match normalized.as_str() {
|
||||
"MON" | "1" => Some("Monday"),
|
||||
"TUE" | "2" => Some("Tuesday"),
|
||||
"WED" | "3" => Some("Wednesday"),
|
||||
"THU" | "4" => Some("Thursday"),
|
||||
"FRI" | "5" => Some("Friday"),
|
||||
"SAT" | "6" => Some("Saturday"),
|
||||
"SUN" | "0" | "7" => Some("Sunday"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn format_time(hour: u8, minute: u8) -> String {
|
||||
if hour == 0 && minute == 0 {
|
||||
return "midnight".to_string();
|
||||
}
|
||||
let (display_hour, am_pm) = match hour {
|
||||
0 => (12, "AM"),
|
||||
1..=11 => (hour, "AM"),
|
||||
12 => (12, "PM"),
|
||||
_ => (hour - 12, "PM"),
|
||||
};
|
||||
format!("{display_hour}:{minute:02} {am_pm}")
|
||||
}
|
||||
|
||||
fn ordinal(n: u8) -> String {
|
||||
let suffix = if (11..=13).contains(&(n % 100)) {
|
||||
"th"
|
||||
} else {
|
||||
match n % 10 {
|
||||
1 => "st",
|
||||
2 => "nd",
|
||||
3 => "rd",
|
||||
_ => "th",
|
||||
}
|
||||
};
|
||||
format!("{n}{suffix}")
|
||||
}
|
||||
|
||||
fn describe_inner(raw: &str) -> Option<String> {
|
||||
let fields: Vec<&str> = raw.split_whitespace().collect();
|
||||
let (sec, min, hour, dom, month, dow, year) = match fields.len() {
|
||||
5 => (
|
||||
"0", fields[0], fields[1], fields[2], fields[3], fields[4], None,
|
||||
),
|
||||
6 => (
|
||||
fields[0], fields[1], fields[2], fields[3], fields[4], fields[5], None,
|
||||
),
|
||||
7 => (
|
||||
fields[0],
|
||||
fields[1],
|
||||
fields[2],
|
||||
fields[3],
|
||||
fields[4],
|
||||
fields[5],
|
||||
Some(fields[6]),
|
||||
),
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
if year.is_some_and(|v| v != "*") {
|
||||
return None;
|
||||
}
|
||||
|
||||
if sec == "0"
|
||||
&& hour == "*"
|
||||
&& dom == "*"
|
||||
&& month == "*"
|
||||
&& dow == "*"
|
||||
&& let Some(step) = parse_step(min)
|
||||
{
|
||||
return Some(match step {
|
||||
1 => "Every minute".to_string(),
|
||||
n => format!("Every {n} minutes"),
|
||||
});
|
||||
}
|
||||
|
||||
if sec == "0"
|
||||
&& min == "0"
|
||||
&& dom == "*"
|
||||
&& month == "*"
|
||||
&& dow == "*"
|
||||
&& let Some(step) = parse_step(hour)
|
||||
{
|
||||
return Some(match step {
|
||||
1 => "Every hour".to_string(),
|
||||
n => format!("Every {n} hours"),
|
||||
});
|
||||
}
|
||||
|
||||
let hour = parse_u8_token(hour).filter(|h| *h <= 23)?;
|
||||
let minute = parse_u8_token(min).filter(|m| *m <= 59)?;
|
||||
let time = format_time(hour, minute);
|
||||
let time_phrase = if time == "midnight" {
|
||||
"at midnight".to_string()
|
||||
} else {
|
||||
format!("at {time}")
|
||||
};
|
||||
|
||||
if sec == "0" && dom == "*" && month == "*" && dow == "*" {
|
||||
return Some(format!("Daily {time_phrase}"));
|
||||
}
|
||||
|
||||
if sec == "0" && dom == "*" && month == "*" && dow.eq_ignore_ascii_case("MON-FRI") {
|
||||
return Some(format!("Weekdays {time_phrase}"));
|
||||
}
|
||||
|
||||
if sec == "0"
|
||||
&& dom == "*"
|
||||
&& month == "*"
|
||||
&& let Some(day_name) = weekday_name(dow)
|
||||
{
|
||||
return Some(format!("Every {day_name} {time_phrase}"));
|
||||
}
|
||||
|
||||
if sec == "0"
|
||||
&& month == "*"
|
||||
&& dow == "*"
|
||||
&& let Some(day_of_month) = parse_u8_token(dom).filter(|d| (1..=31).contains(d))
|
||||
{
|
||||
return Some(format!(
|
||||
"{} of every month {time_phrase}",
|
||||
ordinal(day_of_month)
|
||||
));
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
let mut description = describe_inner(schedule).unwrap_or_else(|| fallback(schedule));
|
||||
if let Some(tz) = timezone.map(str::trim).filter(|tz| !tz.is_empty()) {
|
||||
description.push_str(" (");
|
||||
description.push_str(tz);
|
||||
description.push(')');
|
||||
}
|
||||
description
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::agent::routine::{
|
||||
MAX_TOOL_ROUNDS_LIMIT, RoutineAction, RoutineGuardrails, RunStatus, Trigger, content_hash,
|
||||
next_cron_fire,
|
||||
describe_cron, next_cron_fire,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -698,6 +861,40 @@ mod tests {
|
||||
assert_ne!(next_utc, next_est, "timezone should shift the fire time");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_describe_cron_common_patterns() {
|
||||
let cases = vec![
|
||||
("0 */30 * * * *", None, "Every 30 minutes"),
|
||||
("0 0 9 * * *", None, "Daily at 9:00 AM"),
|
||||
("0 0 9 * * MON-FRI", None, "Weekdays at 9:00 AM"),
|
||||
("0 0 */2 * * *", None, "Every 2 hours"),
|
||||
("0 0 0 * * *", None, "Daily at midnight"),
|
||||
("0 0 9 * * 1", None, "Every Monday at 9:00 AM"),
|
||||
("0 0 9 1 * *", None, "1st of every month at 9:00 AM"),
|
||||
(
|
||||
"0 0 9 * * MON-FRI",
|
||||
Some("America/New_York"),
|
||||
"Weekdays at 9:00 AM (America/New_York)",
|
||||
),
|
||||
("1 2 3 4 5 6", None, "cron: 1 2 3 4 5 6"),
|
||||
];
|
||||
|
||||
for (schedule, timezone, expected) in cases {
|
||||
let actual = describe_cron(schedule, timezone);
|
||||
assert_eq!(actual, expected); // safety: test-only assertion in #[cfg(test)] module
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_describe_cron_edge_cases() {
|
||||
assert_eq!(describe_cron("", None), "cron: (empty)"); // safety: test-only assertion in #[cfg(test)] module
|
||||
assert_eq!(describe_cron("not a cron", None), "cron: not a cron"); // safety: test-only assertion in #[cfg(test)] module
|
||||
let weekdays_5_field = describe_cron("0 9 * * MON-FRI", None);
|
||||
assert_eq!(weekdays_5_field, "Weekdays at 9:00 AM"); // safety: test-only assertion in #[cfg(test)] module
|
||||
let weekdays_7_field = describe_cron("0 0 9 * * MON-FRI *", None);
|
||||
assert_eq!(weekdays_7_field, "Weekdays at 9:00 AM"); // safety: test-only assertion in #[cfg(test)] module
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_guardrails_default() {
|
||||
let g = RoutineGuardrails::default();
|
||||
|
||||
+158
-20
@@ -32,7 +32,9 @@ use crate::llm::{
|
||||
ChatMessage, CompletionRequest, FinishReason, LlmProvider, ToolCall, ToolCompletionRequest,
|
||||
};
|
||||
use crate::safety::SafetyLayer;
|
||||
use crate::tools::{ApprovalContext, ApprovalRequirement, ToolError, ToolRegistry};
|
||||
use crate::tools::{
|
||||
ApprovalContext, ApprovalRequirement, ToolError, ToolRegistry, prepare_tool_params,
|
||||
};
|
||||
use crate::workspace::Workspace;
|
||||
|
||||
enum EventMatcher {
|
||||
@@ -93,19 +95,26 @@ impl RoutineEngine {
|
||||
let mut cache = Vec::new();
|
||||
for routine in routines {
|
||||
match &routine.trigger {
|
||||
Trigger::Event { pattern, .. } => match Regex::new(pattern) {
|
||||
Ok(re) => cache.push(EventMatcher::Message {
|
||||
routine: routine.clone(),
|
||||
regex: re,
|
||||
}),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
routine = %routine.name,
|
||||
"Invalid event regex '{}': {}",
|
||||
pattern, e
|
||||
);
|
||||
Trigger::Event { pattern, .. } => {
|
||||
// Use RegexBuilder with size limit to prevent ReDoS
|
||||
// from user-supplied patterns (issue #825).
|
||||
match regex::RegexBuilder::new(pattern)
|
||||
.size_limit(64 * 1024) // 64KB compiled size limit
|
||||
.build()
|
||||
{
|
||||
Ok(re) => cache.push(EventMatcher::Message {
|
||||
routine: routine.clone(),
|
||||
regex: re,
|
||||
}),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
routine = %routine.name,
|
||||
"Invalid or too complex event regex '{}': {}",
|
||||
pattern, e
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
Trigger::SystemEvent { .. } => {
|
||||
cache.push(EventMatcher::System {
|
||||
routine: routine.clone(),
|
||||
@@ -132,6 +141,32 @@ impl RoutineEngine {
|
||||
let cache = self.event_cache.read().await;
|
||||
let mut fired = 0;
|
||||
|
||||
// Collect routine IDs for batch query
|
||||
let routine_ids: Vec<Uuid> = cache
|
||||
.iter()
|
||||
.filter_map(|matcher| match matcher {
|
||||
EventMatcher::Message { routine, .. } => Some(routine.id),
|
||||
EventMatcher::System { .. } => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
if routine_ids.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Single batch query instead of N queries
|
||||
let concurrent_counts = match self
|
||||
.store
|
||||
.count_running_routine_runs_batch(&routine_ids)
|
||||
.await
|
||||
{
|
||||
Ok(counts) => counts,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to batch-load concurrent counts: {}", e);
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
for matcher in cache.iter() {
|
||||
let (routine, re) = match matcher {
|
||||
EventMatcher::Message { routine, regex } => (routine, regex),
|
||||
@@ -157,8 +192,9 @@ impl RoutineEngine {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Concurrent run check
|
||||
if !self.check_concurrent(routine).await {
|
||||
// Concurrent run check (using batch-loaded counts)
|
||||
let running_count = concurrent_counts.get(&routine.id).copied().unwrap_or(0);
|
||||
if running_count >= routine.guardrails.max_concurrent as i64 {
|
||||
tracing::trace!(routine = %routine.name, "Skipped: max concurrent reached");
|
||||
continue;
|
||||
}
|
||||
@@ -190,6 +226,35 @@ impl RoutineEngine {
|
||||
let cache = self.event_cache.read().await;
|
||||
let mut fired = 0;
|
||||
|
||||
// Collect routine IDs for batch query
|
||||
let routine_ids: Vec<Uuid> = cache
|
||||
.iter()
|
||||
.filter_map(|matcher| match matcher {
|
||||
EventMatcher::System { routine } => Some(routine.id),
|
||||
EventMatcher::Message { .. } => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
if routine_ids.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Single batch query instead of N queries
|
||||
let concurrent_counts = match self
|
||||
.store
|
||||
.count_running_routine_runs_batch(&routine_ids)
|
||||
.await
|
||||
{
|
||||
Ok(counts) => counts,
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"Failed to batch-load concurrent counts for system events: {}",
|
||||
e
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
for matcher in cache.iter() {
|
||||
let routine = match matcher {
|
||||
EventMatcher::System { routine } => routine,
|
||||
@@ -241,7 +306,9 @@ impl RoutineEngine {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !self.check_concurrent(routine).await {
|
||||
// Concurrent run check (using batch-loaded counts)
|
||||
let running_count = concurrent_counts.get(&routine.id).copied().unwrap_or(0);
|
||||
if running_count >= routine.guardrails.max_concurrent as i64 {
|
||||
tracing::debug!(routine = %routine.name, "Skipped: max concurrent reached");
|
||||
continue;
|
||||
}
|
||||
@@ -918,7 +985,8 @@ async fn execute_lightweight_with_tools(
|
||||
.tool_definitions_excluding(ROUTINE_TOOL_DENYLIST)
|
||||
.await;
|
||||
|
||||
let request = ToolCompletionRequest::new(messages.clone(), tool_defs)
|
||||
let request_messages = snapshot_messages_for_tool_iteration(&messages);
|
||||
let request = ToolCompletionRequest::new(request_messages, tool_defs)
|
||||
.with_max_tokens(effective_max_tokens)
|
||||
.with_temperature(0.3);
|
||||
|
||||
@@ -973,6 +1041,18 @@ async fn execute_lightweight_with_tools(
|
||||
}
|
||||
};
|
||||
|
||||
// Truncate oversized tool output to prevent unbounded context growth.
|
||||
// Routine tool loops are lightweight and should not accumulate
|
||||
// large payloads across iterations.
|
||||
const MAX_TOOL_OUTPUT_CHARS: usize = 8192;
|
||||
let result_content = if result_content.len() > MAX_TOOL_OUTPUT_CHARS {
|
||||
let truncated = &result_content
|
||||
[..result_content.floor_char_boundary(MAX_TOOL_OUTPUT_CHARS)];
|
||||
format!("{truncated}\n... [output truncated to {MAX_TOOL_OUTPUT_CHARS} chars]")
|
||||
} else {
|
||||
result_content
|
||||
};
|
||||
|
||||
// Add tool result to context
|
||||
messages.push(ChatMessage::tool_result(&tc.id, &tc.name, &result_content));
|
||||
}
|
||||
@@ -982,6 +1062,31 @@ async fn execute_lightweight_with_tools(
|
||||
}
|
||||
}
|
||||
|
||||
// Bound per-iteration context copy cost for lightweight tool loops.
|
||||
const MAX_TOOL_LOOP_MESSAGES: usize = 32;
|
||||
|
||||
fn snapshot_messages_for_tool_iteration(messages: &[ChatMessage]) -> Vec<ChatMessage> {
|
||||
if messages.len() <= MAX_TOOL_LOOP_MESSAGES {
|
||||
return messages.to_vec();
|
||||
}
|
||||
|
||||
let mut snapshot = Vec::with_capacity(MAX_TOOL_LOOP_MESSAGES);
|
||||
|
||||
if let Some(first) = messages.first()
|
||||
&& first.role == crate::llm::Role::System
|
||||
{
|
||||
snapshot.push(first.clone());
|
||||
let tail_len = MAX_TOOL_LOOP_MESSAGES - 1;
|
||||
let tail_start = (messages.len() - tail_len).max(1);
|
||||
snapshot.extend_from_slice(&messages[tail_start..]);
|
||||
} else {
|
||||
let tail_start = messages.len() - MAX_TOOL_LOOP_MESSAGES;
|
||||
snapshot.extend_from_slice(&messages[tail_start..]);
|
||||
}
|
||||
|
||||
snapshot
|
||||
}
|
||||
|
||||
/// Tools that must never be callable from lightweight routines.
|
||||
///
|
||||
/// These tools pose autonomy-escalation risks: a routine could self-replicate,
|
||||
@@ -1015,13 +1120,14 @@ async fn execute_routine_tool(
|
||||
.get(&tc.name)
|
||||
.await
|
||||
.ok_or_else(|| format!("Tool '{}' not found", tc.name))?;
|
||||
let normalized_params = prepare_tool_params(tool.as_ref(), &tc.arguments);
|
||||
|
||||
// Check approval requirement: only allow Never tools in lightweight routines.
|
||||
// UnlessAutoApproved and Always tools are blocked to prevent prompt injection attacks.
|
||||
// Lightweight routines can be triggered by external events and may process untrusted data,
|
||||
// making them vulnerable to prompt injection that could trick the LLM into calling
|
||||
// sensitive tools. Blocking these tools entirely is the safest approach.
|
||||
match tool.requires_approval(&tc.arguments) {
|
||||
match tool.requires_approval(&normalized_params) {
|
||||
ApprovalRequirement::Never => {}
|
||||
ApprovalRequirement::UnlessAutoApproved | ApprovalRequirement::Always => {
|
||||
return Err(format!(
|
||||
@@ -1033,7 +1139,10 @@ async fn execute_routine_tool(
|
||||
}
|
||||
|
||||
// Validate tool parameters
|
||||
let validation = ctx.safety.validator().validate_tool_params(&tc.arguments);
|
||||
let validation = ctx
|
||||
.safety
|
||||
.validator()
|
||||
.validate_tool_params(&normalized_params);
|
||||
if !validation.is_valid {
|
||||
let details = validation
|
||||
.errors
|
||||
@@ -1048,7 +1157,7 @@ async fn execute_routine_tool(
|
||||
let timeout = tool.execution_timeout();
|
||||
let start = std::time::Instant::now();
|
||||
let result = tokio::time::timeout(timeout, async {
|
||||
tool.execute(tc.arguments.clone(), job_ctx).await
|
||||
tool.execute(normalized_params.clone(), job_ctx).await
|
||||
})
|
||||
.await;
|
||||
let elapsed = start.elapsed();
|
||||
@@ -1367,4 +1476,33 @@ mod tests {
|
||||
let out = super::truncate(input, 5);
|
||||
assert_eq!(out, "abcde...");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_snapshot_messages_keeps_system_and_recent_tail() {
|
||||
let mut messages = vec![crate::llm::ChatMessage::system("sys")];
|
||||
for i in 0..80 {
|
||||
messages.push(crate::llm::ChatMessage::user(format!("u{i}")));
|
||||
}
|
||||
|
||||
let snapshot = super::snapshot_messages_for_tool_iteration(&messages);
|
||||
assert_eq!(snapshot.len(), super::MAX_TOOL_LOOP_MESSAGES); // safety: test-only no-panics CI false positive
|
||||
assert_eq!(snapshot[0].role, crate::llm::Role::System); // safety: test-only no-panics CI false positive
|
||||
assert_eq!(snapshot[0].content, "sys"); // safety: test-only no-panics CI false positive
|
||||
let last_content = snapshot.last().map(|m| m.content.as_str());
|
||||
assert_eq!(last_content, Some("u79")); // safety: test-only no-panics CI false positive
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_snapshot_messages_unchanged_when_within_limit() {
|
||||
let messages = vec![
|
||||
crate::llm::ChatMessage::system("sys"),
|
||||
crate::llm::ChatMessage::user("a"),
|
||||
crate::llm::ChatMessage::assistant("b"),
|
||||
];
|
||||
let snapshot = super::snapshot_messages_for_tool_iteration(&messages);
|
||||
assert_eq!(snapshot.len(), messages.len()); // safety: test-only no-panics CI false positive
|
||||
assert_eq!(snapshot[0].role, crate::llm::Role::System); // safety: test-only no-panics CI false positive
|
||||
assert_eq!(snapshot[1].content, "a"); // safety: test-only no-panics CI false positive
|
||||
assert_eq!(snapshot[2].content, "b"); // safety: test-only no-panics CI false positive
|
||||
}
|
||||
}
|
||||
|
||||
+117
-12
@@ -17,7 +17,7 @@ use crate::error::{Error, JobError};
|
||||
use crate::hooks::HookRegistry;
|
||||
use crate::llm::LlmProvider;
|
||||
use crate::safety::SafetyLayer;
|
||||
use crate::tools::{ApprovalContext, ToolRegistry};
|
||||
use crate::tools::{ApprovalContext, ToolRegistry, prepare_tool_params};
|
||||
use crate::worker::job::{Worker, WorkerDeps};
|
||||
|
||||
/// Message to send to a worker.
|
||||
@@ -179,27 +179,33 @@ impl Scheduler {
|
||||
})
|
||||
.unwrap_or(self.config.max_tokens_per_job);
|
||||
|
||||
// Apply both metadata and token budget in one closure (Issue #813: atomic update)
|
||||
if let Some(meta) = metadata {
|
||||
// Apply both metadata and token budget in one closure (Issue #813: atomic update).
|
||||
// Use update_context_and_get to ensure atomicity: no gap where concurrent workers
|
||||
// can modify the context between update and DB persist (Issue #807).
|
||||
let ctx = if let Some(meta) = metadata {
|
||||
self.context_manager
|
||||
.update_context(job_id, |ctx| {
|
||||
.update_context_and_get(job_id, |ctx| {
|
||||
ctx.metadata = meta;
|
||||
if max_tokens > 0 {
|
||||
ctx.max_tokens = max_tokens;
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
.await?
|
||||
} else if max_tokens > 0 {
|
||||
self.context_manager
|
||||
.update_context(job_id, |ctx| {
|
||||
.update_context_and_get(job_id, |ctx| {
|
||||
ctx.max_tokens = max_tokens;
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
.await?
|
||||
} else {
|
||||
// No metadata or token budget to set; get the initial context
|
||||
self.context_manager.get_context(job_id).await?
|
||||
};
|
||||
|
||||
// Persist to DB before scheduling so the worker's FK references are valid
|
||||
// Persist to DB before scheduling so the worker's FK references are valid.
|
||||
// The context was read under the same lock as the update (atomic), preventing
|
||||
// concurrent worker interference (Issue #807: non-transactional context updates).
|
||||
if let Some(ref store) = self.store {
|
||||
let ctx = self.context_manager.get_context(job_id).await?;
|
||||
store.save_job(&ctx).await.map_err(|e| JobError::Failed {
|
||||
id: job_id,
|
||||
reason: format!("failed to persist job: {e}"),
|
||||
@@ -505,8 +511,10 @@ impl Scheduler {
|
||||
.into());
|
||||
}
|
||||
|
||||
let normalized_params = prepare_tool_params(tool.as_ref(), ¶ms);
|
||||
|
||||
// Scheduler-specific approval check
|
||||
let requirement = tool.requires_approval(¶ms);
|
||||
let requirement = tool.requires_approval(&normalized_params);
|
||||
let blocked =
|
||||
ApprovalContext::is_blocked_or_default(&approval_context, tool_name, requirement);
|
||||
if blocked {
|
||||
@@ -518,7 +526,11 @@ impl Scheduler {
|
||||
|
||||
// Delegate to shared tool execution pipeline
|
||||
let output_str = crate::tools::execute::execute_tool_with_safety(
|
||||
&tools, &safety, tool_name, ¶ms, &job_ctx,
|
||||
&tools,
|
||||
&safety,
|
||||
tool_name,
|
||||
&normalized_params,
|
||||
&job_ctx,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -832,6 +844,24 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_dispatch_job_no_metadata_no_user_tokens_edge_case() {
|
||||
// Edge case coverage: when metadata=None AND max_tokens=0 (config),
|
||||
// the else branch calls get_context() directly (not update_context_and_get).
|
||||
// This test verifies that path works correctly (Issue #807: full branch coverage).
|
||||
let sched = make_test_scheduler(0); // 0 = unlimited, but user provides None
|
||||
let job_id = sched
|
||||
.dispatch_job("user1", "test", "desc", None) // None metadata
|
||||
.await
|
||||
.unwrap(); // safety: test code
|
||||
|
||||
let ctx = sched.context_manager.get_context(job_id).await.unwrap(); // safety: test code
|
||||
// No metadata was set, should have default empty metadata
|
||||
assert!(ctx.metadata.is_null() || ctx.metadata == serde_json::json!({})); // safety: test code
|
||||
// No user tokens AND unlimited config means max_tokens stays at default
|
||||
assert_eq!(ctx.max_tokens, 0, "unlimited config"); // safety: test code
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scheduler_creation() {
|
||||
// Would need to mock dependencies for proper testing
|
||||
@@ -1040,4 +1070,79 @@ mod tests {
|
||||
"hard_gate should pass with explicit permission"
|
||||
);
|
||||
}
|
||||
|
||||
struct NormalizedApprovalTool;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Tool for NormalizedApprovalTool {
|
||||
fn name(&self) -> &str {
|
||||
"normalized_gate"
|
||||
}
|
||||
fn description(&self) -> &str {
|
||||
"approval depends on normalized params"
|
||||
}
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"safe": { "type": "boolean" }
|
||||
}
|
||||
})
|
||||
}
|
||||
async fn execute(
|
||||
&self,
|
||||
_params: serde_json::Value,
|
||||
_ctx: &JobContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
Ok(ToolOutput::text(
|
||||
"normalized_ok",
|
||||
std::time::Instant::now().elapsed(),
|
||||
))
|
||||
}
|
||||
fn requires_approval(&self, params: &serde_json::Value) -> ApprovalRequirement {
|
||||
if params.get("safe").and_then(|v| v.as_bool()) == Some(true) {
|
||||
ApprovalRequirement::Never
|
||||
} else {
|
||||
ApprovalRequirement::Always
|
||||
}
|
||||
}
|
||||
fn requires_sanitization(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_tool_task_normalizes_params_before_approval() {
|
||||
let registry = ToolRegistry::new();
|
||||
registry.register(Arc::new(NormalizedApprovalTool)).await;
|
||||
|
||||
let cm = Arc::new(ContextManager::new(5));
|
||||
let job_id = cm.create_job("test", "normalized approval").await.unwrap(); // safety: test-only setup
|
||||
cm.update_context(job_id, |ctx| ctx.transition_to(JobState::InProgress, None))
|
||||
.await
|
||||
.unwrap() // safety: test-only setup
|
||||
.unwrap(); // safety: test-only setup
|
||||
|
||||
let safety = Arc::new(SafetyLayer::new(&SafetyConfig {
|
||||
max_output_length: 100_000,
|
||||
injection_check_enabled: false,
|
||||
}));
|
||||
|
||||
let result = Scheduler::execute_tool_task(
|
||||
Arc::new(registry),
|
||||
cm,
|
||||
safety,
|
||||
None,
|
||||
job_id,
|
||||
"normalized_gate",
|
||||
serde_json::json!({"safe": "true"}),
|
||||
)
|
||||
.await;
|
||||
|
||||
#[rustfmt::skip]
|
||||
assert!( // safety: test-only assertion
|
||||
result.is_ok(),
|
||||
"stringified boolean should normalize before approval: {result:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -420,6 +420,10 @@ impl Agent {
|
||||
// Complete, fail, or request approval
|
||||
match result {
|
||||
Ok(AgenticLoopResult::Response(response)) => {
|
||||
// Extract <suggestions> from response text before user sees it
|
||||
let (response, suggestions) =
|
||||
crate::agent::dispatcher::extract_suggestions(&response);
|
||||
|
||||
// Hook: TransformResponse — allow hooks to modify or reject the final response
|
||||
let response = {
|
||||
let event = crate::hooks::HookEvent::ResponseTransform {
|
||||
@@ -473,6 +477,18 @@ impl Agent {
|
||||
)
|
||||
.await;
|
||||
|
||||
// Send suggestions after response (best-effort, rendered by web gateway)
|
||||
if !suggestions.is_empty() {
|
||||
let _ = self
|
||||
.channels
|
||||
.send_status(
|
||||
&message.channel,
|
||||
StatusUpdate::Suggestions { suggestions },
|
||||
&message.metadata,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(SubmissionResult::response(response))
|
||||
}
|
||||
Ok(AgenticLoopResult::NeedApproval { pending }) => {
|
||||
@@ -1334,6 +1350,8 @@ impl Agent {
|
||||
|
||||
match result {
|
||||
Ok(AgenticLoopResult::Response(response)) => {
|
||||
let (response, suggestions) =
|
||||
crate::agent::dispatcher::extract_suggestions(&response);
|
||||
thread.complete_turn(&response);
|
||||
let (turn_number, tool_calls) = thread
|
||||
.turns
|
||||
@@ -1364,6 +1382,16 @@ impl Agent {
|
||||
&message.metadata,
|
||||
)
|
||||
.await;
|
||||
if !suggestions.is_empty() {
|
||||
let _ = self
|
||||
.channels
|
||||
.send_status(
|
||||
&message.channel,
|
||||
StatusUpdate::Suggestions { suggestions },
|
||||
&message.metadata,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Ok(SubmissionResult::response(response))
|
||||
}
|
||||
Ok(AgenticLoopResult::NeedApproval {
|
||||
|
||||
Reference in New Issue
Block a user