From 6e1ed939cc4df343e6913a5fd81815c867fcc15a Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Tue, 10 Mar 2026 18:08:04 +0000 Subject: [PATCH] Add event-triggered routines and workflow skill templates (#756) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add event-triggered routines and workflow skill templates * fix(ci): secrets can't be used in step if conditions [skip-regression-check] (#787) GitHub Actions step-level `if:` doesn't have access to `secrets` context. Replace `if: secrets.X != ''` with `continue-on-error: true` and let the Set token step handle the fallback. Co-authored-by: Claude Sonnet 4.6 * fix(ci): clean up staging pipeline — remove hacks, skip redundant checks [skip-regression-check] (#794) - Remove continue-on-error from staging-ci.yml app token steps (secrets are configured) - Skip test.yml and code_style.yml on PRs targeting staging (staging-ci.yml already runs tests before promoting, promotion PR gets full CI on main) - Allow ironclaw-ci[bot] in Claude Code review for bot-created promotion PRs Co-authored-by: Claude Opus 4.6 * fix: address PR review feedback for event_emit security and quality Security fixes: - Require approval (UnlessAutoApproved) for event_emit, matching routine_fire - Enable sanitization on event_emit payload (external JSON reaches LLM) - Remove user_id parameter from event_emit to prevent IDOR — always use ctx.user_id Correctness fixes: - Rename source → event_source in event_emit for consistency with routine_create - Use json_value_as_filter_string for filter parsing (handles numbers/booleans) - Case-insensitive matching for event source and event_type - Add debug logging for missing filter keys in payload - Fix skill_install_routine_webhook_sim test missing .with_skills() - Fix schema_validator test for event_emit payload properties Code quality: - Move EventEmitTool struct/impl after RoutineHistoryTool (fix split layout) - Deduplicate routine_to_info into RoutineInfo::from_routine in types.rs - Add test section headers in e2e_routine_heartbeat.rs - Clarify event_emit description to specify system_event routines only Co-Authored-By: Claude Opus 4.6 * fix(ci): run fmt + clippy on staging PRs, skip Windows clippy [skip-regression-check] (#802) - Remove branches:[main] filter from code_style.yml so it runs on all PRs - Gate clippy-windows with `if: github.base_ref == 'main'` (skip on staging PRs) - Update rollup job to allow skipped clippy-windows - Simplify claude-review.yml to only trigger on labeled event (avoids duplicate runs) Co-authored-by: Claude Opus 4.6 * feat: persist user_id in save_job and expose job_id on routine runs (#709) * feat: persist worker events to DB and fix activity tab rendering In-process Worker (used by Scheduler::dispatch_job) now persists events via save_job_event at key execution points: plan creation, LLM responses, tool_use, tool_result, and job completion/failure/stuck. Event data shapes match the container worker format so the gateway activity tab renders them correctly. Frontend: tool_result errors now show a red X icon with danger styling instead of a silent empty output. The result event falls back to the error field when message is absent. Co-Authored-By: Claude Opus 4.6 * feat: wire RoutineEngine into gateway for direct manual trigger firing Replace the message-channel hack in routines_trigger_handler with a direct call to RoutineEngine::fire_manual(), ensuring FullJob routines dispatch correctly when triggered from the web UI. Inject the engine into GatewayState from Agent::run after construction. Also persists user_id in save_job for both PG and libSQL backends, removes the source='sandbox' filter so all jobs are visible, and exposes job_id on RoutineRunInfo for the frontend job link. Co-Authored-By: Claude Opus 4.6 * fix: remove stale gateway_state argument from Agent::new test call sites The gateway_state parameter was removed from Agent::new during rebase (replaced by post-construction set_routine_engine_slot), but three test call sites still passed the extra None argument. Co-Authored-By: Claude Opus 4.6 * fix: address PR review — restore sandbox source filter, remove blank lines - Revert removal of `source = 'sandbox'` filter in all SandboxStore queries (8 sites across PG and libSQL). Sandbox-specific APIs should stay scoped to sandbox jobs; unified job listing for the Jobs tab should use a separate query path. - Remove extra blank lines in agent_loop.rs and worker.rs that caused formatting CI failure. [skip-regression-check] Co-Authored-By: Claude Opus 4.6 * fix: address review — regenerate Cargo.lock, add user_id regression test - Regenerate Cargo.lock from main's lockfile to eliminate dependency version downgrades (anyhow, syn, etc.) that were churn from rebase. - Add regression test verifying user_id round-trips through save_job and get_job in the libSQL backend. Co-Authored-By: Claude Opus 4.6 * style: remove trailing blank line in libsql jobs.rs [skip-regression-check] Co-Authored-By: Claude Opus 4.6 * test: add Postgres-side regression test for user_id persistence in save_job Mirrors the existing libSQL test (test_save_job_persists_user_id) for the Postgres backend. Gated behind #[cfg(feature = "postgres")] + #[ignore] since it requires a running PostgreSQL instance (integration tier). Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 * fix: make routine_system_event_emit test create routine before emitting - Add routine_create step to trace fixture so event_emit has a matching routine to fire - Assert fired_routines > 0, not just key presence (Copilot review) - Add .with_auto_approve_tools(true) since event_emit now requires approval Co-Authored-By: Claude Opus 4.6 * fix: renumber test headers after system_event test insertion Test 4 was duplicated (routine_cooldown and heartbeat_findings). Renumber heartbeat_findings to Test 5 and heartbeat_empty_skip to Test 6. [skip-regression-check] Co-Authored-By: Claude Opus 4.6 * fix: merge staging and add missing RoutineEngine args in test RoutineEngine::new on staging requires `tools` and `safety` params. Update system_event_trigger_matches_and_filters test to pass them. [skip-regression-check] Co-Authored-By: Claude Opus 4.6 * fix: address new Copilot review comments - Add .with_auto_approve_tools(true) to skill_install_routine_webhook_sim test so event_emit doesn't block on approval - Fix module-level doc comment for event_emit to specify system_event trigger [skip-regression-check] Co-Authored-By: Claude Opus 4.6 * fix: deduplicate json_value_as_string helper Remove private `json_value_as_string` from routine_engine.rs and use the identical public `json_value_as_filter_string` from routine.rs, eliminating divergence risk. (Copilot review) [skip-regression-check] Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Henry Park Co-authored-by: Claude Sonnet 4.6 --- .../ironclaw-workflow-orchestrator/SKILL.md | 80 ++++++++++ .../agents/openai.yaml | 4 + .../references/workflow-routines.md | 128 +++++++++++++++ src/agent/CLAUDE.md | 2 +- src/agent/routine.rs | 109 ++++++++++--- src/agent/routine_engine.rs | 109 ++++++++++++- src/channels/web/handlers/routines.rs | 50 +----- src/channels/web/server.rs | 50 +----- src/channels/web/types.rs | 54 +++++++ src/db/libsql/routines.rs | 2 +- src/history/store.rs | 2 +- src/tools/builtin/mod.rs | 4 +- src/tools/builtin/routine.rs | 147 ++++++++++++++++- src/tools/registry.rs | 8 +- src/tools/schema_validator.rs | 21 ++- tests/e2e_builtin_tool_coverage.rs | 117 +++++++++++++- tests/e2e_routine_heartbeat.rs | 150 +++++++++++++++++- .../tools/routine_system_event_emit.json | 64 ++++++++ .../skill_install_routine_webhook_sim.json | 100 ++++++++++++ tests/support/test_rig.rs | 39 ++++- 20 files changed, 1090 insertions(+), 150 deletions(-) create mode 100644 skills/ironclaw-workflow-orchestrator/SKILL.md create mode 100644 skills/ironclaw-workflow-orchestrator/agents/openai.yaml create mode 100644 skills/ironclaw-workflow-orchestrator/references/workflow-routines.md create mode 100644 tests/fixtures/llm_traces/tools/routine_system_event_emit.json create mode 100644 tests/fixtures/llm_traces/tools/skill_install_routine_webhook_sim.json diff --git a/skills/ironclaw-workflow-orchestrator/SKILL.md b/skills/ironclaw-workflow-orchestrator/SKILL.md new file mode 100644 index 00000000..88d01441 --- /dev/null +++ b/skills/ironclaw-workflow-orchestrator/SKILL.md @@ -0,0 +1,80 @@ +--- +name: ironclaw-workflow-orchestrator +description: "Install and operate a full GitHub issue-to-merge workflow in IronClaw using event-driven and cron routines. Use when setting up or tuning autonomous project orchestration: issue intake, planning, maintainer feedback handling, branch/PR execution, CI/comment follow-up, batched staging review every 8 hours, and memory updates from merge outcomes." +--- + +# IronClaw Workflow Orchestrator + +## Overview +Use this skill to install and maintain a complete project workflow as routines, not core code changes. It maps GitHub webhook events plus scheduled checks into plan/update/implement/review/merge loops with explicit staging-batch analysis. + +## Workflow +1. Gather workflow parameters. +2. Verify runtime prerequisites. +3. Install or update routine set from templates. +4. Run a dry test with `event_emit`. +5. Monitor outcomes and tune prompts/filters. + +## Parameters +Collect these values before creating routines: +- `repository`: `owner/repo` (required) +- `maintainers`: GitHub handles allowed to trigger implement/replan actions +- `staging_branch`: default `staging` +- `main_branch`: default `main` +- `batch_interval_hours`: default `8` +- `implementation_label`: default `autonomous-impl` + +## Prerequisites +Before installing routines, verify: +- Routines system enabled. +- GitHub tool authenticated (for issue/PR/comment/status operations). +- Events are emitted via `event_emit` tool calls (a future HTTP webhook ingestion endpoint is planned but not yet available). + +## Install Procedure +1. Open [`workflow-routines.md`](references/workflow-routines.md). +2. For each template block: +- replace placeholders (`{{repository}}`, `{{maintainers}}`, branch names) +- call `routine_create` +3. If a routine already exists: +- use `routine_update` instead of creating duplicates +- keep names stable so long-lived metrics/history stay intact +4. Confirm install with `routine_list` and `routine_history`. + +## Routine Set +Install these routines: +- `wf-issue-plan`: on `issue.opened` or `issue.reopened`, generate implementation plan comment/checklist. +- `wf-maintainer-comment-gate`: on maintainer comments, decide update-plan vs start implementation. +- `wf-pr-monitor-loop`: on PR open/sync/review-comment/review, address feedback and refresh branch. +- `wf-ci-fix-loop`: on CI status/check failures, apply fixes and push updates. +- `wf-staging-batch-review`: every 8h, review ready PRs, merge into staging, run deep batch correctness analysis, fix findings, then merge staging -> main. +- `wf-learning-memory`: on merged PRs, extract mistakes/lessons and write to shared memory. + +## Event Filters +Prefer top-level filters for stability: +- `repository` (string) +- `sender` (string) +- `issue_number` / `pr_number` +- `ci_status`, `ci_conclusion` +- `review_state`, `comment_author` + +Use narrow filters to avoid accidental triggers across repos. + +## Operating Rules +- All implementation work must occur on non-main branches. +- PR loop must resolve both human and AI review comments. +- On conflicts with `origin/main`, refresh branch before continuing. +- Staging-batch routine is the only path for bulk correctness verification before mainline merge. +- Memory update routine runs only after successful merge. + +## Validation +After install, run: +1. `event_emit` with a synthetic `issue.opened` payload for the target repo. +2. Confirm at least one routine fired. +3. Check corresponding `routine_history` entries. +4. Confirm no unrelated routines fired. + +## When To Update Templates +Update this skill when: +- GitHub event names/payload fields change. +- Team review policy changes (e.g., staging cadence, maintainer gates). +- New CI policy requires different failure routing. diff --git a/skills/ironclaw-workflow-orchestrator/agents/openai.yaml b/skills/ironclaw-workflow-orchestrator/agents/openai.yaml new file mode 100644 index 00000000..3febe0ff --- /dev/null +++ b/skills/ironclaw-workflow-orchestrator/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "IronClaw Workflow Orchestrator" + short_description: "Install and run event-driven GitHub workflow routines" + default_prompt: "Set up the full issue-to-merge workflow using routines and event triggers." diff --git a/skills/ironclaw-workflow-orchestrator/references/workflow-routines.md b/skills/ironclaw-workflow-orchestrator/references/workflow-routines.md new file mode 100644 index 00000000..74a5fb92 --- /dev/null +++ b/skills/ironclaw-workflow-orchestrator/references/workflow-routines.md @@ -0,0 +1,128 @@ +# Workflow Routine Templates + +Replace `{{...}}` placeholders before use. + +## 1) Issue -> Plan + +```json +{ + "name": "wf-issue-plan", + "description": "Create implementation plan when a new issue arrives", + "trigger_type": "system_event", + "event_source": "github", + "event_type": "issue.opened", + "event_filters": { + "repository": "{{repository}}" + }, + "action_type": "full_job", + "prompt": "For issue #{{issue_number}} in {{repository}}, produce a concrete implementation plan with milestones, edge cases, and tests. Post/update an issue comment with the plan.", + "cooldown_secs": 30 +} +``` + +## 2) Maintainer Comment Gate (Update Plan vs Implement) + +Trigger per-maintainer by creating one routine per handle, or maintain a shared author convention. + +```json +{ + "name": "wf-maintainer-comment-gate-{{maintainer}}", + "description": "React to maintainer guidance comments on issues/PRs", + "trigger_type": "system_event", + "event_source": "github", + "event_type": "pr.comment.created", + "event_filters": { + "repository": "{{repository}}", + "comment_author": "{{maintainer}}" + }, + "action_type": "full_job", + "prompt": "Read the maintainer comment and decide: update plan or start/continue implementation. If plan changes are requested, edit the plan artifact first. If implementation is requested, continue on the feature branch and update PR status/comment.", + "cooldown_secs": 20 +} +``` + +## 3) PR Monitor Loop + +```json +{ + "name": "wf-pr-monitor-loop", + "description": "Keep PR healthy: address review comments and refresh branch", + "trigger_type": "system_event", + "event_source": "github", + "event_type": "pr.synchronize", + "event_filters": { + "repository": "{{repository}}" + }, + "action_type": "full_job", + "prompt": "For PR #{{pr_number}}, collect open review comments and unresolved threads, apply fixes, push branch updates, and summarize remaining blockers. If conflict with {{main_branch}}, rebase/merge from origin/{{main_branch}} and resolve safely.", + "cooldown_secs": 20 +} +``` + +## 4) CI Failure Fix Loop + +```json +{ + "name": "wf-ci-fix-loop", + "description": "Fix failing CI checks on active PRs", + "trigger_type": "system_event", + "event_source": "github", + "event_type": "ci.check_run.completed", + "event_filters": { + "repository": "{{repository}}", + "ci_conclusion": "failure" + }, + "action_type": "full_job", + "prompt": "Find failing check details for PR #{{pr_number}}, implement minimal safe fixes, rerun or await CI, and post concise status updates. Prioritize deterministic and test-backed fixes.", + "cooldown_secs": 20 +} +``` + +## 5) Staging Batch Review (Every 8h) + +```json +{ + "name": "wf-staging-batch-review", + "description": "Batch correctness review through staging, then merge to main", + "trigger_type": "cron", + "schedule": "0 0 */{{batch_interval_hours}} * * *", + "action_type": "full_job", + "prompt": "Every cycle: list ready PRs, merge ready ones into {{staging_branch}}, run deep correctness analysis in batch, fix discovered issues on affected branches, ensure CI green, then merge {{staging_branch}} into {{main_branch}} if clean.", + "cooldown_secs": 120 +} +``` + +## 6) Post-Merge Learning -> Common Memory + +```json +{ + "name": "wf-learning-memory", + "description": "Capture merge learnings into shared memory", + "trigger_type": "system_event", + "event_source": "github", + "event_type": "pr.closed", + "event_filters": { + "repository": "{{repository}}", + "pr_merged": "true" + }, + "action_type": "full_job", + "prompt": "From merged PR #{{pr_number}}, extract preventable mistakes, reviewer themes, CI failure causes, and successful patterns. Write/update a shared memory doc with actionable rules to reduce cycle time and regressions.", + "cooldown_secs": 30 +} +``` + +## Optional: Synthetic Event Test + +```json +{ + "source": "github", + "event_type": "issue.opened", + "payload": { + "repository": "{{repository}}", + "issue_number": 99999, + "sender": "test-bot" + } +} +``` + +Use with `event_emit` after routine install. diff --git a/src/agent/CLAUDE.md b/src/agent/CLAUDE.md index 40221341..386492d0 100644 --- a/src/agent/CLAUDE.md +++ b/src/agent/CLAUDE.md @@ -21,7 +21,7 @@ Core agent logic. This is the most complex subsystem — read this before workin | `heartbeat.rs` | Proactive periodic execution. Reads `HEARTBEAT.md`, notifies via channel if findings. | | `submission.rs` | Parses all user submissions into typed variants before routing. | | `undo.rs` | Turn-based undo/redo with checkpoints. Checkpoints store message lists (max 20 by default). | -| `routine.rs` | `Routine` types: `Trigger` (cron/event/webhook/manual) + `RoutineAction` (lightweight/full_job) + `RoutineGuardrails`. | +| `routine.rs` | `Routine` types: `Trigger` (cron/event/system_event/manual) + `RoutineAction` (lightweight/full_job) + `RoutineGuardrails`. | | `routine_engine.rs` | Cron ticker and event matcher. Fires routines when triggers match. Lightweight runs inline; full_job dispatches to `Scheduler`. | | `task.rs` | Task types for the scheduler: `Job`, `ToolExec`, `Background`. Used by `spawn_subtask` and `spawn_batch`. | | `cost_guard.rs` | LLM spend and action-rate enforcement. Tracks daily budget (cents) and hourly call rate. Lives in `AgentDeps`. | diff --git a/src/agent/routine.rs b/src/agent/routine.rs index fdd61012..72226502 100644 --- a/src/agent/routine.rs +++ b/src/agent/routine.rs @@ -8,7 +8,7 @@ //! ┌──────────┐ ┌─────────┐ ┌──────────────────┐ //! │ Trigger │────▶│ Engine │────▶│ Execution Mode │ //! │ cron/event│ │guardrail│ │lightweight│full_job│ -//! │ webhook │ │ check │ └──────────────────┘ +//! │ system │ │ check │ └──────────────────┘ //! │ manual │ └─────────┘ │ //! └──────────┘ ▼ //! ┌──────────────┐ @@ -69,12 +69,15 @@ pub enum Trigger { /// Regex pattern to match against message content. pattern: String, }, - /// Fire on incoming webhook POST to /hooks/routine/{id}. - Webhook { - /// Optional webhook path suffix (defaults to routine id). - path: Option, - /// Optional shared secret for HMAC validation. - secret: Option, + /// Fire when a structured system event is emitted. + SystemEvent { + /// Event source namespace (e.g. "github", "workflow", "tool"). + source: String, + /// Event type within the source (e.g. "issue.opened"). + event_type: String, + /// Optional exact-match filters against payload top-level fields. + #[serde(default)] + filters: std::collections::HashMap, }, /// Only fires via tool call or CLI. Manual, @@ -86,7 +89,7 @@ impl Trigger { match self { Trigger::Cron { .. } => "cron", Trigger::Event { .. } => "event", - Trigger::Webhook { .. } => "webhook", + Trigger::SystemEvent { .. } => "system_event", Trigger::Manual => "manual", } } @@ -134,16 +137,39 @@ impl Trigger { .map(String::from); Ok(Trigger::Event { channel, pattern }) } - "webhook" => { - let path = config - .get("path") + "system_event" => { + let source = config + .get("source") .and_then(|v| v.as_str()) - .map(String::from); - let secret = config - .get("secret") + .ok_or_else(|| RoutineError::MissingField { + context: "system_event trigger".into(), + field: "source".into(), + })? + .to_string(); + let event_type = config + .get("event_type") .and_then(|v| v.as_str()) - .map(String::from); - Ok(Trigger::Webhook { path, secret }) + .ok_or_else(|| RoutineError::MissingField { + context: "system_event trigger".into(), + field: "event_type".into(), + })? + .to_string(); + let filters = config + .get("filters") + .and_then(|v| v.as_object()) + .map(|m| { + m.iter() + .filter_map(|(k, v)| { + json_value_as_filter_string(v).map(|s| (k.clone(), s)) + }) + .collect() + }) + .unwrap_or_default(); + Ok(Trigger::SystemEvent { + source, + event_type, + filters, + }) } "manual" => Ok(Trigger::Manual), other => Err(RoutineError::UnknownTriggerType { @@ -163,9 +189,14 @@ impl Trigger { "pattern": pattern, "channel": channel, }), - Trigger::Webhook { path, secret } => serde_json::json!({ - "path": path, - "secret": secret, + Trigger::SystemEvent { + source, + event_type, + filters, + } => serde_json::json!({ + "source": source, + "event_type": event_type, + "filters": filters, }), Trigger::Manual => serde_json::json!({}), } @@ -428,6 +459,19 @@ pub struct RoutineRun { pub created_at: DateTime, } +/// Convert a JSON value to a string for filter storage. +/// +/// Handles strings, numbers, and booleans — consistent with the matching +/// logic in `routine_engine::json_value_as_string`. +pub fn json_value_as_filter_string(v: &serde_json::Value) -> Option { + match v { + serde_json::Value::String(s) => Some(s.clone()), + serde_json::Value::Number(n) => Some(n.to_string()), + serde_json::Value::Bool(b) => Some(b.to_string()), + _ => None, + } +} + /// Compute a content hash for event dedup. pub fn content_hash(content: &str) -> u64 { let mut hasher = DefaultHasher::new(); @@ -486,6 +530,24 @@ mod tests { if channel == Some("telegram".to_string()) && pattern == r"deploy\s+\w+")); } + #[test] + fn test_system_event_trigger_roundtrip() { + let mut filters = std::collections::HashMap::new(); + filters.insert("repo".to_string(), "nearai/ironclaw".to_string()); + filters.insert("action".to_string(), "opened".to_string()); + let trigger = Trigger::SystemEvent { + source: "github".to_string(), + event_type: "issue".to_string(), + filters: filters.clone(), + }; + let json = trigger.to_config_json(); + let parsed = Trigger::from_db("system_event", json).expect("parse system_event"); + assert!( + matches!(parsed, Trigger::SystemEvent { source, event_type, filters: f } + if source == "github" && event_type == "issue" && f == filters) + ); + } + #[test] fn test_action_lightweight_roundtrip() { let action = RoutineAction::Lightweight { @@ -623,12 +685,13 @@ mod tests { "event" ); assert_eq!( - Trigger::Webhook { - path: None, - secret: None + Trigger::SystemEvent { + source: String::new(), + event_type: String::new(), + filters: std::collections::HashMap::new(), } .type_tag(), - "webhook" + "system_event" ); assert_eq!(Trigger::Manual.type_tag(), "manual"); } diff --git a/src/agent/routine_engine.rs b/src/agent/routine_engine.rs index 3d27bdb1..1d8e7618 100644 --- a/src/agent/routine_engine.rs +++ b/src/agent/routine_engine.rs @@ -35,6 +35,11 @@ use crate::safety::SafetyLayer; use crate::tools::{ApprovalContext, ApprovalRequirement, ToolError, ToolRegistry, redact_params}; use crate::workspace::Workspace; +enum EventMatcher { + Message { routine: Routine, regex: Regex }, + System { routine: Routine }, +} + /// The routine execution engine. pub struct RoutineEngine { config: RoutineConfig, @@ -45,8 +50,8 @@ pub struct RoutineEngine { notify_tx: mpsc::Sender, /// Currently running routine count (across all routines). running_count: Arc, - /// Compiled event regex cache: routine_id -> compiled regex. - event_cache: Arc>>, + /// Cached matchers for all event-driven routines. + event_cache: Arc>>, /// Scheduler for dispatching jobs (FullJob mode). scheduler: Option>, /// Tool registry for lightweight routine tool execution. @@ -87,9 +92,12 @@ impl RoutineEngine { Ok(routines) => { let mut cache = Vec::new(); for routine in routines { - if let Trigger::Event { ref pattern, .. } = routine.trigger { - match Regex::new(pattern) { - Ok(re) => cache.push((routine.id, routine.clone(), re)), + match &routine.trigger { + Trigger::Event { pattern, .. } => match Regex::new(pattern) { + Ok(re) => cache.push(EventMatcher::Message { + routine: routine.clone(), + regex: re, + }), Err(e) => { tracing::warn!( routine = %routine.name, @@ -97,7 +105,13 @@ impl RoutineEngine { pattern, e ); } + }, + Trigger::SystemEvent { .. } => { + cache.push(EventMatcher::System { + routine: routine.clone(), + }); } + _ => {} } } let count = cache.len(); @@ -118,7 +132,11 @@ impl RoutineEngine { let cache = self.event_cache.read().await; let mut fired = 0; - for (_, routine, re) in cache.iter() { + for matcher in cache.iter() { + let (routine, re) = match matcher { + EventMatcher::Message { routine, regex } => (routine, regex), + EventMatcher::System { .. } => continue, + }; // Channel filter if let Trigger::Event { channel: Some(ch), .. @@ -159,6 +177,85 @@ impl RoutineEngine { fired } + /// Emit a structured event to system-event routines. + /// + /// Returns the number of routines that were fired. + pub async fn emit_system_event( + &self, + source: &str, + event_type: &str, + payload: &serde_json::Value, + user_id: Option<&str>, + ) -> usize { + let cache = self.event_cache.read().await; + let mut fired = 0; + + for matcher in cache.iter() { + let routine = match matcher { + EventMatcher::System { routine } => routine, + EventMatcher::Message { .. } => continue, + }; + + let Trigger::SystemEvent { + source: expected_source, + event_type: expected_event, + filters, + } = &routine.trigger + else { + continue; + }; + + if !expected_source.eq_ignore_ascii_case(source) + || !expected_event.eq_ignore_ascii_case(event_type) + { + continue; + } + + if let Some(uid) = user_id + && routine.user_id != uid + { + continue; + } + + let mut matched = true; + for (key, expected) in filters { + let Some(actual) = payload.get(key).and_then(crate::agent::routine::json_value_as_filter_string) else { + tracing::debug!(routine = %routine.name, filter_key = %key, "Filter key not found in payload"); + matched = false; + break; + }; + if !actual.eq_ignore_ascii_case(expected) { + matched = false; + break; + } + } + if !matched { + continue; + } + + if !self.check_cooldown(routine) { + tracing::debug!(routine = %routine.name, "Skipped: cooldown active"); + continue; + } + + if !self.check_concurrent(routine).await { + tracing::debug!(routine = %routine.name, "Skipped: max concurrent reached"); + continue; + } + + if self.running_count.load(Ordering::Relaxed) >= self.config.max_concurrent_routines { + tracing::warn!(routine = %routine.name, "Skipped: global max concurrent reached"); + continue; + } + + let detail = truncate(&format!("{source}:{event_type}"), 200); + self.spawn_fire(routine.clone(), "system_event", Some(detail)); + fired += 1; + } + + fired + } + /// Check all due cron routines and fire them. Called by the cron ticker. pub async fn check_cron_triggers(&self) { let routines = match self.store.list_due_cron_routines().await { diff --git a/src/channels/web/handlers/routines.rs b/src/channels/web/handlers/routines.rs index 8fbcc97b..d8803efa 100644 --- a/src/channels/web/handlers/routines.rs +++ b/src/channels/web/handlers/routines.rs @@ -27,7 +27,7 @@ pub async fn routines_list_handler( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - let items: Vec = routines.iter().map(routine_to_info).collect(); + let items: Vec = routines.iter().map(RoutineInfo::from_routine).collect(); Ok(Json(RoutineListResponse { routines: items })) } @@ -263,54 +263,6 @@ pub async fn routines_runs_handler( }))) } -/// Convert a Routine to the trimmed RoutineInfo for list display. -fn routine_to_info(r: &crate::agent::routine::Routine) -> RoutineInfo { - let (trigger_type, trigger_summary) = match &r.trigger { - crate::agent::routine::Trigger::Cron { schedule, .. } => { - ("cron".to_string(), format!("cron: {}", schedule)) - } - crate::agent::routine::Trigger::Event { - pattern, channel, .. - } => { - let ch = channel.as_deref().unwrap_or("any"); - ("event".to_string(), format!("on {} /{}/", ch, pattern)) - } - crate::agent::routine::Trigger::Webhook { path, .. } => { - let p = path.as_deref().unwrap_or("/"); - ("webhook".to_string(), format!("webhook: {}", p)) - } - crate::agent::routine::Trigger::Manual => ("manual".to_string(), "manual only".to_string()), - }; - - let action_type = match &r.action { - crate::agent::routine::RoutineAction::Lightweight { .. } => "lightweight", - crate::agent::routine::RoutineAction::FullJob { .. } => "full_job", - }; - - let status = if !r.enabled { - "disabled" - } else if r.consecutive_failures > 0 { - "failing" - } else { - "active" - }; - - RoutineInfo { - id: r.id, - name: r.name.clone(), - description: r.description.clone(), - enabled: r.enabled, - trigger_type, - trigger_summary, - action_type: action_type.to_string(), - last_run_at: r.last_run_at.map(|dt| dt.to_rfc3339()), - next_fire_at: r.next_fire_at.map(|dt| dt.to_rfc3339()), - run_count: r.run_count, - consecutive_failures: r.consecutive_failures, - status: status.to_string(), - } -} - /// Map `RoutineError` variants to appropriate HTTP status codes. fn routine_error_status(err: &RoutineError) -> StatusCode { match err { diff --git a/src/channels/web/server.rs b/src/channels/web/server.rs index 62c75c63..e6f78461 100644 --- a/src/channels/web/server.rs +++ b/src/channels/web/server.rs @@ -1936,7 +1936,7 @@ async fn routines_list_handler( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - let items: Vec = routines.iter().map(routine_to_info).collect(); + let items: Vec = routines.iter().map(RoutineInfo::from_routine).collect(); Ok(Json(RoutineListResponse { routines: items })) } @@ -2180,54 +2180,6 @@ async fn routines_runs_handler( }))) } -/// Convert a Routine to the trimmed RoutineInfo for list display. -fn routine_to_info(r: &crate::agent::routine::Routine) -> RoutineInfo { - let (trigger_type, trigger_summary) = match &r.trigger { - crate::agent::routine::Trigger::Cron { schedule, .. } => { - ("cron".to_string(), format!("cron: {}", schedule)) - } - crate::agent::routine::Trigger::Event { - pattern, channel, .. - } => { - let ch = channel.as_deref().unwrap_or("any"); - ("event".to_string(), format!("on {} /{}/", ch, pattern)) - } - crate::agent::routine::Trigger::Webhook { path, .. } => { - let p = path.as_deref().unwrap_or("/"); - ("webhook".to_string(), format!("webhook: {}", p)) - } - crate::agent::routine::Trigger::Manual => ("manual".to_string(), "manual only".to_string()), - }; - - let action_type = match &r.action { - crate::agent::routine::RoutineAction::Lightweight { .. } => "lightweight", - crate::agent::routine::RoutineAction::FullJob { .. } => "full_job", - }; - - let status = if !r.enabled { - "disabled" - } else if r.consecutive_failures > 0 { - "failing" - } else { - "active" - }; - - RoutineInfo { - id: r.id, - name: r.name.clone(), - description: r.description.clone(), - enabled: r.enabled, - trigger_type, - trigger_summary, - action_type: action_type.to_string(), - last_run_at: r.last_run_at.map(|dt| dt.to_rfc3339()), - next_fire_at: r.next_fire_at.map(|dt| dt.to_rfc3339()), - run_count: r.run_count, - consecutive_failures: r.consecutive_failures, - status: status.to_string(), - } -} - // --- Settings handlers --- async fn settings_list_handler( diff --git a/src/channels/web/types.rs b/src/channels/web/types.rs index b6d0d05a..b2355959 100644 --- a/src/channels/web/types.rs +++ b/src/channels/web/types.rs @@ -735,6 +735,60 @@ pub struct RoutineInfo { pub status: String, } +impl RoutineInfo { + /// Convert a `Routine` to the trimmed `RoutineInfo` for list display. + pub fn from_routine(r: &crate::agent::routine::Routine) -> Self { + let (trigger_type, trigger_summary) = match &r.trigger { + crate::agent::routine::Trigger::Cron { schedule, .. } => { + ("cron".to_string(), format!("cron: {}", schedule)) + } + crate::agent::routine::Trigger::Event { + pattern, channel, .. + } => { + let ch = channel.as_deref().unwrap_or("any"); + ("event".to_string(), format!("on {} /{}/", ch, pattern)) + } + crate::agent::routine::Trigger::SystemEvent { + source, event_type, .. + } => ( + "system_event".to_string(), + format!("event: {}.{}", source, event_type), + ), + crate::agent::routine::Trigger::Manual => { + ("manual".to_string(), "manual only".to_string()) + } + }; + + let action_type = match &r.action { + crate::agent::routine::RoutineAction::Lightweight { .. } => "lightweight", + crate::agent::routine::RoutineAction::FullJob { .. } => "full_job", + }; + + let status = if !r.enabled { + "disabled" + } else if r.consecutive_failures > 0 { + "failing" + } else { + "active" + }; + + RoutineInfo { + id: r.id, + name: r.name.clone(), + description: r.description.clone(), + enabled: r.enabled, + trigger_type, + trigger_summary, + action_type: action_type.to_string(), + last_run_at: r.last_run_at.map(|dt| dt.to_rfc3339()), + next_fire_at: r.next_fire_at.map(|dt| dt.to_rfc3339()), + run_count: r.run_count, + consecutive_failures: r.consecutive_failures, + status: status.to_string(), + } + } +} + #[derive(Debug, Serialize)] pub struct RoutineListResponse { pub routines: Vec, diff --git a/src/db/libsql/routines.rs b/src/db/libsql/routines.rs index f85ba0e3..3f2629ea 100644 --- a/src/db/libsql/routines.rs +++ b/src/db/libsql/routines.rs @@ -167,7 +167,7 @@ impl RoutineStore for LibSqlBackend { let mut rows = conn .query( &format!( - "SELECT {} FROM routines WHERE enabled = 1 AND trigger_type = 'event'", + "SELECT {} FROM routines WHERE enabled = 1 AND trigger_type IN ('event', 'system_event')", ROUTINE_COLUMNS ), (), diff --git a/src/history/store.rs b/src/history/store.rs index 1153f3e4..f35f31a6 100644 --- a/src/history/store.rs +++ b/src/history/store.rs @@ -1087,7 +1087,7 @@ impl Store { let conn = self.conn().await?; let rows = conn .query( - "SELECT * FROM routines WHERE enabled AND trigger_type = 'event'", + "SELECT * FROM routines WHERE enabled AND trigger_type IN ('event', 'system_event')", &[], ) .await?; diff --git a/src/tools/builtin/mod.rs b/src/tools/builtin/mod.rs index 0b181986..b52502c9 100644 --- a/src/tools/builtin/mod.rs +++ b/src/tools/builtin/mod.rs @@ -32,8 +32,8 @@ pub use memory::{MemoryReadTool, MemorySearchTool, MemoryTreeTool, MemoryWriteTo pub use message::MessageTool; pub use restart::RestartTool; pub use routine::{ - RoutineCreateTool, RoutineDeleteTool, RoutineFireTool, RoutineHistoryTool, RoutineListTool, - RoutineUpdateTool, + EventEmitTool, RoutineCreateTool, RoutineDeleteTool, RoutineFireTool, RoutineHistoryTool, + RoutineListTool, RoutineUpdateTool, }; pub use secrets_tools::{SecretDeleteTool, SecretListTool}; pub use shell::ShellTool; diff --git a/src/tools/builtin/routine.rs b/src/tools/builtin/routine.rs index 090d1ff9..573c3c60 100644 --- a/src/tools/builtin/routine.rs +++ b/src/tools/builtin/routine.rs @@ -1,12 +1,13 @@ //! LLM-facing tools for managing routines. //! -//! Six tools let the agent manage routines conversationally: +//! 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::sync::Arc; use std::time::Duration; @@ -44,7 +45,7 @@ impl Tool for RoutineCreateTool { fn description(&self) -> &str { "Create a new routine (scheduled or event-driven task). \ - Supports cron schedules, event pattern matching, webhooks, and manual triggers. \ + Supports cron schedules, event pattern matching, system events, and manual triggers. \ Use this when the user wants something to happen periodically or reactively." } @@ -62,7 +63,7 @@ impl Tool for RoutineCreateTool { }, "trigger_type": { "type": "string", - "enum": ["cron", "event", "webhook", "manual"], + "enum": ["cron", "event", "system_event", "manual"], "description": "When the routine fires" }, "schedule": { @@ -77,6 +78,18 @@ impl Tool for RoutineCreateTool { "type": "string", "description": "Optional channel filter for event trigger (e.g. 'telegram')" }, + "event_source": { + "type": "string", + "description": "Event source for system_event triggers (e.g. 'github')" + }, + "event_type": { + "type": "string", + "description": "Event type for system_event triggers (e.g. 'issue.opened')" + }, + "event_filters": { + "type": "object", + "description": "Optional exact-match filters against payload fields for system_event triggers. Values can be strings, numbers, or booleans." + }, "prompt": { "type": "string", "description": "The prompt/instructions for the routine" @@ -190,10 +203,41 @@ impl Tool for RoutineCreateTool { pattern: pattern.to_string(), } } - "webhook" => Trigger::Webhook { - path: None, - secret: None, - }, + "system_event" => { + let source = params + .get("event_source") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + ToolError::InvalidParameters( + "system_event trigger requires 'event_source'".to_string(), + ) + })?; + let event_type = params + .get("event_type") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + ToolError::InvalidParameters( + "system_event trigger requires 'event_type'".to_string(), + ) + })?; + let filters = params + .get("event_filters") + .and_then(|v| v.as_object()) + .map(|obj| { + obj.iter() + .filter_map(|(k, v)| { + crate::agent::routine::json_value_as_filter_string(v) + .map(|s| (k.to_string(), s)) + }) + .collect::>() + }) + .unwrap_or_default(); + Trigger::SystemEvent { + source: source.to_string(), + event_type: event_type.to_string(), + filters, + } + } "manual" => Trigger::Manual, other => { return Err(ToolError::InvalidParameters(format!( @@ -296,7 +340,10 @@ impl Tool for RoutineCreateTool { .map_err(|e| ToolError::ExecutionFailed(format!("failed to create routine: {e}")))?; // Refresh event cache if this is an event trigger - if routine.trigger.type_tag() == "event" { + if matches!( + routine.trigger, + Trigger::Event { .. } | Trigger::SystemEvent { .. } + ) { self.engine.refresh_event_cache().await; } @@ -801,3 +848,87 @@ impl Tool for RoutineHistoryTool { false } } + +// ==================== event_emit ==================== + +pub struct EventEmitTool { + engine: Arc, +} + +impl EventEmitTool { + pub fn new(engine: Arc) -> 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 { + serde_json::json!({ + "type": "object", + "properties": { + "event_source": { + "type": "string", + "description": "Event source (e.g. 'github', 'workflow', 'tool')" + }, + "event_type": { + "type": "string", + "description": "Event type (e.g. 'issue.opened', 'pr.ready')" + }, + "payload": { + "type": "object", + "description": "Structured event payload" + } + }, + "required": ["event_source", "event_type"] + }) + } + + async fn execute( + &self, + params: serde_json::Value, + ctx: &JobContext, + ) -> Result { + let start = std::time::Instant::now(); + + let source = require_str(¶ms, "event_source")?; + let event_type = require_str(¶ms, "event_type")?; + let payload = params + .get("payload") + .cloned() + .unwrap_or_else(|| serde_json::json!({})); + + 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 + } +} diff --git a/src/tools/registry.rs b/src/tools/registry.rs index c6612b32..b487366a 100644 --- a/src/tools/registry.rs +++ b/src/tools/registry.rs @@ -64,6 +64,7 @@ const PROTECTED_TOOL_NAMES: &[&str] = &[ "routine_delete", "routine_fire", "routine_history", + "event_emit", "skill_list", "skill_search", "skill_install", @@ -427,8 +428,8 @@ impl ToolRegistry { engine: Arc, ) { use crate::tools::builtin::{ - RoutineCreateTool, RoutineDeleteTool, RoutineFireTool, RoutineHistoryTool, - RoutineListTool, RoutineUpdateTool, + EventEmitTool, RoutineCreateTool, RoutineDeleteTool, RoutineFireTool, + RoutineHistoryTool, RoutineListTool, RoutineUpdateTool, }; self.register_sync(Arc::new(RoutineCreateTool::new( Arc::clone(&store), @@ -448,7 +449,8 @@ impl ToolRegistry { Arc::clone(&engine), ))); self.register_sync(Arc::new(RoutineHistoryTool::new(store))); - tracing::debug!("Registered 6 routine management tools"); + self.register_sync(Arc::new(EventEmitTool::new(engine))); + tracing::debug!("Registered 7 routine management tools"); } /// Register message tool for sending messages to channels. diff --git a/src/tools/schema_validator.rs b/src/tools/schema_validator.rs index 8da0b613..a5b8fd40 100644 --- a/src/tools/schema_validator.rs +++ b/src/tools/schema_validator.rs @@ -565,12 +565,19 @@ mod tests { "description": { "type": "string", "description": "What it does" }, "trigger_type": { "type": "string", - "enum": ["cron", "event", "webhook", "manual"], + "enum": ["cron", "event", "system_event", "manual"], "description": "When the routine fires" }, "schedule": { "type": "string", "description": "Cron expression" }, "event_pattern": { "type": "string", "description": "Regex pattern" }, "event_channel": { "type": "string", "description": "Channel filter" }, + "event_source": { "type": "string", "description": "System event source" }, + "event_type": { "type": "string", "description": "System event type" }, + "event_filters": { + "type": "object", + "additionalProperties": { "type": "string" }, + "description": "Exact-match payload filters" + }, "prompt": { "type": "string", "description": "Instructions" }, "context_paths": { "type": "array", @@ -647,6 +654,18 @@ mod tests { "required": ["name"] }), ), + ( + "event_emit", + serde_json::json!({ + "type": "object", + "properties": { + "event_source": { "type": "string", "description": "Event source" }, + "event_type": { "type": "string", "description": "Event type" }, + "payload": { "type": "object", "description": "Event payload", "properties": {} } + }, + "required": ["event_source", "event_type"] + }), + ), // Job tools with complex deps ( "job_events", diff --git a/tests/e2e_builtin_tool_coverage.rs b/tests/e2e_builtin_tool_coverage.rs index c5ce339b..4387ebc5 100644 --- a/tests/e2e_builtin_tool_coverage.rs +++ b/tests/e2e_builtin_tool_coverage.rs @@ -27,6 +27,8 @@ mod tests { let rig = TestRigBuilder::new() .with_trace(trace.clone()) + .with_auto_approve_tools(true) + .with_skills() .build() .await; @@ -60,6 +62,8 @@ mod tests { let rig = TestRigBuilder::new() .with_trace(trace.clone()) + .with_auto_approve_tools(true) + .with_skills() .build() .await; @@ -97,6 +101,8 @@ mod tests { let rig = TestRigBuilder::new() .with_trace(trace.clone()) + .with_auto_approve_tools(true) + .with_skills() .build() .await; @@ -197,7 +203,114 @@ mod tests { } // ----------------------------------------------------------------------- - // Test 6: job_create_status + // Test 6: routine_system_event_emit + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn routine_system_event_emit() { + let trace = LlmTrace::from_file(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/llm_traces/tools/routine_system_event_emit.json" + )) + .expect("failed to load routine_system_event_emit.json"); + + let rig = TestRigBuilder::new() + .with_trace(trace.clone()) + .with_auto_approve_tools(true) + .build() + .await; + + rig.send_message("Create a system-event routine and emit an event") + .await; + let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await; + + rig.verify_trace_expects(&trace, &responses); + + let completed = rig.tool_calls_completed(); + assert!( + completed.iter().any(|(n, ok)| n == "event_emit" && *ok), + "event_emit should succeed: {completed:?}" + ); + + let results = rig.tool_results(); + let emit_result = results + .iter() + .find(|(n, _)| n == "event_emit") + .expect("event_emit result missing"); + assert!( + emit_result.1.contains("fired_routines"), + "event_emit should report fired routine count: {:?}", + emit_result.1 + ); + // Verify at least one routine actually fired (not just that the key exists). + let emit_json: serde_json::Value = + serde_json::from_str(&emit_result.1).expect("event_emit result should be valid JSON"); + assert!( + emit_json["fired_routines"].as_u64().unwrap_or(0) > 0, + "event_emit should have fired at least one routine: {:?}", + emit_result.1 + ); + + rig.shutdown(); + } + + // ----------------------------------------------------------------------- + // Test 7: skill_install_routine_webhook_sim + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn skill_install_routine_webhook_sim() { + let trace = LlmTrace::from_file(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/llm_traces/tools/skill_install_routine_webhook_sim.json" + )) + .expect("failed to load skill_install_routine_webhook_sim.json"); + + let rig = TestRigBuilder::new() + .with_trace(trace.clone()) + .with_skills() + .with_auto_approve_tools(true) + .build() + .await; + + rig.send_message("Install the workflow skill template and simulate a webhook routine run") + .await; + let responses = rig.wait_for_responses(1, Duration::from_secs(20)).await; + rig.verify_trace_expects(&trace, &responses); + + let completed = rig.tool_calls_completed(); + assert!( + completed.iter().any(|(n, _)| n == "skill_install"), + "skill_install should be called: {completed:?}" + ); + for tool in &["routine_create", "event_emit", "routine_history"] { + assert!( + completed.iter().any(|(n, ok)| n == tool && *ok), + "{tool} should succeed: {completed:?}" + ); + } + + let results = rig.tool_results(); + let emit_result = results + .iter() + .find(|(n, _)| n == "event_emit") + .expect("event_emit result missing"); + assert!( + emit_result.1.contains("fired_routines"), + "event_emit should include fired_routines: {:?}", + emit_result.1 + ); + + let _history_result = results + .iter() + .find(|(n, _)| n == "routine_history") + .expect("routine_history result missing"); + + rig.shutdown(); + } + + // ----------------------------------------------------------------------- + // Test 8: job_create_status // ----------------------------------------------------------------------- // Uses {{call_cj_1.job_id}} template to forward the dynamic UUID from // create_job's result into job_status's arguments. @@ -266,7 +379,7 @@ mod tests { } // ----------------------------------------------------------------------- - // Test 7: job_list_cancel + // Test 9: job_list_cancel // ----------------------------------------------------------------------- // Uses {{call_cj_lc.job_id}} template to forward the dynamic UUID from // create_job into cancel_job. diff --git a/tests/e2e_routine_heartbeat.rs b/tests/e2e_routine_heartbeat.rs index 4d26e5da..a9ef086b 100644 --- a/tests/e2e_routine_heartbeat.rs +++ b/tests/e2e_routine_heartbeat.rs @@ -255,7 +255,151 @@ mod tests { } // ----------------------------------------------------------------------- - // Test 3: routine_cooldown + // Test 3: system_event_trigger_matches_and_filters + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn system_event_trigger_matches_and_filters() { + let (db, _tmp) = create_test_db().await; + let ws = create_workspace(&db); + + let trace = LlmTrace::single_turn( + "test-system-event-match", + "event", + vec![TraceStep { + request_hint: None, + response: TraceResponse::Text { + content: "System event handled".to_string(), + input_tokens: 40, + output_tokens: 8, + }, + expected_tool_results: vec![], + }], + ); + let llm = Arc::new(TraceLlm::from_trace(trace)); + let (notify_tx, _notify_rx) = tokio::sync::mpsc::channel(16); + + // Create minimal ToolRegistry and SafetyLayer for test. + let tools = Arc::new(ToolRegistry::new()); + let safety_config = SafetyConfig { + max_output_length: 100_000, + injection_check_enabled: true, + }; + let safety = Arc::new(SafetyLayer::new(&safety_config)); + + let engine = Arc::new(RoutineEngine::new( + RoutineConfig::default(), + db.clone(), + llm, + ws, + notify_tx, + None, + tools, + safety, + )); + + let mut filters = std::collections::HashMap::new(); + filters.insert("repository".to_string(), "nearai/ironclaw".to_string()); + + let routine = make_routine( + "github-issue-opened", + Trigger::SystemEvent { + source: "github".to_string(), + event_type: "issue.opened".to_string(), + filters, + }, + "Summarize the issue and propose an implementation plan.", + ); + db.create_routine(&routine).await.expect("create_routine"); + engine.refresh_event_cache().await; + + // Matching event should fire. + let fired = engine + .emit_system_event( + "github", + "issue.opened", + &serde_json::json!({ + "repository": "nearai/ironclaw", + "issue_number": 42 + }), + Some("default"), + ) + .await; + assert_eq!(fired, 1, "Expected one routine to fire for matching event"); + + tokio::time::sleep(Duration::from_millis(300)).await; + + let runs = db + .list_routine_runs(routine.id, 10) + .await + .expect("list runs"); + assert!( + !runs.is_empty(), + "Expected run history after matching event" + ); + + // Wrong event type should not fire. + let fired_wrong_type = engine + .emit_system_event( + "github", + "issue.closed", + &serde_json::json!({"repository": "nearai/ironclaw"}), + Some("default"), + ) + .await; + assert_eq!( + fired_wrong_type, 0, + "Expected no routine for wrong event type" + ); + + // Wrong filter value should not fire. + let fired_wrong_filter = engine + .emit_system_event( + "github", + "issue.opened", + &serde_json::json!({"repository": "other/repo"}), + Some("default"), + ) + .await; + assert_eq!( + fired_wrong_filter, 0, + "Expected no routine for filter mismatch" + ); + + // Case-insensitive source/event_type should still match. + let fired_case = engine + .emit_system_event( + "GitHub", + "Issue.Opened", + &serde_json::json!({ + "repository": "nearai/ironclaw", + "issue_number": 99 + }), + Some("default"), + ) + .await; + assert_eq!( + fired_case, 1, + "Expected case-insensitive match on source/event_type" + ); + + // Case-insensitive filter values should match. + let fired_filter_case = engine + .emit_system_event( + "github", + "issue.opened", + &serde_json::json!({"repository": "NearAI/IronClaw"}), + Some("default"), + ) + .await; + assert_eq!( + fired_filter_case, 1, + "Expected case-insensitive match on filter values" + ); + } + + // ----------------------------------------------------------------------- + // Test 4: routine_cooldown // ----------------------------------------------------------------------- #[tokio::test] @@ -345,7 +489,7 @@ mod tests { } // ----------------------------------------------------------------------- - // Test 4: heartbeat_findings + // Test 5: heartbeat_findings // ----------------------------------------------------------------------- #[tokio::test] @@ -407,7 +551,7 @@ mod tests { } // ----------------------------------------------------------------------- - // Test 5: heartbeat_empty_skip + // Test 6: heartbeat_empty_skip // ----------------------------------------------------------------------- #[tokio::test] diff --git a/tests/fixtures/llm_traces/tools/routine_system_event_emit.json b/tests/fixtures/llm_traces/tools/routine_system_event_emit.json new file mode 100644 index 00000000..484574bb --- /dev/null +++ b/tests/fixtures/llm_traces/tools/routine_system_event_emit.json @@ -0,0 +1,64 @@ +{ + "model_name": "test-routine-system-event-emit", + "expects": { + "tools_used": ["routine_create", "event_emit"], + "all_tools_succeeded": true, + "tool_results_contain": { + "event_emit": "fired_routines" + } + }, + "steps": [ + { + "response": { + "type": "tool_calls", + "tool_calls": [ + { + "id": "call_rc_1", + "name": "routine_create", + "arguments": { + "name": "gh-issue-emit-test", + "description": "React to GitHub issue.opened events", + "trigger_type": "system_event", + "event_source": "github", + "event_type": "issue.opened", + "action_type": "full_job", + "prompt": "Summarize the new issue and propose next steps." + } + } + ], + "input_tokens": 80, + "output_tokens": 30 + } + }, + { + "response": { + "type": "tool_calls", + "tool_calls": [ + { + "id": "call_ee_1", + "name": "event_emit", + "arguments": { + "event_source": "github", + "event_type": "issue.opened", + "payload": { + "repository": "nearai/ironclaw", + "issue_number": 123, + "title": "Support event-driven project workflow" + } + } + } + ], + "input_tokens": 140, + "output_tokens": 28 + } + }, + { + "response": { + "type": "text", + "content": "Created a system-event routine and emitted a matching GitHub event. The routine fired successfully.", + "input_tokens": 200, + "output_tokens": 18 + } + } + ] +} diff --git a/tests/fixtures/llm_traces/tools/skill_install_routine_webhook_sim.json b/tests/fixtures/llm_traces/tools/skill_install_routine_webhook_sim.json new file mode 100644 index 00000000..ef36df3e --- /dev/null +++ b/tests/fixtures/llm_traces/tools/skill_install_routine_webhook_sim.json @@ -0,0 +1,100 @@ +{ + "model_name": "test-skill-install-routine-webhook-sim", + "expects": { + "tools_used": ["skill_install", "routine_create", "event_emit", "routine_history"], + "tool_results_contain": { + "event_emit": "fired_routines" + } + }, + "steps": [ + { + "response": { + "type": "tool_calls", + "tool_calls": [ + { + "id": "call_skill_install_1", + "name": "skill_install", + "arguments": { + "name": "wf-orchestrator-trace-install-1", + "content": "---\nname: wf-orchestrator-trace-install-1\ndescription: Minimal workflow skill for trace install validation\nactivation:\n keywords: [\"workflow\", \"orchestrator\"]\n---\n\nYou are a minimal workflow skill used for trace install validation.\n" + } + } + ], + "input_tokens": 120, + "output_tokens": 32 + } + }, + { + "response": { + "type": "tool_calls", + "tool_calls": [ + { + "id": "call_routine_create_1", + "name": "routine_create", + "arguments": { + "name": "wf-webhook-sim-trace", + "description": "Trace routine to simulate webhook event flow", + "trigger_type": "system_event", + "event_source": "github", + "event_type": "issue.opened", + "event_filters": { + "repository": "nearai/ironclaw" + }, + "action_type": "full_job", + "prompt": "When issue webhook event arrives, start implementation loop and create branch/PR updates." + } + } + ], + "input_tokens": 170, + "output_tokens": 36 + } + }, + { + "response": { + "type": "tool_calls", + "tool_calls": [ + { + "id": "call_event_emit_1", + "name": "event_emit", + "arguments": { + "event_source": "github", + "event_type": "issue.opened", + "payload": { + "repository": "nearai/ironclaw", + "issue_number": 4242, + "sender": "trace-bot" + } + } + } + ], + "input_tokens": 210, + "output_tokens": 28 + } + }, + { + "response": { + "type": "tool_calls", + "tool_calls": [ + { + "id": "call_routine_history_1", + "name": "routine_history", + "arguments": { + "name": "wf-webhook-sim-trace", + "limit": 5 + } + } + ], + "input_tokens": 240, + "output_tokens": 22 + } + }, + { + "response": { + "type": "text", + "content": "Installed the skill template, created a system-event routine, emitted a webhook-equivalent event, and verified the routine run history.", + "input_tokens": 280, + "output_tokens": 25 + } + } + ] +} diff --git a/tests/support/test_rig.rs b/tests/support/test_rig.rs index bedc6d4a..cc7d77ac 100644 --- a/tests/support/test_rig.rs +++ b/tests/support/test_rig.rs @@ -379,6 +379,8 @@ pub struct TestRigBuilder { llm: Option>, max_tool_iterations: usize, injection_check: bool, + auto_approve_tools: Option, + enable_skills: bool, enable_routines: bool, http_exchanges: Vec, extra_tools: Vec>, @@ -392,6 +394,8 @@ impl TestRigBuilder { llm: None, max_tool_iterations: 10, injection_check: false, + auto_approve_tools: None, + enable_skills: false, enable_routines: false, http_exchanges: Vec::new(), extra_tools: Vec::new(), @@ -432,6 +436,18 @@ impl TestRigBuilder { self } + /// Override agent-level automatic approval of `UnlessAutoApproved` tools. + pub fn with_auto_approve_tools(mut self, enable: bool) -> Self { + self.auto_approve_tools = Some(enable); + self + } + + /// Enable skill discovery and registration for this test rig. + pub fn with_skills(mut self) -> Self { + self.enable_skills = true; + self + } + /// Enable the routines system so the scheduler is wired with a `RoutineEngine`, /// allowing routine jobs to actually execute. Routine tools are always registered /// but require the engine to dispatch jobs. @@ -466,6 +482,8 @@ impl TestRigBuilder { llm, max_tool_iterations, injection_check, + auto_approve_tools, + enable_skills, enable_routines, http_exchanges: explicit_http_exchanges, extra_tools, @@ -491,6 +509,10 @@ impl TestRigBuilder { let mut config = Config::for_testing(db_path, skills_dir, installed_skills_dir); config.agent.max_tool_iterations = max_tool_iterations; config.safety.injection_check_enabled = injection_check; + config.skills.enabled = enable_skills; + if let Some(v) = auto_approve_tools { + config.agent.auto_approve_tools = v; + } // 3. Create SessionManager + LogBroadcaster. let session = Arc::new(SessionManager::new(SessionConfig::default())); @@ -540,7 +562,7 @@ impl TestRigBuilder { ); builder.with_database(Arc::clone(&db)); builder.with_llm(llm); - let components = builder + let mut components = builder .build_all() .await .expect("AppBuilder::build_all() failed in test rig"); @@ -583,6 +605,21 @@ impl TestRigBuilder { .register_routine_tools(Arc::clone(db_arc), engine); } + // Skills tools: ensure tests use temp skill dirs (sandbox-safe) even if + // AppBuilder did not wire them for this environment. + if enable_skills { + let registry = Arc::new(std::sync::RwLock::new( + ironclaw::skills::SkillRegistry::new(temp_dir.path().join("skills")) + .with_installed_dir(temp_dir.path().join("installed_skills")), + )); + let catalog = ironclaw::skills::catalog::shared_catalog(); + components + .tools + .register_skill_tools(Arc::clone(®istry), Arc::clone(&catalog)); + components.skill_registry = Some(registry); + components.skill_catalog = Some(catalog); + } + // Register any extra test-specific tools. for tool in extra_tools { components.tools.register(tool).await;