From a76bed33db2bd805c3bcba0a762984f3db6412f4 Mon Sep 17 00:00:00 2001 From: xiaosuo <3476584763@qq.com> Date: Sat, 12 Sep 2026 10:52:06 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20#3088=E3=80=82memory?= =?UTF-8?q?=20flush=20=E5=86=99=E5=85=A5=20memory/YYYY-MM-DD.md=20?= =?UTF-8?q?=E7=9A=84=20section=20header=20=E6=97=B6=E9=97=B4=E6=88=B3?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E6=97=B6=E5=8C=BA=E6=84=9F=E7=9F=A5=EF=BC=8C?= =?UTF-8?q?=20-Duser.timezone=20/=20TZ=20=E7=8E=B0=E5=9C=A8=E4=BC=9A?= =?UTF-8?q?=E7=94=9F=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agent/memory/MemoryFlushManager.java | 48 ++++++-- .../agent/memory/MemoryFlushManagerTest.java | 110 +++++++++++++++++- docs/v2/en/docs/harness/memory.md | 1 + docs/v2/zh/docs/harness/memory.md | 1 + 4 files changed, 148 insertions(+), 12 deletions(-) diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/memory/MemoryFlushManager.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/memory/MemoryFlushManager.java index 679eacb3b1..c2e42f5a5d 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/memory/MemoryFlushManager.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/memory/MemoryFlushManager.java @@ -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); } /** @@ -119,8 +149,8 @@ public Mono flushMemories(RuntimeContext rc, List 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 flushMemories(RuntimeContext rc, List 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"; workspaceManager.appendUtf8WorkspaceRelative(rc, dailyRelPath, dailyEntry); } diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/memory/MemoryFlushManagerTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/memory/MemoryFlushManagerTest.java index 25f8c6648c..81e9463174 100644 --- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/memory/MemoryFlushManagerTest.java +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/memory/MemoryFlushManagerTest.java @@ -29,11 +29,22 @@ 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.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; 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.api.parallel.ResourceLock; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import reactor.core.publisher.Flux; class MemoryFlushManagerTest { @@ -76,6 +87,94 @@ void flushMemories_summaryOnlyInputDoesNotCallModel() { assertTrue(model.inputs.isEmpty()); } + // --------------------------------------------------------------------------------------------- + // A/B tests for #3088 — memory flush header timestamp must be zone-aware. + // + // Group A pins the OLD behaviour (Instant.toString() always renders UTC, ignoring + // -Duser.timezone) as a control. Group B exercises the NEW behaviour (ZonedDateTime renders + // the clock's UTC offset) across multiple zones. + // --------------------------------------------------------------------------------------------- + + /** A (old behaviour): {@code Instant.toString()} is always UTC and ignores the JVM time zone. */ + @Test + void a_oldBehavior_instantToStringAlwaysUtc() { + Instant instant = Instant.parse("2026-09-10T08:05:32.309657400Z"); + assertEquals("2026-09-10T08:05:32.309657400Z", instant.toString()); + } + + /** + * B (new behaviour): the rendered timestamp carries the clock zone's offset. {@code + * ISO_OFFSET_DATE_TIME} strips trailing zeros from the fractional second (e.g. {@code + * .309657400} -> {@code .3096574}); the offset and the parsed instant are unaffected. + */ + @ParameterizedTest(name = "{0}: {1} -> {2}") + @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 b_newBehavior_timestampRespectsZone(String zoneId, String instantStr, String expected) { + Instant instant = Instant.parse(instantStr); + Clock clock = Clock.fixed(instant, ZoneId.of(zoneId)); + + String actual = MemoryFlushManager.formatTimestamp(clock); + + assertEquals(expected, actual); + assertEquals(instant, OffsetDateTime.parse(actual).toInstant()); + } + + /** B (new behaviour): the production default honours the JVM default zone. */ + @Test + @ResourceLock("timezone") + void b_newBehavior_systemDefaultZoneIsUsed() { + TimeZone original = TimeZone.getDefault(); + try { + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Shanghai")); + String timestamp = MemoryFlushManager.formatTimestamp(Clock.systemDefaultZone()); + assertTrue(timestamp.endsWith("+08:00"), timestamp); + } finally { + TimeZone.setDefault(original); + } + } + + /** B (new behaviour): file name date and header share the same zoned instant. */ + @Test + void b_newBehavior_fileNameAndHeaderUseSameZone() { + Instant instant = Instant.parse("2026-09-10T20:00:00Z"); + Clock clock = Clock.fixed(instant, ZoneId.of("Asia/Shanghai")); + + ZonedDateTime now = ZonedDateTime.now(clock); + String headerTimestamp = now.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME); + String fileNameDate = now.toLocalDate().toString(); + + assertEquals("2026-09-11", fileNameDate); + assertTrue(headerTimestamp.startsWith("2026-09-11T04:00:00+08:00")); + } + + /** B (new behaviour) end-to-end: the flushed section and file name use the injected zone. */ + @Test + void b_newBehavior_flushWritesZoneAwareHeaderAndMatchingFileName() 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 be named after the zone's local date"); + String content = Files.readString(daily); + assertTrue( + content.contains("## Memory Flush — 2026-09-11T04:00:00+08:00"), + "header should use the zone'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 +194,15 @@ private static String text(Msg message) { private static final class RecordingModel implements Model { private final List> inputs = new ArrayList<>(); + private final String response; + + RecordingModel() { + this("NO_REPLY"); + } + + RecordingModel(String response) { + this.response = response; + } @Override public Flux stream( @@ -103,7 +211,7 @@ public Flux 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()); } diff --git a/docs/v2/en/docs/harness/memory.md b/docs/v2/en/docs/harness/memory.md index 647e95e4df..87a2f40d8c 100644 --- a/docs/v2/en/docs/harness/memory.md +++ b/docs/v2/en/docs/harness/memory.md @@ -48,6 +48,7 @@ Key points: - Layer 1 only appends, never dedupes; Layer 2 is periodically rewritten as a whole; **the two layers never overwrite each other**. - Layer 2 is the only one injected into the prompt; Layer 1 waits to be merged. - Raw messages dropped during compaction are also saved into a never-compacted log file (`*.log.jsonl`) for later audit or `session_search`. +- Each flushed section header (`## Memory Flush — `) is a **zone-aware** ISO-8601 offset date-time in the JVM default zone, e.g. `2026-09-10T16:05:32.3096574+08:00` (previously it always rendered `...Z`). The daily file name date and the header timestamp are derived from the same instant, and the value still round-trips through `OffsetDateTime.parse(...).toInstant()`, so existing readers are unaffected. ## When flush fires diff --git a/docs/v2/zh/docs/harness/memory.md b/docs/v2/zh/docs/harness/memory.md index bdd7ceec89..e66588fa7d 100644 --- a/docs/v2/zh/docs/harness/memory.md +++ b/docs/v2/zh/docs/harness/memory.md @@ -47,6 +47,7 @@ graph LR - 第一层只追加,不去重;第二层周期性整体重写;**两层互不覆盖**。 - 第二层永远是 LLM 注入提示的来源;第一层等待被合并。 - 对话被压缩前的原始消息会另存一份永不压缩的日志(`*.log.jsonl`),供事后审计或 `session_search`。 +- 每次 flush 写入的 section header(`## Memory Flush — <时间戳>`)使用 JVM 默认时区的**带偏移** ISO-8601 时间戳,例如 `2026-09-10T16:05:32.3096574+08:00`(旧版本固定输出 `...Z`)。文件名日期与 header 时间戳来自同一时刻,且字符串仍可通过 `OffsetDateTime.parse(...).toInstant()` 还原为原 `Instant`,读取逻辑无需改动。 ## Flush 的三个触发点 From af8e6f26895ad9d13c5b6e2d08df6840748a02b6 Mon Sep 17 00:00:00 2001 From: xiaosuo <3476584763@qq.com> Date: Sat, 12 Sep 2026 11:10:40 +0800 Subject: [PATCH 2/2] fix(harness): use zone-aware timestamp in memory flush header (#3088) --- .../agent/memory/MemoryFlushManagerTest.java | 58 ++++--------------- 1 file changed, 10 insertions(+), 48 deletions(-) diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/memory/MemoryFlushManagerTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/memory/MemoryFlushManagerTest.java index 81e9463174..3edd6df44c 100644 --- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/memory/MemoryFlushManagerTest.java +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/memory/MemoryFlushManagerTest.java @@ -35,14 +35,11 @@ import java.time.Instant; import java.time.OffsetDateTime; import java.time.ZoneId; -import java.time.ZonedDateTime; -import java.time.format.DateTimeFormatter; 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.api.parallel.ResourceLock; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; import reactor.core.publisher.Flux; @@ -87,47 +84,26 @@ void flushMemories_summaryOnlyInputDoesNotCallModel() { assertTrue(model.inputs.isEmpty()); } - // --------------------------------------------------------------------------------------------- - // A/B tests for #3088 — memory flush header timestamp must be zone-aware. - // - // Group A pins the OLD behaviour (Instant.toString() always renders UTC, ignoring - // -Duser.timezone) as a control. Group B exercises the NEW behaviour (ZonedDateTime renders - // the clock's UTC offset) across multiple zones. - // --------------------------------------------------------------------------------------------- - - /** A (old behaviour): {@code Instant.toString()} is always UTC and ignores the JVM time zone. */ - @Test - void a_oldBehavior_instantToStringAlwaysUtc() { - Instant instant = Instant.parse("2026-09-10T08:05:32.309657400Z"); - assertEquals("2026-09-10T08:05:32.309657400Z", instant.toString()); - } - - /** - * B (new behaviour): the rendered timestamp carries the clock zone's offset. {@code - * ISO_OFFSET_DATE_TIME} strips trailing zeros from the fractional second (e.g. {@code - * .309657400} -> {@code .3096574}); the offset and the parsed instant are unaffected. - */ - @ParameterizedTest(name = "{0}: {1} -> {2}") + // 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 b_newBehavior_timestampRespectsZone(String zoneId, String instantStr, String expected) { + void formatTimestamp_rendersClockZoneOffset(String zoneId, String instantStr, String expected) { Instant instant = Instant.parse(instantStr); - Clock clock = Clock.fixed(instant, ZoneId.of(zoneId)); - String actual = MemoryFlushManager.formatTimestamp(clock); + String actual = MemoryFlushManager.formatTimestamp(Clock.fixed(instant, ZoneId.of(zoneId))); assertEquals(expected, actual); assertEquals(instant, OffsetDateTime.parse(actual).toInstant()); } - /** B (new behaviour): the production default honours the JVM default zone. */ @Test - @ResourceLock("timezone") - void b_newBehavior_systemDefaultZoneIsUsed() { + void formatTimestamp_honorsSystemDefaultZone() { TimeZone original = TimeZone.getDefault(); try { TimeZone.setDefault(TimeZone.getTimeZone("Asia/Shanghai")); @@ -138,23 +114,8 @@ void b_newBehavior_systemDefaultZoneIsUsed() { } } - /** B (new behaviour): file name date and header share the same zoned instant. */ - @Test - void b_newBehavior_fileNameAndHeaderUseSameZone() { - Instant instant = Instant.parse("2026-09-10T20:00:00Z"); - Clock clock = Clock.fixed(instant, ZoneId.of("Asia/Shanghai")); - - ZonedDateTime now = ZonedDateTime.now(clock); - String headerTimestamp = now.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME); - String fileNameDate = now.toLocalDate().toString(); - - assertEquals("2026-09-11", fileNameDate); - assertTrue(headerTimestamp.startsWith("2026-09-11T04:00:00+08:00")); - } - - /** B (new behaviour) end-to-end: the flushed section and file name use the injected zone. */ @Test - void b_newBehavior_flushWritesZoneAwareHeaderAndMatchingFileName() throws Exception { + 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"); @@ -163,15 +124,16 @@ void b_newBehavior_flushWritesZoneAwareHeaderAndMatchingFileName() throws Except 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 be named after the zone's local date"); + 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 use the zone's offset: " + content); + "header should carry the clock's offset: " + content); assertFalse(content.contains("2026-09-10T20:00:00Z"), content); }