Files
optimclaw/src/tools/builtin/routine.rs
T

2510 lines
83 KiB
Rust

//! LLM-facing tools for managing routines.
//!
//! Seven tools let the agent manage routines conversationally:
//! - `routine_create` - Create a new routine
//! - `routine_list` - List all routines with status
//! - `routine_update` - Modify or toggle a routine
//! - `routine_delete` - Remove a routine
//! - `routine_fire` - Manually trigger a routine
//! - `routine_history` - View past runs
//! - `event_emit` - Emit a structured system event to `system_event`-triggered routines
use std::collections::HashMap;
use std::sync::{Arc, OnceLock};
use std::time::Duration;
use async_trait::async_trait;
use chrono::Utc;
use serde_json::{Map, Value};
use uuid::Uuid;
use crate::agent::routine::{
NotifyConfig, Routine, RoutineAction, RoutineGuardrails, Trigger, next_cron_fire,
normalize_cron_expression,
};
use crate::agent::routine_engine::RoutineEngine;
use crate::context::JobContext;
use crate::db::Database;
use crate::tools::tool::{
ApprovalRequirement, Tool, ToolDiscoverySummary, ToolError, ToolOutput, require_str,
};
// ==================== routine_create ====================
#[derive(Debug, Clone, PartialEq, Eq)]
enum NormalizedTriggerRequest {
Cron {
schedule: String,
timezone: Option<String>,
},
Manual,
MessageEvent {
pattern: String,
channel: Option<String>,
},
SystemEvent {
source: String,
event_type: String,
filters: HashMap<String, String>,
},
Webhook {
path: Option<String>,
secret: Option<String>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum NormalizedExecutionMode {
Lightweight,
FullJob,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct NormalizedExecutionRequest {
mode: NormalizedExecutionMode,
context_paths: Vec<String>,
use_tools: bool,
max_tool_rounds: u32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct NormalizedDeliveryRequest {
channel: Option<String>,
user: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct NormalizedRoutineCreateRequest {
name: String,
description: String,
prompt: String,
trigger: NormalizedTriggerRequest,
execution: NormalizedExecutionRequest,
delivery: NormalizedDeliveryRequest,
cooldown_secs: u64,
}
fn routine_request_properties() -> Value {
serde_json::json!({
"kind": {
"type": "string",
"enum": ["cron", "manual", "message_event", "system_event"],
"description": "How the routine should start."
},
"schedule": {
"type": "string",
"description": "Cron expression for request.kind='cron'. Uses 6-field cron: second minute hour day month weekday."
},
"timezone": {
"type": "string",
"description": "IANA timezone for request.kind='cron', such as 'America/New_York'."
},
"pattern": {
"type": "string",
"description": "Regex pattern for request.kind='message_event'."
},
"channel": {
"type": "string",
"description": "Optional channel filter for request.kind='message_event'."
},
"source": {
"type": "string",
"description": "Event source namespace for request.kind='system_event', such as 'github'."
},
"event_type": {
"type": "string",
"description": "Event type for request.kind='system_event', such as 'issue.opened'."
},
"filters": {
"type": "object",
"properties": {},
"additionalProperties": {
"type": ["string", "number", "boolean"]
},
"description": "Optional exact-match filters for request.kind='system_event'. Only top-level string, number, and boolean payload fields are matched."
}
})
}
fn execution_properties() -> Value {
serde_json::json!({
"mode": {
"type": "string",
"enum": ["lightweight", "full_job"],
"description": "Execution mode. 'lightweight' is the default. 'full_job' runs a multi-turn autonomous job."
},
"context_paths": {
"type": "array",
"items": { "type": "string" },
"description": "Workspace paths to preload for lightweight routines."
},
"use_tools": {
"type": "boolean",
"default": true,
"description": "Only applies to lightweight mode. New lightweight routines default this to true; when enabled, the routine can use the owner's live autonomous tool scope."
},
"max_tool_rounds": {
"type": "integer",
"minimum": 1,
"maximum": crate::agent::routine::MAX_TOOL_ROUNDS_LIMIT,
"default": 3,
"description": "Only applies when execution.mode='lightweight' and use_tools=true. Runtime-capped to prevent loops."
}
})
}
fn delivery_properties() -> Value {
serde_json::json!({
"channel": {
"type": "string",
"description": "Default channel for notifications and routine job message calls."
},
"user": {
"type": "string",
"description": "Default user or target for notifications and routine job message calls. If omitted, the owner's last-seen notification target is used."
}
})
}
fn advanced_properties() -> Value {
serde_json::json!({
"cooldown_secs": {
"type": "integer",
"description": "Minimum seconds between automatic fires. Manual fires still bypass cooldown."
}
})
}
fn manual_request_variant() -> Value {
serde_json::json!({
"type": "object",
"description": "Manual routines run only when explicitly fired.",
"properties": {
"kind": {
"type": "string",
"enum": ["manual"],
"description": "Manual trigger."
}
},
"required": ["kind"]
})
}
fn cron_request_variant() -> Value {
serde_json::json!({
"type": "object",
"description": "Cron routines require request.schedule and may optionally set request.timezone.",
"properties": {
"kind": {
"type": "string",
"enum": ["cron"],
"description": "Scheduled trigger."
},
"schedule": {
"type": "string",
"description": "Cron expression for request.kind='cron'. Uses 6-field cron: second minute hour day month weekday."
},
"timezone": {
"type": "string",
"description": "IANA timezone for request.kind='cron', such as 'America/New_York'."
}
},
"required": ["kind", "schedule"]
})
}
fn message_event_request_variant() -> Value {
serde_json::json!({
"type": "object",
"description": "Message-event routines require request.pattern and may optionally filter by request.channel.",
"properties": {
"kind": {
"type": "string",
"enum": ["message_event"],
"description": "Pattern-matching message trigger."
},
"pattern": {
"type": "string",
"description": "Regex pattern for request.kind='message_event'."
},
"channel": {
"type": "string",
"description": "Optional channel filter for request.kind='message_event'."
}
},
"required": ["kind", "pattern"]
})
}
fn system_event_request_variant() -> Value {
serde_json::json!({
"type": "object",
"description": "System-event routines require request.source and request.event_type. request.filters is optional.",
"properties": {
"kind": {
"type": "string",
"enum": ["system_event"],
"description": "Structured event trigger."
},
"source": {
"type": "string",
"description": "Event source namespace for request.kind='system_event', such as 'github'."
},
"event_type": {
"type": "string",
"description": "Event type for request.kind='system_event', such as 'issue.opened'."
},
"filters": {
"type": "object",
"properties": {},
"additionalProperties": {
"type": ["string", "number", "boolean"]
},
"description": "Optional exact-match filters for request.kind='system_event'. Only top-level string, number, and boolean payload fields are matched."
}
},
"required": ["kind", "source", "event_type"]
})
}
fn routine_request_discovery_schema() -> Value {
serde_json::json!({
"type": "object",
"description": "Canonical trigger config. Set request.kind first, then follow the matching variant branch below.",
"properties": routine_request_properties(),
"required": ["kind"],
"oneOf": [
manual_request_variant(),
cron_request_variant(),
message_event_request_variant(),
system_event_request_variant()
],
"examples": [
{ "kind": "manual" },
{ "kind": "cron", "schedule": "0 0 9 * * MON-FRI", "timezone": "UTC" },
{ "kind": "message_event", "pattern": "deploy\\s+prod", "channel": "slack" },
{ "kind": "system_event", "source": "github", "event_type": "issue.opened", "filters": { "repository": "nearai/ironclaw" } }
]
})
}
fn lightweight_execution_variant() -> Value {
serde_json::json!({
"type": "object",
"description": "Default lightweight execution. Applies when execution is omitted or execution.mode='lightweight'. New lightweight routines default to tools enabled unless execution.use_tools=false is set.",
"properties": {
"mode": {
"type": "string",
"enum": ["lightweight"],
"description": "Lightweight execution mode."
},
"context_paths": {
"type": "array",
"items": { "type": "string" },
"description": "Workspace paths to preload for lightweight routines."
},
"use_tools": {
"type": "boolean",
"default": true,
"description": "Defaults to true for new lightweight routines. When enabled, the routine can use the owner's live autonomous tool scope."
},
"max_tool_rounds": {
"type": "integer",
"minimum": 1,
"maximum": crate::agent::routine::MAX_TOOL_ROUNDS_LIMIT,
"default": 3,
"description": "Only applies when use_tools=true. Runtime-capped to prevent loops."
}
}
})
}
fn full_job_execution_variant() -> Value {
serde_json::json!({
"type": "object",
"description": "Full-job execution. Uses the owner's live autonomous tool scope and ignores lightweight-only fields such as use_tools, max_tool_rounds, and context_paths.",
"properties": {
"mode": {
"type": "string",
"enum": ["full_job"],
"description": "Full-job execution mode."
}
},
"required": ["mode"]
})
}
fn execution_discovery_schema() -> Value {
serde_json::json!({
"type": "object",
"description": "Optional execution settings. Omit this block for the default lightweight mode with tools enabled.",
"properties": execution_properties(),
"oneOf": [
lightweight_execution_variant(),
full_job_execution_variant()
],
"examples": [
{ "mode": "lightweight", "use_tools": true, "max_tool_rounds": 3 },
{ "mode": "full_job" }
]
})
}
fn routine_create_examples() -> Vec<Value> {
vec![
serde_json::json!({
"name": "manual-check",
"prompt": "Inspect the repo for issues.",
"request": { "kind": "manual" }
}),
serde_json::json!({
"name": "weekday-digest",
"prompt": "Prepare the morning digest.",
"request": {
"kind": "cron",
"schedule": "0 0 9 * * MON-FRI",
"timezone": "UTC"
},
"delivery": {
"channel": "telegram",
"user": "ops-team"
}
}),
serde_json::json!({
"name": "deploy-watch",
"prompt": "Look for deploy requests.",
"request": {
"kind": "message_event",
"pattern": "deploy\\s+prod",
"channel": "slack"
},
"execution": {
"mode": "lightweight",
"use_tools": true,
"max_tool_rounds": 5
}
}),
serde_json::json!({
"name": "issue-watch",
"prompt": "Summarize new GitHub issues.",
"request": {
"kind": "system_event",
"source": "github",
"event_type": "issue.opened",
"filters": { "repository": "nearai/ironclaw" }
},
"execution": {
"mode": "full_job"
}
}),
]
}
fn routine_create_tool_summary() -> ToolDiscoverySummary {
ToolDiscoverySummary {
always_required: vec!["name".into(), "prompt".into(), "request.kind".into()],
conditional_requirements: vec![
"request.kind='cron' requires request.schedule.".into(),
"request.kind='message_event' requires request.pattern.".into(),
"request.kind='system_event' requires request.source and request.event_type.".into(),
"execution.mode='full_job' uses the owner's live autonomous tool scope and ignores use_tools, max_tool_rounds, and context_paths.".into(),
],
notes: vec![
"Omitting execution defaults to lightweight mode with tools enabled.".into(),
"Set execution.use_tools=false to keep a new lightweight routine text-only.".into(),
"Omitting delivery.user falls back to the owner's last-seen notification target.".into(),
"advanced.cooldown_secs defaults to 300.".into(),
"Legacy flat aliases are still accepted for compatibility, but grouped fields are preferred.".into(),
],
examples: routine_create_examples(),
}
}
fn routine_create_schema(include_compatibility_aliases: bool) -> Value {
let mut schema = serde_json::json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Unique name for the routine (e.g. 'daily-pr-review')."
},
"prompt": {
"type": "string",
"description": "Instructions for what the routine should do when it fires."
},
"description": {
"type": "string",
"description": "Optional human-readable summary of what the routine does."
},
"request": if include_compatibility_aliases {
routine_request_discovery_schema()
} else {
serde_json::json!({
"type": "object",
"description": "Canonical trigger config. Set request.kind first, then only fill fields that match that kind.",
"properties": routine_request_properties(),
"required": ["kind"]
})
},
"execution": if include_compatibility_aliases {
execution_discovery_schema()
} else {
serde_json::json!({
"type": "object",
"description": "Optional execution settings. Omit for the default lightweight mode.",
"properties": execution_properties()
})
},
"delivery": {
"type": "object",
"description": "Optional delivery defaults for notifications and message tool calls inside routine jobs.",
"properties": delivery_properties()
},
"advanced": {
"type": "object",
"description": "Optional advanced knobs. Most routines can omit this block.",
"properties": advanced_properties()
}
},
"required": ["name", "prompt"]
});
if include_compatibility_aliases {
if let Some(properties) = schema.get_mut("properties").and_then(Value::as_object_mut) {
properties.insert(
"trigger_type".to_string(),
serde_json::json!({
"type": "string",
"enum": ["cron", "event", "system_event", "manual"],
"description": "Compatibility alias for request.kind. Prefer request.kind."
}),
);
properties.insert(
"schedule".to_string(),
serde_json::json!({
"type": "string",
"description": "Compatibility alias for request.schedule. Prefer request.schedule."
}),
);
properties.insert(
"timezone".to_string(),
serde_json::json!({
"type": "string",
"description": "Compatibility alias for request.timezone. Prefer request.timezone."
}),
);
properties.insert(
"event_pattern".to_string(),
serde_json::json!({
"type": "string",
"description": "Compatibility alias for request.pattern when request.kind='message_event'."
}),
);
properties.insert(
"event_channel".to_string(),
serde_json::json!({
"type": "string",
"description": "Compatibility alias for request.channel when request.kind='message_event'."
}),
);
properties.insert(
"event_source".to_string(),
serde_json::json!({
"type": "string",
"description": "Compatibility alias for request.source when request.kind='system_event'."
}),
);
properties.insert(
"event_type".to_string(),
serde_json::json!({
"type": "string",
"description": "Compatibility alias for request.event_type when request.kind='system_event'."
}),
);
properties.insert(
"event_filters".to_string(),
serde_json::json!({
"type": "object",
"properties": {},
"additionalProperties": {
"type": ["string", "number", "boolean"]
},
"description": "Compatibility alias for request.filters when request.kind='system_event'."
}),
);
properties.insert(
"action_type".to_string(),
serde_json::json!({
"type": "string",
"enum": ["lightweight", "full_job"],
"description": "Compatibility alias for execution.mode."
}),
);
properties.insert(
"context_paths".to_string(),
serde_json::json!({
"type": "array",
"items": { "type": "string" },
"description": "Compatibility alias for execution.context_paths."
}),
);
properties.insert(
"use_tools".to_string(),
serde_json::json!({
"type": "boolean",
"description": "Compatibility alias for execution.use_tools."
}),
);
properties.insert(
"max_tool_rounds".to_string(),
serde_json::json!({
"type": "integer",
"minimum": 1,
"maximum": crate::agent::routine::MAX_TOOL_ROUNDS_LIMIT,
"default": 3,
"description": "Compatibility alias for execution.max_tool_rounds."
}),
);
properties.insert(
"notify_channel".to_string(),
serde_json::json!({
"type": "string",
"description": "Compatibility alias for delivery.channel."
}),
);
properties.insert(
"notify_user".to_string(),
serde_json::json!({
"type": "string",
"description": "Compatibility alias for delivery.user."
}),
);
properties.insert(
"cooldown_secs".to_string(),
serde_json::json!({
"type": "integer",
"description": "Compatibility alias for advanced.cooldown_secs."
}),
);
}
if let Some(schema_obj) = schema.as_object_mut() {
schema_obj.insert(
"anyOf".to_string(),
serde_json::json!([
{ "required": ["request"] },
{ "required": ["trigger_type"] }
]),
);
schema_obj.insert(
"examples".to_string(),
Value::Array(routine_create_examples()),
);
}
} else if let Some(required) = schema.get_mut("required").and_then(Value::as_array_mut) {
required.push(Value::String("request".to_string()));
}
schema
}
pub(crate) fn routine_create_parameters_schema() -> Value {
static CACHE: OnceLock<Value> = OnceLock::new();
CACHE.get_or_init(|| routine_create_schema(false)).clone()
}
fn routine_create_discovery_schema() -> Value {
static CACHE: OnceLock<Value> = OnceLock::new();
CACHE.get_or_init(|| routine_create_schema(true)).clone()
}
pub(crate) fn routine_update_parameters_schema() -> Value {
serde_json::json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Name of the routine to update"
},
"enabled": {
"type": "boolean",
"description": "Enable or disable the routine"
},
"prompt": {
"type": "string",
"description": "New prompt/instructions"
},
"schedule": {
"type": "string",
"description": "New cron schedule (for cron triggers)"
},
"timezone": {
"type": "string",
"description": "IANA timezone for cron schedule (e.g. 'America/New_York'). Only valid for cron triggers."
},
"description": {
"type": "string",
"description": "New description"
}
},
"required": ["name"]
})
}
const ROUTINE_LAST_NAME_STASH_KEY: &str = "__routine_last_name";
async fn stash_last_routine_name(ctx: &JobContext, name: &str) {
ctx.tool_output_stash
.write()
.await
.insert(ROUTINE_LAST_NAME_STASH_KEY.to_string(), name.to_string());
}
async fn restore_last_routine_name(ctx: &JobContext) -> Option<String> {
ctx.tool_output_stash
.read()
.await
.get(ROUTINE_LAST_NAME_STASH_KEY)
.cloned()
}
fn nested_object<'a>(params: &'a Value, field: &str) -> Option<&'a Map<String, Value>> {
params.get(field).and_then(Value::as_object)
}
fn string_field(params: &Value, group: &str, field: &str, aliases: &[&str]) -> Option<String> {
nested_object(params, group)
.and_then(|obj| obj.get(field))
.and_then(Value::as_str)
.map(String::from)
.or_else(|| {
aliases
.iter()
.find_map(|alias| params.get(*alias).and_then(Value::as_str).map(String::from))
})
}
fn bool_field(params: &Value, group: &str, field: &str, aliases: &[&str]) -> Option<bool> {
nested_object(params, group)
.and_then(|obj| obj.get(field))
.and_then(Value::as_bool)
.or_else(|| {
aliases
.iter()
.find_map(|alias| params.get(*alias).and_then(Value::as_bool))
})
}
fn u64_field(params: &Value, group: &str, field: &str, aliases: &[&str]) -> Option<u64> {
nested_object(params, group)
.and_then(|obj| obj.get(field))
.and_then(Value::as_u64)
.or_else(|| {
aliases
.iter()
.find_map(|alias| params.get(*alias).and_then(Value::as_u64))
})
}
fn string_array_field(params: &Value, group: &str, field: &str, aliases: &[&str]) -> Vec<String> {
nested_object(params, group)
.and_then(|obj| obj.get(field))
.and_then(Value::as_array)
.or_else(|| {
aliases
.iter()
.find_map(|alias| params.get(*alias).and_then(Value::as_array))
})
.map(|arr| {
let mut seen = std::collections::HashSet::new();
arr.iter()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.filter_map(|value| {
if seen.insert(value.to_string()) {
Some(value.to_string())
} else {
None
}
})
.collect()
})
.unwrap_or_default()
}
fn object_field(
params: &Value,
group: &str,
field: &str,
aliases: &[&str],
) -> Option<Map<String, Value>> {
nested_object(params, group)
.and_then(|obj| obj.get(field))
.and_then(Value::as_object)
.cloned()
.or_else(|| {
aliases
.iter()
.find_map(|alias| params.get(*alias).and_then(Value::as_object).cloned())
})
}
fn validate_timezone_param(timezone: Option<String>) -> Result<Option<String>, ToolError> {
timezone
.map(|tz| {
crate::timezone::parse_timezone(&tz)
.map(|_| tz.clone())
.ok_or_else(|| {
ToolError::InvalidParameters(format!("invalid IANA timezone: '{tz}'"))
})
})
.transpose()
}
fn parse_system_event_filters(
filters: Option<Map<String, Value>>,
) -> Result<HashMap<String, String>, ToolError> {
let Some(obj) = filters else {
return Ok(HashMap::new());
};
let mut parsed = HashMap::with_capacity(obj.len());
for (key, value) in obj {
let rendered = crate::agent::routine::json_value_as_filter_string(&value).ok_or_else(|| {
ToolError::InvalidParameters(format!(
"system_event filters only support string, number, and boolean values (invalid '{key}')"
))
})?;
parsed.insert(key, rendered);
}
Ok(parsed)
}
fn parse_routine_trigger(params: &Value) -> Result<NormalizedTriggerRequest, ToolError> {
let kind = string_field(params, "request", "kind", &["trigger_type"])
.map(|value| match value.as_str() {
"event" => "message_event".to_string(),
other => other.to_string(),
})
.ok_or_else(|| {
ToolError::InvalidParameters(
"routine_create requires request.kind (canonical) or trigger_type (legacy)"
.to_string(),
)
})?;
match kind.as_str() {
"cron" => {
let schedule =
string_field(params, "request", "schedule", &["schedule"]).ok_or_else(|| {
ToolError::InvalidParameters("cron request requires 'schedule'".to_string())
})?;
let timezone = validate_timezone_param(string_field(
params,
"request",
"timezone",
&["timezone"],
))?;
next_cron_fire(&schedule, timezone.as_deref())
.map_err(|e| ToolError::InvalidParameters(format!("invalid cron schedule: {e}")))?;
Ok(NormalizedTriggerRequest::Cron { schedule, timezone })
}
"manual" => Ok(NormalizedTriggerRequest::Manual),
"message_event" => {
let pattern = string_field(params, "request", "pattern", &["event_pattern"])
.ok_or_else(|| {
ToolError::InvalidParameters(
"message_event request requires 'pattern'".to_string(),
)
})?;
regex::RegexBuilder::new(&pattern)
.size_limit(64 * 1024)
.build()
.map_err(|e| {
ToolError::InvalidParameters(format!("invalid or too complex regex: {e}"))
})?;
let channel = string_field(params, "request", "channel", &["event_channel"]);
Ok(NormalizedTriggerRequest::MessageEvent { pattern, channel })
}
"system_event" => {
let source =
string_field(params, "request", "source", &["event_source"]).ok_or_else(|| {
ToolError::InvalidParameters(
"system_event request requires 'source'".to_string(),
)
})?;
let event_type = string_field(params, "request", "event_type", &["event_type"])
.ok_or_else(|| {
ToolError::InvalidParameters(
"system_event request requires 'event_type'".to_string(),
)
})?;
let filters = parse_system_event_filters(object_field(
params,
"request",
"filters",
&["event_filters"],
))?;
Ok(NormalizedTriggerRequest::SystemEvent {
source,
event_type,
filters,
})
}
"webhook" => {
let path = string_field(params, "request", "path", &["webhook_path"]);
let secret = string_field(params, "request", "secret", &["webhook_secret"]);
Ok(NormalizedTriggerRequest::Webhook { path, secret })
}
other => Err(ToolError::InvalidParameters(format!(
"unknown request.kind: {other}"
))),
}
}
fn parse_execution_mode(value: Option<String>) -> Result<NormalizedExecutionMode, ToolError> {
match value.as_deref().unwrap_or("lightweight") {
"lightweight" => Ok(NormalizedExecutionMode::Lightweight),
"full_job" => Ok(NormalizedExecutionMode::FullJob),
other => Err(ToolError::InvalidParameters(format!(
"unknown execution mode: {other}"
))),
}
}
fn parse_routine_execution(
params: &Value,
default_use_tools: bool,
) -> Result<NormalizedExecutionRequest, ToolError> {
let mode = parse_execution_mode(string_field(params, "execution", "mode", &["action_type"]))?;
let context_paths =
string_array_field(params, "execution", "context_paths", &["context_paths"]);
let use_tools =
bool_field(params, "execution", "use_tools", &["use_tools"]).unwrap_or(default_use_tools);
let max_tool_rounds = u64_field(params, "execution", "max_tool_rounds", &["max_tool_rounds"])
.unwrap_or(3)
.clamp(1, crate::agent::routine::MAX_TOOL_ROUNDS_LIMIT as u64)
as u32;
Ok(NormalizedExecutionRequest {
mode,
context_paths,
use_tools,
max_tool_rounds,
})
}
fn parse_routine_delivery(params: &Value) -> NormalizedDeliveryRequest {
NormalizedDeliveryRequest {
channel: string_field(params, "delivery", "channel", &["notify_channel"]),
user: string_field(params, "delivery", "user", &["notify_user"]),
}
}
fn parse_routine_create_request(
params: &Value,
) -> Result<NormalizedRoutineCreateRequest, ToolError> {
let name = require_str(params, "name")?.to_string();
let prompt = require_str(params, "prompt")?.to_string();
let description = params
.get("description")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let trigger = parse_routine_trigger(params)?;
let execution = parse_routine_execution(params, true)?;
let delivery = parse_routine_delivery(params);
let cooldown_secs =
u64_field(params, "advanced", "cooldown_secs", &["cooldown_secs"]).unwrap_or(300);
Ok(NormalizedRoutineCreateRequest {
name,
description,
prompt,
trigger,
execution,
delivery,
cooldown_secs,
})
}
fn build_routine_trigger(trigger: &NormalizedTriggerRequest) -> Trigger {
match trigger {
NormalizedTriggerRequest::Cron { schedule, timezone } => Trigger::Cron {
schedule: normalize_cron_expression(schedule),
timezone: timezone.clone(),
},
NormalizedTriggerRequest::Manual => Trigger::Manual,
NormalizedTriggerRequest::MessageEvent { pattern, channel } => Trigger::Event {
channel: channel.clone(),
pattern: pattern.clone(),
},
NormalizedTriggerRequest::SystemEvent {
source,
event_type,
filters,
} => Trigger::SystemEvent {
source: source.clone(),
event_type: event_type.clone(),
filters: filters.clone(),
},
NormalizedTriggerRequest::Webhook { path, secret } => Trigger::Webhook {
path: path.clone(),
secret: secret.clone(),
},
}
}
fn build_routine_action(
name: &str,
prompt: &str,
execution: &NormalizedExecutionRequest,
) -> RoutineAction {
match execution.mode {
NormalizedExecutionMode::Lightweight => RoutineAction::Lightweight {
prompt: prompt.to_string(),
context_paths: execution.context_paths.clone(),
max_tokens: 4096,
use_tools: execution.use_tools,
max_tool_rounds: execution.max_tool_rounds,
},
NormalizedExecutionMode::FullJob => RoutineAction::FullJob {
title: name.to_string(),
description: prompt.to_string(),
max_iterations: 10,
},
}
}
fn routine_requests_full_job(params: &Value) -> bool {
matches!(
string_field(params, "execution", "mode", &["action_type"]).as_deref(),
Some("full_job")
)
}
fn event_emit_schema(include_source_alias: bool) -> Value {
let mut schema = serde_json::json!({
"type": "object",
"properties": {
"event_source": {
"type": "string",
"description": "Canonical event source, such as 'github'."
},
"event_type": {
"type": "string",
"description": "Event type, such as 'issue.opened'."
},
"payload": {
"properties": {},
"type": "object",
"description": "Structured event payload."
}
},
"required": ["event_type"]
});
if include_source_alias {
if let Some(properties) = schema.get_mut("properties").and_then(Value::as_object_mut) {
properties.insert(
"source".to_string(),
serde_json::json!({
"type": "string",
"description": "Compatibility alias for event_source."
}),
);
}
if let Some(schema_obj) = schema.as_object_mut() {
schema_obj.insert(
"anyOf".to_string(),
serde_json::json!([
{ "required": ["event_source"] },
{ "required": ["source"] }
]),
);
}
} else if let Some(required) = schema.get_mut("required").and_then(Value::as_array_mut) {
required.push(Value::String("event_source".to_string()));
}
schema
}
pub(crate) fn event_emit_parameters_schema() -> Value {
static CACHE: OnceLock<Value> = OnceLock::new();
CACHE.get_or_init(|| event_emit_schema(false)).clone()
}
fn event_emit_discovery_schema() -> Value {
static CACHE: OnceLock<Value> = OnceLock::new();
CACHE.get_or_init(|| event_emit_schema(true)).clone()
}
fn parse_event_emit_args(params: &Value) -> Result<(String, String, Value), ToolError> {
let source = params
.get("event_source")
.and_then(Value::as_str)
.or_else(|| params.get("source").and_then(Value::as_str))
.ok_or_else(|| {
ToolError::InvalidParameters(
"event_emit requires 'event_source' (canonical) or 'source' (alias)".to_string(),
)
})?
.to_string();
let event_type = require_str(params, "event_type")?.to_string();
let payload = params
.get("payload")
.cloned()
.unwrap_or_else(|| serde_json::json!({}));
Ok((source, event_type, payload))
}
pub struct RoutineCreateTool {
store: Arc<dyn Database>,
engine: Arc<RoutineEngine>,
}
impl RoutineCreateTool {
pub fn new(store: Arc<dyn Database>, engine: Arc<RoutineEngine>) -> Self {
Self { store, engine }
}
}
#[async_trait]
impl Tool for RoutineCreateTool {
fn name(&self) -> &str {
"routine_create"
}
fn description(&self) -> &str {
"Create a new routine (scheduled or event-driven task). \
Supports cron schedules, event pattern matching, system events, and manual triggers. \
Use this when the user wants something to happen periodically or reactively."
}
fn requires_approval(&self, params: &serde_json::Value) -> ApprovalRequirement {
if routine_requests_full_job(params) {
ApprovalRequirement::UnlessAutoApproved
} else {
ApprovalRequirement::Never
}
}
fn parameters_schema(&self) -> serde_json::Value {
routine_create_parameters_schema()
}
fn discovery_schema(&self) -> serde_json::Value {
routine_create_discovery_schema()
}
fn discovery_summary(&self) -> Option<ToolDiscoverySummary> {
Some(routine_create_tool_summary())
}
async fn execute(
&self,
params: serde_json::Value,
ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();
let normalized = parse_routine_create_request(&params)?;
stash_last_routine_name(ctx, &normalized.name).await;
let trigger = build_routine_trigger(&normalized.trigger);
let action =
build_routine_action(&normalized.name, &normalized.prompt, &normalized.execution);
// Compute next fire time for cron
let next_fire = if let Trigger::Cron {
ref schedule,
ref timezone,
} = trigger
{
next_cron_fire(schedule, timezone.as_deref()).unwrap_or(None)
} else {
None
};
let routine = Routine {
id: Uuid::new_v4(),
name: normalized.name.clone(),
description: normalized.description.clone(),
user_id: ctx.user_id.clone(),
enabled: true,
trigger,
action,
guardrails: RoutineGuardrails {
cooldown: Duration::from_secs(normalized.cooldown_secs),
max_concurrent: 1,
dedup_window: None,
},
notify: NotifyConfig {
channel: normalized.delivery.channel.clone(),
user: normalized.delivery.user.clone(),
..NotifyConfig::default()
},
last_run_at: None,
next_fire_at: next_fire,
run_count: 0,
consecutive_failures: 0,
state: serde_json::json!({}),
created_at: Utc::now(),
updated_at: Utc::now(),
};
self.store
.create_routine(&routine)
.await
.map_err(|e| ToolError::ExecutionFailed(format!("failed to create routine: {e}")))?;
// Refresh event cache if this is an event trigger
if matches!(
routine.trigger,
Trigger::Event { .. } | Trigger::SystemEvent { .. }
) {
self.engine.refresh_event_cache().await;
}
let result = serde_json::json!({
"id": routine.id.to_string(),
"name": routine.name,
"trigger_type": routine.trigger.type_tag(),
"next_fire_at": routine.next_fire_at.map(|t| t.to_rfc3339()),
"status": "created",
});
Ok(ToolOutput::success(result, start.elapsed()))
}
fn requires_sanitization(&self) -> bool {
false
}
}
// ==================== routine_list ====================
pub struct RoutineListTool {
store: Arc<dyn Database>,
}
impl RoutineListTool {
pub fn new(store: Arc<dyn Database>) -> Self {
Self { store }
}
}
#[async_trait]
impl Tool for RoutineListTool {
fn name(&self) -> &str {
"routine_list"
}
fn description(&self) -> &str {
"List all routines with their status, trigger info, and next fire time."
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {},
"required": []
})
}
async fn execute(
&self,
_params: serde_json::Value,
ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();
let routines = self
.store
.list_routines(&ctx.user_id)
.await
.map_err(|e| ToolError::ExecutionFailed(format!("failed to list routines: {e}")))?;
let list: Vec<serde_json::Value> = routines
.iter()
.map(|r| {
serde_json::json!({
"id": r.id.to_string(),
"name": r.name,
"description": r.description,
"enabled": r.enabled,
"trigger_type": r.trigger.type_tag(),
"action_type": r.action.type_tag(),
"last_run_at": r.last_run_at.map(|t| t.to_rfc3339()),
"next_fire_at": r.next_fire_at.map(|t| t.to_rfc3339()),
"run_count": r.run_count,
"consecutive_failures": r.consecutive_failures,
})
})
.collect();
let result = serde_json::json!({
"count": list.len(),
"routines": list,
});
Ok(ToolOutput::success(result, start.elapsed()))
}
fn requires_sanitization(&self) -> bool {
false
}
}
// ==================== routine_update ====================
pub struct RoutineUpdateTool {
store: Arc<dyn Database>,
engine: Arc<RoutineEngine>,
}
impl RoutineUpdateTool {
pub fn new(store: Arc<dyn Database>, engine: Arc<RoutineEngine>) -> Self {
Self { store, engine }
}
}
#[async_trait]
impl Tool for RoutineUpdateTool {
fn name(&self) -> &str {
"routine_update"
}
fn description(&self) -> &str {
"Update an existing routine. Can change prompt, description, enabled state, cron schedule/timezone, \
Pass the routine name and only the fields you want to change. This does not convert trigger types."
}
fn parameters_schema(&self) -> serde_json::Value {
routine_update_parameters_schema()
}
async fn execute(
&self,
params: serde_json::Value,
ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();
let name = require_str(&params, "name")?;
stash_last_routine_name(ctx, name).await;
let mut routine = self
.store
.get_routine_by_name(&ctx.user_id, name)
.await
.map_err(|e| ToolError::ExecutionFailed(format!("DB error: {e}")))?
.ok_or_else(|| ToolError::ExecutionFailed(format!("routine '{}' not found", name)))?;
// Apply updates
if let Some(enabled) = params.get("enabled").and_then(|v| v.as_bool()) {
routine.enabled = enabled;
}
if let Some(desc) = params.get("description").and_then(|v| v.as_str()) {
routine.description = desc.to_string();
}
if let Some(prompt) = params.get("prompt").and_then(|v| v.as_str()) {
match &mut routine.action {
RoutineAction::Lightweight { prompt: p, .. } => *p = prompt.to_string(),
RoutineAction::FullJob { description: d, .. } => *d = prompt.to_string(),
}
}
// Validate timezone param if provided
let new_timezone = params
.get("timezone")
.and_then(|v| v.as_str())
.map(|tz| {
crate::timezone::parse_timezone(tz)
.map(|_| tz.to_string())
.ok_or_else(|| {
ToolError::InvalidParameters(format!("invalid IANA timezone: '{tz}'"))
})
})
.transpose()?;
let new_schedule = params
.get("schedule")
.and_then(|v| v.as_str())
.map(normalize_cron_expression);
if new_schedule.is_some() || new_timezone.is_some() {
// Extract existing cron fields (cloned to avoid borrow conflict)
let existing_cron = match &routine.trigger {
Trigger::Cron { schedule, timezone } => Some((schedule.clone(), timezone.clone())),
_ => None,
};
if let Some((old_schedule, old_tz)) = existing_cron {
let effective_schedule = new_schedule.as_deref().unwrap_or(&old_schedule);
let effective_tz = new_timezone.or(old_tz);
// Validate
next_cron_fire(effective_schedule, effective_tz.as_deref()).map_err(|e| {
ToolError::InvalidParameters(format!("invalid cron schedule: {e}"))
})?;
routine.trigger = Trigger::Cron {
schedule: effective_schedule.to_string(),
timezone: effective_tz.clone(),
};
routine.next_fire_at =
next_cron_fire(effective_schedule, effective_tz.as_deref()).unwrap_or(None);
} else {
return Err(ToolError::InvalidParameters(
"Cannot update schedule or timezone on a non-cron routine.".to_string(),
));
}
}
self.store
.update_routine(&routine)
.await
.map_err(|e| ToolError::ExecutionFailed(format!("failed to update: {e}")))?;
// Refresh event cache in case trigger changed
self.engine.refresh_event_cache().await;
let result = serde_json::json!({
"name": routine.name,
"enabled": routine.enabled,
"trigger_type": routine.trigger.type_tag(),
"next_fire_at": routine.next_fire_at.map(|t| t.to_rfc3339()),
"status": "updated",
});
Ok(ToolOutput::success(result, start.elapsed()))
}
fn requires_sanitization(&self) -> bool {
false
}
}
// ==================== routine_delete ====================
pub struct RoutineDeleteTool {
store: Arc<dyn Database>,
engine: Arc<RoutineEngine>,
}
impl RoutineDeleteTool {
pub fn new(store: Arc<dyn Database>, engine: Arc<RoutineEngine>) -> Self {
Self { store, engine }
}
}
#[async_trait]
impl Tool for RoutineDeleteTool {
fn name(&self) -> &str {
"routine_delete"
}
fn description(&self) -> &str {
"Delete a routine permanently. This also removes all run history."
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Name of the routine to delete"
}
},
"required": ["name"]
})
}
async fn execute(
&self,
params: serde_json::Value,
ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();
let name = if let Some(name) = params.get("name").and_then(|v| v.as_str()) {
if name.trim().is_empty() {
return Err(ToolError::InvalidParameters(
"'name' parameter cannot be empty".to_string(),
));
}
name.to_string()
} else {
restore_last_routine_name(ctx).await.ok_or_else(|| {
ToolError::InvalidParameters(
"missing 'name' parameter and no previous routine target to infer".to_string(),
)
})?
};
let routine = self
.store
.get_routine_by_name(&ctx.user_id, &name)
.await
.map_err(|e| ToolError::ExecutionFailed(format!("DB error: {e}")))?
.ok_or_else(|| ToolError::ExecutionFailed(format!("routine '{}' not found", name)))?;
let deleted = self
.store
.delete_routine(routine.id)
.await
.map_err(|e| ToolError::ExecutionFailed(format!("failed to delete: {e}")))?;
// Refresh event cache
self.engine.refresh_event_cache().await;
let result = serde_json::json!({
"name": &name,
"deleted": deleted,
});
Ok(ToolOutput::success(result, start.elapsed()))
}
fn requires_sanitization(&self) -> bool {
false
}
}
// ==================== routine_fire ====================
pub struct RoutineFireTool {
store: Arc<dyn Database>,
engine: Arc<RoutineEngine>,
}
impl RoutineFireTool {
pub fn new(store: Arc<dyn Database>, engine: Arc<RoutineEngine>) -> Self {
Self { store, engine }
}
}
#[async_trait]
impl Tool for RoutineFireTool {
fn name(&self) -> &str {
"routine_fire"
}
fn description(&self) -> &str {
"Manually trigger a routine to run immediately, bypassing schedule, trigger type, and cooldown."
}
fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
// Firing a routine can dispatch a full_job with pre-authorized Always-gated tools,
// so this is a meaningful escalation that warrants auto-approval gating.
ApprovalRequirement::UnlessAutoApproved
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Name of the routine to fire"
}
},
"required": ["name"]
})
}
async fn execute(
&self,
params: serde_json::Value,
ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();
let name = require_str(&params, "name")?;
let routine = self
.store
.get_routine_by_name(&ctx.user_id, name)
.await
.map_err(|e| ToolError::ExecutionFailed(format!("DB error: {e}")))?
.ok_or_else(|| ToolError::ExecutionFailed(format!("routine '{}' not found", name)))?;
let run_id = self
.engine
.fire_manual(routine.id, None)
.await
.map_err(|e| {
ToolError::ExecutionFailed(format!("failed to fire routine '{}': {e}", name))
})?;
let result = serde_json::json!({
"name": name,
"run_id": run_id.to_string(),
"status": "fired",
});
Ok(ToolOutput::success(result, start.elapsed()))
}
fn requires_sanitization(&self) -> bool {
false
}
}
// ==================== routine_history ====================
pub struct RoutineHistoryTool {
store: Arc<dyn Database>,
}
impl RoutineHistoryTool {
pub fn new(store: Arc<dyn Database>) -> Self {
Self { store }
}
}
#[async_trait]
impl Tool for RoutineHistoryTool {
fn name(&self) -> &str {
"routine_history"
}
fn description(&self) -> &str {
"View the execution history of a routine. Shows recent runs with status, duration, and results."
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Name of the routine"
},
"limit": {
"type": "integer",
"description": "Max runs to return (default: 10)",
"default": 10
}
},
"required": ["name"]
})
}
async fn execute(
&self,
params: serde_json::Value,
ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();
let name = require_str(&params, "name")?;
let limit = params
.get("limit")
.and_then(|v| v.as_i64())
.unwrap_or(10)
.min(50);
let routine = self
.store
.get_routine_by_name(&ctx.user_id, name)
.await
.map_err(|e| ToolError::ExecutionFailed(format!("DB error: {e}")))?
.ok_or_else(|| ToolError::ExecutionFailed(format!("routine '{}' not found", name)))?;
let runs = self
.store
.list_routine_runs(routine.id, limit)
.await
.map_err(|e| ToolError::ExecutionFailed(format!("failed to list runs: {e}")))?;
let run_list: Vec<serde_json::Value> = runs
.iter()
.map(|r| {
let duration_secs = r
.completed_at
.map(|c| c.signed_duration_since(r.started_at).num_seconds());
serde_json::json!({
"id": r.id.to_string(),
"trigger_type": r.trigger_type,
"trigger_detail": r.trigger_detail,
"started_at": r.started_at.to_rfc3339(),
"completed_at": r.completed_at.map(|t| t.to_rfc3339()),
"duration_secs": duration_secs,
"status": r.status.to_string(),
"result_summary": r.result_summary,
"tokens_used": r.tokens_used,
})
})
.collect();
let result = serde_json::json!({
"routine": name,
"total_runs": routine.run_count,
"runs": run_list,
});
Ok(ToolOutput::success(result, start.elapsed()))
}
fn requires_sanitization(&self) -> bool {
false
}
}
// ==================== event_emit ====================
pub struct EventEmitTool {
engine: Arc<RoutineEngine>,
}
impl EventEmitTool {
pub fn new(engine: Arc<RoutineEngine>) -> Self {
Self { engine }
}
}
#[async_trait]
impl Tool for EventEmitTool {
fn name(&self) -> &str {
"event_emit"
}
fn description(&self) -> &str {
"Emit a structured system event to routines with a system_event trigger. \
Use this to trigger routines from tool workflows without waiting for cron."
}
fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
// Emitting an event can fire system_event routines that dispatch full_jobs
// with pre-authorized Always-gated tools — same escalation risk as routine_fire.
ApprovalRequirement::UnlessAutoApproved
}
fn parameters_schema(&self) -> serde_json::Value {
event_emit_parameters_schema()
}
fn discovery_schema(&self) -> serde_json::Value {
event_emit_discovery_schema()
}
async fn execute(
&self,
params: serde_json::Value,
ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();
let (source, event_type, payload) = parse_event_emit_args(&params)?;
let fired = self
.engine
.emit_system_event(&source, &event_type, &payload, Some(&ctx.user_id))
.await;
let result = serde_json::json!({
"event_source": &source,
"event_type": &event_type,
"user_id": &ctx.user_id,
"fired_routines": fired,
});
Ok(ToolOutput::success(result, start.elapsed()))
}
fn requires_sanitization(&self) -> bool {
true
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::validate_tool_schema;
// These tests intentionally use direct assertion macros.
const ROUTINE_CREATE_LEGACY_ALIASES: &[&str] = &[
"trigger_type",
"schedule",
"timezone",
"event_pattern",
"event_channel",
"event_source",
"event_type",
"event_filters",
"action_type",
"context_paths",
"use_tools",
"max_tool_rounds",
"notify_channel",
"notify_user",
"cooldown_secs",
];
fn schema_property<'a>(schema: &'a Value, name: &str) -> &'a Value {
schema
.get("properties")
.and_then(Value::as_object)
.and_then(|properties| properties.get(name))
.unwrap_or_else(|| panic!("missing schema property {name}"))
}
fn maybe_schema_property<'a>(schema: &'a Value, name: &str) -> Option<&'a Value> {
schema
.get("properties")
.and_then(Value::as_object)
.and_then(|properties| properties.get(name))
}
fn nested_schema_property<'a>(schema: &'a Value, object_name: &str, name: &str) -> &'a Value {
schema_property(schema, object_name)
.get("properties")
.and_then(Value::as_object)
.and_then(|properties| properties.get(name))
.unwrap_or_else(|| panic!("missing nested schema property {object_name}.{name}"))
}
fn variant_with_kind<'a>(variants: &'a [Value], kind: &str) -> &'a Value {
variants
.iter()
.find(|variant| {
variant
.get("properties")
.and_then(Value::as_object)
.and_then(|properties| properties.get("kind"))
.and_then(|kind_schema| kind_schema.get("enum"))
.and_then(Value::as_array)
.is_some_and(|enums| enums.contains(&Value::String(kind.to_string())))
})
.unwrap_or_else(|| panic!("missing variant for kind={kind}"))
}
fn variant_with_mode<'a>(variants: &'a [Value], mode: &str) -> &'a Value {
variants
.iter()
.find(|variant| {
variant
.get("properties")
.and_then(Value::as_object)
.and_then(|properties| properties.get("mode"))
.and_then(|mode_schema| mode_schema.get("enum"))
.and_then(Value::as_array)
.is_some_and(|enums| enums.contains(&Value::String(mode.to_string())))
})
.unwrap_or_else(|| panic!("missing variant for mode={mode}"))
}
#[test]
fn parses_grouped_manual_lightweight_request() {
let params = serde_json::json!({
"name": "manual-check",
"prompt": "Inspect the repo for issues.",
"request": {
"kind": "manual"
}
});
let parsed = parse_routine_create_request(&params).expect("parse grouped manual request");
assert_eq!(parsed.name.as_str(), "manual-check");
assert_eq!(parsed.prompt.as_str(), "Inspect the repo for issues.");
assert!(
matches!(parsed.trigger, NormalizedTriggerRequest::Manual),
"expected manual trigger",
);
assert!(
matches!(parsed.execution.mode, NormalizedExecutionMode::Lightweight),
"expected lightweight execution mode",
);
assert_eq!(parsed.cooldown_secs, 300);
assert!(
parsed.delivery.user.is_none(),
"expected omitted delivery.user to remain unspecified",
);
}
#[test]
fn parses_grouped_cron_full_job_request() {
let params = serde_json::json!({
"name": "weekday-digest",
"prompt": "Prepare the morning digest.",
"request": {
"kind": "cron",
"schedule": "0 0 9 * * MON-FRI",
"timezone": "UTC"
},
"execution": {
"mode": "full_job"
},
"delivery": {
"channel": "telegram",
"user": "ops-team"
},
"advanced": {
"cooldown_secs": 30
}
});
let parsed = parse_routine_create_request(&params).expect("parse grouped cron request");
assert!(
matches!(
parsed.trigger,
NormalizedTriggerRequest::Cron { ref schedule, ref timezone }
if schedule == "0 0 9 * * MON-FRI" && timezone.as_deref() == Some("UTC")
),
"expected grouped cron trigger",
);
assert!(
matches!(parsed.execution.mode, NormalizedExecutionMode::FullJob),
"expected full_job execution mode",
);
assert_eq!(parsed.delivery.channel.as_deref(), Some("telegram"));
assert_eq!(parsed.delivery.user.as_deref(), Some("ops-team"));
assert_eq!(parsed.cooldown_secs, 30);
}
#[test]
fn build_routine_trigger_normalizes_cron_schedule() {
let trigger = build_routine_trigger(&NormalizedTriggerRequest::Cron {
schedule: "0 0 9 * * MON-FRI".to_string(),
timezone: Some("UTC".to_string()),
});
assert!(matches!(
trigger,
Trigger::Cron { schedule, timezone }
if schedule == "0 0 9 * * MON-FRI *" && timezone.as_deref() == Some("UTC")
));
}
#[test]
fn parses_grouped_message_event_with_tools() {
let params = serde_json::json!({
"name": "deploy-watch",
"prompt": "Look for deploy requests.",
"request": {
"kind": "message_event",
"pattern": "deploy\\s+prod",
"channel": "slack"
},
"execution": {
"use_tools": true,
"max_tool_rounds": 5,
"context_paths": ["context/deploy.md"]
}
});
let parsed =
parse_routine_create_request(&params).expect("parse grouped message event request");
assert!(
matches!(
parsed.trigger,
NormalizedTriggerRequest::MessageEvent { ref pattern, ref channel }
if pattern == "deploy\\s+prod" && channel.as_deref() == Some("slack")
),
"expected grouped message_event trigger",
);
assert!(parsed.execution.use_tools, "expected use_tools=true");
assert_eq!(parsed.execution.max_tool_rounds, 5);
assert_eq!(
parsed.execution.context_paths,
vec!["context/deploy.md".to_string()],
);
}
#[test]
fn parses_lightweight_create_with_tools_enabled_by_default() {
let params = serde_json::json!({
"name": "manual-check",
"prompt": "Inspect the repo for issues.",
"request": {
"kind": "manual"
}
});
let parsed = parse_routine_create_request(&params).expect("parse default lightweight");
assert!(
matches!(parsed.execution.mode, NormalizedExecutionMode::Lightweight),
"expected lightweight execution mode",
);
assert!(
parsed.execution.use_tools,
"new lightweight routines should default use_tools=true",
);
assert_eq!(parsed.execution.max_tool_rounds, 3);
}
#[test]
fn parses_lightweight_create_with_explicit_tools_disabled() {
let params = serde_json::json!({
"name": "manual-check",
"prompt": "Inspect the repo for issues.",
"request": {
"kind": "manual"
},
"execution": {
"use_tools": false
}
});
let parsed =
parse_routine_create_request(&params).expect("parse lightweight with tools disabled");
assert!(
matches!(parsed.execution.mode, NormalizedExecutionMode::Lightweight),
"expected lightweight execution mode",
);
assert!(
!parsed.execution.use_tools,
"explicit use_tools=false should be preserved",
);
assert_eq!(parsed.execution.max_tool_rounds, 3);
}
#[test]
fn parses_context_paths_with_trim_drop_empty_and_stable_dedupe() {
let params = serde_json::json!({
"name": "deploy-watch",
"prompt": "Look for deploy requests.",
"request": {
"kind": "manual"
},
"execution": {
"context_paths": [
" context/deploy.md ",
"",
" ",
"context/deploy.md",
"context/notes.md"
]
}
});
let parsed =
parse_routine_create_request(&params).expect("parse context_paths normalization");
assert_eq!(
parsed.execution.context_paths,
vec![
"context/deploy.md".to_string(),
"context/notes.md".to_string()
],
);
}
#[test]
fn parses_grouped_system_event_request() {
let params = serde_json::json!({
"name": "issue-watch",
"prompt": "Summarize new GitHub issues.",
"request": {
"kind": "system_event",
"source": "github",
"event_type": "issue.opened",
"filters": {
"repository": "nearai/ironclaw",
"public": true,
"issue_number": 42
}
},
"execution": {
"mode": "full_job"
}
});
let parsed =
parse_routine_create_request(&params).expect("parse grouped system event request");
assert!(
matches!(
parsed.trigger,
NormalizedTriggerRequest::SystemEvent { ref source, ref event_type, ref filters }
if source == "github"
&& event_type == "issue.opened"
&& filters.get("repository") == Some(&"nearai/ironclaw".to_string())
&& filters.get("public") == Some(&"true".to_string())
&& filters.get("issue_number") == Some(&"42".to_string())
),
"expected grouped system_event trigger",
);
}
#[test]
fn rejects_system_event_filters_with_nested_values() {
let params = serde_json::json!({
"name": "issue-watch",
"prompt": "Summarize new GitHub issues.",
"request": {
"kind": "system_event",
"source": "github",
"event_type": "issue.opened",
"filters": {
"repository": {
"owner": "nearai",
"name": "ironclaw"
}
}
}
});
let err = parse_routine_create_request(&params)
.expect_err("reject nested system event filter values");
match err {
ToolError::InvalidParameters(message) => {
assert!(
message.contains(
"system_event filters only support string, number, and boolean values",
),
"unexpected invalid filter error: {message}",
)
}
other => panic!("expected InvalidParameters, got {other:?}"),
}
}
#[test]
fn parses_legacy_flat_shape() {
let params = serde_json::json!({
"name": "legacy-routine",
"prompt": "Legacy create path.",
"trigger_type": "event",
"event_pattern": "hello",
"event_channel": "telegram",
"action_type": "full_job",
"notify_channel": "telegram",
"notify_user": "123"
});
let parsed = parse_routine_create_request(&params).expect("parse legacy flat request");
assert!(
matches!(
parsed.trigger,
NormalizedTriggerRequest::MessageEvent { ref pattern, ref channel }
if pattern == "hello" && channel.as_deref() == Some("telegram")
),
"expected legacy message_event trigger",
);
assert!(
matches!(parsed.execution.mode, NormalizedExecutionMode::FullJob),
"expected full_job execution mode",
);
assert_eq!(parsed.delivery.channel.as_deref(), Some("telegram"));
assert_eq!(parsed.delivery.user.as_deref(), Some("123"));
}
#[test]
fn parses_mixed_grouped_and_legacy_aliases() {
let params = serde_json::json!({
"name": "mixed-routine",
"prompt": "Mixed payload.",
"request": {
"kind": "cron"
},
"schedule": "0 0 8 * * *",
"timezone": "UTC",
"execution": {
"mode": "lightweight"
},
"notify_user": "fallback-user",
"advanced": {
"cooldown_secs": 45
}
});
let parsed = parse_routine_create_request(&params).expect("parse mixed request");
assert!(
matches!(
parsed.trigger,
NormalizedTriggerRequest::Cron { ref schedule, ref timezone }
if schedule == "0 0 8 * * *" && timezone.as_deref() == Some("UTC")
),
"expected mixed cron trigger",
);
assert_eq!(parsed.delivery.user.as_deref(), Some("fallback-user"));
assert_eq!(parsed.cooldown_secs, 45);
}
#[test]
fn parses_event_emit_with_source_alias() {
let params = serde_json::json!({
"source": "github",
"event_type": "issue.opened",
"payload": { "issue_number": 7 }
});
let (source, event_type, payload) =
parse_event_emit_args(&params).expect("parse event_emit source alias");
assert_eq!(source, "github".to_string());
assert_eq!(event_type, "issue.opened".to_string());
assert_eq!(payload["issue_number"].clone(), serde_json::json!(7));
}
#[test]
fn parses_event_emit_with_event_source() {
let params = serde_json::json!({
"event_source": "github",
"event_type": "issue.opened"
});
let (source, event_type, payload) =
parse_event_emit_args(&params).expect("parse canonical event_emit args");
assert_eq!(source, "github".to_string());
assert_eq!(event_type, "issue.opened".to_string());
assert_eq!(payload, serde_json::json!({}));
}
#[test]
fn routine_create_parameters_schema_prefers_grouped_request_shape() {
let schema = routine_create_parameters_schema();
let errors = validate_tool_schema(&schema, "routine_create");
assert!(
errors.is_empty(),
"routine_create schema should validate cleanly: {errors:?}",
);
let request = schema_property(&schema, "request");
assert!(
request.is_object(),
"request should be present in compact schema",
);
let required = schema
.get("required")
.and_then(Value::as_array)
.expect("routine_create required list");
assert!(
required.contains(&Value::String("request".to_string())),
"compact parameters schema should require request",
);
for legacy_alias in ROUTINE_CREATE_LEGACY_ALIASES {
assert!(
maybe_schema_property(&schema, legacy_alias).is_none(),
"compact parameters schema should hide legacy alias",
);
}
}
#[test]
fn routine_create_discovery_schema_keeps_legacy_aliases() {
let schema = routine_create_discovery_schema();
let any_of = schema
.get("anyOf")
.and_then(Value::as_array)
.expect("routine_create discovery anyOf");
assert_eq!(any_of.len(), 2usize);
for legacy_alias in ROUTINE_CREATE_LEGACY_ALIASES {
assert!(
schema_property(&schema, legacy_alias).is_object(),
"discovery schema should retain legacy alias",
);
}
}
#[test]
fn routine_create_discovery_schema_splits_request_variants() {
let schema = routine_create_discovery_schema();
let request = schema_property(&schema, "request");
let variants = request
.get("oneOf")
.and_then(Value::as_array)
.expect("request.oneOf variants");
assert_eq!(variants.len(), 4usize);
let cron = variant_with_kind(variants, "cron");
let cron_required = cron
.get("required")
.and_then(Value::as_array)
.expect("cron required list");
assert!(
cron_required.contains(&Value::String("schedule".to_string())),
"cron variant should require schedule",
);
let message_event = variant_with_kind(variants, "message_event");
let message_required = message_event
.get("required")
.and_then(Value::as_array)
.expect("message_event required list");
assert!(
message_required.contains(&Value::String("pattern".to_string())),
"message_event variant should require pattern",
);
let system_event = variant_with_kind(variants, "system_event");
let system_required = system_event
.get("required")
.and_then(Value::as_array)
.expect("system_event required list");
assert!(
system_required.contains(&Value::String("source".to_string()))
&& system_required.contains(&Value::String("event_type".to_string())),
"system_event variant should require source and event_type",
);
}
#[test]
fn routine_create_discovery_schema_splits_execution_variants() {
let schema = routine_create_discovery_schema();
let execution = schema_property(&schema, "execution");
let variants = execution
.get("oneOf")
.and_then(Value::as_array)
.expect("execution.oneOf variants");
assert_eq!(variants.len(), 2usize);
let lightweight = variant_with_mode(variants, "lightweight");
let lightweight_props = lightweight
.get("properties")
.and_then(Value::as_object)
.expect("lightweight properties");
assert!(
lightweight_props.contains_key("use_tools")
&& lightweight_props.contains_key("context_paths")
&& lightweight_props.contains_key("max_tool_rounds"),
"lightweight variant should expose lightweight-only fields",
);
let full_job = variant_with_mode(variants, "full_job");
let full_job_props = full_job
.get("properties")
.and_then(Value::as_object)
.expect("full_job properties");
assert!(
full_job_props.len() == 1 && full_job_props.contains_key("mode"),
"full_job variant should only expose the execution mode",
);
}
#[test]
fn routine_create_discovery_summary_explains_rules_and_examples() {
let summary = routine_create_tool_summary();
assert_eq!(
summary.always_required,
vec![
"name".to_string(),
"prompt".to_string(),
"request.kind".to_string()
],
);
assert!(
summary
.conditional_requirements
.iter()
.any(|rule| rule.contains("request.kind='cron'")),
"summary should explain cron requirement",
);
assert!(
summary
.notes
.iter()
.any(|note| note.contains("lightweight mode with tools enabled")),
"summary should mention the new lightweight default",
);
assert!(
summary
.notes
.iter()
.any(|note| note.contains("execution.use_tools=false")),
"summary should mention the text-only opt-out",
);
assert!(
summary
.notes
.iter()
.any(|note| note.contains("Legacy flat aliases")),
"summary should mention legacy aliases",
);
assert_eq!(summary.examples.len(), 4usize);
}
#[test]
fn routine_create_parameters_schema_describes_grouped_trigger_fields() {
let schema = routine_create_parameters_schema();
let request_description = schema_property(&schema, "request")
.get("description")
.and_then(Value::as_str)
.expect("request description");
assert!(
request_description.contains("Set request.kind first"),
"request description should mention kind-first guidance",
);
let pattern_description = nested_schema_property(&schema, "request", "pattern")
.get("description")
.and_then(Value::as_str)
.expect("request.pattern description");
assert!(
pattern_description.contains("message_event"),
"pattern description should mention message_event",
);
let source_description = nested_schema_property(&schema, "request", "source")
.get("description")
.and_then(Value::as_str)
.expect("request.source description");
assert!(
source_description.contains("system_event"),
"source description should mention system_event",
);
let filters_description = nested_schema_property(&schema, "request", "filters")
.get("description")
.and_then(Value::as_str)
.expect("request.filters description");
assert!(
filters_description.contains("top-level string, number, and boolean"),
"filters description should mention supported scalar payload types",
);
let filters_schema = nested_schema_property(&schema, "request", "filters");
let additional_properties = filters_schema
.get("additionalProperties")
.expect("request.filters additionalProperties");
let allowed_types = additional_properties
.get("type")
.and_then(Value::as_array)
.expect("request.filters additionalProperties.type");
assert!(
allowed_types.contains(&Value::String("string".to_string()))
&& allowed_types.contains(&Value::String("number".to_string()))
&& allowed_types.contains(&Value::String("boolean".to_string())),
"filters schema should constrain additionalProperties to scalar values",
);
}
#[test]
fn routine_update_schema_exposes_supported_fields_and_limits() {
let schema = routine_update_parameters_schema();
let errors = validate_tool_schema(&schema, "routine_update");
assert!(
errors.is_empty(),
"routine_update schema should validate cleanly: {errors:?}",
);
for field in [
"name",
"enabled",
"prompt",
"schedule",
"timezone",
"description",
] {
let _ = schema_property(&schema, field);
}
let schedule_description = schema_property(&schema, "schedule")
.get("description")
.and_then(Value::as_str)
.expect("schedule description");
assert!(
schedule_description.contains("cron triggers"),
"schedule description should mention cron triggers",
);
let timezone_description = schema_property(&schema, "timezone")
.get("description")
.and_then(Value::as_str)
.expect("timezone description");
assert!(
timezone_description.contains("cron triggers"),
"timezone description should mention cron triggers",
);
}
#[test]
fn routine_create_detects_full_job_requests_for_approval() {
let full_job = serde_json::json!({
"name": "approve-me",
"prompt": "Run autonomously",
"request": { "kind": "manual" },
"execution": { "mode": "full_job" }
});
let lightweight = serde_json::json!({
"name": "safe",
"prompt": "Stay lightweight",
"request": { "kind": "manual" }
});
assert!(routine_requests_full_job(&full_job));
assert!(!routine_requests_full_job(&lightweight));
}
#[test]
fn event_emit_parameters_schema_prefers_canonical_event_source() {
let schema = event_emit_parameters_schema();
let errors = validate_tool_schema(&schema, "event_emit");
assert!(
errors.is_empty(),
"event_emit schema should validate cleanly: {errors:?}",
);
assert!(
schema_property(&schema, "event_source").is_object(),
"event_emit parameters schema should expose event_source",
);
let required = schema
.get("required")
.and_then(Value::as_array)
.expect("event_emit required list");
assert!(
required.contains(&Value::String("event_source".to_string())),
"event_emit parameters schema should require event_source",
);
assert!(
maybe_schema_property(&schema, "source").is_none(),
"event_emit parameters schema should hide source alias",
);
}
#[test]
fn event_emit_discovery_schema_keeps_source_alias() {
let schema = event_emit_discovery_schema();
let any_of = schema
.get("anyOf")
.and_then(Value::as_array)
.expect("event_emit discovery anyOf");
assert_eq!(any_of.len(), 2usize);
assert!(
schema_property(&schema, "source").is_object(),
"event_emit discovery schema should keep source alias",
);
}
#[test]
fn build_full_job_action_uses_live_owner_scope_defaults() {
let execution = NormalizedExecutionRequest {
mode: NormalizedExecutionMode::FullJob,
context_paths: Vec::new(),
use_tools: false,
max_tool_rounds: 3,
};
let action = build_routine_action("issue-1316", "Run it", &execution);
assert!(matches!(
action,
RoutineAction::FullJob {
title,
description,
max_iterations,
} if title == "issue-1316"
&& description == "Run it"
&& max_iterations == 10
));
}
}