mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-30 16:19:21 +00:00
* Rebase onto staging
* fix(routines): prevent autonomy-escalation in lightweight routines
- Add ROUTINE_TOOL_DENYLIST to block routine_create/update/delete/fire
and restart from being callable by lightweight routines
- Deduplicate sentinel logic by reusing handle_text_response() in the
no-tools path
- Filter tool definitions sent to LLM to only include callable tools,
avoiding wasted tokens on tools that would be rejected
This commit is contained in:
+116
-3
@@ -207,7 +207,7 @@ impl Trigger {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum RoutineAction {
|
||||
/// Single LLM call, no tools. Cheap and fast.
|
||||
/// Single LLM call (optionally with tools). Cheap and fast.
|
||||
Lightweight {
|
||||
/// The prompt sent to the LLM.
|
||||
prompt: String,
|
||||
@@ -217,6 +217,14 @@ pub enum RoutineAction {
|
||||
/// Max output tokens (default: 4096).
|
||||
#[serde(default = "default_max_tokens")]
|
||||
max_tokens: u32,
|
||||
/// Enable tool access (default: false for backward compatibility).
|
||||
/// When true, the LLM can call tools during execution.
|
||||
/// Tools requiring approval are automatically filtered out.
|
||||
#[serde(default)]
|
||||
use_tools: bool,
|
||||
/// Max tool call rounds (default: 3). Only used when use_tools is true.
|
||||
#[serde(default = "default_max_tool_rounds")]
|
||||
max_tool_rounds: u32,
|
||||
},
|
||||
/// Full multi-turn worker job with tool access.
|
||||
FullJob {
|
||||
@@ -243,6 +251,19 @@ fn default_max_iterations() -> u32 {
|
||||
10
|
||||
}
|
||||
|
||||
fn default_max_tool_rounds() -> u32 {
|
||||
3
|
||||
}
|
||||
|
||||
/// Hard upper bound for max_tool_rounds to prevent runaway loops and cost explosion.
|
||||
pub(crate) const MAX_TOOL_ROUNDS_LIMIT: u32 = 20;
|
||||
|
||||
/// Clamp max_tool_rounds to [1, MAX_TOOL_ROUNDS_LIMIT].
|
||||
/// Accepts u64 to avoid truncation before clamping.
|
||||
fn clamp_max_tool_rounds(value: u64) -> u32 {
|
||||
value.clamp(1, MAX_TOOL_ROUNDS_LIMIT as u64) as u32
|
||||
}
|
||||
|
||||
/// Parse a `tool_permissions` JSON array into a `Vec<String>`.
|
||||
pub fn parse_tool_permissions(value: &serde_json::Value) -> Vec<String> {
|
||||
value
|
||||
@@ -290,10 +311,22 @@ impl RoutineAction {
|
||||
.get("max_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(default_max_tokens() as u64) as u32;
|
||||
let use_tools = config
|
||||
.get("use_tools")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let max_tool_rounds = clamp_max_tool_rounds(
|
||||
config
|
||||
.get("max_tool_rounds")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(default_max_tool_rounds() as u64),
|
||||
);
|
||||
Ok(RoutineAction::Lightweight {
|
||||
prompt,
|
||||
context_paths,
|
||||
max_tokens,
|
||||
use_tools,
|
||||
max_tool_rounds,
|
||||
})
|
||||
}
|
||||
"full_job" => {
|
||||
@@ -339,10 +372,14 @@ impl RoutineAction {
|
||||
prompt,
|
||||
context_paths,
|
||||
max_tokens,
|
||||
use_tools,
|
||||
max_tool_rounds,
|
||||
} => serde_json::json!({
|
||||
"prompt": prompt,
|
||||
"context_paths": context_paths,
|
||||
"max_tokens": max_tokens,
|
||||
"use_tools": use_tools,
|
||||
"max_tool_rounds": max_tool_rounds,
|
||||
}),
|
||||
RoutineAction::FullJob {
|
||||
title,
|
||||
@@ -504,7 +541,8 @@ pub fn next_cron_fire(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::agent::routine::{
|
||||
RoutineAction, RoutineGuardrails, RunStatus, Trigger, content_hash, next_cron_fire,
|
||||
MAX_TOOL_ROUNDS_LIMIT, RoutineAction, RoutineGuardrails, RunStatus, Trigger, content_hash,
|
||||
next_cron_fire,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -554,11 +592,13 @@ mod tests {
|
||||
prompt: "Check PRs".to_string(),
|
||||
context_paths: vec!["context/priorities.md".to_string()],
|
||||
max_tokens: 2048,
|
||||
use_tools: false,
|
||||
max_tool_rounds: 3,
|
||||
};
|
||||
let json = action.to_config_json();
|
||||
let parsed = RoutineAction::from_db("lightweight", json).expect("parse lightweight");
|
||||
assert!(
|
||||
matches!(parsed, RoutineAction::Lightweight { prompt, context_paths, max_tokens }
|
||||
matches!(parsed, RoutineAction::Lightweight { prompt, context_paths, max_tokens, .. }
|
||||
if prompt == "Check PRs" && context_paths.len() == 1 && max_tokens == 2048)
|
||||
);
|
||||
}
|
||||
@@ -695,4 +735,77 @@ mod tests {
|
||||
);
|
||||
assert_eq!(Trigger::Manual.type_tag(), "manual");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_action_lightweight_backward_compat_no_use_tools() {
|
||||
// Simulate old DB record without use_tools field
|
||||
let json = serde_json::json!({
|
||||
"prompt": "old routine",
|
||||
"context_paths": [],
|
||||
"max_tokens": 4096
|
||||
});
|
||||
let parsed = RoutineAction::from_db("lightweight", json).expect("parse lightweight");
|
||||
assert!(
|
||||
matches!(parsed, RoutineAction::Lightweight { use_tools, max_tool_rounds, .. }
|
||||
if !use_tools && max_tool_rounds == 3),
|
||||
"missing use_tools should default to false, max_tool_rounds to 3"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_max_tool_rounds_clamped_to_upper_bound() {
|
||||
let json = serde_json::json!({
|
||||
"prompt": "test",
|
||||
"use_tools": true,
|
||||
"max_tool_rounds": 9999
|
||||
});
|
||||
let parsed = RoutineAction::from_db("lightweight", json).expect("parse");
|
||||
match parsed {
|
||||
RoutineAction::Lightweight {
|
||||
max_tool_rounds, ..
|
||||
} => {
|
||||
assert_eq!(
|
||||
max_tool_rounds, MAX_TOOL_ROUNDS_LIMIT,
|
||||
"should clamp to MAX_TOOL_ROUNDS_LIMIT"
|
||||
);
|
||||
}
|
||||
_ => panic!("expected Lightweight"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_max_tool_rounds_clamped_to_lower_bound() {
|
||||
let json = serde_json::json!({
|
||||
"prompt": "test",
|
||||
"use_tools": true,
|
||||
"max_tool_rounds": 0
|
||||
});
|
||||
let parsed = RoutineAction::from_db("lightweight", json).expect("parse");
|
||||
match parsed {
|
||||
RoutineAction::Lightweight {
|
||||
max_tool_rounds, ..
|
||||
} => {
|
||||
assert_eq!(max_tool_rounds, 1, "should clamp 0 to 1");
|
||||
}
|
||||
_ => panic!("expected Lightweight"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_max_tool_rounds_normal_value_passes_through() {
|
||||
let json = serde_json::json!({
|
||||
"prompt": "test",
|
||||
"use_tools": true,
|
||||
"max_tool_rounds": 10
|
||||
});
|
||||
let parsed = RoutineAction::from_db("lightweight", json).expect("parse");
|
||||
match parsed {
|
||||
RoutineAction::Lightweight {
|
||||
max_tool_rounds, ..
|
||||
} => {
|
||||
assert_eq!(max_tool_rounds, 10, "normal value should pass through");
|
||||
}
|
||||
_ => panic!("expected Lightweight"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user