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