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

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

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -34,6 +37,9 @@
* <li>system - System instructions</li>
* <li>tool - Tool execution results</li>
* </ul>
*
* <p>Optional {@code metadata} carries AgentScope-specific extensions such as HITL
* confirmation results under {@code agentscope_confirm_results}.
*/
public class AguiMessage {

Expand All @@ -42,6 +48,25 @@ public class AguiMessage {
private final String content;
private final List<AguiToolCall> toolCalls;
private final String toolCallId;
private final Map<String, Object> 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<AguiToolCall> toolCalls,
String toolCallId) {
this(id, role, content, toolCalls, toolCallId, null);
}

/**
* Creates a new AguiMessage.
Expand All @@ -51,14 +76,16 @@ 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(
@JsonProperty("id") String id,
@JsonProperty("role") String role,
@JsonProperty("content") String content,
@JsonProperty("toolCalls") List<AguiToolCall> toolCalls,
@JsonProperty("toolCallId") String toolCallId) {
@JsonProperty("toolCallId") String toolCallId,
@JsonProperty("metadata") Map<String, Object> metadata) {
this.id = Objects.requireNonNull(id, "id cannot be null");
this.role = Objects.requireNonNull(role, "role cannot be null");
this.content = content;
Expand All @@ -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();
}

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

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

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

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

/**
Expand Down Expand Up @@ -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<String, Object> getMetadata() {
return metadata;
}

/**
* Check if this is a user message.
*
Expand Down Expand Up @@ -216,7 +257,9 @@ public String toString() {
+ toolCalls
+ ", toolCallId='"
+ toolCallId
+ "'}";
+ "', metadata="
+ metadata
+ "}";
}

@Override
Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -370,4 +370,55 @@ void testConvertToolCallWithInvalidJson() {
// Invalid JSON should result in empty map
assertTrue(tub.getInput().isEmpty());
}

@Test
void testConvertAguiMessageMetadataToMsg() {
Map<String, Object> confirm =
Map.of(
"confirmed",
true,
"toolCall",
Map.of("id", "call_xxx", "name", "dangerous_delete"));
Map<String, Object> 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<String, Object> 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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -451,22 +455,81 @@ public int syncFromLog() {
// Private helpers
// -------------------------------------------------------------------------

/**
* Blocks until previously scheduled remote mirrors have finished (or the timeout elapses).
*
* <p>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.
*
* <p>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));
mirrorToFilesystem(logFile, resolveRelativePath(logFile));
});
}

/**
* 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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();
}
}
Loading