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("alpha", "d", (_, _, _) => Task.FromResult(ToolResult.Text("")), promptSnippet: "alpha: does a", promptGuidelines: ["Use alpha wisely"]), new DelegateTool("silent", "d", (_, _, _) => Task.FromResult(ToolResult.Text(""))), }; - var builder = new SystemPromptBuilder(sub, () => tools, () => new DateTimeOffset(2026, 9, 25, 0, 0, 0, TimeSpan.Zero)); + var builder = new SystemPromptBuilder(sub, () => tools, new FakeTimeProvider(new DateTimeOffset(2026, 9, 25, 0, 0, 0, TimeSpan.Zero)), () => TimeZoneInfo.Utc); var options = builder.DefaultOptions("BASE"); options.Sections["extra"] = "more"; var prompt = builder.Render(options); @@ -41,7 +42,7 @@ public class SystemPromptBuilderTests var ctxStart = prompt.IndexOf("", StringComparison.Ordinal); Assert.True(prompt.IndexOf("Sub rules", StringComparison.Ordinal) < prompt.IndexOf("Root rules", StringComparison.Ordinal)); Assert.True(ctxStart > 0); - Assert.Contains("Current date: 2026-09-25", prompt); + Assert.Contains("Current date: 2026-09-25 (Friday)\nTime zone: ", prompt); Assert.Contains($"Working directory: {sub}", prompt); options.SelectedTools.Clear(); @@ -66,7 +67,7 @@ public class SystemPromptBuilderTests File.WriteAllText(Path.Combine(dir, "memory.md"), "- User prefers tabs"); try { - var builder = new SystemPromptBuilder(dir, () => [], () => DateTimeOffset.UnixEpoch); + var builder = new SystemPromptBuilder(dir, () => [], new FakeTimeProvider(DateTimeOffset.UnixEpoch), () => TimeZoneInfo.Utc); var prompt = builder.Render(builder.DefaultOptions("BASE")); Assert.Contains("Agent rules", prompt); diff --git a/tests/Volcanic.AgenticCore.Tests/Features/SessionTimeZoneTests.cs b/tests/Volcanic.AgenticCore.Tests/Features/SessionTimeZoneTests.cs index 075195c..1db0939 100644 --- a/tests/Volcanic.AgenticCore.Tests/Features/SessionTimeZoneTests.cs +++ b/tests/Volcanic.AgenticCore.Tests/Features/SessionTimeZoneTests.cs @@ -1,5 +1,7 @@ +using Microsoft.Extensions.Time.Testing; using Volcanic.AgenticCore.Abstractions.Extensions; using Volcanic.AgenticCore.Abstractions.Tools; +using Volcanic.AgenticCore.Features.Agent; using Volcanic.AgenticCore.Features.Extensions; using Volcanic.AgenticCore.Hosting; using Volcanic.AgenticCore.Testing; @@ -76,4 +78,72 @@ public class SessionTimeZoneTests Assert.Equal("Europe/Berlin", TimeZoneIds.ToIana(Berlin)); Assert.Equal("Europe/Berlin", TimeZoneIds.ToIana(TimeZoneInfo.FindSystemTimeZoneById("W. Europe Standard Time"))); } + + private static readonly TimeZoneInfo NewYork = TimeZoneInfo.FindSystemTimeZoneById("America/New_York"); + private static readonly DateTimeOffset LateSaturdayUtc = new(2026, 9, 26, 23, 30, 0, TimeSpan.Zero); + + [Theory] + [InlineData("Asia/Tokyo", "Current date: 2026-09-27 (Sunday)", "Time zone: Asia/Tokyo (UTC+09:00)")] + [InlineData("Europe/Berlin", "Current date: 2026-09-27 (Sunday)", "Time zone: Europe/Berlin (UTC+02:00)")] + [InlineData("America/New_York", "Current date: 2026-09-26 (Saturday)", "Time zone: America/New_York (UTC-04:00)")] + public async Task Prompt_shows_the_date_and_zone_in_the_session_zone(string zoneId, string dateLine, string zoneLine) + { + await using var host = new TestHost(new FakeTimeProvider(LateSaturdayUtc)); + await using var session = await host.CreateSessionAsync(b => b.TimeZone = TimeZoneInfo.FindSystemTimeZoneById(zoneId)); + + var prompt = session.SystemPrompt; + + Assert.Contains($"{dateLine}\n{zoneLine}\nWorking directory: ", prompt); + Assert.DoesNotMatch(@"\d{2}:\d{2}\n", prompt.Split("")[1]); // no time of day in the cached prefix + } + + [Fact] + public async Task SetTimeZone_during_a_run_changes_the_next_runs_prompt_not_the_current_one() + { + AgentSession? session = null; + var switchZone = new DelegateTool("switch_zone", "s", (_, _, _) => + { + session!.SetTimeZone(Tokyo); + return Task.FromResult(ToolResult.Text("switched")); + }); + await using var host = new TestHost(new FakeTimeProvider(LateSaturdayUtc), null, + new LambdaExtension("tools", api => { api.RegisterTool(switchZone); return ValueTask.CompletedTask; })); + host.Faux.Enqueue(FauxTurn.Tool("switch_zone", new { value = "x" }), FauxTurn.Say("done"), FauxTurn.Say("second")); + session = await host.CreateSessionAsync(b => b.TimeZone = Berlin); + + await session.PromptAsync("first"); + Assert.Equal("Asia/Tokyo", TimeZoneIds.ToIana(session.TimeZone)); // the property changes at once + await session.PromptAsync("again"); + + Assert.Equal(3, host.Faux.Requests.Count); + Assert.Contains("Time zone: Europe/Berlin", host.Faux.Requests[0].SystemPrompt); + Assert.Contains("Time zone: Europe/Berlin", host.Faux.Requests[1].SystemPrompt); // same run, same prompt + Assert.Contains("Time zone: Asia/Tokyo (UTC+09:00)", host.Faux.Requests[2].SystemPrompt); + await session.DisposeAsync(); + } + + [Fact] + public async Task SetTimeZone_changes_the_TZ_id_for_processes() + { + await using var host = new TestHost(); + await using var session = await host.CreateSessionAsync(); + session.SetTimeZone(Tokyo); + Assert.Equal("Asia/Tokyo", ((IExtensionHost)session).TimeZoneId); + } + + [Fact] + public async Task Without_options_the_prompt_uses_the_local_zone() + { + await using var host = new TestHost(); + await using var session = await host.CreateSessionAsync(); + Assert.Contains($"Time zone: {TimeZoneIds.Name(TimeZoneInfo.Local)} (", session.SystemPrompt); + } + + [Theory] + [InlineData(9, 0, "UTC+09:00")] + [InlineData(-4, 0, "UTC-04:00")] + [InlineData(5, 30, "UTC+05:30")] + [InlineData(0, 0, "UTC+00:00")] + public void FormatOffset_formats_sign_hours_and_minutes(int hours, int minutes, string expected) => + Assert.Equal(expected, TimeZoneIds.FormatOffset(new TimeSpan(hours, hours < 0 ? -minutes : minutes, 0))); } diff --git a/tests/Volcanic.AgenticCore.Tests/Support/TestHost.cs b/tests/Volcanic.AgenticCore.Tests/Support/TestHost.cs index 0c95b88..9badb40 100644 --- a/tests/Volcanic.AgenticCore.Tests/Support/TestHost.cs +++ b/tests/Volcanic.AgenticCore.Tests/Support/TestHost.cs @@ -19,13 +19,21 @@ public sealed class TestHost : IAsyncDisposable public string Cwd { get; } public List Events { get; } = []; - public TestHost(Action? configure = null, params IExtension[] extensions) : this(useFileStore: false, configure, extensions) { } + public TestHost(Action? configure = null, params IExtension[] extensions) : this(useFileStore: false, time: null, configure, extensions) { } public TestHost(bool useFileStore, Action? configure = null, params IExtension[] extensions) + : this(useFileStore, time: null, configure, extensions) { } + + /// A host whose sessions read (registered as the DI ). + public TestHost(TimeProvider time, Action? configure = null, params IExtension[] extensions) + : this(useFileStore: false, time, configure, extensions) { } + + private TestHost(bool useFileStore, TimeProvider? time, Action? configure, IExtension[] extensions) { Cwd = Path.Combine(Path.GetTempPath(), "volcanic-host", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(Cwd); var services = new ServiceCollection(); + if (time is not null) services.AddSingleton(time); services.AddAgenticCore(o => { o.AgentDir = Path.Combine(Cwd, ".agent"); diff --git a/tests/Volcanic.AgenticCore.Tests/Volcanic.AgenticCore.Tests.csproj b/tests/Volcanic.AgenticCore.Tests/Volcanic.AgenticCore.Tests.csproj index c3e10c6..bb09cef 100644 --- a/tests/Volcanic.AgenticCore.Tests/Volcanic.AgenticCore.Tests.csproj +++ b/tests/Volcanic.AgenticCore.Tests/Volcanic.AgenticCore.Tests.csproj @@ -10,6 +10,7 @@ +