Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Warning] The new Clock overload 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, and Clock.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.timezone happens to be") it would help to plumb a ZoneId/Clock through MemoryFlushMiddleware/HarnessAgent.Builder and 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.

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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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:

  1. The rendered value is no longer Instant.parse-compatible. Instant.parse("2026-09-10T16:05:32.3096574+08:00") throws, so any consumer (Studio/export tooling/user scripts) that parses the ## Memory Flush — header as an Instant will break on the new output. Nothing in this repo parses the header today, so the risk is downstream only, but it is a user-visible storage-format change.
  2. ISO_OFFSET_DATE_TIME drops trailing zeros in the fractional part, so the timestamp width is variable (.3096574, .3, or none at all for a whole second). Anything doing fixed-width regex/lexicographic sorting on the header will be surprised.

If stable output matters more than formatter brevity, an explicit pattern such as DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ss.SSSXXX") gives fixed 3-digit millis and a preserved offset. Otherwise please add a line to the changelog / migration notes calling out the header format change.

}

/**
Expand All @@ -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();
Expand Down Expand Up @@ -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();
});
}
Expand Down Expand Up @@ -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";

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Warning] This is only half of the daily-ledger write path. MemorySaveTool.memorySave (agentscope-harness/.../tool/MemorySaveTool.java:76-79) appends into the same memory/YYYY-MM-DD.md file and still does:

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 Instant — they disagree across a local midnight), and after this PR a single ledger file will contain two different timestamp conventions side by side.

Consider extracting the zone-aware helper (e.g. a small MemoryTimestamps.now(clock) used by both) or filing a follow-up so the two writers stay consistent.

workspaceManager.appendUtf8WorkspaceRelative(rc, dailyRelPath, dailyEntry);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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 formatTimestamp(Clock) or the explicit 4-arg constructor. There is no regression test that pins the reported symptom — the 2-arg production constructor honouring the configured zone. That is the path a future refactor is most likely to silently break. Something like "default constructor + fixed default zone ⇒ header offset matches ZoneId.systemDefault()" would close it (the honorsSystemDefaultZone case above covers the helper, not the constructor wiring).

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"));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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: TimeZone.setDefault(...) leaks to every other test in the same fork, and Surefire/JUnit concurrent execution can run them while it is set. The finally restore only helps if nothing else runs concurrently.

Suggested options, cheapest first:

  • annotate the method with @ResourceLock("user.timezone") (and @Execution(SAME_THREAD)), or
  • replace the mutation with an assertion that does not need it, e.g. assertEquals(ZoneId.systemDefault(), Clock.systemDefaultZone().getZone()) plus a fixed-instant check through ZonedDateTime.now(Clock.system(ZoneId.of("Asia/Shanghai"))).

Also note the CI signal here is still missing — the only check reported on this PR is license/cla, so the test suite has not actually run yet (workflows for first contributions normally need maintainer approval). Could you confirm mvn -pl agentscope-harness test -Dtest=MemoryFlushManager*Test is green locally under the different TZ values you listed?

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();
}
Expand All @@ -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(
Expand All @@ -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());
}

Expand Down
1 change: 1 addition & 0 deletions docs/v2/en/docs/harness/memory.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 — <timestamp>`) 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

Expand Down
1 change: 1 addition & 0 deletions docs/v2/zh/docs/harness/memory.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 的三个触发点

Expand Down