Files
optimclaw/src/tools/builtin/routine.rs
T
6d847c6009 feat(webhooks): add public webhook trigger endpoint for routines (#736)
* feat(webhooks): add public webhook trigger endpoint for routines

Add POST /api/webhooks/{path} endpoint that matches incoming webhooks
against routines with Trigger::Webhook, validates secrets using
constant-time comparison (subtle crate), and fires the matched routine
through the message pipeline.

Closes #651

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(webhooks): address PR review feedback - access control, targeted query, rate limiting

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(ci): add missing webhook_rate_limiter field and fix formatting

Add the webhook_rate_limiter field to the GatewayState initializer in
gateway_workflow_harness.rs and fix rustfmt formatting for the webhook
tuple in types.rs.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(security): require webhook secret, add rate limiting, improve tests

Extract validate_webhook_secret() from the handler so the security-critical
secret validation logic (mandatory secret, constant-time comparison) is
directly testable without mocking the database layer. Improves the error
message for misconfigured routines to guide users toward the fix.

Replaces the previous unit tests (which only tested Rust pattern matching
and status code constants) with tests that exercise the actual validation
function against all rejection paths: missing secret (403), non-webhook
trigger (403), wrong secret (401), empty secret (401), and different-length
secret (401).

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* Route webhook triggers through RoutineEngine instead of chat pipeline

Adds fire_webhook() to RoutineEngine and updates the webhook handler
to use it. This ensures webhook-triggered routines get proper run
tracking, guardrail enforcement (cooldown + max_concurrent),
notifications, and FullJob dispatch support.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* style: fix formatting in webhook handler

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
Co-authored-by: [email protected] <[email protected]>
2026-03-20 15:50:31 -07:00

2391 lines
79 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",
"description": "Only applies to lightweight mode. When true, safe non-approval tools are available."
},
"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'.",
"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",
"description": "When true, safe non-approval tools are available."
},
"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.",
"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.".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 {
routine_create_schema(false)
}
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"]
})
}
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) -> 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(false);
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)?;
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: schedule.clone(),
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 {
event_emit_schema(false)
}
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)?;
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")?;
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 = 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 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 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_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("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
));
}
}