Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
0a1195d
feat(routines): add approval context for autonomous job execution
ilblackdragon Mar 5, 2026
910b930
test(routines): add E2E trace for routine news digest workflow
ilblackdragon Mar 5, 2026
f5760e7
fix(test): wire RoutineEngine into test rig for routine_create E2E
ilblackdragon Mar 5, 2026
2bc6c0e
refactor(scheduler): deduplicate dispatch_job and dispatch_job_with_c…
ilblackdragon Mar 5, 2026
d1e0594
feat(routines): add routine_fire tool and real E2E routine execution …
ilblackdragon Mar 6, 2026
8eafa57
feat(routines): wire HttpInterceptor through scheduler for routine wo…
ilblackdragon Mar 6, 2026
d0c7c84
Merge remote-tracking branch 'origin/main' into feat/routine-approval…
ilblackdragon Mar 6, 2026
55d4acb
fix: address review comments from Copilot on PR #577
ilblackdragon Mar 6, 2026
850b9df
style: fix formatting in is_blocked_or_default test
ilblackdragon Mar 6, 2026
35847fe
refactor(test_rig): destructure self in build() to avoid partial-move…
ilblackdragon Mar 6, 2026
c138926
docs: clarify that routine_fire bypasses cooldown
ilblackdragon Mar 6, 2026
70e0e9d
fix(routines): fix message tool approval in routine context
ilblackdragon Mar 6, 2026
0ee09bd
Merge remote-tracking branch 'origin/main' into feat/routine-approval…
ilblackdragon Mar 6, 2026
81c3c2d
refactor(message): remove approval requirement from message tool
ilblackdragon Mar 6, 2026
fc15ff0
fix: address review comments — routine_fire approval + test rename
ilblackdragon Mar 7, 2026
1a38775
fix: address review nits — update stale docs and comments
ilblackdragon Mar 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/agent/agent_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,9 @@ impl Agent {
if let Some(ref tx) = deps.sse_tx {
scheduler.set_sse_sender(tx.clone());
}
if let Some(ref interceptor) = deps.http_interceptor {
scheduler.set_http_interceptor(Arc::clone(interceptor));
}
let scheduler = Arc::new(scheduler);

Self {
Expand Down
27 changes: 25 additions & 2 deletions src/agent/routine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,11 @@ pub enum RoutineAction {
/// Max reasoning iterations (default: 10).
#[serde(default = "default_max_iterations")]
max_iterations: u32,
/// Tool names pre-authorized for `Always`-approval tools (e.g. destructive
/// shell commands, cross-channel messaging). `UnlessAutoApproved` tools are
/// automatically permitted in routine jobs without listing them here.
#[serde(default)]
tool_permissions: Vec<String>,
},
}

Expand All @@ -186,6 +191,19 @@ fn default_max_iterations() -> u32 {
10
}

/// Parse a `tool_permissions` JSON array into a `Vec<String>`.
pub fn parse_tool_permissions(value: &serde_json::Value) -> Vec<String> {
value
.get("tool_permissions")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default()
}

impl RoutineAction {
/// The string tag stored in the DB action_type column.
pub fn type_tag(&self) -> &'static str {
Expand Down Expand Up @@ -248,10 +266,12 @@ impl RoutineAction {
.and_then(|v| v.as_u64())
.unwrap_or(default_max_iterations() as u64)
as u32;
let tool_permissions = parse_tool_permissions(&config);
Ok(RoutineAction::FullJob {
title,
description,
max_iterations,
tool_permissions,
})
}
other => Err(RoutineError::UnknownActionType {
Expand All @@ -276,10 +296,12 @@ impl RoutineAction {
title,
description,
max_iterations,
tool_permissions,
} => serde_json::json!({
"title": title,
"description": description,
"max_iterations": max_iterations,
"tool_permissions": tool_permissions,
}),
}
}
Expand Down Expand Up @@ -450,12 +472,13 @@ mod tests {
title: "Deploy review".to_string(),
description: "Review and deploy pending changes".to_string(),
max_iterations: 5,
tool_permissions: vec!["shell".to_string()],
};
let json = action.to_config_json();
let parsed = RoutineAction::from_db("full_job", json).expect("parse full_job");
assert!(
matches!(parsed, RoutineAction::FullJob { title, max_iterations, .. }
if title == "Deploy review" && max_iterations == 5)
matches!(parsed, RoutineAction::FullJob { title, max_iterations, tool_permissions, .. }
if title == "Deploy review" && max_iterations == 5 && tool_permissions == vec!["shell".to_string()])
);
}

Expand Down
42 changes: 40 additions & 2 deletions src/agent/routine_engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ use crate::config::RoutineConfig;
use crate::db::Database;
use crate::error::RoutineError;
use crate::llm::{ChatMessage, CompletionRequest, FinishReason, LlmProvider};
use crate::tools::ApprovalContext;
use crate::workspace::Workspace;

/// The routine execution engine.
Expand Down Expand Up @@ -180,6 +181,9 @@ impl RoutineEngine {
}

/// Fire a routine manually (from tool call or CLI).
///
/// Bypasses cooldown checks (those only apply to cron/event triggers).
/// Still enforces enabled check and concurrent run limit.
pub async fn fire_manual(&self, routine_id: Uuid) -> Result<Uuid, RoutineError> {
let routine = self
.store
Expand Down Expand Up @@ -327,7 +331,19 @@ async fn execute_routine(ctx: EngineContext, routine: Routine, run: RoutineRun)
title,
description,
max_iterations,
} => execute_full_job(&ctx, &routine, &run, title, description, *max_iterations).await,
tool_permissions,
} => {
execute_full_job(
&ctx,
&routine,
&run,
title,
description,
*max_iterations,
tool_permissions,
)
.await
}
};

// Decrement running count
Expand Down Expand Up @@ -418,6 +434,7 @@ async fn execute_full_job(
title: &str,
description: &str,
max_iterations: u32,
tool_permissions: &[String],
) -> Result<(RunStatus, Option<String>, Option<i32>), RoutineError> {
let scheduler = ctx
.scheduler
Expand All @@ -426,10 +443,31 @@ async fn execute_full_job(
reason: "scheduler not available".to_string(),
})?;

// Set the message tool's default channel/target from the routine's notify config
// so the LLM can send results without triggering cross-channel approval.
// TODO: This mutates shared global state and can race with concurrent jobs.
// Move notify config into JobContext metadata and apply per-job instead.
if let Some(channel) = &routine.notify.channel {
scheduler
.tools()
.set_message_tool_context(Some(channel.clone()), Some(routine.notify.user.clone()))
.await;
}

let metadata = serde_json::json!({ "max_iterations": max_iterations });

Comment on lines +446 to 458
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Setting the message tool’s default channel/target via ToolRegistry mutates shared global state. Because the scheduler can run multiple jobs concurrently, this can race with other jobs/interactive turns and cause messages to be sent to the wrong channel/target when the message tool is invoked without explicit params (and it also affects requires_approval() decisions). Prefer storing the routine’s notify channel/target in the JobContext (e.g., metadata) and applying it per-job immediately before message tool approval/execute, or require routines to always pass explicit channel/target instead of relying on global defaults.

Suggested change
// Set the message tool's default channel/target from the routine's notify config
// so the LLM can send results without triggering cross-channel approval.
if let Some(channel) = &routine.notify.channel {
scheduler
.tools()
.set_message_tool_context(Some(channel.clone()), Some(routine.notify.user.clone()))
.await;
}
let metadata = serde_json::json!({ "max_iterations": max_iterations });
// Attach the routine's notify configuration to the job metadata instead of
// mutating global message tool defaults, to avoid cross-job races.
let metadata = serde_json::json!({
"max_iterations": max_iterations,
"notify": {
"channel": routine.notify.channel,
"user": routine.notify.user,
},
});

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid concern. Added TODO comment in 55d4acb noting the race. Will move notify config into JobContext metadata in a follow-up.

Comment on lines +446 to 458
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

set_message_tool_context() mutates the global MessageTool defaults (shared ToolRegistry state). In concurrent routine runs or while an interactive session is active, this can race and cause messages to be sent with the wrong channel/target and can also change message.requires_approval() outcomes across jobs. Prefer making channel/target a per-job/per-call input (e.g., pass explicitly in tool args or derive from JobContext metadata) rather than mutating shared global state.

Suggested change
// Set the message tool's default channel/target from the routine's notify config
// so the LLM can send results without triggering cross-channel approval.
// TODO: This mutates shared global state and can race with concurrent jobs.
// Move notify config into JobContext metadata and apply per-job instead.
if let Some(channel) = &routine.notify.channel {
scheduler
.tools()
.set_message_tool_context(Some(channel.clone()), Some(routine.notify.user.clone()))
.await;
}
let metadata = serde_json::json!({ "max_iterations": max_iterations });
// Embed the routine's notify config into the job metadata so that any
// message tools can derive channel/target per job instead of mutating
// shared global state.
let metadata = serde_json::json!({
"max_iterations": max_iterations,
"notify": {
"channel": routine.notify.channel,
"user": routine.notify.user,
},
});

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate of the earlier comment on the same line. TODO already added in 55d4acb noting the race. Will move to per-job metadata in a follow-up.

// Build approval context: UnlessAutoApproved tools are auto-approved for routines;
// Always tools require explicit listing in tool_permissions.
let approval_context = ApprovalContext::autonomous_with_tools(tool_permissions.iter().cloned());

Comment on lines +446 to +462
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This updates shared/global message tool defaults, which can race when multiple jobs/routines run concurrently (e.g., one routine can accidentally change another routine’s default channel/target, impacting both routing and approval behavior). Prefer setting per-job message context (e.g., store notify channel/user in JobContext/metadata and have MessageTool resolve defaults from the executing job context rather than global mutable state).

Suggested change
// Set the message tool's default channel/target from the routine's notify config
// so the LLM can send results without triggering cross-channel approval.
// TODO: This mutates shared global state and can race with concurrent jobs.
// Move notify config into JobContext metadata and apply per-job instead.
if let Some(channel) = &routine.notify.channel {
scheduler
.tools()
.set_message_tool_context(Some(channel.clone()), Some(routine.notify.user.clone()))
.await;
}
let metadata = serde_json::json!({ "max_iterations": max_iterations });
// Build approval context: UnlessAutoApproved tools are auto-approved for routines;
// Always tools require explicit listing in tool_permissions.
let approval_context = ApprovalContext::autonomous_with_tools(tool_permissions.iter().cloned());
// Attach notify configuration to the job metadata so the worker/message tool
// can resolve per-job defaults instead of mutating shared global state.
let metadata = serde_json::json!({
"max_iterations": max_iterations,
"notify": {
"channel": routine.notify.channel,
"user": routine.notify.user,
},
});
// Build approval context: UnlessAutoApproved tools are auto-approved for routines;
// Always tools require explicit listing in tool_permissions.
let approval_context = ApprovalContext::autonomous_with_tools(tool_permissions.iter().cloned());
let approval_context = ApprovalContext::autonomous_with_tools(tool_permissions.iter().cloned());

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate of earlier comment. TODO already added in 55d4acb. Will move to per-job metadata in a follow-up.

let job_id = scheduler
.dispatch_job(&routine.user_id, title, description, Some(metadata))
.dispatch_job_with_context(
&routine.user_id,
title,
description,
Some(metadata),
approval_context,
)
.await
.map_err(|e| RoutineError::JobDispatchFailed {
reason: format!("failed to dispatch job: {e}"),
Expand Down
Loading
Loading