mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
feat(routines): approval context for autonomous job execution (#577)
* feat(routines): add approval context for autonomous job execution Routines and background jobs were unable to use any tools that required approval (file ops, shell, message, http), making them effectively useless. This adds an ApprovalContext system that lets autonomous jobs pre-authorize tools at dispatch time. - Add ApprovalContext enum with Autonomous variant that auto-approves UnlessAutoApproved tools and optionally pre-authorizes Always tools - Add tool_permissions field to RoutineAction::FullJob for pre-authorizing Always-gated tools (e.g. destructive shell, cross-channel messaging) - Add Scheduler::dispatch_job_with_context() to thread approval context through to workers - Set message tool default channel/target from routine NotifyConfig so routines can send results without cross-channel approval - Fix Completed→Completed state transition error in worker (plan marks job completed, then direct loop or outer run() tries again) Co-Authored-By: Claude Opus 4.6 <[email protected]> * test(routines): add E2E trace for routine news digest workflow Add a 3-turn trace fixture and test that exercises: - Turn 1: routine_create with full_job mode and tool_permissions - Turn 2: Simulated digest workflow with echo + memory_write - Turn 3: Verification via memory_search [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix(test): wire RoutineEngine into test rig for routine_create E2E - Add `with_routines()` to TestRigBuilder that passes a RoutineConfig to Agent::new, enabling routine tool registration during agent startup - Add Turn 2 (routine_list) to the trace to verify routine persistence in the database after routine_create - Fix formatting issues flagged by CI (cargo fmt) [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * refactor(scheduler): deduplicate dispatch_job and dispatch_job_with_context Extract shared logic into private `dispatch_job_inner` to prevent divergence when dispatch behavior changes in the future. [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat(routines): add routine_fire tool and real E2E routine execution test - Add `routine_fire` tool that calls `RoutineEngine::fire_manual` to trigger a routine on demand. Registered alongside the other 5 routine tools (now 6 total). - Rewrite the routine_news_digest E2E trace to exercise the full execution stack end-to-end: 1. routine_create (manual trigger, full_job, tool_permissions: [message]) 2. routine_fire → RoutineEngine → Scheduler::dispatch_job_with_context → autonomous Worker consuming TraceLlm steps 3. Worker calls echo → memory_write → message (broadcast to test channel) 4. Test verifies the message broadcast arrived, proving ApprovalContext correctly allowed the Always-approval message tool - Register message tools in TestRig so routines can send messages to the test channel via channel_manager.broadcast(). [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat(routines): wire HttpInterceptor through scheduler for routine worker http calls Propagate http_interceptor from AgentDeps → Scheduler → WorkerDeps → JobContext so that routine workers (and any scheduler-dispatched workers) can use the ReplayingHttpInterceptor for mock HTTP responses during tests. Changes: - Add http_interceptor field to Scheduler and WorkerDeps - Set job_ctx.http_interceptor in Worker before tool execution - Add with_http_exchanges() builder method to TestRigBuilder - Replace echo tool with http tool in routine_news_digest trace - Test now exercises real http tool with mock response → memory_write → message Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address review comments from Copilot on PR #577 - Extract `ApprovalContext::is_blocked_or_default()` helper to deduplicate approval check logic in worker.rs and scheduler.rs - Extract `parse_tool_permissions()` helper to deduplicate JSON array parsing in routine.rs and builtin/routine.rs - Fix test name: `test_mark_completed_twice_does_not_error` → `test_mark_completed_twice_returns_error` (matches actual behavior) - Fix ApprovalContext doc comment to clarify it only models autonomous mode - Fix flaky index-based assertion in routine_news_digest test — now uses content-based search instead of fixed position - Fix stale comment: echo → http in routine test header - Add TODO for subtask approval context propagation (latent, not in active code paths) - Add TODO for global message tool context race in routine_engine Co-Authored-By: Claude Opus 4.6 <[email protected]> * style: fix formatting in is_blocked_or_default test Co-Authored-By: Claude Opus 4.6 <[email protected]> * refactor(test_rig): destructure self in build() to avoid partial-move fragility Destructure TestRigBuilder at the top of build() instead of accessing self.* fields after moving self.http_exchanges. While the prior code compiled (remaining fields are Copy), it was fragile and would break if any non-Copy field were added. Co-Authored-By: Claude Opus 4.6 <[email protected]> * docs: clarify that routine_fire bypasses cooldown Manual fires are explicitly user-initiated and intentionally bypass cooldown checks (which only apply to automated cron/event triggers). Updated tool description and fire_manual docstring to make this clear. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix(routines): fix message tool approval in routine context Two fixes for message tool failures in autonomous routine jobs: 1. MessageTool::requires_approval() now returns UnlessAutoApproved when the explicit channel param matches the default channel (was Always, causing "requires authentication" errors for routine workers). 2. routine_create tool now accepts notify_channel and notify_user params, wired into NotifyConfig. Without these, routines had channel: None, so set_message_tool_context was never called, causing "No channel specified" errors. Co-Authored-By: Claude Opus 4.6 <[email protected]> * refactor(message): remove approval requirement from message tool The message tool only sends to user-owned channels via ChannelManager::broadcast (TUI, Telegram, Slack, web gateway, etc.). It cannot reach arbitrary external services, so approval adds friction with no security benefit. This also eliminates the routine context errors entirely since approval is never checked. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address review comments — routine_fire approval + test rename - routine_fire now returns UnlessAutoApproved since firing a routine can dispatch a full_job with pre-authorized Always-gated tools - Rename test_approval_context_never_always_passes to test_approval_context_never_is_not_blocked for clarity Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address review nits — update stale docs and comments - Remove 'message' from tool_permissions example (no longer Always) - Reword message tool approval comment for accuracy - Clarify with_routines() docstring re: tool registration vs engine wiring Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
5c2ba44f12
commit
ae89a52ac2
+71
-15
@@ -27,6 +27,8 @@ use crate::support::metrics::{ToolInvocation, TraceMetrics};
|
||||
use crate::support::test_channel::TestChannel;
|
||||
use crate::support::trace_llm::{LlmTrace, TraceLlm};
|
||||
|
||||
use ironclaw::llm::recording::{HttpExchange, ReplayingHttpInterceptor};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TestChannelHandle -- wraps Arc<TestChannel> as Box<dyn Channel>
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -362,6 +364,8 @@ pub struct TestRigBuilder {
|
||||
llm: Option<Arc<dyn LlmProvider>>,
|
||||
max_tool_iterations: usize,
|
||||
injection_check: bool,
|
||||
enable_routines: bool,
|
||||
http_exchanges: Vec<HttpExchange>,
|
||||
extra_tools: Vec<Arc<dyn Tool>>,
|
||||
}
|
||||
|
||||
@@ -373,6 +377,8 @@ impl TestRigBuilder {
|
||||
llm: None,
|
||||
max_tool_iterations: 10,
|
||||
injection_check: false,
|
||||
enable_routines: false,
|
||||
http_exchanges: Vec::new(),
|
||||
extra_tools: Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -411,6 +417,23 @@ impl TestRigBuilder {
|
||||
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.
|
||||
pub fn with_routines(mut self) -> Self {
|
||||
self.enable_routines = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Add pre-recorded HTTP exchanges for the `ReplayingHttpInterceptor`.
|
||||
///
|
||||
/// When set, all `http` tool calls will return these responses in order
|
||||
/// instead of making real network requests.
|
||||
pub fn with_http_exchanges(mut self, exchanges: Vec<HttpExchange>) -> Self {
|
||||
self.http_exchanges = exchanges;
|
||||
self
|
||||
}
|
||||
|
||||
/// Build the test rig, creating a real agent and spawning it in the background.
|
||||
///
|
||||
/// Uses `AppBuilder::build_all()` to get the same component set as the real
|
||||
@@ -422,6 +445,17 @@ impl TestRigBuilder {
|
||||
use ironclaw::channels::ChannelManager;
|
||||
use ironclaw::db::libsql::LibSqlBackend;
|
||||
|
||||
// Destructure self up front to avoid partial-move issues.
|
||||
let TestRigBuilder {
|
||||
trace,
|
||||
llm,
|
||||
max_tool_iterations,
|
||||
injection_check,
|
||||
enable_routines,
|
||||
http_exchanges: explicit_http_exchanges,
|
||||
extra_tools,
|
||||
} = self;
|
||||
|
||||
// 1. Create temp dir + libSQL database + run migrations.
|
||||
let temp_dir = tempfile::tempdir().expect("failed to create temp dir");
|
||||
let db_path = temp_dir.path().join("test_rig.db");
|
||||
@@ -440,24 +474,23 @@ impl TestRigBuilder {
|
||||
let _ = std::fs::create_dir_all(&skills_dir);
|
||||
let _ = std::fs::create_dir_all(&installed_skills_dir);
|
||||
let mut config = Config::for_testing(db_path, skills_dir, installed_skills_dir);
|
||||
config.agent.max_tool_iterations = self.max_tool_iterations;
|
||||
config.safety.injection_check_enabled = self.injection_check;
|
||||
config.agent.max_tool_iterations = max_tool_iterations;
|
||||
config.safety.injection_check_enabled = injection_check;
|
||||
|
||||
// 3. Create SessionManager + LogBroadcaster.
|
||||
let session = Arc::new(SessionManager::new(SessionConfig::default()));
|
||||
let log_broadcaster = Arc::new(LogBroadcaster::new());
|
||||
|
||||
// 4. Create TraceLlm + InstrumentedLlm, extract HTTP exchanges for replay.
|
||||
let http_exchanges = self
|
||||
.trace
|
||||
let trace_http_exchanges = trace
|
||||
.as_ref()
|
||||
.map(|t| t.http_exchanges.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut trace_llm_ref: Option<Arc<TraceLlm>> = None;
|
||||
let base_llm: Arc<dyn LlmProvider> = if let Some(llm) = self.llm {
|
||||
let base_llm: Arc<dyn LlmProvider> = if let Some(llm) = llm {
|
||||
llm
|
||||
} else if let Some(trace) = self.trace {
|
||||
} else if let Some(trace) = trace {
|
||||
let tlm = Arc::new(TraceLlm::from_trace(trace));
|
||||
trace_llm_ref = Some(Arc::clone(&tlm));
|
||||
tlm
|
||||
@@ -536,7 +569,7 @@ impl TestRigBuilder {
|
||||
}
|
||||
|
||||
// Register any extra test-specific tools.
|
||||
for tool in self.extra_tools {
|
||||
for tool in extra_tools {
|
||||
components.tools.register(tool).await;
|
||||
}
|
||||
}
|
||||
@@ -560,12 +593,19 @@ impl TestRigBuilder {
|
||||
hooks: components.hooks,
|
||||
cost_guard: components.cost_guard,
|
||||
sse_tx: None,
|
||||
http_interceptor: if http_exchanges.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(Arc::new(
|
||||
ironclaw::llm::recording::ReplayingHttpInterceptor::new(http_exchanges),
|
||||
))
|
||||
http_interceptor: {
|
||||
// Prefer explicit exchanges from with_http_exchanges(), fall back to trace.
|
||||
let exchanges = if explicit_http_exchanges.is_empty() {
|
||||
trace_http_exchanges
|
||||
} else {
|
||||
explicit_http_exchanges
|
||||
};
|
||||
if exchanges.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(Arc::new(ReplayingHttpInterceptor::new(exchanges))
|
||||
as Arc<dyn ironclaw::llm::recording::HttpInterceptor>)
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -576,14 +616,30 @@ impl TestRigBuilder {
|
||||
channel_manager.add(Box::new(handle)).await;
|
||||
let channels = Arc::new(channel_manager);
|
||||
|
||||
// 7b. Register message tool so routines can send messages to channels.
|
||||
deps.tools
|
||||
.register_message_tools(Arc::clone(&channels))
|
||||
.await;
|
||||
|
||||
// 8. Create Agent.
|
||||
let routine_config = if enable_routines {
|
||||
Some(ironclaw::config::RoutineConfig {
|
||||
enabled: true,
|
||||
cron_check_interval_secs: 60,
|
||||
max_concurrent_routines: 3,
|
||||
default_cooldown_secs: 300,
|
||||
max_lightweight_tokens: 4096,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let agent = Agent::new(
|
||||
components.config.agent.clone(),
|
||||
deps,
|
||||
channels,
|
||||
None, // heartbeat_config
|
||||
None, // hygiene_config
|
||||
None, // routine_config
|
||||
routine_config,
|
||||
None, // context_manager
|
||||
None, // session_manager
|
||||
);
|
||||
@@ -604,7 +660,7 @@ impl TestRigBuilder {
|
||||
channel: test_channel,
|
||||
instrumented_llm: instrumented,
|
||||
start_time: Instant::now(),
|
||||
max_tool_iterations: self.max_tool_iterations,
|
||||
max_tool_iterations,
|
||||
agent_handle: Some(agent_handle),
|
||||
db: db_ref,
|
||||
workspace: workspace_ref,
|
||||
|
||||
Reference in New Issue
Block a user