-
Notifications
You must be signed in to change notification settings - Fork 1.3k
fix(harness): use zone-aware timestamp in memory flush header (#3088) #3111
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -28,7 +28,8 @@ | |
| import io.agentscope.harness.agent.memory.session.SessionTranscriptWriter; | ||
| import io.agentscope.harness.agent.workspace.WorkspaceConstants; | ||
| import io.agentscope.harness.agent.workspace.WorkspaceManager; | ||
| import java.time.LocalDate; | ||
| import java.time.Clock; | ||
| import java.time.ZonedDateTime; | ||
| import java.time.format.DateTimeFormatter; | ||
| import java.util.ArrayList; | ||
| import java.util.List; | ||
|
|
@@ -91,6 +92,7 @@ public class MemoryFlushManager { | |
| private final WorkspaceManager workspaceManager; | ||
| private final Model model; | ||
| private final String flushPrompt; | ||
| private final Clock clock; | ||
|
|
||
| public MemoryFlushManager(WorkspaceManager workspaceManager, Model model) { | ||
| this(workspaceManager, model, DEFAULT_FLUSH_PROMPT); | ||
|
|
@@ -101,9 +103,37 @@ public MemoryFlushManager(WorkspaceManager workspaceManager, Model model) { | |
| * {@link #DEFAULT_FLUSH_PROMPT}. | ||
| */ | ||
| public MemoryFlushManager(WorkspaceManager workspaceManager, Model model, String flushPrompt) { | ||
| this(workspaceManager, model, flushPrompt, Clock.systemDefaultZone()); | ||
| } | ||
|
|
||
| /** | ||
| * @param flushPrompt SYSTEM prompt for the extraction LLM call. {@code null} falls back to | ||
| * {@link #DEFAULT_FLUSH_PROMPT}. | ||
| * @param clock time source used for the daily file name and the flushed section header. The | ||
| * zone of this clock determines the rendered UTC offset, so {@code -Duser.timezone} is | ||
| * honored by the production default ({@link Clock#systemDefaultZone()}). Tests can inject | ||
| * {@link Clock#fixed} to pin an instant. | ||
| */ | ||
| public MemoryFlushManager( | ||
| WorkspaceManager workspaceManager, Model model, String flushPrompt, Clock clock) { | ||
| this.workspaceManager = workspaceManager; | ||
| this.model = model; | ||
| this.flushPrompt = flushPrompt != null ? flushPrompt : DEFAULT_FLUSH_PROMPT; | ||
| this.clock = clock != null ? clock : Clock.systemDefaultZone(); | ||
| } | ||
|
|
||
| /** | ||
| * Formats an instant as an ISO-8601 offset date-time in the clock's zone, e.g. | ||
| * {@code 2026-09-10T16:05:32.3096574+08:00}. Unlike {@code Instant.toString()} (which always | ||
| * renders {@code ...Z}), the offset is preserved so the value round-trips through | ||
| * {@link java.time.OffsetDateTime#parse(CharSequence)} back to the original instant. | ||
| */ | ||
| static String formatTimestamp(Clock clock) { | ||
| return formatTimestamp(ZonedDateTime.now(clock)); | ||
| } | ||
|
|
||
| private static String formatTimestamp(ZonedDateTime now) { | ||
| return now.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Warning] Two things worth pinning down before this format ships in the on-disk ledger:
If stable output matters more than formatter brevity, an explicit pattern such as |
||
| } | ||
|
|
||
| /** | ||
|
|
@@ -119,8 +149,8 @@ public Mono<Void> flushMemories(RuntimeContext rc, List<Msg> messages) { | |
| } | ||
|
|
||
| String existingMemory = readExistingContent(rc, WorkspaceConstants.MEMORY_MD); | ||
| String today = LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE); | ||
| String dailyRelPath = WorkspaceConstants.MEMORY_DIR + "/" + today + ".md"; | ||
| ZonedDateTime now = ZonedDateTime.now(clock); | ||
| String dailyRelPath = WorkspaceConstants.MEMORY_DIR + "/" + now.toLocalDate() + ".md"; | ||
| String existingDaily = readExistingContent(rc, dailyRelPath); | ||
|
|
||
| StringBuilder userPrompt = new StringBuilder(); | ||
|
|
@@ -178,7 +208,7 @@ public Mono<Void> flushMemories(RuntimeContext rc, List<Msg> messages) { | |
| log.debug("No memories to flush"); | ||
| return Mono.empty(); | ||
| } | ||
| writeMemoryFiles(rc, extracted); | ||
| writeMemoryFiles(rc, extracted, now); | ||
| return Mono.empty(); | ||
| }); | ||
| } | ||
|
|
@@ -222,15 +252,11 @@ public void offloadMessages( | |
| * {@link MemoryConsolidator}, which periodically merges the daily ledgers into a | ||
| * curated, size-bounded MEMORY.md. | ||
| */ | ||
| private void writeMemoryFiles(RuntimeContext rc, String content) { | ||
| String today = LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE); | ||
|
|
||
| private void writeMemoryFiles(RuntimeContext rc, String content, ZonedDateTime now) { | ||
| String dailyEntry = | ||
| String.format( | ||
| "\n## Memory Flush — %s\n%s\n", | ||
| java.time.Instant.now().toString(), content); | ||
| String.format("\n## Memory Flush — %s\n%s\n", formatTimestamp(now), content); | ||
|
|
||
| String dailyRelPath = WorkspaceConstants.MEMORY_DIR + "/" + today + ".md"; | ||
| String dailyRelPath = WorkspaceConstants.MEMORY_DIR + "/" + now.toLocalDate() + ".md"; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Warning] This is only half of the daily-ledger write path. String today = LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE);
...
"\n## Memory Save — %s\n%s\n", Instant.now().toString(), ...That path still has exactly the bug #3088 describes (file name from the default-zone date, header from a UTC Consider extracting the zone-aware helper (e.g. a small |
||
| workspaceManager.appendUtf8WorkspaceRelative(rc, dailyRelPath, dailyEntry); | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -29,11 +29,19 @@ | |
| import io.agentscope.core.model.ToolSchema; | ||
| import io.agentscope.harness.agent.memory.compaction.ConversationCompactor; | ||
| import io.agentscope.harness.agent.workspace.WorkspaceManager; | ||
| import java.nio.file.Files; | ||
| import java.nio.file.Path; | ||
| import java.time.Clock; | ||
| import java.time.Instant; | ||
| import java.time.OffsetDateTime; | ||
| import java.time.ZoneId; | ||
| import java.util.ArrayList; | ||
| import java.util.List; | ||
| import java.util.TimeZone; | ||
| import org.junit.jupiter.api.Test; | ||
| import org.junit.jupiter.api.io.TempDir; | ||
| import org.junit.jupiter.params.ParameterizedTest; | ||
| import org.junit.jupiter.params.provider.CsvSource; | ||
| import reactor.core.publisher.Flux; | ||
|
|
||
| class MemoryFlushManagerTest { | ||
|
|
@@ -76,6 +84,59 @@ void flushMemories_summaryOnlyInputDoesNotCallModel() { | |
| assertTrue(model.inputs.isEmpty()); | ||
| } | ||
|
|
||
| // ISO_OFFSET_DATE_TIME strips trailing zeros from the fractional second, so .309657400 is | ||
| // rendered as .3096574; the offset and the instant it parses back to are unaffected. | ||
| @ParameterizedTest | ||
| @CsvSource({ | ||
| "UTC, 2026-09-10T08:05:32.309657400Z, 2026-09-10T08:05:32.3096574Z", | ||
| "Asia/Shanghai, 2026-09-10T08:05:32.309657400Z, 2026-09-10T16:05:32.3096574+08:00", | ||
| "America/New_York, 2026-09-10T08:05:32.309657400Z, 2026-09-10T04:05:32.3096574-04:00", | ||
| "Asia/Shanghai, 2026-09-10T20:00:00Z, 2026-09-11T04:00:00+08:00" | ||
| }) | ||
| void formatTimestamp_rendersClockZoneOffset(String zoneId, String instantStr, String expected) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Info] Nice coverage on the helper and the end-to-end file-name/header pairing. One gap: every new assertion goes through the package-private |
||
| Instant instant = Instant.parse(instantStr); | ||
|
|
||
| String actual = MemoryFlushManager.formatTimestamp(Clock.fixed(instant, ZoneId.of(zoneId))); | ||
|
|
||
| assertEquals(expected, actual); | ||
| assertEquals(instant, OffsetDateTime.parse(actual).toInstant()); | ||
| } | ||
|
|
||
| @Test | ||
| void formatTimestamp_honorsSystemDefaultZone() { | ||
| TimeZone original = TimeZone.getDefault(); | ||
| try { | ||
| TimeZone.setDefault(TimeZone.getTimeZone("Asia/Shanghai")); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Warning] Mutating the JVM-global default time zone from a unit test is a flake source: Suggested options, cheapest first:
Also note the CI signal here is still missing — the only check reported on this PR is |
||
| String timestamp = MemoryFlushManager.formatTimestamp(Clock.systemDefaultZone()); | ||
| assertTrue(timestamp.endsWith("+08:00"), timestamp); | ||
| } finally { | ||
| TimeZone.setDefault(original); | ||
| } | ||
| } | ||
|
|
||
| @Test | ||
| void flushMemories_writesZoneAwareHeaderAndMatchingFileName() throws Exception { | ||
| Instant instant = Instant.parse("2026-09-10T20:00:00Z"); | ||
| Clock clock = Clock.fixed(instant, ZoneId.of("Asia/Shanghai")); | ||
| RecordingModel model = new RecordingModel("- user prefers dark mode"); | ||
| RuntimeContext rc = RuntimeContext.builder().sessionId("session-1").build(); | ||
|
|
||
| try (WorkspaceManager workspaceManager = new WorkspaceManager(workspace)) { | ||
| MemoryFlushManager flushManager = | ||
| new MemoryFlushManager(workspaceManager, model, null, clock); | ||
|
|
||
| flushManager.flushMemories(rc, List.of(message(MsgRole.USER, "hi"))).block(); | ||
| } | ||
|
|
||
| Path daily = workspace.resolve("memory/2026-09-11.md"); | ||
| assertTrue(Files.exists(daily), "daily file should follow the clock's local date"); | ||
| String content = Files.readString(daily); | ||
| assertTrue( | ||
| content.contains("## Memory Flush — 2026-09-11T04:00:00+08:00"), | ||
| "header should carry the clock's offset: " + content); | ||
| assertFalse(content.contains("2026-09-10T20:00:00Z"), content); | ||
| } | ||
|
|
||
| private static Msg message(MsgRole role, String text) { | ||
| return Msg.builder().role(role).content(TextBlock.builder().text(text).build()).build(); | ||
| } | ||
|
|
@@ -95,6 +156,15 @@ private static String text(Msg message) { | |
| private static final class RecordingModel implements Model { | ||
|
|
||
| private final List<List<Msg>> inputs = new ArrayList<>(); | ||
| private final String response; | ||
|
|
||
| RecordingModel() { | ||
| this("NO_REPLY"); | ||
| } | ||
|
|
||
| RecordingModel(String response) { | ||
| this.response = response; | ||
| } | ||
|
|
||
| @Override | ||
| public Flux<ChatResponse> stream( | ||
|
|
@@ -103,7 +173,7 @@ public Flux<ChatResponse> stream( | |
| return Flux.just( | ||
| ChatResponse.builder() | ||
| .id("flush-response") | ||
| .content(List.of(TextBlock.builder().text("NO_REPLY").build())) | ||
| .content(List.of(TextBlock.builder().text(response).build())) | ||
| .build()); | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Warning] The new
Clockoverload is not reachable from production wiring: the three construction sites still use the 2-/3-arg constructors —HarnessAgent.java:1078,CompactionMiddleware.java:107,MemoryFlushMiddleware.java:269. So in a real run the zone can only ever be the JVM default, andClock.systemDefaultZone()is effectively fixed at construction time.Fixing the header rendering is the right first step, but for #3088 to be configurable (rather than "whatever
-Duser.timezonehappens to be") it would help to plumb aZoneId/ClockthroughMemoryFlushMiddleware/HarnessAgent.Builderand pass it down here. Happy to see that as a follow-up issue rather than in this PR — just want to make sure the injectable clock does not stay test-only.