diff --git a/src/Volcanic.AgenticCore/Features/Agent/AgentSession.Host.cs b/src/Volcanic.AgenticCore/Features/Agent/AgentSession.Host.cs
index 6f32ab4..c358f76 100644
--- a/src/Volcanic.AgenticCore/Features/Agent/AgentSession.Host.cs
+++ b/src/Volcanic.AgenticCore/Features/Agent/AgentSession.Host.cs
@@ -23,7 +23,7 @@ public sealed partial class AgentSession : IExtensionHost
ThinkingLevel IExtensionHost.ThinkingLevel => _thinkingLevel;
TimeZoneInfo IExtensionHost.TimeZone => TimeZone;
string? IExtensionHost.TimeZoneId =>
- (_options.TimeZone ?? _coreOptions.DefaultTimeZone) is { } zone ? TimeZoneIds.ToIana(zone) : null;
+ (_timeZoneOverride ?? _options.TimeZone ?? _coreOptions.DefaultTimeZone) is { } zone ? TimeZoneIds.ToIana(zone) : null;
CancellationToken IExtensionHost.Signal
{
get { lock (_stateLock) return _abortCts?.Token ?? CancellationToken.None; }
diff --git a/src/Volcanic.AgenticCore/Features/Agent/AgentSession.cs b/src/Volcanic.AgenticCore/Features/Agent/AgentSession.cs
index 9fdd8e4..679ba2c 100644
--- a/src/Volcanic.AgenticCore/Features/Agent/AgentSession.cs
+++ b/src/Volcanic.AgenticCore/Features/Agent/AgentSession.cs
@@ -67,6 +67,7 @@ public sealed partial class AgentSession : IAsyncDisposable
private bool _isCompacting;
private CompactOptions? _pendingCompaction;
private string? _systemPromptForRun;
+ private volatile TimeZoneInfo? _timeZoneOverride;
private bool _disposed;
internal AgentSession(
@@ -100,8 +101,19 @@ public sealed partial class AgentSession : IAsyncDisposable
public string SessionId => _session.SessionId;
public string Cwd => _options.Cwd;
- /// The session's time zone: the session option, else the host default, else the machine's zone.
- public TimeZoneInfo TimeZone => _options.TimeZone ?? _coreOptions.DefaultTimeZone ?? TimeZoneInfo.Local;
+ /// The session's time zone: , else the session option, else the host default, else the machine's zone.
+ public TimeZoneInfo TimeZone => _timeZoneOverride ?? _options.TimeZone ?? _coreOptions.DefaultTimeZone ?? TimeZoneInfo.Local;
+
+ ///
+ /// Change the session's zone (a host's per-chat setting). Tools and process spawns see it at once; the system prompt
+ /// is rendered per run, so a run in progress keeps its prompt and the next run shows the new date and zone. Not
+ /// persisted in session entries: the host owns that setting and passes .
+ ///
+ public void SetTimeZone(TimeZoneInfo zone)
+ {
+ ArgumentNullException.ThrowIfNull(zone);
+ _timeZoneOverride = zone;
+ }
/// The host's chat channel for this session (e.g. for scheduled deliveries), or null.
public IChatChannel? Channel => _options.Channel;
public Model? Model => _model;
diff --git a/src/Volcanic.AgenticCore/Features/Agent/AgentSessionFactory.cs b/src/Volcanic.AgenticCore/Features/Agent/AgentSessionFactory.cs
index 4bca2da..b5a3683 100644
--- a/src/Volcanic.AgenticCore/Features/Agent/AgentSessionFactory.cs
+++ b/src/Volcanic.AgenticCore/Features/Agent/AgentSessionFactory.cs
@@ -101,7 +101,11 @@ public sealed class AgentSessionFactory : IAgentSessionFactory
}
if (options.ActiveTools is not null) tools.SetActive(options.ActiveTools);
- var promptBuilder = new SystemPromptBuilder(cwd, () => tools.ActiveTools);
+ // The builder is created before the session; the zone is read from the session at every render (SetTimeZone).
+ AgentSession? owner = null;
+ var time = (TimeProvider?)_services.GetService(typeof(TimeProvider)) ?? TimeProvider.System;
+ var promptBuilder = new SystemPromptBuilder(cwd, () => tools.ActiveTools, time,
+ () => owner?.TimeZone ?? options.TimeZone ?? _options.DefaultTimeZone ?? TimeZoneInfo.Local);
var compactor = new Compactor(_options.Compaction, models);
Model? model = null;
@@ -114,6 +118,7 @@ public sealed class AgentSessionFactory : IAgentSessionFactory
var thinking = options.ThinkingLevel ?? _options.DefaultThinkingLevel;
var agentSession = new AgentSession(session, store, runner, tools, commands, models, compactor, promptBuilder, options, _options, model, thinking, logger, _redactor);
+ owner = agentSession;
runner.Bind(agentSession);
if (resumed) agentSession.RestoreBranchSettings();
else if (model is not null) await session.AppendModelChangeAsync(model, cancellationToken).ConfigureAwait(false);
diff --git a/src/Volcanic.AgenticCore/Features/SystemPrompt/SystemPromptBuilder.cs b/src/Volcanic.AgenticCore/Features/SystemPrompt/SystemPromptBuilder.cs
index 005911c..8358001 100644
--- a/src/Volcanic.AgenticCore/Features/SystemPrompt/SystemPromptBuilder.cs
+++ b/src/Volcanic.AgenticCore/Features/SystemPrompt/SystemPromptBuilder.cs
@@ -1,13 +1,15 @@
+using System.Globalization;
using System.Text;
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
using Volcanic.AgenticCore.Abstractions.Tools;
+using Volcanic.AgenticCore.Hosting;
namespace Volcanic.AgenticCore.Features.SystemPrompt;
///
/// Composes the system prompt: base prompt, tools section (from ), guidelines,
/// extra sections, project context files (AGENTS.md, else CLAUDE.md, per directory walking up from cwd), the agent's
-/// persona (PERSONA.md in cwd), its memory (MEMORY.md in cwd), then date and cwd. File names match case-insensitively.
+/// persona (PERSONA.md in cwd), its memory (MEMORY.md in cwd), then date, time zone and cwd. File names match case-insensitively.
///
public sealed class SystemPromptBuilder
{
@@ -15,13 +17,19 @@ public sealed class SystemPromptBuilder
private readonly string _cwd;
private readonly Func> _activeTools;
- private readonly Func _now;
+ private readonly TimeProvider _time;
+ private readonly Func _zone;
- public SystemPromptBuilder(string cwd, Func> activeTools, Func? now = null)
+ /// The session's working directory.
+ /// The session's active tools, read at every render.
+ /// Clock for the date line; when null.
+ /// The session's zone, read at every render; when null.
+ public SystemPromptBuilder(string cwd, Func> activeTools, TimeProvider? time = null, Func? zone = null)
{
_cwd = cwd;
_activeTools = activeTools;
- _now = now ?? (() => DateTimeOffset.Now);
+ _time = time ?? TimeProvider.System;
+ _zone = zone ?? (() => TimeZoneInfo.Local);
}
public SystemPromptOptions DefaultOptions(string? basePrompt)
@@ -62,10 +70,21 @@ public sealed class SystemPromptBuilder
if (memory.Path is not null) AppendSection(sb, "memory", DefaultPrompts.MemoryPreamble(memory.Path) + (string.IsNullOrWhiteSpace(memory.Content) ? "" : "\n\n" + memory.Content));
}
- AppendSection(sb, "environment", $"Current date: {_now():yyyy-MM-dd}\nWorking directory: {_cwd}");
+ AppendSection(sb, "environment", EnvironmentSection(_time.GetUtcNow(), _zone(), _cwd));
return sb.ToString().TrimEnd();
}
+ ///
+ /// Date (in ), zone and working directory. No time of day: the prompt is rendered per run and
+ /// a clock time would change the cached prefix every run; the model gets the time from the timestamp header instead.
+ ///
+ public static string EnvironmentSection(DateTimeOffset utcNow, TimeZoneInfo zone, string cwd)
+ {
+ var local = TimeZoneInfo.ConvertTime(utcNow, zone);
+ return string.Create(CultureInfo.InvariantCulture,
+ $"Current date: {local:yyyy-MM-dd} ({local:dddd})\nTime zone: {TimeZoneIds.Name(zone)} ({TimeZoneIds.FormatOffset(local.Offset)})\nWorking directory: {cwd}");
+ }
+
///
/// Concatenates one project context file per directory walking up from cwd (nearest first): the first of
/// present. Size capped.
diff --git a/src/Volcanic.AgenticCore/Hosting/TimeZoneIds.cs b/src/Volcanic.AgenticCore/Hosting/TimeZoneIds.cs
index 9a0e91e..c1010eb 100644
--- a/src/Volcanic.AgenticCore/Hosting/TimeZoneIds.cs
+++ b/src/Volcanic.AgenticCore/Hosting/TimeZoneIds.cs
@@ -15,4 +15,11 @@ public static class TimeZoneIds
var id = zone.HasIanaId ? zone.Id : TimeZoneInfo.TryConvertWindowsIdToIanaId(zone.Id, out var iana) ? iana : null;
return id is not null && TimeZoneInfo.TryFindSystemTimeZoneById(id, out _) ? id : null;
}
+
+ /// The zone's IANA id when it has one (), else its own id. For display.
+ public static string Name(TimeZoneInfo zone) => ToIana(zone) ?? zone.Id;
+
+ /// UTC+09:00, UTC-04:00, UTC+00:00.
+ public static string FormatOffset(TimeSpan offset) =>
+ (offset < TimeSpan.Zero ? "UTC-" : "UTC+") + offset.Duration().ToString(@"hh\:mm", System.Globalization.CultureInfo.InvariantCulture);
}
diff --git a/tests/Volcanic.AgenticCore.Tests/Features/PromptAndCompactionTests.cs b/tests/Volcanic.AgenticCore.Tests/Features/PromptAndCompactionTests.cs
index 7c4a11f..1920e27 100644
--- a/tests/Volcanic.AgenticCore.Tests/Features/PromptAndCompactionTests.cs
+++ b/tests/Volcanic.AgenticCore.Tests/Features/PromptAndCompactionTests.cs
@@ -1,3 +1,4 @@
+using Microsoft.Extensions.Time.Testing;
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
using Volcanic.AgenticCore.Abstractions.Json;
using Volcanic.AgenticCore.Abstractions.Messages;
@@ -28,7 +29,7 @@ public class SystemPromptBuilderTests
new DelegateTool