diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/converter/AguiMessageConverter.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/converter/AguiMessageConverter.java
index 78ee08a227..f5351b81e2 100644
--- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/converter/AguiMessageConverter.java
+++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/converter/AguiMessageConverter.java
@@ -75,7 +75,11 @@ public Msg toMsg(AguiMessage aguiMessage) {
}
}
- return Msg.builder().id(aguiMessage.getId()).role(role).content(blocks).build();
+ Msg.Builder builder = Msg.builder().id(aguiMessage.getId()).role(role).content(blocks);
+ if (aguiMessage.getMetadata() != null && !aguiMessage.getMetadata().isEmpty()) {
+ builder.metadata(aguiMessage.getMetadata());
+ }
+ return builder.build();
}
/**
@@ -117,7 +121,10 @@ public AguiMessage toAguiMessage(Msg msg) {
role,
content.length() > 0 ? content.toString() : null,
toolCalls.isEmpty() ? null : toolCalls,
- toolCallId);
+ toolCallId,
+ msg.getMetadata() != null && !msg.getMetadata().isEmpty()
+ ? msg.getMetadata()
+ : null);
}
/**
diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/model/AguiMessage.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/model/AguiMessage.java
index 91ae8f98a0..d2bcdd716d 100644
--- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/model/AguiMessage.java
+++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/main/java/io/agentscope/core/agui/model/AguiMessage.java
@@ -16,9 +16,12 @@
package io.agentscope.core.agui.model;
import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.Collections;
+import java.util.LinkedHashMap;
import java.util.List;
+import java.util.Map;
import java.util.Objects;
/**
@@ -34,6 +37,9 @@
*
system - System instructions
* tool - Tool execution results
*
+ *
+ * Optional {@code metadata} carries AgentScope-specific extensions such as HITL
+ * confirmation results under {@code agentscope_confirm_results}.
*/
public class AguiMessage {
@@ -42,6 +48,25 @@ public class AguiMessage {
private final String content;
private final List toolCalls;
private final String toolCallId;
+ private final Map metadata;
+
+ /**
+ * Creates a new AguiMessage without metadata.
+ *
+ * @param id The unique message ID
+ * @param role The message role (user, assistant, system, tool)
+ * @param content The message content
+ * @param toolCalls Tool calls for assistant messages (optional)
+ * @param toolCallId Tool call ID for tool messages (optional)
+ */
+ public AguiMessage(
+ String id,
+ String role,
+ String content,
+ List toolCalls,
+ String toolCallId) {
+ this(id, role, content, toolCalls, toolCallId, null);
+ }
/**
* Creates a new AguiMessage.
@@ -51,6 +76,7 @@ public class AguiMessage {
* @param content The message content
* @param toolCalls Tool calls for assistant messages (optional)
* @param toolCallId Tool call ID for tool messages (optional)
+ * @param metadata Optional message metadata (e.g. HITL confirm results)
*/
@JsonCreator
public AguiMessage(
@@ -58,7 +84,8 @@ public AguiMessage(
@JsonProperty("role") String role,
@JsonProperty("content") String content,
@JsonProperty("toolCalls") List toolCalls,
- @JsonProperty("toolCallId") String toolCallId) {
+ @JsonProperty("toolCallId") String toolCallId,
+ @JsonProperty("metadata") Map metadata) {
this.id = Objects.requireNonNull(id, "id cannot be null");
this.role = Objects.requireNonNull(role, "role cannot be null");
this.content = content;
@@ -67,6 +94,10 @@ public AguiMessage(
? Collections.unmodifiableList(toolCalls)
: Collections.emptyList();
this.toolCallId = toolCallId;
+ this.metadata =
+ metadata != null && !metadata.isEmpty()
+ ? Collections.unmodifiableMap(new LinkedHashMap<>(metadata))
+ : Collections.emptyMap();
}
/**
@@ -77,7 +108,7 @@ public AguiMessage(
* @return A new user message
*/
public static AguiMessage userMessage(String id, String content) {
- return new AguiMessage(id, "user", content, null, null);
+ return new AguiMessage(id, "user", content, null, null, null);
}
/**
@@ -88,7 +119,7 @@ public static AguiMessage userMessage(String id, String content) {
* @return A new assistant message
*/
public static AguiMessage assistantMessage(String id, String content) {
- return new AguiMessage(id, "assistant", content, null, null);
+ return new AguiMessage(id, "assistant", content, null, null, null);
}
/**
@@ -99,7 +130,7 @@ public static AguiMessage assistantMessage(String id, String content) {
* @return A new system message
*/
public static AguiMessage systemMessage(String id, String content) {
- return new AguiMessage(id, "system", content, null, null);
+ return new AguiMessage(id, "system", content, null, null, null);
}
/**
@@ -111,7 +142,7 @@ public static AguiMessage systemMessage(String id, String content) {
* @return A new tool message
*/
public static AguiMessage toolMessage(String id, String toolCallId, String content) {
- return new AguiMessage(id, "tool", content, null, toolCallId);
+ return new AguiMessage(id, "tool", content, null, toolCallId, null);
}
/**
@@ -159,6 +190,16 @@ public String getToolCallId() {
return toolCallId;
}
+ /**
+ * Get optional message metadata.
+ *
+ * @return immutable metadata map, never null (empty when absent)
+ */
+ @JsonInclude(JsonInclude.Include.NON_EMPTY)
+ public Map getMetadata() {
+ return metadata;
+ }
+
/**
* Check if this is a user message.
*
@@ -216,7 +257,9 @@ public String toString() {
+ toolCalls
+ ", toolCallId='"
+ toolCallId
- + "'}";
+ + "', metadata="
+ + metadata
+ + "}";
}
@Override
@@ -228,11 +271,12 @@ public boolean equals(Object o) {
&& Objects.equals(role, that.role)
&& Objects.equals(content, that.content)
&& Objects.equals(toolCalls, that.toolCalls)
- && Objects.equals(toolCallId, that.toolCallId);
+ && Objects.equals(toolCallId, that.toolCallId)
+ && Objects.equals(metadata, that.metadata);
}
@Override
public int hashCode() {
- return Objects.hash(id, role, content, toolCalls, toolCallId);
+ return Objects.hash(id, role, content, toolCalls, toolCallId, metadata);
}
}
diff --git a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/converter/AguiMessageConverterTest.java b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/converter/AguiMessageConverterTest.java
index e0a8da8f1c..ff6054a248 100644
--- a/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/converter/AguiMessageConverterTest.java
+++ b/agentscope-extensions/agentscope-extensions-protocol/agentscope-extensions-agui/src/test/java/io/agentscope/core/agui/converter/AguiMessageConverterTest.java
@@ -370,4 +370,55 @@ void testConvertToolCallWithInvalidJson() {
// Invalid JSON should result in empty map
assertTrue(tub.getInput().isEmpty());
}
+
+ @Test
+ void testConvertAguiMessageMetadataToMsg() {
+ Map confirm =
+ Map.of(
+ "confirmed",
+ true,
+ "toolCall",
+ Map.of("id", "call_xxx", "name", "dangerous_delete"));
+ Map metadata = Map.of(Msg.METADATA_CONFIRM_RESULTS, List.of(confirm));
+
+ AguiMessage aguiMsg =
+ new AguiMessage("msg-hitl", "user", "confirm delete", null, null, metadata);
+
+ Msg msg = converter.toMsg(aguiMsg);
+
+ assertNotNull(msg.getMetadata());
+ assertTrue(msg.getMetadata().containsKey(Msg.METADATA_CONFIRM_RESULTS));
+ assertEquals(List.of(confirm), msg.getMetadata().get(Msg.METADATA_CONFIRM_RESULTS));
+ }
+
+ @Test
+ void testConvertMsgMetadataToAguiMessage() {
+ Map metadata =
+ Map.of(Msg.METADATA_CONFIRM_RESULTS, List.of(Map.of("confirmed", false)));
+
+ Msg msg =
+ Msg.builder()
+ .id("msg-hitl-out")
+ .role(MsgRole.USER)
+ .content(TextBlock.builder().text("deny").build())
+ .metadata(metadata)
+ .build();
+
+ AguiMessage aguiMsg = converter.toAguiMessage(msg);
+
+ assertFalse(aguiMsg.getMetadata().isEmpty());
+ assertEquals(
+ metadata.get(Msg.METADATA_CONFIRM_RESULTS),
+ aguiMsg.getMetadata().get(Msg.METADATA_CONFIRM_RESULTS));
+ }
+
+ @Test
+ void testAguiMessageWithoutMetadataKeepsEmptyMap() {
+ AguiMessage aguiMsg = AguiMessage.userMessage("msg-1", "hello");
+ assertNotNull(aguiMsg.getMetadata());
+ assertTrue(aguiMsg.getMetadata().isEmpty());
+
+ Msg msg = converter.toMsg(aguiMsg);
+ assertTrue(msg.getMetadata() == null || msg.getMetadata().isEmpty());
+ }
}
diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/HarnessAgent.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/HarnessAgent.java
index 78ffa6943c..b6484dd5d8 100644
--- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/HarnessAgent.java
+++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/HarnessAgent.java
@@ -59,6 +59,7 @@
import io.agentscope.harness.agent.memory.compaction.CompactionConfig;
import io.agentscope.harness.agent.memory.compaction.ConversationCompactor;
import io.agentscope.harness.agent.memory.compaction.ToolResultEvictionConfig;
+import io.agentscope.harness.agent.memory.session.SessionTree;
import io.agentscope.harness.agent.middleware.AgentTraceMiddleware;
import io.agentscope.harness.agent.middleware.AsyncToolMiddleware;
import io.agentscope.harness.agent.middleware.AtPathExpansionMiddleware;
@@ -378,11 +379,18 @@ public void close() {
shutdownTaskRepository();
} finally {
try {
- if (ownedWorkspaceIndex != null) {
- ownedWorkspaceIndex.close();
- }
+ // Drain SessionTree remote mirrors before closing the workspace index /
+ // releasing the workspace — otherwise async uploads can race with
+ // WorkspaceIndex.close() and @TempDir cleanup.
+ SessionTree.awaitPendingMirrors();
} finally {
- delegate.close();
+ try {
+ if (ownedWorkspaceIndex != null) {
+ ownedWorkspaceIndex.close();
+ }
+ } finally {
+ delegate.close();
+ }
}
}
}
diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/BakedContextFilesystem.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/BakedContextFilesystem.java
index 1e75dc9c2b..36a96f274e 100644
--- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/BakedContextFilesystem.java
+++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/BakedContextFilesystem.java
@@ -48,6 +48,11 @@ public BakedContextFilesystem(AbstractFilesystem delegate, RuntimeContext bakedR
this.bakedRc = bakedRc != null ? bakedRc : RuntimeContext.empty();
}
+ /** Returns the wrapped filesystem. */
+ public AbstractFilesystem getDelegate() {
+ return delegate;
+ }
+
@Override
public LsResult ls(RuntimeContext runtimeContext, String path) {
return delegate.ls(bakedRc, path);
diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/memory/session/SessionTree.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/memory/session/SessionTree.java
index 0413860b39..adeb6cc91f 100644
--- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/memory/session/SessionTree.java
+++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/memory/session/SessionTree.java
@@ -18,6 +18,9 @@
import io.agentscope.core.agent.RuntimeContext;
import io.agentscope.core.util.JsonUtils;
import io.agentscope.harness.agent.filesystem.AbstractFilesystem;
+import io.agentscope.harness.agent.filesystem.BakedContextFilesystem;
+import io.agentscope.harness.agent.filesystem.OverlayFilesystem;
+import io.agentscope.harness.agent.filesystem.local.LocalFilesystem;
import io.agentscope.harness.agent.filesystem.model.ReadResult;
import io.agentscope.harness.agent.workspace.WorkspaceIndex;
import java.io.BufferedReader;
@@ -36,6 +39,7 @@
import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -451,15 +455,40 @@ public int syncFromLog() {
// Private helpers
// -------------------------------------------------------------------------
+ /**
+ * Blocks until previously scheduled remote mirrors have finished (or the timeout elapses).
+ *
+ * Call this before tearing down shared resources that mirrors may touch (e.g. closing a
+ * {@link WorkspaceIndex} or deleting a {@code @TempDir} workspace). Safe to call when no
+ * mirrors are pending.
+ */
+ public static void awaitPendingMirrors() {
+ try {
+ MIRROR_EXECUTOR.submit(() -> {}).get(5, TimeUnit.SECONDS);
+ } catch (Exception e) {
+ log.warn("Timed out or failed waiting for session-tree mirrors: {}", e.getMessage());
+ }
+ }
+
/**
* Schedules an asynchronous, best-effort mirror of both session files to the remote
* filesystem. Uses a daemon single-thread executor to serialise uploads and avoid
* blocking the caller on remote I/O.
+ *
+ *
When the write target is already a {@link LocalFilesystem} (directly or as an overlay
+ * upper layer), the local files written by {@link #appendToFile}/{@link #overwriteFile} are
+ * authoritative — re-uploading asynchronously is redundant and races with test {@code
+ * @TempDir} cleanup / {@link WorkspaceIndex#close()}. In that case only the workspace index
+ * is refreshed synchronously.
*/
private void scheduleMirror() {
if (filesystem == null || workspaceRoot == null) {
return;
}
+ if (isLocalWriteTarget(filesystem)) {
+ refreshIndexFromLocal();
+ return;
+ }
MIRROR_EXECUTOR.execute(
() -> {
mirrorToFilesystem(contextFile, resolveRelativePath(contextFile));
@@ -467,6 +496,40 @@ private void scheduleMirror() {
});
}
+ /**
+ * Returns true when filesystem writes land on local disk that SessionTree already updated
+ * via direct {@link Files} IO — so an async re-upload would be a no-op race.
+ */
+ private static boolean isLocalWriteTarget(AbstractFilesystem fs) {
+ if (fs == null) {
+ return true;
+ }
+ if (fs instanceof LocalFilesystem) {
+ return true;
+ }
+ if (fs instanceof OverlayFilesystem overlay) {
+ return isLocalWriteTarget(overlay.getUpper());
+ }
+ if (fs instanceof BakedContextFilesystem baked) {
+ return isLocalWriteTarget(baked.getDelegate());
+ }
+ return false;
+ }
+
+ private void refreshIndexFromLocal() {
+ if (index == null) {
+ return;
+ }
+ String contextRel = resolveRelativePath(contextFile);
+ String logRel = resolveRelativePath(logFile);
+ if (contextRel != null && !contextRel.isBlank()) {
+ index.upsertFromLocalFile(contextRel, contextFile);
+ }
+ if (logRel != null && !logRel.isBlank()) {
+ index.upsertFromLocalFile(logRel, logFile);
+ }
+ }
+
/**
* Fetches the remote copy of {@code file} and parses it as JSONL session entries.
* Returns an empty list if no filesystem is configured or the remote read fails.
diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/memory/session/SessionTreeMirrorTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/memory/session/SessionTreeMirrorTest.java
index c0f01fa097..07605ae556 100644
--- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/memory/session/SessionTreeMirrorTest.java
+++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/memory/session/SessionTreeMirrorTest.java
@@ -26,7 +26,6 @@
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
-import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@@ -204,7 +203,7 @@ void flush_localWriteCompletesImmediately() throws Exception {
// Helper
// -----------------------------------------------------------------------
- private static void awaitMirror() throws InterruptedException {
- TimeUnit.MILLISECONDS.sleep(300);
+ private static void awaitMirror() {
+ SessionTree.awaitPendingMirrors();
}
}