feat(timezone): add timezone-aware session context (#671)

* feat(timezone): add timezone-aware session context (#661)

All timestamps were UTC-only, causing daily logs to split at UTC midnight,
cron schedules to fire in UTC, and no quiet hours for heartbeat. This adds
timezone as a per-session property flowing from the client.

Key changes:
- New `src/timezone.rs` module with resolution chain, parsing, and detection
- `IncomingMessage` carries optional timezone from client
- `JobContext.user_timezone` flows timezone to tools
- `next_cron_fire()` accepts timezone for schedule evaluation
- `Trigger::Cron` stores optional timezone (backward-compatible)
- Workspace gains `_tz` variants for daily logs and system prompt
- Heartbeat supports quiet hours (`HEARTBEAT_QUIET_START/END`)
- Web frontend sends `Intl.DateTimeFormat().resolvedOptions().timeZone`
- REPL auto-detects system timezone
- `DEFAULT_TIMEZONE` env var / settings for server-wide default

Storage stays UTC. Conversion happens at display boundaries.

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

* fix(timezone): address review feedback on timezone-aware sessions

- Validate quiet hours values (0-23) in HeartbeatConfig::resolve()
- Fall back to settings values when env vars are unset for quiet hours
- Validate IANA timezone strings in routine_create/update with parse_timezone
- Add timezone field to routine_create tool schema
- Allow standalone timezone update on cron routines without changing schedule
- Return path from append_daily_log_tz to avoid TOCTOU race at midnight
- Delegate append_daily_log to append_daily_log_tz(entry, UTC) to avoid drift
- Preserve timezone through approval flow via PendingApproval.user_timezone
- Improve test_today_in_tz to not depend on hardcoded year
- Add 3 regression tests for quiet hours config validation

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

* style: fix formatting in routine.rs

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

* fix(timezone): address second round of review feedback

- Remove .claude/scheduled_tasks.lock from repo and add to .gitignore
- Store resolved timezone (not raw message.timezone) in PendingApproval
- Carry forward user_timezone through chained approvals in thread_ops
- Wire quiet_hours_start/end from config to HeartbeatRunner
- Support X-Timezone header as fallback in chat_send_handler

[skip-regression-check]

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

* fix(timezone): include user's local time in time tool response

The time tool's "now" operation now returns local_iso and timezone
fields based on ctx.user_timezone, so the LLM can report time in
the user's timezone instead of always UTC.

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

* style: fix formatting in time.rs

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

* fix(timezone): address Copilot review round 3 — validation, deterministic tests, schema fixes

- Validate DEFAULT_TIMEZONE and HEARTBEAT_TIMEZONE at config load time
- Add timezone field to HeartbeatSettings and config::HeartbeatConfig
- Wire heartbeat timezone from config through agent_loop to HeartbeatRunner
- Add timezone to routine_update tool schema (was accepted but not advertised)
- Error on schedule/timezone update for non-cron routines
- Validate timezone in Trigger::from_db (coerce invalid to None with warning)
- Validate timezone in approval path (thread_ops.rs) before overwriting
- Time tool always includes timezone/local_iso fields (fallback to UTC)
- Make quiet hours tests deterministic using current UTC hour
- Add regression tests for config validation

[skip-regression-check]

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Illia Polosukhin
2026-03-08 08:01:56 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent a20e19ab16
commit df3635d6be
31 changed files with 797 additions and 47 deletions
+2 -1
View File
@@ -4,8 +4,9 @@
.env.* .env.*
!.env.example !.env.example
# Claude Code worktrees # Claude Code worktrees and lock files
.claude/worktrees/ .claude/worktrees/
.claude/scheduled_tasks.lock
# Sidecar tool data # Sidecar tool data
.sidecar/ .sidecar/
Generated
+30
View File
@@ -864,6 +864,16 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "chrono-tz"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3"
dependencies = [
"chrono",
"phf 0.12.1",
]
[[package]] [[package]]
name = "cipher" name = "cipher"
version = "0.4.4" version = "0.4.4"
@@ -2872,6 +2882,7 @@ dependencies = [
"bollard", "bollard",
"bytes", "bytes",
"chrono", "chrono",
"chrono-tz",
"clap", "clap",
"clap_complete", "clap_complete",
"cron", "cron",
@@ -2890,6 +2901,7 @@ dependencies = [
"http-body-util", "http-body-util",
"hyper 1.8.1", "hyper 1.8.1",
"hyper-util", "hyper-util",
"iana-time-zone",
"insta", "insta",
"libsql", "libsql",
"lru", "lru",
@@ -3892,6 +3904,15 @@ dependencies = [
"phf_shared 0.11.3", "phf_shared 0.11.3",
] ]
[[package]]
name = "phf"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7"
dependencies = [
"phf_shared 0.12.1",
]
[[package]] [[package]]
name = "phf" name = "phf"
version = "0.13.1" version = "0.13.1"
@@ -3966,6 +3987,15 @@ dependencies = [
"uncased", "uncased",
] ]
[[package]]
name = "phf_shared"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981"
dependencies = [
"siphasher",
]
[[package]] [[package]]
name = "phf_shared" name = "phf_shared"
version = "0.13.1" version = "0.13.1"
+2
View File
@@ -73,6 +73,8 @@ toml = "0.8"
# Core types # Core types
uuid = { version = "1", features = ["v4", "v5", "serde"] } uuid = { version = "1", features = ["v4", "v5", "serde"] }
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
chrono-tz = "0.10"
iana-time-zone = "0.1"
rust_decimal = { version = "1", features = ["serde", "serde-with-str", "maths"] } rust_decimal = { version = "1", features = ["serde", "serde-with-str", "maths"] }
rust_decimal_macros = "1" rust_decimal_macros = "1"
+6
View File
@@ -356,6 +356,12 @@ impl Agent {
if let Some(workspace) = self.workspace() { if let Some(workspace) = self.workspace() {
let mut config = AgentHeartbeatConfig::default() let mut config = AgentHeartbeatConfig::default()
.with_interval(std::time::Duration::from_secs(hb_config.interval_secs)); .with_interval(std::time::Duration::from_secs(hb_config.interval_secs));
config.quiet_hours_start = hb_config.quiet_hours_start;
config.quiet_hours_end = hb_config.quiet_hours_end;
config.timezone = hb_config
.timezone
.clone()
.or_else(|| Some(self.config.default_timezone.clone()));
if let (Some(user), Some(channel)) = if let (Some(user), Some(channel)) =
(&hb_config.notify_user, &hb_config.notify_channel) (&hb_config.notify_user, &hb_config.notify_channel)
{ {
+17 -1
View File
@@ -50,8 +50,18 @@ impl Agent {
// Load workspace system prompt (identity files: AGENTS.md, SOUL.md, etc.) // Load workspace system prompt (identity files: AGENTS.md, SOUL.md, etc.)
// In group chats, MEMORY.md is excluded to prevent leaking personal context. // In group chats, MEMORY.md is excluded to prevent leaking personal context.
// Resolve the user's timezone
let user_tz = crate::timezone::resolve_timezone(
message.timezone.as_deref(),
None, // user setting lookup can be added later
&self.config.default_timezone,
);
let system_prompt = if let Some(ws) = self.workspace() { let system_prompt = if let Some(ws) = self.workspace() {
match ws.system_prompt_for_context(is_group_chat).await { match ws
.system_prompt_for_context_tz(is_group_chat, user_tz)
.await
{
Ok(prompt) if !prompt.is_empty() => Some(prompt), Ok(prompt) if !prompt.is_empty() => Some(prompt),
Ok(_) => None, Ok(_) => None,
Err(e) => { Err(e) => {
@@ -130,6 +140,7 @@ impl Agent {
let mut job_ctx = let mut job_ctx =
JobContext::with_user(&message.user_id, "chat", "Interactive chat session"); JobContext::with_user(&message.user_id, "chat", "Interactive chat session");
job_ctx.http_interceptor = self.deps.http_interceptor.clone(); job_ctx.http_interceptor = self.deps.http_interceptor.clone();
job_ctx.user_timezone = user_tz.name().to_string();
// Build system prompts once for this turn. Two variants: with tools // Build system prompts once for this turn. Two variants: with tools
// (normal iterations) and without (force_text final iteration). // (normal iterations) and without (force_text final iteration).
@@ -785,6 +796,7 @@ impl Agent {
tool_call_id: tc.id.clone(), tool_call_id: tc.id.clone(),
context_messages: context_messages.clone(), context_messages: context_messages.clone(),
deferred_tool_calls: tool_calls[approval_idx + 1..].to_vec(), deferred_tool_calls: tool_calls[approval_idx + 1..].to_vec(),
user_timezone: Some(user_tz.name().to_string()),
}; };
return Ok(AgenticLoopResult::NeedApproval { pending }); return Ok(AgenticLoopResult::NeedApproval { pending });
@@ -1146,6 +1158,7 @@ mod tests {
max_actions_per_hour: None, max_actions_per_hour: None,
max_tool_iterations: 50, max_tool_iterations: 50,
auto_approve_tools: false, auto_approve_tools: false,
default_timezone: "UTC".to_string(),
}, },
deps, deps,
Arc::new(ChannelManager::new()), Arc::new(ChannelManager::new()),
@@ -1248,6 +1261,7 @@ mod tests {
arguments: serde_json::json!({"message": "done"}), arguments: serde_json::json!({"message": "done"}),
}, },
], ],
user_timezone: None,
}; };
let json = serde_json::to_string(&pending).expect("serialize"); let json = serde_json::to_string(&pending).expect("serialize");
@@ -1900,6 +1914,7 @@ mod tests {
max_actions_per_hour: None, max_actions_per_hour: None,
max_tool_iterations, max_tool_iterations,
auto_approve_tools: true, auto_approve_tools: true,
default_timezone: "UTC".to_string(),
}, },
deps, deps,
Arc::new(ChannelManager::new()), Arc::new(ChannelManager::new()),
@@ -2015,6 +2030,7 @@ mod tests {
max_actions_per_hour: None, max_actions_per_hour: None,
max_tool_iterations: max_iter, max_tool_iterations: max_iter,
auto_approve_tools: true, auto_approve_tools: true,
default_timezone: "UTC".to_string(),
}, },
deps, deps,
Arc::new(ChannelManager::new()), Arc::new(ChannelManager::new()),
+112
View File
@@ -48,6 +48,12 @@ pub struct HeartbeatConfig {
pub notify_user_id: Option<String>, pub notify_user_id: Option<String>,
/// Channel to notify on heartbeat findings. /// Channel to notify on heartbeat findings.
pub notify_channel: Option<String>, pub notify_channel: Option<String>,
/// Hour (0-23) when quiet hours start.
pub quiet_hours_start: Option<u32>,
/// Hour (0-23) when quiet hours end.
pub quiet_hours_end: Option<u32>,
/// Timezone for quiet hours evaluation (IANA name).
pub timezone: Option<String>,
} }
impl Default for HeartbeatConfig { impl Default for HeartbeatConfig {
@@ -58,6 +64,9 @@ impl Default for HeartbeatConfig {
max_failures: 3, max_failures: 3,
notify_user_id: None, notify_user_id: None,
notify_channel: None, notify_channel: None,
quiet_hours_start: None,
quiet_hours_end: None,
timezone: None,
} }
} }
} }
@@ -75,6 +84,26 @@ impl HeartbeatConfig {
self self
} }
/// Check whether the current time falls within configured quiet hours.
pub fn is_quiet_hours(&self) -> bool {
use chrono::Timelike;
let (Some(start), Some(end)) = (self.quiet_hours_start, self.quiet_hours_end) else {
return false;
};
let tz = self
.timezone
.as_deref()
.and_then(crate::timezone::parse_timezone)
.unwrap_or(chrono_tz::UTC);
let now_hour = crate::timezone::now_in_tz(tz).hour();
if start <= end {
now_hour >= start && now_hour < end
} else {
// Wraps midnight, e.g. 22..06
now_hour >= start || now_hour < end
}
}
/// Set the notification target. /// Set the notification target.
pub fn with_notify(mut self, user_id: impl Into<String>, channel: impl Into<String>) -> Self { pub fn with_notify(mut self, user_id: impl Into<String>, channel: impl Into<String>) -> Self {
self.notify_user_id = Some(user_id.into()); self.notify_user_id = Some(user_id.into());
@@ -162,6 +191,12 @@ impl HeartbeatRunner {
loop { loop {
interval.tick().await; interval.tick().await;
// Skip during quiet hours
if self.config.is_quiet_hours() {
tracing::debug!("Heartbeat skipped: quiet hours");
continue;
}
// Run memory hygiene in the background so it never delays the // Run memory hygiene in the background so it never delays the
// heartbeat checklist. Failures are logged inside run_if_due. // heartbeat checklist. Failures are logged inside run_if_due.
let hygiene_workspace = Arc::clone(&self.workspace); let hygiene_workspace = Arc::clone(&self.workspace);
@@ -532,6 +567,83 @@ mod tests {
assert!(!is_effectively_empty(content)); assert!(!is_effectively_empty(content));
} }
// ==================== quiet hours ====================
#[test]
fn test_quiet_hours_inside() {
use chrono::{Timelike, Utc};
let now_utc = Utc::now();
let hour = now_utc.hour();
let start = hour;
let end = (hour + 1) % 24;
let config = HeartbeatConfig {
quiet_hours_start: Some(start),
quiet_hours_end: Some(end),
timezone: Some("UTC".to_string()),
..HeartbeatConfig::default()
};
// Current UTC hour is inside [start, end) by construction
assert!(config.is_quiet_hours());
}
#[test]
fn test_quiet_hours_outside() {
use chrono::{Timelike, Utc};
let now_utc = Utc::now();
let hour = now_utc.hour();
let start = (hour + 1) % 24;
let end = (hour + 2) % 24;
let config = HeartbeatConfig {
quiet_hours_start: Some(start),
quiet_hours_end: Some(end),
timezone: Some("UTC".to_string()),
..HeartbeatConfig::default()
};
// Current UTC hour is outside [start, end) by construction
assert!(!config.is_quiet_hours());
}
#[test]
fn test_quiet_hours_wraparound_excludes_now() {
use chrono::{Timelike, Utc};
let now_utc = Utc::now();
let hour = now_utc.hour();
// Window covers all hours except the current one
let start = (hour + 1) % 24;
let end = hour;
let config = HeartbeatConfig {
quiet_hours_start: Some(start),
quiet_hours_end: Some(end),
timezone: Some("UTC".to_string()),
..HeartbeatConfig::default()
};
assert!(!config.is_quiet_hours());
}
#[test]
fn test_quiet_hours_none_configured() {
let config = HeartbeatConfig::default();
assert!(!config.is_quiet_hours());
}
#[test]
fn test_quiet_hours_same_start_end() {
let config = HeartbeatConfig {
quiet_hours_start: Some(10),
quiet_hours_end: Some(10),
timezone: Some("UTC".to_string()),
..HeartbeatConfig::default()
};
// start == end means zero-width window, should be false
assert!(!config.is_quiet_hours());
}
#[test] #[test]
fn test_spawn_heartbeat_accepts_store_param() { fn test_spawn_heartbeat_accepts_store_param() {
// Regression: spawn_heartbeat must accept an optional Database store // Regression: spawn_heartbeat must accept an optional Database store
+87 -9
View File
@@ -57,7 +57,11 @@ pub struct Routine {
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
pub enum Trigger { pub enum Trigger {
/// Fire on a cron schedule (e.g. "0 9 * * MON-FRI" or "every 2h"). /// Fire on a cron schedule (e.g. "0 9 * * MON-FRI" or "every 2h").
Cron { schedule: String }, Cron {
schedule: String,
#[serde(default)]
timezone: Option<String>,
},
/// Fire when a channel message matches a pattern. /// Fire when a channel message matches a pattern.
Event { Event {
/// Optional channel filter (e.g. "telegram", "slack"). /// Optional channel filter (e.g. "telegram", "slack").
@@ -99,7 +103,21 @@ impl Trigger {
field: "schedule".into(), field: "schedule".into(),
})? })?
.to_string(); .to_string();
Ok(Trigger::Cron { schedule }) let timezone = config
.get("timezone")
.and_then(|v| v.as_str())
.and_then(|tz| {
if crate::timezone::parse_timezone(tz).is_some() {
Some(tz.to_string())
} else {
tracing::warn!(
"Ignoring invalid timezone '{}' from DB for cron trigger",
tz
);
None
}
});
Ok(Trigger::Cron { schedule, timezone })
} }
"event" => { "event" => {
let pattern = config let pattern = config
@@ -137,7 +155,10 @@ impl Trigger {
/// Serialize trigger-specific config to JSON for DB storage. /// Serialize trigger-specific config to JSON for DB storage.
pub fn to_config_json(&self) -> serde_json::Value { pub fn to_config_json(&self) -> serde_json::Value {
match self { match self {
Trigger::Cron { schedule } => serde_json::json!({ "schedule": schedule }), Trigger::Cron { schedule, timezone } => serde_json::json!({
"schedule": schedule,
"timezone": timezone,
}),
Trigger::Event { channel, pattern } => serde_json::json!({ Trigger::Event { channel, pattern } => serde_json::json!({
"pattern": pattern, "pattern": pattern,
"channel": channel, "channel": channel,
@@ -415,12 +436,25 @@ pub fn content_hash(content: &str) -> u64 {
} }
/// Parse a cron expression and compute the next fire time from now. /// Parse a cron expression and compute the next fire time from now.
pub fn next_cron_fire(schedule: &str) -> Result<Option<DateTime<Utc>>, RoutineError> { ///
/// When `timezone` is provided and valid, the schedule is evaluated in that
/// timezone and the result is converted back to UTC. Otherwise UTC is used.
pub fn next_cron_fire(
schedule: &str,
timezone: Option<&str>,
) -> Result<Option<DateTime<Utc>>, RoutineError> {
let cron_schedule = let cron_schedule =
cron::Schedule::from_str(schedule).map_err(|e| RoutineError::InvalidCron { cron::Schedule::from_str(schedule).map_err(|e| RoutineError::InvalidCron {
reason: e.to_string(), reason: e.to_string(),
})?; })?;
Ok(cron_schedule.upcoming(Utc).next()) if let Some(tz) = timezone.and_then(crate::timezone::parse_timezone) {
Ok(cron_schedule
.upcoming(tz)
.next()
.map(|dt| dt.with_timezone(&Utc)))
} else {
Ok(cron_schedule.upcoming(Utc).next())
}
} }
#[cfg(test)] #[cfg(test)]
@@ -433,10 +467,11 @@ mod tests {
fn test_trigger_roundtrip() { fn test_trigger_roundtrip() {
let trigger = Trigger::Cron { let trigger = Trigger::Cron {
schedule: "0 9 * * MON-FRI".to_string(), schedule: "0 9 * * MON-FRI".to_string(),
timezone: None,
}; };
let json = trigger.to_config_json(); let json = trigger.to_config_json();
let parsed = Trigger::from_db("cron", json).expect("parse cron"); let parsed = Trigger::from_db("cron", json).expect("parse cron");
assert!(matches!(parsed, Trigger::Cron { schedule } if schedule == "0 9 * * MON-FRI")); assert!(matches!(parsed, Trigger::Cron { schedule, .. } if schedule == "0 9 * * MON-FRI"));
} }
#[test] #[test]
@@ -509,16 +544,58 @@ mod tests {
#[test] #[test]
fn test_next_cron_fire_valid() { fn test_next_cron_fire_valid() {
// Every minute should always have a next fire // Every minute should always have a next fire
let next = next_cron_fire("* * * * * *").expect("valid cron"); let next = next_cron_fire("* * * * * *", None).expect("valid cron");
assert!(next.is_some()); assert!(next.is_some());
} }
#[test] #[test]
fn test_next_cron_fire_invalid() { fn test_next_cron_fire_invalid() {
let result = next_cron_fire("not a cron"); let result = next_cron_fire("not a cron", None);
assert!(result.is_err()); assert!(result.is_err());
} }
#[test]
fn test_trigger_cron_timezone_roundtrip() {
let trigger = Trigger::Cron {
schedule: "0 9 * * MON-FRI".to_string(),
timezone: Some("America/New_York".to_string()),
};
let json = trigger.to_config_json();
let parsed = Trigger::from_db("cron", json).expect("parse cron");
assert!(matches!(parsed, Trigger::Cron { schedule, timezone }
if schedule == "0 9 * * MON-FRI"
&& timezone.as_deref() == Some("America/New_York")));
}
#[test]
fn test_trigger_cron_no_timezone_backward_compat() {
let json = serde_json::json!({"schedule": "0 9 * * *"});
let parsed = Trigger::from_db("cron", json).expect("parse cron");
assert!(matches!(parsed, Trigger::Cron { timezone, .. } if timezone.is_none()));
}
#[test]
fn test_trigger_cron_invalid_timezone_coerced_to_none() {
let json = serde_json::json!({"schedule": "0 9 * * *", "timezone": "Fake/Zone"});
let parsed = Trigger::from_db("cron", json).expect("parse cron");
assert!(
matches!(parsed, Trigger::Cron { timezone, .. } if timezone.is_none()),
"invalid timezone should be coerced to None"
);
}
#[test]
fn test_next_cron_fire_with_timezone() {
let next_utc = next_cron_fire("0 0 9 * * * *", None)
.expect("valid cron")
.expect("has next");
let next_est = next_cron_fire("0 0 9 * * * *", Some("America/New_York"))
.expect("valid cron")
.expect("has next");
// EST is UTC-5 (or EDT UTC-4), so the UTC result should differ
assert_ne!(next_utc, next_est, "timezone should shift the fire time");
}
#[test] #[test]
fn test_guardrails_default() { fn test_guardrails_default() {
let g = RoutineGuardrails::default(); let g = RoutineGuardrails::default();
@@ -531,7 +608,8 @@ mod tests {
fn test_trigger_type_tag() { fn test_trigger_type_tag() {
assert_eq!( assert_eq!(
Trigger::Cron { Trigger::Cron {
schedule: String::new() schedule: String::new(),
timezone: None,
} }
.type_tag(), .type_tag(),
"cron" "cron"
+7 -3
View File
@@ -170,7 +170,7 @@ impl RoutineEngine {
continue; continue;
} }
let detail = if let Trigger::Cron { ref schedule } = routine.trigger { let detail = if let Trigger::Cron { ref schedule, .. } = routine.trigger {
Some(schedule.clone()) Some(schedule.clone())
} else { } else {
None None
@@ -380,8 +380,12 @@ async fn execute_routine(ctx: EngineContext, routine: Routine, run: RoutineRun)
// Update routine runtime state // Update routine runtime state
let now = Utc::now(); let now = Utc::now();
let next_fire = if let Trigger::Cron { ref schedule } = routine.trigger { let next_fire = if let Trigger::Cron {
next_cron_fire(schedule).unwrap_or(None) ref schedule,
ref timezone,
} = routine.trigger
{
next_cron_fire(schedule, timezone.as_deref()).unwrap_or(None)
} else { } else {
None None
}; };
+6
View File
@@ -164,6 +164,10 @@ pub struct PendingApproval {
/// executed yet when approval was requested. /// executed yet when approval was requested.
#[serde(default)] #[serde(default)]
pub deferred_tool_calls: Vec<ToolCall>, pub deferred_tool_calls: Vec<ToolCall>,
/// User timezone at the time the approval was requested, so it persists
/// through the approval flow even if the approval message lacks timezone.
#[serde(default)]
pub user_timezone: Option<String>,
} }
/// A conversation thread within a session. /// A conversation thread within a session.
@@ -976,6 +980,7 @@ mod tests {
tool_call_id: "call_123".to_string(), tool_call_id: "call_123".to_string(),
context_messages: vec![ChatMessage::user("do it")], context_messages: vec![ChatMessage::user("do it")],
deferred_tool_calls: vec![], deferred_tool_calls: vec![],
user_timezone: None,
}; };
thread.await_approval(approval); thread.await_approval(approval);
@@ -1001,6 +1006,7 @@ mod tests {
tool_call_id: "call_456".to_string(), tool_call_id: "call_456".to_string(),
context_messages: vec![], context_messages: vec![],
deferred_tool_calls: vec![], deferred_tool_calls: vec![],
user_timezone: None,
}; };
thread.await_approval(approval); thread.await_approval(approval);
+12
View File
@@ -746,6 +746,16 @@ impl Agent {
let mut job_ctx = let mut job_ctx =
JobContext::with_user(&message.user_id, "chat", "Interactive chat session"); JobContext::with_user(&message.user_id, "chat", "Interactive chat session");
job_ctx.http_interceptor = self.deps.http_interceptor.clone(); job_ctx.http_interceptor = self.deps.http_interceptor.clone();
// Prefer a valid timezone from the approval message, fall back to the
// resolved timezone stored when the approval was originally requested.
let tz_candidate = message
.timezone
.as_deref()
.filter(|tz| crate::timezone::parse_timezone(tz).is_some())
.or(pending.user_timezone.as_deref());
if let Some(tz) = tz_candidate {
job_ctx.user_timezone = tz.to_string();
}
let _ = self let _ = self
.channels .channels
@@ -1111,6 +1121,8 @@ impl Agent {
tool_call_id: tc.id.clone(), tool_call_id: tc.id.clone(),
context_messages: context_messages.clone(), context_messages: context_messages.clone(),
deferred_tool_calls: deferred_tool_calls[approval_idx + 1..].to_vec(), deferred_tool_calls: deferred_tool_calls[approval_idx + 1..].to_vec(),
// Carry forward the resolved timezone from the original pending approval
user_timezone: pending.user_timezone.clone(),
}; };
let request_id = new_pending.request_id; let request_id = new_pending.request_id;
+15
View File
@@ -79,6 +79,8 @@ pub struct IncomingMessage {
pub received_at: DateTime<Utc>, pub received_at: DateTime<Utc>,
/// Channel-specific metadata. /// Channel-specific metadata.
pub metadata: serde_json::Value, pub metadata: serde_json::Value,
/// IANA timezone string from the client (e.g. "America/New_York").
pub timezone: Option<String>,
/// File or media attachments on this message. /// File or media attachments on this message.
pub attachments: Vec<IncomingAttachment>, pub attachments: Vec<IncomingAttachment>,
} }
@@ -99,6 +101,7 @@ impl IncomingMessage {
thread_id: None, thread_id: None,
received_at: Utc::now(), received_at: Utc::now(),
metadata: serde_json::Value::Null, metadata: serde_json::Value::Null,
timezone: None,
attachments: Vec::new(), attachments: Vec::new(),
} }
} }
@@ -121,6 +124,12 @@ impl IncomingMessage {
self self
} }
/// Set the client timezone.
pub fn with_timezone(mut self, tz: impl Into<String>) -> Self {
self.timezone = Some(tz.into());
self
}
/// Set attachments. /// Set attachments.
pub fn with_attachments(mut self, attachments: Vec<IncomingAttachment>) -> Self { pub fn with_attachments(mut self, attachments: Vec<IncomingAttachment>) -> Self {
self.attachments = attachments; self.attachments = attachments;
@@ -454,4 +463,10 @@ mod tests {
panic!("expected ToolCompleted variant"); panic!("expected ToolCompleted variant");
} }
} }
#[test]
fn test_incoming_message_with_timezone() {
let msg = IncomingMessage::new("test", "user1", "hello").with_timezone("America/New_York");
assert_eq!(msg.timezone.as_deref(), Some("America/New_York"));
}
} }
+13 -6
View File
@@ -297,9 +297,11 @@ impl Channel for ReplChannel {
let esc_interrupt_triggered_for_thread = Arc::new(AtomicBool::new(false)); let esc_interrupt_triggered_for_thread = Arc::new(AtomicBool::new(false));
std::thread::spawn(move || { std::thread::spawn(move || {
let sys_tz = crate::timezone::detect_system_timezone().name().to_string();
// Single message mode: send it and return // Single message mode: send it and return
if let Some(msg) = single_message { if let Some(msg) = single_message {
let incoming = IncomingMessage::new("repl", "default", &msg); let incoming = IncomingMessage::new("repl", "default", &msg).with_timezone(&sys_tz);
let _ = tx.blocking_send(incoming); let _ = tx.blocking_send(incoming);
return; return;
} }
@@ -361,7 +363,8 @@ impl Channel for ReplChannel {
"/quit" | "/exit" => { "/quit" | "/exit" => {
// Forward shutdown command so the agent loop exits even // Forward shutdown command so the agent loop exits even
// when other channels (e.g. web gateway) are still active. // when other channels (e.g. web gateway) are still active.
let msg = IncomingMessage::new("repl", "default", "/quit"); let msg = IncomingMessage::new("repl", "default", "/quit")
.with_timezone(&sys_tz);
let _ = tx.blocking_send(msg); let _ = tx.blocking_send(msg);
break; break;
} }
@@ -382,7 +385,8 @@ impl Channel for ReplChannel {
_ => {} _ => {}
} }
let msg = IncomingMessage::new("repl", "default", line); let msg =
IncomingMessage::new("repl", "default", line).with_timezone(&sys_tz);
if tx.blocking_send(msg).is_err() { if tx.blocking_send(msg).is_err() {
break; break;
} }
@@ -390,20 +394,23 @@ impl Channel for ReplChannel {
Err(ReadlineError::Interrupted) => { Err(ReadlineError::Interrupted) => {
if esc_interrupt_triggered_for_thread.swap(false, Ordering::Relaxed) { if esc_interrupt_triggered_for_thread.swap(false, Ordering::Relaxed) {
// Esc: interrupt current operation and keep REPL open. // Esc: interrupt current operation and keep REPL open.
let msg = IncomingMessage::new("repl", "default", "/interrupt"); let msg = IncomingMessage::new("repl", "default", "/interrupt")
.with_timezone(&sys_tz);
if tx.blocking_send(msg).is_err() { if tx.blocking_send(msg).is_err() {
break; break;
} }
} else { } else {
// Ctrl+C (VINTR): request graceful shutdown. // Ctrl+C (VINTR): request graceful shutdown.
let msg = IncomingMessage::new("repl", "default", "/quit"); let msg = IncomingMessage::new("repl", "default", "/quit")
.with_timezone(&sys_tz);
let _ = tx.blocking_send(msg); let _ = tx.blocking_send(msg);
break; break;
} }
} }
Err(ReadlineError::Eof) => { Err(ReadlineError::Eof) => {
// Ctrl+D: send /quit so the agent loop runs graceful shutdown // Ctrl+D: send /quit so the agent loop runs graceful shutdown
let msg = IncomingMessage::new("repl", "default", "/quit"); let msg =
IncomingMessage::new("repl", "default", "/quit").with_timezone(&sys_tz);
let _ = tx.blocking_send(msg); let _ = tx.blocking_send(msg);
break; break;
} }
+1 -1
View File
@@ -264,7 +264,7 @@ pub async fn routines_runs_handler(
/// Convert a Routine to the trimmed RoutineInfo for list display. /// Convert a Routine to the trimmed RoutineInfo for list display.
fn routine_to_info(r: &crate::agent::routine::Routine) -> RoutineInfo { fn routine_to_info(r: &crate::agent::routine::Routine) -> RoutineInfo {
let (trigger_type, trigger_summary) = match &r.trigger { let (trigger_type, trigger_summary) = match &r.trigger {
crate::agent::routine::Trigger::Cron { schedule } => { crate::agent::routine::Trigger::Cron { schedule, .. } => {
("cron".to_string(), format!("cron: {}", schedule)) ("cron".to_string(), format!("cron: {}", schedule))
} }
crate::agent::routine::Trigger::Event { crate::agent::routine::Trigger::Event {
+10 -1
View File
@@ -610,6 +610,7 @@ async fn oauth_callback_handler(
async fn chat_send_handler( async fn chat_send_handler(
State(state): State<Arc<GatewayState>>, State(state): State<Arc<GatewayState>>,
headers: axum::http::HeaderMap,
Json(req): Json<SendMessageRequest>, Json(req): Json<SendMessageRequest>,
) -> Result<(StatusCode, Json<SendMessageResponse>), (StatusCode, String)> { ) -> Result<(StatusCode, Json<SendMessageResponse>), (StatusCode, String)> {
tracing::debug!( tracing::debug!(
@@ -626,6 +627,14 @@ async fn chat_send_handler(
} }
let mut msg = IncomingMessage::new("gateway", &state.user_id, &req.content); let mut msg = IncomingMessage::new("gateway", &state.user_id, &req.content);
// Prefer timezone from JSON body, fall back to X-Timezone header
let tz = req
.timezone
.as_deref()
.or_else(|| headers.get("X-Timezone").and_then(|v| v.to_str().ok()));
if let Some(tz) = tz {
msg = msg.with_timezone(tz);
}
if let Some(ref thread_id) = req.thread_id { if let Some(ref thread_id) = req.thread_id {
msg = msg.with_thread(thread_id); msg = msg.with_thread(thread_id);
@@ -2115,7 +2124,7 @@ async fn routines_runs_handler(
/// Convert a Routine to the trimmed RoutineInfo for list display. /// Convert a Routine to the trimmed RoutineInfo for list display.
fn routine_to_info(r: &crate::agent::routine::Routine) -> RoutineInfo { fn routine_to_info(r: &crate::agent::routine::Routine) -> RoutineInfo {
let (trigger_type, trigger_summary) = match &r.trigger { let (trigger_type, trigger_summary) = match &r.trigger {
crate::agent::routine::Trigger::Cron { schedule } => { crate::agent::routine::Trigger::Cron { schedule, .. } => {
("cron".to_string(), format!("cron: {}", schedule)) ("cron".to_string(), format!("cron: {}", schedule))
} }
crate::agent::routine::Trigger::Event { crate::agent::routine::Trigger::Event {
+2 -1
View File
@@ -181,6 +181,7 @@ function confirmRestart() {
body: { body: {
content: '/restart', content: '/restart',
thread_id: currentThreadId, thread_id: currentThreadId,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
}, },
}) })
.then((response) => { .then((response) => {
@@ -454,7 +455,7 @@ function sendMessage() {
apiFetch('/api/chat/send', { apiFetch('/api/chat/send', {
method: 'POST', method: 'POST',
body: { content, thread_id: currentThreadId || undefined }, body: { content, thread_id: currentThreadId || undefined, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone },
}).catch((err) => { }).catch((err) => {
addMessage('system', 'Failed to send: ' + err.message); addMessage('system', 'Failed to send: ' + err.message);
}); });
+8 -2
View File
@@ -9,6 +9,7 @@ use uuid::Uuid;
pub struct SendMessageRequest { pub struct SendMessageRequest {
pub content: String, pub content: String,
pub thread_id: Option<String>, pub thread_id: Option<String>,
pub timezone: Option<String>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
@@ -613,6 +614,7 @@ pub enum WsClientMessage {
Message { Message {
content: String, content: String,
thread_id: Option<String>, thread_id: Option<String>,
timezone: Option<String>,
}, },
/// Approve or deny a pending tool execution. /// Approve or deny a pending tool execution.
#[serde(rename = "approval")] #[serde(rename = "approval")]
@@ -798,7 +800,9 @@ mod tests {
let json = r#"{"type":"message","content":"hello","thread_id":"t1"}"#; let json = r#"{"type":"message","content":"hello","thread_id":"t1"}"#;
let msg: WsClientMessage = serde_json::from_str(json).unwrap(); let msg: WsClientMessage = serde_json::from_str(json).unwrap();
match msg { match msg {
WsClientMessage::Message { content, thread_id } => { WsClientMessage::Message {
content, thread_id, ..
} => {
assert_eq!(content, "hello"); assert_eq!(content, "hello");
assert_eq!(thread_id.as_deref(), Some("t1")); assert_eq!(thread_id.as_deref(), Some("t1"));
} }
@@ -811,7 +815,9 @@ mod tests {
let json = r#"{"type":"message","content":"hi"}"#; let json = r#"{"type":"message","content":"hi"}"#;
let msg: WsClientMessage = serde_json::from_str(json).unwrap(); let msg: WsClientMessage = serde_json::from_str(json).unwrap();
match msg { match msg {
WsClientMessage::Message { content, thread_id } => { WsClientMessage::Message {
content, thread_id, ..
} => {
assert_eq!(content, "hi"); assert_eq!(content, "hi");
assert!(thread_id.is_none()); assert!(thread_id.is_none());
} }
+10 -1
View File
@@ -156,8 +156,15 @@ async fn handle_client_message(
direct_tx: &mpsc::Sender<WsServerMessage>, direct_tx: &mpsc::Sender<WsServerMessage>,
) { ) {
match msg { match msg {
WsClientMessage::Message { content, thread_id } => { WsClientMessage::Message {
content,
thread_id,
timezone,
} => {
let mut incoming = IncomingMessage::new("gateway", user_id, &content); let mut incoming = IncomingMessage::new("gateway", user_id, &content);
if let Some(ref tz) = timezone {
incoming = incoming.with_timezone(tz);
}
if let Some(ref tid) = thread_id { if let Some(ref tid) = thread_id {
incoming = incoming.with_thread(tid); incoming = incoming.with_thread(tid);
} }
@@ -349,6 +356,7 @@ mod tests {
WsClientMessage::Message { WsClientMessage::Message {
content: "hello agent".to_string(), content: "hello agent".to_string(),
thread_id: Some("t1".to_string()), thread_id: Some("t1".to_string()),
timezone: None,
}, },
&state, &state,
"user1", "user1",
@@ -373,6 +381,7 @@ mod tests {
WsClientMessage::Message { WsClientMessage::Message {
content: "hello".to_string(), content: "hello".to_string(),
thread_id: None, thread_id: None,
timezone: None,
}, },
&state, &state,
"user1", "user1",
+37
View File
@@ -27,6 +27,8 @@ pub struct AgentConfig {
pub max_tool_iterations: usize, pub max_tool_iterations: usize,
/// When true, skip tool approval checks entirely. For benchmarks/CI. /// When true, skip tool approval checks entirely. For benchmarks/CI.
pub auto_approve_tools: bool, pub auto_approve_tools: bool,
/// Default timezone for new sessions (IANA name, e.g. "America/New_York").
pub default_timezone: String,
} }
impl AgentConfig { impl AgentConfig {
@@ -47,6 +49,7 @@ impl AgentConfig {
max_actions_per_hour: None, max_actions_per_hour: None,
max_tool_iterations: 10, max_tool_iterations: 10,
auto_approve_tools: true, auto_approve_tools: true,
default_timezone: "UTC".to_string(),
} }
} }
@@ -89,6 +92,40 @@ impl AgentConfig {
"AGENT_AUTO_APPROVE_TOOLS", "AGENT_AUTO_APPROVE_TOOLS",
settings.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
},
}) })
} }
} }
#[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");
}
}
+102 -1
View File
@@ -1,4 +1,4 @@
use crate::config::helpers::{optional_env, parse_bool_env, parse_optional_env}; use crate::config::helpers::{optional_env, parse_bool_env, parse_option_env, parse_optional_env};
use crate::error::ConfigError; use crate::error::ConfigError;
use crate::settings::Settings; use crate::settings::Settings;
@@ -13,6 +13,12 @@ pub struct HeartbeatConfig {
pub notify_channel: Option<String>, pub notify_channel: Option<String>,
/// User ID to notify on heartbeat findings. /// User ID to notify on heartbeat findings.
pub notify_user: Option<String>, pub notify_user: Option<String>,
/// Hour (0-23) when quiet hours start.
pub quiet_hours_start: Option<u32>,
/// Hour (0-23) when quiet hours end.
pub quiet_hours_end: Option<u32>,
/// Timezone for quiet hours evaluation (IANA name).
pub timezone: Option<String>,
} }
impl Default for HeartbeatConfig { impl Default for HeartbeatConfig {
@@ -22,6 +28,9 @@ impl Default for HeartbeatConfig {
interval_secs: 1800, // 30 minutes interval_secs: 1800, // 30 minutes
notify_channel: None, notify_channel: None,
notify_user: None, notify_user: None,
quiet_hours_start: None,
quiet_hours_end: None,
timezone: None,
} }
} }
} }
@@ -38,6 +47,98 @@ impl HeartbeatConfig {
.or_else(|| settings.heartbeat.notify_channel.clone()), .or_else(|| settings.heartbeat.notify_channel.clone()),
notify_user: optional_env("HEARTBEAT_NOTIFY_USER")? notify_user: optional_env("HEARTBEAT_NOTIFY_USER")?
.or_else(|| settings.heartbeat.notify_user.clone()), .or_else(|| settings.heartbeat.notify_user.clone()),
quiet_hours_start: parse_option_env::<u32>("HEARTBEAT_QUIET_START")?
.or(settings.heartbeat.quiet_hours_start)
.map(|h| {
if h > 23 {
return Err(ConfigError::InvalidValue {
key: "HEARTBEAT_QUIET_START".into(),
message: "must be 0-23".into(),
});
}
Ok(h)
})
.transpose()?,
quiet_hours_end: parse_option_env::<u32>("HEARTBEAT_QUIET_END")?
.or(settings.heartbeat.quiet_hours_end)
.map(|h| {
if h > 23 {
return Err(ConfigError::InvalidValue {
key: "HEARTBEAT_QUIET_END".into(),
message: "must be 0-23".into(),
});
}
Ok(h)
})
.transpose()?,
timezone: {
let tz = optional_env("HEARTBEAT_TIMEZONE")?
.or_else(|| settings.heartbeat.timezone.clone());
if let Some(ref tz_str) = tz
&& crate::timezone::parse_timezone(tz_str).is_none()
{
return Err(ConfigError::InvalidValue {
key: "HEARTBEAT_TIMEZONE".into(),
message: format!("invalid IANA timezone: '{tz_str}'"),
});
}
tz
},
}) })
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_quiet_hours_settings_fallback() {
// When env vars are not set, settings values should be used
let mut settings = Settings::default();
settings.heartbeat.quiet_hours_start = Some(22);
settings.heartbeat.quiet_hours_end = Some(6);
let config = HeartbeatConfig::resolve(&settings).expect("resolve");
assert_eq!(config.quiet_hours_start, Some(22));
assert_eq!(config.quiet_hours_end, Some(6));
}
#[test]
fn test_quiet_hours_rejects_invalid_hour() {
let mut settings = Settings::default();
settings.heartbeat.quiet_hours_start = Some(24);
let result = HeartbeatConfig::resolve(&settings);
assert!(result.is_err());
}
#[test]
fn test_quiet_hours_accepts_boundary_values() {
let mut settings = Settings::default();
settings.heartbeat.quiet_hours_start = Some(0);
settings.heartbeat.quiet_hours_end = Some(23);
let config = HeartbeatConfig::resolve(&settings).expect("resolve");
assert_eq!(config.quiet_hours_start, Some(0));
assert_eq!(config.quiet_hours_end, Some(23));
}
#[test]
fn test_heartbeat_timezone_rejects_invalid() {
let mut settings = Settings::default();
settings.heartbeat.timezone = Some("Fake/Zone".to_string());
let result = HeartbeatConfig::resolve(&settings);
assert!(result.is_err(), "invalid IANA timezone should be rejected");
}
#[test]
fn test_heartbeat_timezone_accepts_valid() {
let mut settings = Settings::default();
settings.heartbeat.timezone = Some("America/New_York".to_string());
let config = HeartbeatConfig::resolve(&settings).expect("resolve");
assert_eq!(config.timezone.as_deref(), Some("America/New_York"));
}
}
+9
View File
@@ -164,6 +164,8 @@ pub struct JobContext {
/// previous results by ID via `$tool_call_id` parameter syntax. /// previous results by ID via `$tool_call_id` parameter syntax.
#[serde(skip)] #[serde(skip)]
pub tool_output_stash: Arc<tokio::sync::RwLock<HashMap<String, String>>>, pub tool_output_stash: Arc<tokio::sync::RwLock<HashMap<String, String>>>,
/// User's preferred timezone (IANA name, e.g. "America/New_York"). Defaults to "UTC".
pub user_timezone: String,
} }
impl JobContext { impl JobContext {
@@ -203,9 +205,16 @@ impl JobContext {
http_interceptor: None, http_interceptor: None,
metadata: serde_json::Value::Null, metadata: serde_json::Value::Null,
tool_output_stash: Arc::new(tokio::sync::RwLock::new(HashMap::new())), tool_output_stash: Arc::new(tokio::sync::RwLock::new(HashMap::new())),
user_timezone: "UTC".to_string(),
} }
} }
/// Set the user timezone on this context.
pub fn with_timezone(mut self, tz: impl Into<String>) -> Self {
self.user_timezone = tz.into();
self
}
/// Transition to a new state. /// Transition to a new state.
pub fn transition_to( pub fn transition_to(
&mut self, &mut self,
+3
View File
@@ -121,6 +121,9 @@ impl JobStore for LibSqlBackend {
tool_output_stash: std::sync::Arc::new(tokio::sync::RwLock::new( tool_output_stash: std::sync::Arc::new(tokio::sync::RwLock::new(
std::collections::HashMap::new(), std::collections::HashMap::new(),
)), )),
// TODO(#661): persist user_timezone in agent_jobs table so
// background/routine jobs retain the session's timezone context.
user_timezone: "UTC".to_string(),
})) }))
} }
None => Ok(None), None => Ok(None),
+3
View File
@@ -241,6 +241,9 @@ impl Store {
tool_output_stash: std::sync::Arc::new(tokio::sync::RwLock::new( tool_output_stash: std::sync::Arc::new(tokio::sync::RwLock::new(
std::collections::HashMap::new(), std::collections::HashMap::new(),
)), )),
// TODO(#661): persist user_timezone in agent_jobs table so
// background/routine jobs retain the session's timezone context.
user_timezone: "UTC".to_string(),
})) }))
} }
None => Ok(None), None => Ok(None),
+1
View File
@@ -66,6 +66,7 @@ pub mod service;
pub mod settings; pub mod settings;
pub mod setup; pub mod setup;
pub mod skills; pub mod skills;
pub mod timezone;
pub mod tools; pub mod tools;
pub mod tracing_fmt; pub mod tracing_fmt;
pub mod transcription; pub mod transcription;
+24
View File
@@ -291,6 +291,18 @@ pub struct HeartbeatSettings {
/// User ID to notify on heartbeat findings. /// User ID to notify on heartbeat findings.
#[serde(default)] #[serde(default)]
pub notify_user: Option<String>, pub notify_user: Option<String>,
/// Hour (0-23) when quiet hours start (heartbeat skipped).
#[serde(default)]
pub quiet_hours_start: Option<u32>,
/// Hour (0-23) when quiet hours end (heartbeat resumes).
#[serde(default)]
pub quiet_hours_end: Option<u32>,
/// Timezone for quiet hours evaluation (IANA name, e.g. "America/New_York").
#[serde(default)]
pub timezone: Option<String>,
} }
fn default_heartbeat_interval() -> u64 { fn default_heartbeat_interval() -> u64 {
@@ -304,6 +316,9 @@ impl Default for HeartbeatSettings {
interval_secs: default_heartbeat_interval(), interval_secs: default_heartbeat_interval(),
notify_channel: None, notify_channel: None,
notify_user: None, notify_user: None,
quiet_hours_start: None,
quiet_hours_end: None,
timezone: None,
} }
} }
} }
@@ -351,6 +366,10 @@ pub struct AgentSettings {
/// When true, skip tool approval checks entirely. For benchmarks/CI. /// When true, skip tool approval checks entirely. For benchmarks/CI.
#[serde(default)] #[serde(default)]
pub auto_approve_tools: bool, pub auto_approve_tools: bool,
/// Default timezone for new sessions (IANA name, e.g. "America/New_York").
#[serde(default = "default_timezone")]
pub default_timezone: String,
} }
fn default_agent_name() -> String { fn default_agent_name() -> String {
@@ -385,6 +404,10 @@ fn default_max_tool_iterations() -> usize {
50 50
} }
fn default_timezone() -> String {
"UTC".to_string()
}
fn default_true() -> bool { fn default_true() -> bool {
true true
} }
@@ -402,6 +425,7 @@ impl Default for AgentSettings {
session_idle_timeout_secs: default_session_idle_timeout(), session_idle_timeout_secs: default_session_idle_timeout(),
max_tool_iterations: default_max_tool_iterations(), max_tool_iterations: default_max_tool_iterations(),
auto_approve_tools: false, auto_approve_tools: false,
default_timezone: default_timezone(),
} }
} }
} }
+1
View File
@@ -1009,6 +1009,7 @@ mod tests {
enabled: true, enabled: true,
trigger: Trigger::Cron { trigger: Trigger::Cron {
schedule: "0 * * * *".to_string(), schedule: "0 * * * *".to_string(),
timezone: None,
}, },
action: RoutineAction::Lightweight { action: RoutineAction::Lightweight {
prompt: "Check status".to_string(), prompt: "Check status".to_string(),
+110
View File
@@ -0,0 +1,110 @@
//! Timezone resolution and utilities.
use chrono::{DateTime, NaiveDate, Utc};
use chrono_tz::Tz;
/// Resolve the effective timezone from a priority chain.
///
/// Priority: client_tz > user_setting > config_default > UTC
pub fn resolve_timezone(
client_tz: Option<&str>,
user_setting: Option<&str>,
config_default: &str,
) -> Tz {
// Try each in priority order, skipping invalid values
for candidate in [client_tz, user_setting, Some(config_default)] {
if let Some(tz) = candidate.and_then(parse_timezone) {
return tz;
}
}
Tz::UTC
}
/// Parse a timezone string (IANA name) into a `Tz`.
pub fn parse_timezone(s: &str) -> Option<Tz> {
s.parse::<Tz>().ok()
}
/// Get today's date in the given timezone.
pub fn today_in_tz(tz: Tz) -> NaiveDate {
Utc::now().with_timezone(&tz).date_naive()
}
/// Get the current time in the given timezone.
pub fn now_in_tz(tz: Tz) -> DateTime<Tz> {
Utc::now().with_timezone(&tz)
}
/// Detect the system's timezone, falling back to UTC.
pub fn detect_system_timezone() -> Tz {
iana_time_zone::get_timezone()
.ok()
.and_then(|s| parse_timezone(&s))
.unwrap_or(Tz::UTC)
}
#[cfg(test)]
mod tests {
use chrono::Datelike;
use super::*;
#[test]
fn test_resolve_client_wins() {
let tz = resolve_timezone(Some("America/New_York"), Some("Europe/London"), "UTC");
assert_eq!(tz, chrono_tz::America::New_York);
}
#[test]
fn test_resolve_user_setting_fallback() {
let tz = resolve_timezone(None, Some("Europe/London"), "UTC");
assert_eq!(tz, chrono_tz::Europe::London);
}
#[test]
fn test_resolve_config_fallback() {
let tz = resolve_timezone(None, None, "Asia/Tokyo");
assert_eq!(tz, chrono_tz::Asia::Tokyo);
}
#[test]
fn test_resolve_all_none_utc() {
let tz = resolve_timezone(None, None, "UTC");
assert_eq!(tz, Tz::UTC);
}
#[test]
fn test_resolve_invalid_client_skipped() {
let tz = resolve_timezone(Some("Fake/Zone"), Some("Europe/London"), "UTC");
assert_eq!(tz, chrono_tz::Europe::London);
}
#[test]
fn test_parse_valid() {
assert_eq!(
parse_timezone("America/Chicago"),
Some(chrono_tz::America::Chicago)
);
}
#[test]
fn test_parse_invalid() {
assert_eq!(parse_timezone("Fake/Zone"), None);
}
#[test]
fn test_detect_system_tz() {
// Should always return a valid Tz (at minimum UTC)
let tz = detect_system_timezone();
let _ = now_in_tz(tz); // Should not panic
}
#[test]
fn test_today_in_tz_returns_valid_date() {
let date = today_in_tz(Tz::UTC);
// Verify it returns a valid date (year, month, day are all positive)
assert!(date.year() > 0);
assert!((1..=12).contains(&date.month()));
assert!((1..=31).contains(&date.day()));
}
}
+5 -4
View File
@@ -172,7 +172,7 @@ impl Tool for MemoryWriteTool {
async fn execute( async fn execute(
&self, &self,
params: serde_json::Value, params: serde_json::Value,
_ctx: &JobContext, ctx: &JobContext,
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now(); let start = std::time::Instant::now();
@@ -239,11 +239,12 @@ impl Tool for MemoryWriteTool {
paths::MEMORY.to_string() paths::MEMORY.to_string()
} }
"daily_log" => { "daily_log" => {
let tz = crate::timezone::parse_timezone(&ctx.user_timezone)
.unwrap_or(chrono_tz::Tz::UTC);
self.workspace self.workspace
.append_daily_log(content) .append_daily_log_tz(content, tz)
.await .await
.map_err(|e| ToolError::ExecutionFailed(format!("Write failed: {}", e)))?; .map_err(|e| ToolError::ExecutionFailed(format!("Write failed: {}", e)))?
format!("daily/{}.md", chrono::Utc::now().format("%Y-%m-%d"))
} }
"heartbeat" => { "heartbeat" => {
if append { if append {
+68 -10
View File
@@ -107,6 +107,10 @@ impl Tool for RoutineCreateTool {
"notify_user": { "notify_user": {
"type": "string", "type": "string",
"description": "User/target to notify (e.g. username, chat ID). Defaults to 'default'." "description": "User/target to notify (e.g. username, chat ID). Defaults to 'default'."
},
"timezone": {
"type": "string",
"description": "IANA timezone for cron schedule evaluation (e.g. 'America/New_York'). Defaults to UTC."
} }
}, },
"required": ["name", "trigger_type", "prompt"] "required": ["name", "trigger_type", "prompt"]
@@ -143,12 +147,26 @@ impl Tool for RoutineCreateTool {
"cron trigger requires 'schedule'".to_string(), "cron trigger requires 'schedule'".to_string(),
) )
})?; })?;
let timezone = params
.get("timezone")
.and_then(|v| v.as_str())
.map(|tz| {
crate::timezone::parse_timezone(tz)
.map(|_| tz.to_string())
.ok_or_else(|| {
ToolError::InvalidParameters(format!(
"invalid IANA timezone: '{tz}'"
))
})
})
.transpose()?;
// Validate cron expression // Validate cron expression
next_cron_fire(schedule).map_err(|e| { next_cron_fire(schedule, timezone.as_deref()).map_err(|e| {
ToolError::InvalidParameters(format!("invalid cron schedule: {e}")) ToolError::InvalidParameters(format!("invalid cron schedule: {e}"))
})?; })?;
Trigger::Cron { Trigger::Cron {
schedule: schedule.to_string(), schedule: schedule.to_string(),
timezone,
} }
} }
"event" => { "event" => {
@@ -228,8 +246,12 @@ impl Tool for RoutineCreateTool {
.unwrap_or(300); .unwrap_or(300);
// Compute next fire time for cron // Compute next fire time for cron
let next_fire = if let Trigger::Cron { ref schedule } = trigger { let next_fire = if let Trigger::Cron {
next_cron_fire(schedule).unwrap_or(None) ref schedule,
ref timezone,
} = trigger
{
next_cron_fire(schedule, timezone.as_deref()).unwrap_or(None)
} else { } else {
None None
}; };
@@ -412,6 +434,10 @@ impl Tool for RoutineUpdateTool {
"type": "string", "type": "string",
"description": "New cron schedule (for cron triggers)" "description": "New cron schedule (for cron triggers)"
}, },
"timezone": {
"type": "string",
"description": "IANA timezone for cron schedule (e.g. 'America/New_York'). Only valid for cron triggers."
},
"description": { "description": {
"type": "string", "type": "string",
"description": "New description" "description": "New description"
@@ -453,15 +479,47 @@ impl Tool for RoutineUpdateTool {
} }
} }
if let Some(schedule) = params.get("schedule").and_then(|v| v.as_str()) { // Validate timezone param if provided
// Validate let new_timezone = params
next_cron_fire(schedule) .get("timezone")
.map_err(|e| ToolError::InvalidParameters(format!("invalid cron schedule: {e}")))?; .and_then(|v| v.as_str())
.map(|tz| {
crate::timezone::parse_timezone(tz)
.map(|_| tz.to_string())
.ok_or_else(|| {
ToolError::InvalidParameters(format!("invalid IANA timezone: '{tz}'"))
})
})
.transpose()?;
routine.trigger = Trigger::Cron { let new_schedule = params.get("schedule").and_then(|v| v.as_str());
schedule: schedule.to_string(),
if new_schedule.is_some() || new_timezone.is_some() {
// Extract existing cron fields (cloned to avoid borrow conflict)
let existing_cron = match &routine.trigger {
Trigger::Cron { schedule, timezone } => Some((schedule.clone(), timezone.clone())),
_ => None,
}; };
routine.next_fire_at = next_cron_fire(schedule).unwrap_or(None);
if let Some((old_schedule, old_tz)) = existing_cron {
let effective_schedule = new_schedule.unwrap_or(&old_schedule);
let effective_tz = new_timezone.or(old_tz);
// Validate
next_cron_fire(effective_schedule, effective_tz.as_deref()).map_err(|e| {
ToolError::InvalidParameters(format!("invalid cron schedule: {e}"))
})?;
routine.trigger = Trigger::Cron {
schedule: effective_schedule.to_string(),
timezone: effective_tz.clone(),
};
routine.next_fire_at =
next_cron_fire(effective_schedule, effective_tz.as_deref()).unwrap_or(None);
} else {
return Err(ToolError::InvalidParameters(
"Cannot update schedule or timezone on a non-cron routine.".to_string(),
));
}
} }
self.store self.store
+46 -2
View File
@@ -48,7 +48,7 @@ impl Tool for TimeTool {
async fn execute( async fn execute(
&self, &self,
params: serde_json::Value, params: serde_json::Value,
_ctx: &JobContext, ctx: &JobContext,
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now(); let start = std::time::Instant::now();
@@ -57,10 +57,15 @@ impl Tool for TimeTool {
let result = match operation { let result = match operation {
"now" => { "now" => {
let now = Utc::now(); let now = Utc::now();
let tz =
crate::timezone::parse_timezone(&ctx.user_timezone).unwrap_or(chrono_tz::UTC);
let local = now.with_timezone(&tz);
serde_json::json!({ serde_json::json!({
"iso": now.to_rfc3339(), "iso": now.to_rfc3339(),
"unix": now.timestamp(), "unix": now.timestamp(),
"unix_millis": now.timestamp_millis() "unix_millis": now.timestamp_millis(),
"local_iso": local.to_rfc3339(),
"timezone": tz.name()
}) })
} }
"parse" => { "parse" => {
@@ -112,3 +117,42 @@ impl Tool for TimeTool {
false // Internal tool, no external data false // Internal tool, no external data
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_now_includes_local_time_when_timezone_set() {
let tool = TimeTool;
let mut ctx = JobContext::with_user("test", "chat", "test");
ctx.user_timezone = "America/New_York".to_string();
let output = tool
.execute(serde_json::json!({"operation": "now"}), &ctx)
.await
.expect("execute");
assert!(
output.result.get("local_iso").is_some(),
"should have local_iso"
);
assert_eq!(
output.result["timezone"].as_str(),
Some("America/New_York"),
"should report timezone"
);
}
#[tokio::test]
async fn test_now_includes_utc_timezone_by_default() {
let tool = TimeTool;
let ctx = JobContext::with_user("test", "chat", "test");
// Default user_timezone is "UTC" which is a valid IANA timezone
let output = tool
.execute(serde_json::json!({"operation": "now"}), &ctx)
.await
.expect("execute");
assert!(output.result.get("iso").is_some(), "should have iso");
assert_eq!(output.result["timezone"].as_str(), Some("UTC"));
}
}
+44 -4
View File
@@ -565,11 +565,26 @@ impl Workspace {
/// ///
/// Daily logs are raw, append-only notes for the current day. /// Daily logs are raw, append-only notes for the current day.
pub async fn append_daily_log(&self, entry: &str) -> Result<(), WorkspaceError> { pub async fn append_daily_log(&self, entry: &str) -> Result<(), WorkspaceError> {
let today = Utc::now().date_naive(); self.append_daily_log_tz(entry, chrono_tz::Tz::UTC)
.await
.map(|_| ())
}
/// Append an entry to today's daily log using the given timezone.
///
/// Returns the path that was written to (e.g. `daily/2024-01-15.md`).
pub async fn append_daily_log_tz(
&self,
entry: &str,
tz: chrono_tz::Tz,
) -> Result<String, WorkspaceError> {
let now = crate::timezone::now_in_tz(tz);
let today = now.date_naive();
let path = format!("daily/{}.md", today.format("%Y-%m-%d")); let path = format!("daily/{}.md", today.format("%Y-%m-%d"));
let timestamp = Utc::now().format("%H:%M:%S"); let timestamp = now.format("%H:%M:%S");
let timestamped_entry = format!("[{}] {}", timestamp, entry); let timestamped_entry = format!("[{}] {}", timestamp, entry);
self.append(&path, &timestamped_entry).await self.append(&path, &timestamped_entry).await?;
Ok(path)
} }
// ==================== System Prompt ==================== // ==================== System Prompt ====================
@@ -584,6 +599,18 @@ impl Workspace {
self.system_prompt_for_context(false).await self.system_prompt_for_context(false).await
} }
/// Build the system prompt with timezone-aware daily log dates.
///
/// Uses the given timezone to determine "today" and "yesterday" for daily log injection.
pub async fn system_prompt_for_context_tz(
&self,
is_group_chat: bool,
tz: chrono_tz::Tz,
) -> Result<String, WorkspaceError> {
self.system_prompt_for_context_inner(is_group_chat, Some(tz))
.await
}
/// Build the system prompt, optionally excluding personal memory. /// Build the system prompt, optionally excluding personal memory.
/// ///
/// When `is_group_chat` is true, MEMORY.md is excluded to prevent /// When `is_group_chat` is true, MEMORY.md is excluded to prevent
@@ -591,6 +618,16 @@ impl Workspace {
pub async fn system_prompt_for_context( pub async fn system_prompt_for_context(
&self, &self,
is_group_chat: bool, is_group_chat: bool,
) -> Result<String, WorkspaceError> {
self.system_prompt_for_context_inner(is_group_chat, None)
.await
}
/// Inner implementation for system prompt building.
async fn system_prompt_for_context_inner(
&self,
is_group_chat: bool,
tz: Option<chrono_tz::Tz>,
) -> Result<String, WorkspaceError> { ) -> Result<String, WorkspaceError> {
let mut parts = Vec::new(); let mut parts = Vec::new();
@@ -645,7 +682,10 @@ impl Workspace {
} }
// Add today's memory context (last 2 days of daily logs) // Add today's memory context (last 2 days of daily logs)
let today = Utc::now().date_naive(); let today = match tz {
Some(t) => crate::timezone::today_in_tz(t),
None => Utc::now().date_naive(),
};
let yesterday = today.pred_opt().unwrap_or(today); let yesterday = today.pred_opt().unwrap_or(today);
for date in [today, yesterday] { for date in [today, yesterday] {
+4
View File
@@ -118,6 +118,7 @@ mod tests {
"cron-test", "cron-test",
Trigger::Cron { Trigger::Cron {
schedule: "* * * * *".to_string(), schedule: "* * * * *".to_string(),
timezone: None,
}, },
"Check system status.", "Check system status.",
); );
@@ -203,6 +204,7 @@ mod tests {
thread_id: None, thread_id: None,
received_at: Utc::now(), received_at: Utc::now(),
metadata: serde_json::json!({}), metadata: serde_json::json!({}),
timezone: None,
attachments: Vec::new(), attachments: Vec::new(),
}; };
let fired = engine.check_event_triggers(&matching_msg).await; let fired = engine.check_event_triggers(&matching_msg).await;
@@ -224,6 +226,7 @@ mod tests {
thread_id: None, thread_id: None,
received_at: Utc::now(), received_at: Utc::now(),
metadata: serde_json::json!({}), metadata: serde_json::json!({}),
timezone: None,
attachments: Vec::new(), attachments: Vec::new(),
}; };
let fired_neg = engine.check_event_triggers(&non_matching_msg).await; let fired_neg = engine.check_event_triggers(&non_matching_msg).await;
@@ -288,6 +291,7 @@ mod tests {
thread_id: None, thread_id: None,
received_at: Utc::now(), received_at: Utc::now(),
metadata: serde_json::json!({}), metadata: serde_json::json!({}),
timezone: None,
attachments: Vec::new(), attachments: Vec::new(),
}; };
let fired1 = engine.check_event_triggers(&msg).await; let fired1 = engine.check_event_triggers(&msg).await;