forked from admin/VolcanicAgenticCore
feat(core): session SetTimeZone and the date and zone in the system prompt
This commit is contained in:
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
/// <summary>The session's time zone: the session option, else the host default, else the machine's zone.</summary>
|
||||
public TimeZoneInfo TimeZone => _options.TimeZone ?? _coreOptions.DefaultTimeZone ?? TimeZoneInfo.Local;
|
||||
/// <summary>The session's time zone: <see cref="SetTimeZone"/>, else the session option, else the host default, else the machine's zone.</summary>
|
||||
public TimeZoneInfo TimeZone => _timeZoneOverride ?? _options.TimeZone ?? _coreOptions.DefaultTimeZone ?? TimeZoneInfo.Local;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="AgentSessionOptions.TimeZone"/>.
|
||||
/// </summary>
|
||||
public void SetTimeZone(TimeZoneInfo zone)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(zone);
|
||||
_timeZoneOverride = zone;
|
||||
}
|
||||
/// <summary>The host's chat channel for this session (e.g. for scheduled deliveries), or null.</summary>
|
||||
public IChatChannel? Channel => _options.Channel;
|
||||
public Model? Model => _model;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Composes the system prompt: base prompt, tools section (from <see cref="ITool.PromptSnippet"/>), 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.
|
||||
/// </summary>
|
||||
public sealed class SystemPromptBuilder
|
||||
{
|
||||
@@ -15,13 +17,19 @@ public sealed class SystemPromptBuilder
|
||||
|
||||
private readonly string _cwd;
|
||||
private readonly Func<IReadOnlyList<ITool>> _activeTools;
|
||||
private readonly Func<DateTimeOffset> _now;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly Func<TimeZoneInfo> _zone;
|
||||
|
||||
public SystemPromptBuilder(string cwd, Func<IReadOnlyList<ITool>> activeTools, Func<DateTimeOffset>? now = null)
|
||||
/// <param name="cwd">The session's working directory.</param>
|
||||
/// <param name="activeTools">The session's active tools, read at every render.</param>
|
||||
/// <param name="time">Clock for the date line; <see cref="TimeProvider.System"/> when null.</param>
|
||||
/// <param name="zone">The session's zone, read at every render; <see cref="TimeZoneInfo.Local"/> when null.</param>
|
||||
public SystemPromptBuilder(string cwd, Func<IReadOnlyList<ITool>> activeTools, TimeProvider? time = null, Func<TimeZoneInfo>? 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Date (in <paramref name="zone"/>), 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.
|
||||
/// </summary>
|
||||
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}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Concatenates one project context file per directory walking up from cwd (nearest first): the first of
|
||||
/// <see cref="DefaultPrompts.ProjectContextFiles"/> present. Size capped.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/// <summary>The zone's IANA id when it has one (<see cref="ToIana"/>), else its own id. For display.</summary>
|
||||
public static string Name(TimeZoneInfo zone) => ToIana(zone) ?? zone.Id;
|
||||
|
||||
/// <summary><c>UTC+09:00</c>, <c>UTC-04:00</c>, <c>UTC+00:00</c>.</summary>
|
||||
public static string FormatOffset(TimeSpan offset) =>
|
||||
(offset < TimeSpan.Zero ? "UTC-" : "UTC+") + offset.Duration().ToString(@"hh\:mm", System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
@@ -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<object>("alpha", "d", (_, _, _) => Task.FromResult(ToolResult.Text("")), promptSnippet: "alpha: does a", promptGuidelines: ["Use alpha wisely"]),
|
||||
new DelegateTool<object>("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("<project_context>", 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);
|
||||
|
||||
@@ -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("<environment>")[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<ProbeArgs>("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)));
|
||||
}
|
||||
|
||||
@@ -19,13 +19,21 @@ public sealed class TestHost : IAsyncDisposable
|
||||
public string Cwd { get; }
|
||||
public List<AgentSessionEvent> Events { get; } = [];
|
||||
|
||||
public TestHost(Action<AgenticCoreOptions>? configure = null, params IExtension[] extensions) : this(useFileStore: false, configure, extensions) { }
|
||||
public TestHost(Action<AgenticCoreOptions>? configure = null, params IExtension[] extensions) : this(useFileStore: false, time: null, configure, extensions) { }
|
||||
|
||||
public TestHost(bool useFileStore, Action<AgenticCoreOptions>? configure = null, params IExtension[] extensions)
|
||||
: this(useFileStore, time: null, configure, extensions) { }
|
||||
|
||||
/// <summary>A host whose sessions read <paramref name="time"/> (registered as the DI <see cref="TimeProvider"/>).</summary>
|
||||
public TestHost(TimeProvider time, Action<AgenticCoreOptions>? configure = null, params IExtension[] extensions)
|
||||
: this(useFileStore: false, time, configure, extensions) { }
|
||||
|
||||
private TestHost(bool useFileStore, TimeProvider? time, Action<AgenticCoreOptions>? 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");
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.9.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../src/Volcanic.AgenticCore/Volcanic.AgenticCore.csproj" />
|
||||
|
||||
Reference in New Issue
Block a user