mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-27 08:00:17 +00:00
* 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 <[email protected]> * 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 <[email protected]> * 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 <[email protected]> * 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 <[email protected]> * 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 <[email protected]> * style: remove trailing blank line in libsql jobs.rs [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * 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 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]> * fix: add job token budget, change iteration cap to Failed, fix web cancel (#698) Jobs could enter infinite retry loops because: (1) no token budget was enforced, (2) iteration cap marked jobs as Stuck (allowing self-repair to restart them), and (3) the web UI cancel button only updated the DB without stopping the running worker. - Add `max_tokens_per_job` config (settings.json + AGENT_MAX_TOKENS_PER_JOB env var, default 0 = unlimited) with per-job metadata override - Track token usage after respond_with_tools() and fail the job on budget exceeded - Change iteration cap and persistent rate limiting from mark_stuck to mark_failed, preventing self-repair restart loops - Fix web cancel handler to call scheduler.stop() which updates in-memory state AND aborts the worker task, falling back to DB-only update Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address PR review — always persist cancel to DB, simplify token check - Cancel handler now always persists Cancelled to DB regardless of whether scheduler.stop() ran, fixing the edge case where stop() returns Ok(()) for jobs not in the scheduler map - Collapse nested ifs per clippy (let-chains) - Add NOTE comment about select_tools() not exposing TokenUsage [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: rustfmt formatting in wizard.rs (pre-existing) [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
139 lines
5.4 KiB
Rust
139 lines
5.4 KiB
Rust
use std::time::Duration;
|
|
|
|
use crate::config::helpers::{parse_bool_env, parse_option_env, parse_optional_env};
|
|
use crate::error::ConfigError;
|
|
use crate::settings::Settings;
|
|
|
|
/// Agent behavior configuration.
|
|
#[derive(Debug, Clone)]
|
|
pub struct AgentConfig {
|
|
pub name: String,
|
|
pub max_parallel_jobs: usize,
|
|
pub job_timeout: Duration,
|
|
pub stuck_threshold: Duration,
|
|
pub repair_check_interval: Duration,
|
|
pub max_repair_attempts: u32,
|
|
/// Whether to use planning before tool execution.
|
|
pub use_planning: bool,
|
|
/// Session idle timeout. Sessions inactive longer than this are pruned.
|
|
pub session_idle_timeout: Duration,
|
|
/// Allow chat to use filesystem/shell tools directly (bypass sandbox).
|
|
pub allow_local_tools: bool,
|
|
/// Maximum daily LLM spend in cents (e.g. 10000 = $100). None = unlimited.
|
|
pub max_cost_per_day_cents: Option<u64>,
|
|
/// Maximum LLM/tool actions per hour. None = unlimited.
|
|
pub max_actions_per_hour: Option<u64>,
|
|
/// Maximum tool-call iterations per agentic loop invocation. Default 50.
|
|
pub max_tool_iterations: usize,
|
|
/// When true, skip tool approval checks entirely. For benchmarks/CI.
|
|
pub auto_approve_tools: bool,
|
|
/// Default timezone for new sessions (IANA name, e.g. "America/New_York").
|
|
pub default_timezone: String,
|
|
/// Maximum tokens per job (0 = unlimited).
|
|
pub max_tokens_per_job: u64,
|
|
}
|
|
|
|
impl AgentConfig {
|
|
/// Create a test-friendly config without reading env vars.
|
|
#[cfg(feature = "libsql")]
|
|
pub fn for_testing() -> Self {
|
|
Self {
|
|
name: "test-rig".to_string(),
|
|
max_parallel_jobs: 1,
|
|
job_timeout: Duration::from_secs(30),
|
|
stuck_threshold: Duration::from_secs(300),
|
|
repair_check_interval: Duration::from_secs(3600),
|
|
max_repair_attempts: 0,
|
|
use_planning: false,
|
|
session_idle_timeout: Duration::from_secs(3600),
|
|
allow_local_tools: true,
|
|
max_cost_per_day_cents: None,
|
|
max_actions_per_hour: None,
|
|
max_tool_iterations: 10,
|
|
auto_approve_tools: true,
|
|
default_timezone: "UTC".to_string(),
|
|
max_tokens_per_job: 0,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn resolve(settings: &Settings) -> Result<Self, ConfigError> {
|
|
Ok(Self {
|
|
name: parse_optional_env("AGENT_NAME", settings.agent.name.clone())?,
|
|
max_parallel_jobs: parse_optional_env(
|
|
"AGENT_MAX_PARALLEL_JOBS",
|
|
settings.agent.max_parallel_jobs as usize,
|
|
)?,
|
|
job_timeout: Duration::from_secs(parse_optional_env(
|
|
"AGENT_JOB_TIMEOUT_SECS",
|
|
settings.agent.job_timeout_secs,
|
|
)?),
|
|
stuck_threshold: Duration::from_secs(parse_optional_env(
|
|
"AGENT_STUCK_THRESHOLD_SECS",
|
|
settings.agent.stuck_threshold_secs,
|
|
)?),
|
|
repair_check_interval: Duration::from_secs(parse_optional_env(
|
|
"SELF_REPAIR_CHECK_INTERVAL_SECS",
|
|
settings.agent.repair_check_interval_secs,
|
|
)?),
|
|
max_repair_attempts: parse_optional_env(
|
|
"SELF_REPAIR_MAX_ATTEMPTS",
|
|
settings.agent.max_repair_attempts,
|
|
)?,
|
|
use_planning: parse_bool_env("AGENT_USE_PLANNING", settings.agent.use_planning)?,
|
|
session_idle_timeout: Duration::from_secs(parse_optional_env(
|
|
"SESSION_IDLE_TIMEOUT_SECS",
|
|
settings.agent.session_idle_timeout_secs,
|
|
)?),
|
|
allow_local_tools: parse_bool_env("ALLOW_LOCAL_TOOLS", false)?,
|
|
max_cost_per_day_cents: parse_option_env("MAX_COST_PER_DAY_CENTS")?,
|
|
max_actions_per_hour: parse_option_env("MAX_ACTIONS_PER_HOUR")?,
|
|
max_tool_iterations: parse_optional_env(
|
|
"AGENT_MAX_TOOL_ITERATIONS",
|
|
settings.agent.max_tool_iterations,
|
|
)?,
|
|
auto_approve_tools: parse_bool_env(
|
|
"AGENT_AUTO_APPROVE_TOOLS",
|
|
settings.agent.auto_approve_tools,
|
|
)?,
|
|
default_timezone: {
|
|
let tz: String = parse_optional_env(
|
|
"DEFAULT_TIMEZONE",
|
|
settings.agent.default_timezone.clone(),
|
|
)?;
|
|
if crate::timezone::parse_timezone(&tz).is_none() {
|
|
return Err(ConfigError::InvalidValue {
|
|
key: "DEFAULT_TIMEZONE".into(),
|
|
message: format!("invalid IANA timezone: '{tz}'"),
|
|
});
|
|
}
|
|
tz
|
|
},
|
|
max_tokens_per_job: parse_optional_env(
|
|
"AGENT_MAX_TOKENS_PER_JOB",
|
|
settings.agent.max_tokens_per_job,
|
|
)?,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_default_timezone_rejects_invalid() {
|
|
let mut settings = Settings::default();
|
|
settings.agent.default_timezone = "Fake/Zone".to_string();
|
|
|
|
let result = AgentConfig::resolve(&settings);
|
|
assert!(result.is_err(), "invalid IANA timezone should be rejected");
|
|
}
|
|
|
|
#[test]
|
|
fn test_default_timezone_accepts_valid() {
|
|
let settings = Settings::default(); // default is "UTC"
|
|
let config = AgentConfig::resolve(&settings).expect("resolve");
|
|
assert_eq!(config.default_timezone, "UTC");
|
|
}
|
|
}
|