diff --git a/docs/content/docs/(configuration)/config.mdx b/docs/content/docs/(configuration)/config.mdx index 140b1fd2d..690de7874 100644 --- a/docs/content/docs/(configuration)/config.mdx +++ b/docs/content/docs/(configuration)/config.mdx @@ -62,6 +62,7 @@ context_window = 128000 # context window size in tokens history_backfill_count = 50 # messages to fetch from platform on new channel worker_log_mode = "errors_only" # "errors_only", "all_separate", or "all_combined" cron_timezone = "UTC" # optional default timezone for cron active hours +user_timezone = "UTC" # optional default timezone for channel/worker time context # Model routing per process type. [defaults.routing] @@ -116,6 +117,7 @@ id = "main" default = true workspace = "/custom/workspace/path" # optional, defaults to ~/.spacebot/agents/{id}/workspace cron_timezone = "America/Los_Angeles" # optional per-agent cron timezone override +user_timezone = "America/Los_Angeles" # optional per-agent timezone override for channel/worker time context # Per-agent routing overrides (merges with defaults). [agents.routing] @@ -417,6 +419,7 @@ At least one provider (legacy key or custom provider) must be configured. | `history_backfill_count` | integer | 50 | Messages to fetch from platform on new channel | | `worker_log_mode` | string | `"errors_only"` | Worker log persistence: `"errors_only"`, `"all_separate"`, or `"all_combined"` | | `cron_timezone` | string | None | Default timezone for cron active-hours evaluation (IANA name like `UTC` or `America/New_York`) | +| `user_timezone` | string | inherits `cron_timezone` | Default timezone for channel/worker temporal context (IANA name) | ### `[defaults.routing]` @@ -522,6 +525,7 @@ When branch/worker/cron dispatch happens before readiness is satisfied, Spacebot | `default` | bool | false | Whether this is the default agent | | `workspace` | string | `~/.spacebot/agents/{id}/workspace` | Custom workspace path | | `cron_timezone` | string | inherits | Per-agent timezone override for cron active-hours evaluation | +| `user_timezone` | string | inherits | Per-agent timezone override for channel/worker temporal context | | `max_concurrent_branches` | integer | inherits | Override instance default | | `max_turns` | integer | inherits | Override instance default | | `context_window` | integer | inherits | Override instance default | @@ -577,6 +581,14 @@ Cron timezone precedence is: If a configured timezone is invalid, Spacebot logs a warning and falls back to server local time. +Channel/worker temporal context timezone precedence is: + +1. `agents.user_timezone` +2. `defaults.user_timezone` +3. `SPACEBOT_USER_TIMEZONE` +4. resolved cron timezone (from `agents.cron_timezone` / `defaults.cron_timezone` / `SPACEBOT_CRON_TIMEZONE`) +5. server local timezone + ### `[messaging.discord]` | Key | Type | Default | Description | diff --git a/prompts/en/branch.md.j2 b/prompts/en/branch.md.j2 index a60d9097f..56b262120 100644 --- a/prompts/en/branch.md.j2 +++ b/prompts/en/branch.md.j2 @@ -63,3 +63,4 @@ Refine a task. Update the description as the user clarifies scope — append sec - **observation** — a pattern noticed over time ("the user usually asks for code-first answers") - **goal** — something the user or agent wants to achieve ("migrate to the new API by Q3"). Goals are aspirational and may span multiple conversations. - **todo** — a concrete actionable task or reminder ("update the auth tests", "remind me to check the deploy tomorrow"). Todos are specific and completable. +7. For time-sensitive conclusions, anchor on explicit timestamps/context and include concrete dates when ambiguity is possible. diff --git a/prompts/en/channel.md.j2 b/prompts/en/channel.md.j2 index 894f33ec4..1902365cf 100644 --- a/prompts/en/channel.md.j2 +++ b/prompts/en/channel.md.j2 @@ -33,6 +33,7 @@ You have a soul, an identity, and a personality. These are loaded separately and ## How You Work Every turn, you receive the user's message along with a live status block showing active workers, branches, and recently completed work. Use this to stay aware of what's happening without asking. +The status block includes a current date/time line with timezone and UTC. Treat that as the source of truth for words like "today", "tomorrow", "yesterday", "now", and "later today". When a background process (branch or worker) completes, you will receive a system message containing the full result text, tagged with the process type and ID. The user has NOT seen any of it — you must relay the substance to them using the reply tool. Include actual content and details, not just a summary teaser. Do not mention internal processes (branch, worker, process IDs). If a result is background work the user didn't ask about, incorporate it silently. @@ -105,6 +106,7 @@ When in doubt, skip. Being a lurker who speaks when it matters is better than be 9. Save important information to memory. Be selective. When the user asks to forget something, branch to find and delete the relevant memories. 10. One worker per task. Never spawn multiple workers for the same request. If a worker is already handling something, wait for it to finish or route follow-ups to it. Check your status block before spawning. 11. On Discord and Slack, prefer rich responses when output is structured or multi-part (task outcomes, summaries, comparisons, checklists, incident/debug updates, plans). Use `reply` with `cards`/interactive elements (Discord) or `blocks` (Slack) instead of plain text walls when it improves clarity. +12. For time-sensitive responses, prefer concrete dates (for example, "March 5, 2026") in addition to relative phrases. {%- if adapter_prompt %} ## Adapter Guidance diff --git a/prompts/en/fragments/system/worker_time_context.md.j2 b/prompts/en/fragments/system/worker_time_context.md.j2 new file mode 100644 index 000000000..fc06944c5 --- /dev/null +++ b/prompts/en/fragments/system/worker_time_context.md.j2 @@ -0,0 +1,4 @@ +## Time Context +- Current local date/time: {{ current_local_datetime }} +- Current UTC date/time: {{ current_utc_datetime }} +- Use this context for relative dates (today/tomorrow/yesterday/now) and include absolute dates when timing matters. diff --git a/src/agent/channel.rs b/src/agent/channel.rs index d514cfc00..ada4b3fa4 100644 --- a/src/agent/channel.rs +++ b/src/agent/channel.rs @@ -13,6 +13,8 @@ use crate::{ AgentDeps, BranchId, ChannelId, InboundMessage, OutboundResponse, ProcessEvent, ProcessId, ProcessType, WorkerId, }; +use chrono::{DateTime, Local, Utc}; +use chrono_tz::Tz; use rig::agent::AgentBuilder; use rig::completion::{CompletionModel, Prompt}; use rig::message::{ImageMediaType, MimeType, UserContent}; @@ -33,6 +35,124 @@ const RETRIGGER_DEBOUNCE_MS: u64 = 500; /// infinite retrigger cascades where each retrigger spawns more work. const MAX_RETRIGGERS_PER_TURN: usize = 3; +#[derive(Debug, Clone)] +enum TemporalTimezone { + Named { timezone_name: String, timezone: Tz }, + SystemLocal, +} + +#[derive(Debug, Clone)] +struct TemporalContext { + now_utc: DateTime, + timezone: TemporalTimezone, +} + +impl TemporalContext { + fn from_runtime(runtime_config: &crate::config::RuntimeConfig) -> Self { + let now_utc = Utc::now(); + let user_timezone = runtime_config.user_timezone.load().as_ref().clone(); + let cron_timezone = runtime_config.cron_timezone.load().as_ref().clone(); + + Self { + now_utc, + timezone: Self::resolve_timezone_from_names(user_timezone, cron_timezone), + } + } + + fn resolve_timezone_from_names( + user_timezone: Option, + cron_timezone: Option, + ) -> TemporalTimezone { + if let Some(timezone_name) = user_timezone { + match timezone_name.parse::() { + Ok(timezone) => { + return TemporalTimezone::Named { + timezone_name, + timezone, + }; + } + Err(_) => { + let cron_timezone_candidate = + cron_timezone.as_deref().unwrap_or("none configured"); + tracing::warn!( + timezone = %timezone_name, + cron_timezone = %cron_timezone_candidate, + "invalid runtime timezone for channel temporal context, will try cron_timezone then fall back to system local" + ); + } + } + } + + if let Some(timezone_name) = cron_timezone { + match timezone_name.parse::() { + Ok(timezone) => { + return TemporalTimezone::Named { + timezone_name, + timezone, + }; + } + Err(error) => { + tracing::warn!( + timezone = %timezone_name, + error = %error, + "invalid cron_timezone for channel temporal context, falling back to system local" + ); + } + } + } + + TemporalTimezone::SystemLocal + } + + fn format_timestamp(&self, timestamp: DateTime) -> String { + match &self.timezone { + TemporalTimezone::Named { + timezone_name, + timezone, + } => { + let local_timestamp = timestamp.with_timezone(timezone); + format!( + "{} ({}, UTC{})", + local_timestamp.format("%Y-%m-%d %H:%M:%S %Z"), + timezone_name, + local_timestamp.format("%:z") + ) + } + TemporalTimezone::SystemLocal => { + let local_timestamp = timestamp.with_timezone(&Local); + format!( + "{} (system local, UTC{})", + local_timestamp.format("%Y-%m-%d %H:%M:%S %Z"), + local_timestamp.format("%:z") + ) + } + } + } + + fn current_time_line(&self) -> String { + format!( + "{}; UTC {}", + self.format_timestamp(self.now_utc), + self.now_utc.format("%Y-%m-%d %H:%M:%S UTC") + ) + } + + fn worker_task_preamble(&self, prompt_engine: &crate::prompts::PromptEngine) -> Result { + let local_time = self.format_timestamp(self.now_utc); + let utc_time = self.now_utc.format("%Y-%m-%d %H:%M:%S UTC").to_string(); + prompt_engine.render_system_worker_time_context(&local_time, &utc_time) + } +} + +fn build_worker_task_with_temporal_context( + task: &str, + temporal_context: &TemporalContext, + prompt_engine: &crate::prompts::PromptEngine, +) -> Result { + let preamble = temporal_context.worker_task_preamble(prompt_engine)?; + Ok(format!("{preamble}\n\n{task}")) +} + /// A background process result waiting to be relayed to the user via retrigger. /// /// Instead of injecting raw result text into history as a fake "User" message @@ -481,15 +601,17 @@ impl Channel { #[tracing::instrument(skip(self, messages), fields(channel_id = %self.id, agent_id = %self.deps.agent_id, message_count = messages.len()))] async fn handle_message_batch(&mut self, messages: Vec) -> Result<()> { let message_count = messages.len(); - let first_timestamp = messages - .first() - .map(|m| m.timestamp) + let batch_start_timestamp = messages + .iter() + .map(|message| message.timestamp) + .min() .unwrap_or_else(chrono::Utc::now); - let last_timestamp = messages - .last() - .map(|m| m.timestamp) - .unwrap_or(first_timestamp); - let elapsed = last_timestamp.signed_duration_since(first_timestamp); + let batch_tail_timestamp = messages + .iter() + .map(|message| message.timestamp) + .max() + .unwrap_or(batch_start_timestamp); + let elapsed = batch_tail_timestamp.signed_duration_since(batch_start_timestamp); let elapsed_secs = elapsed.num_milliseconds() as f64 / 1000.0; tracing::info!( @@ -553,6 +675,7 @@ impl Channel { // Persist each message to conversation log (individual audit trail) let mut user_contents: Vec = Vec::new(); let mut conversation_id = String::new(); + let temporal_context = TemporalContext::from_runtime(self.deps.runtime_config.as_ref()); for message in &messages { if message.source != "system" { @@ -586,11 +709,11 @@ impl Channel { conversation_id = message.conversation_id.clone(); - // Format with relative timestamp - let relative_secs = message - .timestamp - .signed_duration_since(first_timestamp) - .num_seconds(); + // Include both absolute and relative time context. + let relative_secs = batch_tail_timestamp + .signed_duration_since(message.timestamp) + .num_seconds() + .max(0); let relative_text = if relative_secs < 1 { "just now".to_string() } else if relative_secs < 60 { @@ -598,15 +721,16 @@ impl Channel { } else { format!("{}m ago", relative_secs / 60) }; + let absolute_timestamp = temporal_context.format_timestamp(message.timestamp); - let display_name = message - .metadata - .get("sender_display_name") - .and_then(|v| v.as_str()) - .unwrap_or(&message.sender_id); + let display_name = message_display_name(message); - let formatted_text = - format!("[{}] ({}): {}", display_name, relative_text, raw_text); + let formatted_text = format_batched_user_message( + display_name, + &absolute_timestamp, + &relative_text, + &raw_text, + ); // Download attachments for this message if !attachments.is_empty() { @@ -694,9 +818,11 @@ impl Channel { opencode_enabled, )?; + let temporal_context = TemporalContext::from_runtime(rc.as_ref()); + let current_time_line = temporal_context.current_time_line(); let status_text = { let status = self.state.status_block.read().await; - status.render() + status.render_with_time_context(Some(¤t_time_line)) }; // Render coalesce hint @@ -760,7 +886,9 @@ impl Channel { crate::MessageContent::Interaction { .. } => (message.content.to_string(), Vec::new()), }; - let user_text = format_user_message(&raw_text, &message); + let temporal_context = TemporalContext::from_runtime(self.deps.runtime_config.as_ref()); + let message_timestamp = temporal_context.format_timestamp(message.timestamp); + let user_text = format_user_message(&raw_text, &message, &message_timestamp); let attachment_content = if !attachments.is_empty() { download_attachments(&self.deps, &attachments).await @@ -992,9 +1120,11 @@ impl Channel { opencode_enabled, )?; + let temporal_context = TemporalContext::from_runtime(rc.as_ref()); + let current_time_line = temporal_context.current_time_line(); let status_text = { let status = self.state.status_block.read().await; - status.render() + status.render_with_time_context(Some(¤t_time_line)) }; let available_channels = self.build_available_channels().await; @@ -1689,8 +1819,10 @@ impl Channel { /// Get the current status block as a string. pub async fn get_status(&self) -> String { + let temporal_context = TemporalContext::from_runtime(self.deps.runtime_config.as_ref()); + let current_time_line = temporal_context.current_time_line(); let status = self.state.status_block.read().await; - status.render() + status.render_with_time_context(Some(¤t_time_line)) } /// Check if a memory persistence branch should be spawned based on message count. @@ -1947,6 +2079,10 @@ pub async fn spawn_worker_from_state( let rc = &state.deps.runtime_config; let prompt_engine = rc.prompts.load(); + let temporal_context = TemporalContext::from_runtime(rc.as_ref()); + let worker_task = + build_worker_task_with_temporal_context(&task, &temporal_context, &prompt_engine) + .map_err(|error| AgentError::Other(anyhow::anyhow!("{error}")))?; let worker_system_prompt = prompt_engine .render_worker_prompt( &rc.instance_dir.display().to_string(), @@ -1974,7 +2110,7 @@ pub async fn spawn_worker_from_state( let worker = if interactive { let (worker, input_tx) = Worker::new_interactive( Some(state.channel_id.clone()), - &task, + &worker_task, &system_prompt, state.deps.clone(), browser_config.clone(), @@ -1992,7 +2128,7 @@ pub async fn spawn_worker_from_state( } else { Worker::new( Some(state.channel_id.clone()), - &task, + &worker_task, &system_prompt, state.deps.clone(), browser_config, @@ -2059,6 +2195,11 @@ pub async fn spawn_opencode_worker_from_state( let directory = std::path::PathBuf::from(directory); let rc = &state.deps.runtime_config; + let prompt_engine = rc.prompts.load(); + let temporal_context = TemporalContext::from_runtime(rc.as_ref()); + let worker_task = + build_worker_task_with_temporal_context(&task, &temporal_context, &prompt_engine) + .map_err(|error| AgentError::Other(anyhow::anyhow!("{error}")))?; let opencode_config = rc.opencode.load(); if !opencode_config.enabled { @@ -2073,7 +2214,7 @@ pub async fn spawn_opencode_worker_from_state( let (worker, input_tx) = crate::opencode::OpenCodeWorker::new_interactive( Some(state.channel_id.clone()), state.deps.agent_id.clone(), - &task, + &worker_task, directory, server_pool, state.deps.event_tx.clone(), @@ -2089,7 +2230,7 @@ pub async fn spawn_opencode_worker_from_state( crate::opencode::OpenCodeWorker::new( Some(state.channel_id.clone()), state.deps.agent_id.clone(), - &task, + &worker_task, directory, server_pool, state.deps.event_tx.clone(), @@ -2258,7 +2399,20 @@ fn extract_reply_from_tool_syntax(text: &str) -> Option { /// /// In multi-user channels, this lets the LLM distinguish who said what. /// System-generated messages (re-triggers) are passed through as-is. -fn format_user_message(raw_text: &str, message: &InboundMessage) -> String { +fn message_display_name(message: &InboundMessage) -> &str { + message + .formatted_author + .as_deref() + .or_else(|| { + message + .metadata + .get("sender_display_name") + .and_then(|v| v.as_str()) + }) + .unwrap_or(&message.sender_id) +} + +fn format_user_message(raw_text: &str, message: &InboundMessage, timestamp_text: &str) -> String { if message.source == "system" { // System messages should never be empty, but guard against it return if raw_text.trim().is_empty() { @@ -2268,17 +2422,7 @@ fn format_user_message(raw_text: &str, message: &InboundMessage) -> String { }; } - // Use platform-formatted author if available, fall back to metadata - let display_name = message - .formatted_author - .as_deref() - .or_else(|| { - message - .metadata - .get("sender_display_name") - .and_then(|v| v.as_str()) - }) - .unwrap_or(&message.sender_id); + let display_name = message_display_name(message); let bot_tag = if message .metadata @@ -2318,7 +2462,21 @@ fn format_user_message(raw_text: &str, message: &InboundMessage) -> String { raw_text }; - format!("{display_name}{bot_tag}{reply_context}: {text_content}") + format!("{display_name}{bot_tag}{reply_context} [{timestamp_text}]: {text_content}") +} + +fn format_batched_user_message( + display_name: &str, + absolute_timestamp: &str, + relative_text: &str, + raw_text: &str, +) -> String { + let text_content = if raw_text.trim().is_empty() { + "[attachment or empty message]" + } else { + raw_text + }; + format!("[{display_name}] ({absolute_timestamp}; {relative_text}): {text_content}") } fn extract_discord_message_id(message: &InboundMessage) -> Option { @@ -3189,7 +3347,7 @@ mod tests { timestamp: Utc::now(), }; - let formatted = format_user_message("", &message); + let formatted = format_user_message("", &message, "2026-02-26 12:00:00 UTC"); assert!( !formatted.trim().is_empty(), "formatted message should not be empty" @@ -3200,7 +3358,7 @@ mod tests { ); // Test whitespace-only text - let formatted_ws = format_user_message(" ", &message); + let formatted_ws = format_user_message(" ", &message, "2026-02-26 12:00:00 UTC"); assert!( formatted_ws.contains("[attachment or empty message]"), "should use placeholder for whitespace-only text" @@ -3219,21 +3377,173 @@ mod tests { timestamp: Utc::now(), }; - let formatted_sys = format_user_message("", &system_message); + let formatted_sys = format_user_message("", &system_message, "2026-02-26 12:00:00 UTC"); assert_eq!( formatted_sys, "[system event]", "system messages should use [system event] placeholder" ); // Test normal message with text - let formatted_normal = format_user_message("hello", &message); + let formatted_normal = format_user_message("hello", &message, "2026-02-26 12:00:00 UTC"); assert!( formatted_normal.contains("hello"), "normal messages should preserve text" ); + assert!( + formatted_normal.contains("[2026-02-26 12:00:00 UTC]"), + "normal messages should include absolute timestamp context" + ); assert!( !formatted_normal.contains("[attachment or empty message]"), "normal messages should not use placeholder" ); } + + #[test] + fn message_display_name_uses_consistent_fallback_order() { + use super::message_display_name; + use crate::{Arc, InboundMessage}; + use chrono::Utc; + use std::collections::HashMap; + + let mut metadata_only = HashMap::new(); + metadata_only.insert( + "sender_display_name".to_string(), + serde_json::Value::String("Metadata User".to_string()), + ); + let metadata_message = InboundMessage { + id: "metadata".to_string(), + agent_id: Some(Arc::from("test_agent")), + sender_id: "sender123".to_string(), + conversation_id: "conv".to_string(), + content: crate::MessageContent::Text("hello".to_string()), + source: "discord".to_string(), + metadata: metadata_only, + formatted_author: None, + timestamp: Utc::now(), + }; + assert_eq!(message_display_name(&metadata_message), "Metadata User"); + + let mut both_metadata = HashMap::new(); + both_metadata.insert( + "sender_display_name".to_string(), + serde_json::Value::String("Metadata User".to_string()), + ); + let formatted_author_message = InboundMessage { + id: "formatted".to_string(), + agent_id: Some(Arc::from("test_agent")), + sender_id: "sender123".to_string(), + conversation_id: "conv".to_string(), + content: crate::MessageContent::Text("hello".to_string()), + source: "discord".to_string(), + metadata: both_metadata, + formatted_author: Some("Formatted Author".to_string()), + timestamp: Utc::now(), + }; + assert_eq!( + message_display_name(&formatted_author_message), + "Formatted Author" + ); + + let sender_fallback_message = InboundMessage { + id: "fallback".to_string(), + agent_id: Some(Arc::from("test_agent")), + sender_id: "sender123".to_string(), + conversation_id: "conv".to_string(), + content: crate::MessageContent::Text("hello".to_string()), + source: "discord".to_string(), + metadata: HashMap::new(), + formatted_author: None, + timestamp: Utc::now(), + }; + assert_eq!(message_display_name(&sender_fallback_message), "sender123"); + } + + #[test] + fn worker_task_temporal_context_preamble_includes_absolute_dates() { + let prompt_engine = + crate::prompts::PromptEngine::new("en").expect("prompt engine should initialize"); + let temporal_context = super::TemporalContext { + now_utc: chrono::DateTime::parse_from_rfc3339("2026-02-26T20:30:00Z") + .expect("valid RFC3339 timestamp") + .with_timezone(&chrono::Utc), + timezone: super::TemporalTimezone::Named { + timezone_name: "America/New_York".to_string(), + timezone: "America/New_York" + .parse() + .expect("valid timezone identifier"), + }, + }; + + let worker_task = super::build_worker_task_with_temporal_context( + "Run the migration checks", + &temporal_context, + &prompt_engine, + ) + .expect("worker task preamble should render"); + assert!( + worker_task.contains("Current local date/time:"), + "worker task should include local time context" + ); + assert!( + worker_task.contains("Current UTC date/time:"), + "worker task should include UTC time context" + ); + assert!( + worker_task.contains("Run the migration checks"), + "worker task should preserve the original task body" + ); + } + + #[test] + fn temporal_context_uses_cron_timezone_when_user_timezone_is_invalid() { + let resolved = super::TemporalContext::resolve_timezone_from_names( + Some("Not/A-Real-Tz".to_string()), + Some("America/Los_Angeles".to_string()), + ); + match resolved { + super::TemporalTimezone::Named { timezone_name, .. } => { + assert_eq!(timezone_name, "America/Los_Angeles"); + } + super::TemporalTimezone::SystemLocal => { + panic!("expected cron timezone fallback, got system local") + } + } + } + + #[test] + fn format_batched_message_includes_absolute_and_relative_time() { + let formatted = super::format_batched_user_message( + "alice", + "2026-02-26 15:04:05 PST (America/Los_Angeles, UTC-08:00)", + "12s ago", + "ship it", + ); + assert!( + formatted.contains("2026-02-26 15:04:05 PST"), + "batched formatting should include absolute timestamp" + ); + assert!( + formatted.contains("12s ago"), + "batched formatting should include relative timestamp hint" + ); + assert!( + formatted.contains("ship it"), + "batched formatting should include original message text" + ); + } + + #[test] + fn format_batched_message_uses_placeholder_for_empty_text() { + let formatted = super::format_batched_user_message( + "alice", + "2026-02-26 15:04:05 PST (America/Los_Angeles, UTC-08:00)", + "just now", + " ", + ); + assert!( + formatted.contains("[attachment or empty message]"), + "batched formatting should use placeholder for empty/whitespace text" + ); + } } diff --git a/src/agent/status.rs b/src/agent/status.rs index f410e6e18..bb321a75d 100644 --- a/src/agent/status.rs +++ b/src/agent/status.rs @@ -158,8 +158,17 @@ impl StatusBlock { /// Render the status block as a string for context injection. pub fn render(&self) -> String { + self.render_with_time_context(None) + } + + /// Render the status block with optional current time context. + pub fn render_with_time_context(&self, current_time_line: Option<&str>) -> String { let mut output = String::new(); + if let Some(current_time_line) = current_time_line { + output.push_str(&format!("Current date/time: {current_time_line}\n\n")); + } + // Active workers if !self.active_workers.is_empty() { output.push_str("## Active Workers\n"); @@ -269,3 +278,15 @@ impl StatusBlock { .retain(|l| l.peer_agent != peer_agent); } } + +#[cfg(test)] +mod tests { + use super::StatusBlock; + + #[test] + fn render_with_time_context_renders_current_time_when_empty() { + let status = StatusBlock::new(); + let rendered = status.render_with_time_context(Some("2026-02-26 12:00:00 UTC")); + assert!(rendered.contains("Current date/time: 2026-02-26 12:00:00 UTC")); + } +} diff --git a/src/api/agents.rs b/src/api/agents.rs index 9a460675c..0d64da420 100644 --- a/src/api/agents.rs +++ b/src/api/agents.rs @@ -536,6 +536,7 @@ pub(super) async fn create_agent( mcp: None, brave_search_key: None, cron_timezone: None, + user_timezone: None, sandbox: None, cron: Vec::new(), }; diff --git a/src/config.rs b/src/config.rs index 216a2f9a3..ea05f87aa 100644 --- a/src/config.rs +++ b/src/config.rs @@ -11,6 +11,7 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; const CRON_TIMEZONE_ENV_VAR: &str = "SPACEBOT_CRON_TIMEZONE"; +const USER_TIMEZONE_ENV_VAR: &str = "SPACEBOT_USER_TIMEZONE"; /// OpenTelemetry export configuration. /// @@ -534,6 +535,8 @@ pub struct DefaultsConfig { pub brave_search_key: Option, /// Default timezone used when evaluating cron active hours. pub cron_timezone: Option, + /// Default timezone for channel/worker temporal context. + pub user_timezone: Option, pub history_backfill_count: usize, pub cron: Vec, pub opencode: OpenCodeConfig, @@ -562,6 +565,8 @@ impl std::fmt::Debug for DefaultsConfig { "brave_search_key", &self.brave_search_key.as_ref().map(|_| "[REDACTED]"), ) + .field("cron_timezone", &self.cron_timezone) + .field("user_timezone", &self.user_timezone) .field("history_backfill_count", &self.history_backfill_count) .field("cron", &self.cron) .field("opencode", &self.opencode) @@ -953,6 +958,8 @@ pub struct AgentConfig { pub brave_search_key: Option, /// Optional timezone override for cron active-hours evaluation. pub cron_timezone: Option, + /// Optional timezone override for channel/worker temporal context. + pub user_timezone: Option, /// Sandbox configuration for process containment. pub sandbox: Option, /// Cron job definitions for this agent. @@ -1001,6 +1008,7 @@ pub struct ResolvedAgentConfig { pub mcp: Vec, pub brave_search_key: Option, pub cron_timezone: Option, + pub user_timezone: Option, /// Sandbox configuration for process containment. pub sandbox: crate::sandbox::SandboxConfig, /// Number of messages to fetch from the platform when a new channel is created. @@ -1027,6 +1035,7 @@ impl Default for DefaultsConfig { mcp: Vec::new(), brave_search_key: None, cron_timezone: None, + user_timezone: None, history_backfill_count: 50, cron: Vec::new(), opencode: OpenCodeConfig::default(), @@ -1039,6 +1048,17 @@ impl AgentConfig { /// Resolve this agent config against instance defaults and base paths. pub fn resolve(&self, instance_dir: &Path, defaults: &DefaultsConfig) -> ResolvedAgentConfig { let agent_root = instance_dir.join("agents").join(&self.id); + let resolved_cron_timezone = resolve_cron_timezone( + &self.id, + self.cron_timezone.as_deref(), + defaults.cron_timezone.as_deref(), + ); + let resolved_user_timezone = resolve_user_timezone( + &self.id, + self.user_timezone.as_deref(), + defaults.user_timezone.as_deref(), + resolved_cron_timezone.as_deref(), + ); ResolvedAgentConfig { id: self.id.clone(), @@ -1080,11 +1100,8 @@ impl AgentConfig { .brave_search_key .clone() .or_else(|| defaults.brave_search_key.clone()), - cron_timezone: resolve_cron_timezone( - &self.id, - self.cron_timezone.as_deref(), - defaults.cron_timezone.as_deref(), - ), + cron_timezone: resolved_cron_timezone, + user_timezone: resolved_user_timezone, sandbox: self.sandbox.clone().unwrap_or_default(), history_backfill_count: defaults.history_backfill_count, cron: self.cron.clone(), @@ -1962,6 +1979,7 @@ struct TomlDefaultsConfig { mcp: Vec, brave_search_key: Option, cron_timezone: Option, + user_timezone: Option, opencode: Option, worker_log_mode: Option, } @@ -2107,6 +2125,7 @@ struct TomlAgentConfig { mcp: Option>, brave_search_key: Option, cron_timezone: Option, + user_timezone: Option, sandbox: Option, #[serde(default)] cron: Vec, @@ -2313,27 +2332,67 @@ fn resolve_cron_timezone( agent_timezone: Option<&str>, default_timezone: Option<&str>, ) -> Option { - let timezone = agent_timezone - .and_then(normalize_timezone) - .or_else(|| default_timezone.and_then(normalize_timezone)) - .or_else(|| { - std::env::var(CRON_TIMEZONE_ENV_VAR) - .ok() - .and_then(|value| normalize_timezone(&value)) - }); + let env_timezone = std::env::var(CRON_TIMEZONE_ENV_VAR) + .ok() + .and_then(|value| normalize_timezone(&value)); + + for timezone in [ + agent_timezone.and_then(normalize_timezone), + default_timezone.and_then(normalize_timezone), + env_timezone, + ] { + let Some(timezone) = timezone else { + continue; + }; - let timezone = timezone?; + if timezone.parse::().is_ok() { + return Some(timezone); + } - if timezone.parse::().is_err() { tracing::warn!( agent_id, cron_timezone = %timezone, "invalid cron timezone configured, falling back to system local timezone" ); - return None; } - Some(timezone) + None +} + +fn resolve_user_timezone( + agent_id: &str, + agent_timezone: Option<&str>, + default_timezone: Option<&str>, + fallback_timezone: Option<&str>, +) -> Option { + let env_timezone = std::env::var(USER_TIMEZONE_ENV_VAR) + .ok() + .and_then(|value| normalize_timezone(&value)); + + for (source, timezone) in [ + ("agent", agent_timezone.and_then(normalize_timezone)), + ("defaults", default_timezone.and_then(normalize_timezone)), + ("env", env_timezone), + ( + "cron_or_system", + fallback_timezone.and_then(normalize_timezone), + ), + ] { + let Some(timezone) = timezone else { + continue; + }; + if timezone.parse::().is_ok() { + return Some(timezone); + } + tracing::warn!( + agent_id, + user_timezone = %timezone, + user_timezone_source = source, + "invalid user timezone configured, trying next fallback" + ); + } + + None } fn parse_otlp_headers(value: Option) -> Result> { @@ -2995,6 +3054,7 @@ impl Config { mcp: None, brave_search_key: None, cron_timezone: None, + user_timezone: None, sandbox: None, cron: Vec::new(), }]; @@ -3629,6 +3689,11 @@ impl Config { .cron_timezone .as_deref() .and_then(resolve_env_value), + user_timezone: toml + .defaults + .user_timezone + .as_deref() + .and_then(resolve_env_value), history_backfill_count: base_defaults.history_backfill_count, cron: Vec::new(), opencode: toml @@ -3812,6 +3877,7 @@ impl Config { }, brave_search_key: a.brave_search_key.as_deref().and_then(resolve_env_value), cron_timezone: a.cron_timezone.as_deref().and_then(resolve_env_value), + user_timezone: a.user_timezone.as_deref().and_then(resolve_env_value), sandbox: a.sandbox, cron, }) @@ -3841,6 +3907,7 @@ impl Config { mcp: None, brave_search_key: None, cron_timezone: None, + user_timezone: None, sandbox: None, cron: Vec::new(), }); @@ -4171,6 +4238,7 @@ pub struct RuntimeConfig { pub history_backfill_count: ArcSwap, pub brave_search_key: ArcSwap>, pub cron_timezone: ArcSwap>, + pub user_timezone: ArcSwap>, pub cortex: ArcSwap, pub warmup: ArcSwap, /// Current warmup lifecycle status for API and observability. @@ -4231,6 +4299,7 @@ impl RuntimeConfig { history_backfill_count: ArcSwap::from_pointee(agent_config.history_backfill_count), brave_search_key: ArcSwap::from_pointee(agent_config.brave_search_key.clone()), cron_timezone: ArcSwap::from_pointee(agent_config.cron_timezone.clone()), + user_timezone: ArcSwap::from_pointee(agent_config.user_timezone.clone()), cortex: ArcSwap::from_pointee(agent_config.cortex), warmup: ArcSwap::from_pointee(agent_config.warmup), warmup_status: ArcSwap::from_pointee(WarmupStatus::default()), @@ -4318,6 +4387,7 @@ impl RuntimeConfig { self.brave_search_key .store(Arc::new(resolved.brave_search_key)); self.cron_timezone.store(Arc::new(resolved.cron_timezone)); + self.user_timezone.store(Arc::new(resolved.user_timezone)); self.cortex.store(Arc::new(resolved.cortex)); self.warmup.store(Arc::new(resolved.warmup)); // sandbox config is not hot-reloaded here because the Sandbox instance @@ -5050,10 +5120,11 @@ mod tests { impl EnvGuard { fn new() -> Self { - const KEYS: [&str; 25] = [ + const KEYS: [&str; 26] = [ "SPACEBOT_DIR", "SPACEBOT_DEPLOYMENT", "SPACEBOT_CRON_TIMEZONE", + "SPACEBOT_USER_TIMEZONE", "ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN", "OPENAI_API_KEY", @@ -5635,6 +5706,159 @@ id = "main" assert_eq!(resolved.cron_timezone, None); } + #[test] + fn test_cron_timezone_invalid_default_uses_env_fallback() { + let _lock = env_test_lock() + .lock() + .expect("failed to lock env test mutex"); + let _env = EnvGuard::new(); + + unsafe { + std::env::set_var(CRON_TIMEZONE_ENV_VAR, "Asia/Tokyo"); + } + + let toml = r#" +[defaults] +cron_timezone = "Not/A-Real-Tz" + +[[agents]] +id = "main" +"#; + + let parsed: TomlConfig = toml::from_str(toml).expect("failed to parse test TOML"); + let config = Config::from_toml(parsed, PathBuf::from(".")).expect("failed to build Config"); + let resolved = config.agents[0].resolve(&config.instance_dir, &config.defaults); + assert_eq!(resolved.cron_timezone.as_deref(), Some("Asia/Tokyo")); + } + + #[test] + fn test_user_timezone_resolution_precedence() { + let _lock = env_test_lock() + .lock() + .expect("failed to lock env test mutex"); + let _env = EnvGuard::new(); + + unsafe { + std::env::set_var(USER_TIMEZONE_ENV_VAR, "Asia/Tokyo"); + } + + let toml = r#" +[defaults] +user_timezone = "America/New_York" + +[[agents]] +id = "main" +user_timezone = "Europe/Berlin" +"#; + + let parsed: TomlConfig = toml::from_str(toml).expect("failed to parse test TOML"); + let config = Config::from_toml(parsed, PathBuf::from(".")).expect("failed to build Config"); + let resolved = config.agents[0].resolve(&config.instance_dir, &config.defaults); + assert_eq!(resolved.user_timezone.as_deref(), Some("Europe/Berlin")); + + let toml_without_agent_override = r#" +[defaults] +user_timezone = "America/New_York" + +[[agents]] +id = "main" +"#; + let parsed: TomlConfig = + toml::from_str(toml_without_agent_override).expect("failed to parse test TOML"); + let config = Config::from_toml(parsed, PathBuf::from(".")).expect("failed to build Config"); + let resolved = config.agents[0].resolve(&config.instance_dir, &config.defaults); + assert_eq!(resolved.user_timezone.as_deref(), Some("America/New_York")); + + let toml_without_default = r#" +[[agents]] +id = "main" +"#; + let parsed: TomlConfig = + toml::from_str(toml_without_default).expect("failed to parse test TOML"); + let config = Config::from_toml(parsed, PathBuf::from(".")).expect("failed to build Config"); + let resolved = config.agents[0].resolve(&config.instance_dir, &config.defaults); + assert_eq!(resolved.user_timezone.as_deref(), Some("Asia/Tokyo")); + } + + #[test] + fn test_user_timezone_falls_back_to_cron_timezone() { + let _lock = env_test_lock() + .lock() + .expect("failed to lock env test mutex"); + let _env = EnvGuard::new(); + + let toml = r#" +[defaults] +cron_timezone = "America/Los_Angeles" + +[[agents]] +id = "main" +"#; + + let parsed: TomlConfig = toml::from_str(toml).expect("failed to parse test TOML"); + let config = Config::from_toml(parsed, PathBuf::from(".")).expect("failed to build Config"); + let resolved = config.agents[0].resolve(&config.instance_dir, &config.defaults); + assert_eq!( + resolved.cron_timezone.as_deref(), + Some("America/Los_Angeles") + ); + assert_eq!( + resolved.user_timezone.as_deref(), + Some("America/Los_Angeles") + ); + } + + #[test] + fn test_user_timezone_invalid_falls_back_to_cron_timezone() { + let _lock = env_test_lock() + .lock() + .expect("failed to lock env test mutex"); + let _env = EnvGuard::new(); + + let toml = r#" +[defaults] +cron_timezone = "America/Los_Angeles" +user_timezone = "Not/A-Real-Tz" + +[[agents]] +id = "main" +"#; + + let parsed: TomlConfig = toml::from_str(toml).expect("failed to parse test TOML"); + let config = Config::from_toml(parsed, PathBuf::from(".")).expect("failed to build Config"); + let resolved = config.agents[0].resolve(&config.instance_dir, &config.defaults); + assert_eq!( + resolved.user_timezone.as_deref(), + Some("America/Los_Angeles") + ); + } + + #[test] + fn test_user_timezone_invalid_config_uses_env_fallback() { + let _lock = env_test_lock() + .lock() + .expect("failed to lock env test mutex"); + let _env = EnvGuard::new(); + + unsafe { + std::env::set_var(USER_TIMEZONE_ENV_VAR, "Asia/Tokyo"); + } + + let toml = r#" +[defaults] +cron_timezone = "America/Los_Angeles" +user_timezone = "Not/A-Real-Tz" + +[[agents]] +id = "main" +"#; + + let parsed: TomlConfig = toml::from_str(toml).expect("failed to parse test TOML"); + let config = Config::from_toml(parsed, PathBuf::from(".")).expect("failed to build Config"); + let resolved = config.agents[0].resolve(&config.instance_dir, &config.defaults); + assert_eq!(resolved.user_timezone.as_deref(), Some("Asia/Tokyo")); + } + #[test] fn ollama_base_url_registers_provider() { let toml = r#" diff --git a/src/prompts/engine.rs b/src/prompts/engine.rs index 361164846..dd9465493 100644 --- a/src/prompts/engine.rs +++ b/src/prompts/engine.rs @@ -100,10 +100,6 @@ impl PromptEngine { "fragments/org_context", crate::prompts::text::get("fragments/org_context"), )?; - env.add_template( - "fragments/link_context", - crate::prompts::text::get("fragments/link_context"), - )?; // System message fragments env.add_template( @@ -146,6 +142,10 @@ impl PromptEngine { "fragments/system/tool_syntax_correction", crate::prompts::text::get("fragments/system/tool_syntax_correction"), )?; + env.add_template( + "fragments/system/worker_time_context", + crate::prompts::text::get("fragments/system/worker_time_context"), + )?; env.add_template( "fragments/coalesce_hint", crate::prompts::text::get("fragments/coalesce_hint"), @@ -304,6 +304,21 @@ impl PromptEngine { self.render_static("fragments/system/tool_syntax_correction") } + /// Render worker task time-context preamble. + pub fn render_system_worker_time_context( + &self, + current_local_datetime: &str, + current_utc_datetime: &str, + ) -> Result { + self.render( + "fragments/system/worker_time_context", + context! { + current_local_datetime => current_local_datetime, + current_utc_datetime => current_utc_datetime, + }, + ) + } + /// Convenience method for rendering truncation marker. pub fn render_system_truncation(&self, remove_count: usize) -> Result { self.render( diff --git a/src/prompts/text.rs b/src/prompts/text.rs index e7fded748..028039058 100644 --- a/src/prompts/text.rs +++ b/src/prompts/text.rs @@ -116,6 +116,9 @@ fn lookup(lang: &str, key: &str) -> &'static str { ("en", "fragments/system/tool_syntax_correction") => { include_str!("../../prompts/en/fragments/system/tool_syntax_correction.md.j2") } + ("en", "fragments/system/worker_time_context") => { + include_str!("../../prompts/en/fragments/system/worker_time_context.md.j2") + } // Agent Communication Fragments ("en", "fragments/org_context") => {