diff --git a/src/main/java/com/alibaba/dashscope/agentstudio/AgentStudioConstants.java b/src/main/java/com/alibaba/dashscope/agentstudio/AgentStudioConstants.java index ec1008a8..2dfb3256 100644 --- a/src/main/java/com/alibaba/dashscope/agentstudio/AgentStudioConstants.java +++ b/src/main/java/com/alibaba/dashscope/agentstudio/AgentStudioConstants.java @@ -37,6 +37,13 @@ public static final class SSEEventType { public static final String SESSION_STATUS = "session_status"; public static final String ERROR = "error"; public static final String SESSION_UPDATED = "session_updated"; + public static final String THREAD_CREATED = "thread_created"; + public static final String THREAD_STATUS = "thread_status"; + public static final String THREAD_MESSAGE_SENT = "thread_message_sent"; + public static final String THREAD_MESSAGE_RECEIVED = "thread_message_received"; + public static final String THREAD_CONTEXT_COMPACTED = "thread_context_compacted"; + public static final String MODEL_REQUEST_START = "model_request_start"; + public static final String MODEL_REQUEST_END = "model_request_end"; public static final String OUTCOME_EVALUATION = "outcome_evaluation"; private SSEEventType() {} diff --git a/src/main/java/com/alibaba/dashscope/agentstudio/message/Message.java b/src/main/java/com/alibaba/dashscope/agentstudio/message/Message.java index 2db5eacd..a23b98b8 100644 --- a/src/main/java/com/alibaba/dashscope/agentstudio/message/Message.java +++ b/src/main/java/com/alibaba/dashscope/agentstudio/message/Message.java @@ -44,6 +44,10 @@ public class Message { @SerializedName("session_thread_id") private String sessionThreadId; + /** Thread this event belongs to; server sends it on all {@code thread_*} events. */ + @SerializedName("thread_id") + private String threadId; + @SerializedName("code") private String code; diff --git a/src/main/java/com/alibaba/dashscope/agentstudio/model/Agent.java b/src/main/java/com/alibaba/dashscope/agentstudio/model/Agent.java index fc05b9f2..7c4658cd 100644 --- a/src/main/java/com/alibaba/dashscope/agentstudio/model/Agent.java +++ b/src/main/java/com/alibaba/dashscope/agentstudio/model/Agent.java @@ -3,6 +3,7 @@ import com.alibaba.dashscope.agentstudio.model.Configs.McpServerConfig; import com.alibaba.dashscope.agentstudio.model.Configs.ModelConfig; +import com.alibaba.dashscope.agentstudio.model.Configs.MultiAgentConfig; import com.alibaba.dashscope.agentstudio.model.Configs.SkillConfig; import com.alibaba.dashscope.agentstudio.model.Configs.ToolConfig; import com.alibaba.dashscope.common.FlattenResultBase; @@ -42,6 +43,9 @@ public class Agent extends FlattenResultBase { @SerializedName("mcp_servers") private List mcpServers; + @SerializedName("multiagent") + private MultiAgentConfig multiagent; + @SerializedName("version") private Integer version; diff --git a/src/main/java/com/alibaba/dashscope/agentstudio/model/Configs.java b/src/main/java/com/alibaba/dashscope/agentstudio/model/Configs.java index 7c515c1c..4b25ad0d 100644 --- a/src/main/java/com/alibaba/dashscope/agentstudio/model/Configs.java +++ b/src/main/java/com/alibaba/dashscope/agentstudio/model/Configs.java @@ -70,4 +70,33 @@ public static class McpServerConfig { @SerializedName("url") private String url; } + + /** + * The {@code multiagent} field on an agent: {@code type} is currently always {@code + * "coordinator"}, {@code agents} is the roster of 1-20 entries (an empty list clears it). + */ + @Data + public static class MultiAgentConfig { + @SerializedName("type") + private String type; + + @SerializedName("agents") + private List agents; + + /** + * One roster entry: {@code type} is {@code "agent"} (reference another agent by {@code id} plus + * optional {@code version}) or {@code "self"} (a copy of the coordinator; at most one). + */ + @Data + public static class RosterEntry { + @SerializedName("type") + private String type; + + @SerializedName("id") + private String id; + + @SerializedName("version") + private Integer version; + } + } } diff --git a/src/main/java/com/alibaba/dashscope/agentstudio/param/AgentCreateParam.java b/src/main/java/com/alibaba/dashscope/agentstudio/param/AgentCreateParam.java index 867e6cf1..ee90036b 100644 --- a/src/main/java/com/alibaba/dashscope/agentstudio/param/AgentCreateParam.java +++ b/src/main/java/com/alibaba/dashscope/agentstudio/param/AgentCreateParam.java @@ -2,6 +2,7 @@ package com.alibaba.dashscope.agentstudio.param; import com.alibaba.dashscope.agentstudio.model.Configs.McpServerConfig; +import com.alibaba.dashscope.agentstudio.model.Configs.MultiAgentConfig; import com.alibaba.dashscope.agentstudio.model.Configs.SkillConfig; import com.alibaba.dashscope.agentstudio.model.Configs.ToolConfig; import com.alibaba.dashscope.base.FlattenHalfDuplexParamBase; @@ -26,6 +27,7 @@ public class AgentCreateParam extends FlattenHalfDuplexParamBase { @Default private List tools = null; @Default private List mcpServers = null; @Default private List skills = null; + @Default private MultiAgentConfig multiagent = null; @Default private Map metadata = null; @Override @@ -50,6 +52,9 @@ public JsonObject getHttpBody() { if (skills != null) { body.add("skills", JsonUtils.toJsonElement(skills)); } + if (multiagent != null) { + body.add("multiagent", JsonUtils.toJsonElement(multiagent)); + } if (metadata != null && !metadata.isEmpty()) { body.add("metadata", JsonUtils.toJsonElement(metadata)); } diff --git a/src/main/java/com/alibaba/dashscope/agentstudio/param/AgentUpdateParam.java b/src/main/java/com/alibaba/dashscope/agentstudio/param/AgentUpdateParam.java index fdc89af2..d9b16eb5 100644 --- a/src/main/java/com/alibaba/dashscope/agentstudio/param/AgentUpdateParam.java +++ b/src/main/java/com/alibaba/dashscope/agentstudio/param/AgentUpdateParam.java @@ -2,6 +2,7 @@ package com.alibaba.dashscope.agentstudio.param; import com.alibaba.dashscope.agentstudio.model.Configs.McpServerConfig; +import com.alibaba.dashscope.agentstudio.model.Configs.MultiAgentConfig; import com.alibaba.dashscope.agentstudio.model.Configs.SkillConfig; import com.alibaba.dashscope.agentstudio.model.Configs.ToolConfig; import com.alibaba.dashscope.base.FlattenHalfDuplexParamBase; @@ -29,6 +30,7 @@ public class AgentUpdateParam extends FlattenHalfDuplexParamBase { @Default private List tools = null; @Default private List mcpServers = null; @Default private List skills = null; + @Default private MultiAgentConfig multiagent = null; @Default private Map metadata = null; @Override @@ -60,6 +62,9 @@ public JsonObject getHttpBody() { if (skills != null) { body.add("skills", JsonUtils.toJsonElement(skills)); } + if (multiagent != null) { + body.add("multiagent", JsonUtils.toJsonElement(multiagent)); + } if (metadata != null && !metadata.isEmpty()) { body.add("metadata", JsonUtils.toJsonElement(metadata)); } diff --git a/src/main/java/com/alibaba/dashscope/agentstudio/param/SessionCreateParam.java b/src/main/java/com/alibaba/dashscope/agentstudio/param/SessionCreateParam.java index 3de391dc..ac01e01c 100644 --- a/src/main/java/com/alibaba/dashscope/agentstudio/param/SessionCreateParam.java +++ b/src/main/java/com/alibaba/dashscope/agentstudio/param/SessionCreateParam.java @@ -20,6 +20,7 @@ public class SessionCreateParam extends FlattenHalfDuplexParamBase { @Default private String environmentId = null; @Default private String title = null; @Default private List> resources = null; + @Default private List vaultIds = null; @Default private Map metadata = null; @Override @@ -35,6 +36,9 @@ public JsonObject getHttpBody() { if (resources != null && !resources.isEmpty()) { body.add("resources", JsonUtils.toJsonElement(resources)); } + if (vaultIds != null && !vaultIds.isEmpty()) { + body.add("vault_ids", JsonUtils.toJsonElement(vaultIds)); + } if (metadata != null && !metadata.isEmpty()) { body.add("metadata", JsonUtils.toJsonElement(metadata)); } diff --git a/src/main/java/com/alibaba/dashscope/agentstudio/resource/FileContent.java b/src/main/java/com/alibaba/dashscope/agentstudio/resource/FileContent.java new file mode 100644 index 00000000..69c05f58 --- /dev/null +++ b/src/main/java/com/alibaba/dashscope/agentstudio/resource/FileContent.java @@ -0,0 +1,48 @@ +// Copyright (c) Alibaba, Inc. and its affiliates. +package com.alibaba.dashscope.agentstudio.resource; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Objects; + +/** + * Binary content of a downloaded file. + * + *

Mirrors the Python SDK's {@code FileContent}: a thin wrapper over the raw bytes with a {@link + * #writeToFile(Path)} helper, so callers can do {@code + * client.files().download(fileId).writeToFile("output.txt")}. + */ +public final class FileContent { + private final byte[] data; + + public FileContent(byte[] data) { + this.data = data != null ? data : new byte[0]; + } + + /** The raw file bytes. */ + public byte[] getBytes() { + return data; + } + + /** Number of bytes. */ + public int length() { + return data.length; + } + + /** Write the content to {@code path} (creating parent directories) and return it. */ + public Path writeToFile(String path) throws IOException { + return writeToFile(Paths.get(path)); + } + + /** Write the content to {@code path} (creating parent directories) and return it. */ + public Path writeToFile(Path path) throws IOException { + Objects.requireNonNull(path, "path"); + if (path.getParent() != null) { + Files.createDirectories(path.getParent()); + } + Files.write(path, data); + return path; + } +} diff --git a/src/main/java/com/alibaba/dashscope/agentstudio/resource/Files.java b/src/main/java/com/alibaba/dashscope/agentstudio/resource/Files.java index 8397a0e6..18d09a9e 100644 --- a/src/main/java/com/alibaba/dashscope/agentstudio/resource/Files.java +++ b/src/main/java/com/alibaba/dashscope/agentstudio/resource/Files.java @@ -85,6 +85,61 @@ public AgentStudioDeletionStatus delete( return AsyncHelper.joinAndUnwrap(deleteAsync(fileId, apiKey, headers)); } + /** + * Download a file's binary content. + * + *

Only files whose {@code downloadable} flag is true can be fetched; the service answers 403 + * otherwise. Use {@link FileContent#writeToFile(String)} to persist the bytes to disk. + */ + public FileContent download(String fileId) { + return AsyncHelper.joinAndUnwrap(downloadAsync(fileId)); + } + + public CompletableFuture downloadAsync(String fileId) { + if (fileId == null || fileId.isEmpty()) { + return AsyncHelper.failedFuture(new InputRequiredException("fileId is required!")); + } + String key; + try { + key = ApiKey.getApiKey(this.apiKey); + } catch (Exception e) { + return AsyncHelper.failedFuture(e); + } + String resolvedBase = resolveUploadBaseUrl(); + String url = resolvedBase + StringUtils.format("/files/%s/content", fileId); + Request request = + new Request.Builder().url(url).header("Authorization", "Bearer " + key).get().build(); + + CompletableFuture future = new CompletableFuture<>(); + uploadClient + .newCall(request) + .enqueue( + new Callback() { + @Override + public void onFailure(Call call, IOException e) { + future.completeExceptionally(new ApiException(e)); + } + + @Override + public void onResponse(Call call, Response response) { + try (Response r = response) { + if (!r.isSuccessful()) { + String body = r.body() != null ? r.body().string() : ""; + future.completeExceptionally( + new ApiException( + Status.builder().statusCode(r.code()).message(body).build())); + return; + } + byte[] bytes = r.body() != null ? r.body().bytes() : new byte[0]; + future.complete(new FileContent(bytes)); + } catch (Exception e) { + future.completeExceptionally(new ApiException(e)); + } + } + }); + return future; + } + public CompletableFuture uploadAsync(String filePath, String mimeType) { if (filePath == null || filePath.isEmpty()) { return AsyncHelper.failedFuture(new InputRequiredException("filePath is required!")); diff --git a/src/test/java/com/alibaba/dashscope/TestAgentStudio.java b/src/test/java/com/alibaba/dashscope/TestAgentStudio.java index 69a820f1..543f9d13 100644 --- a/src/test/java/com/alibaba/dashscope/TestAgentStudio.java +++ b/src/test/java/com/alibaba/dashscope/TestAgentStudio.java @@ -1,6 +1,8 @@ package com.alibaba.dashscope; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -14,6 +16,7 @@ import com.alibaba.dashscope.agentstudio.model.AgentStudioDeletionStatus; import com.alibaba.dashscope.agentstudio.model.AgentStudioFile; import com.alibaba.dashscope.agentstudio.model.AgentVersion; +import com.alibaba.dashscope.agentstudio.model.Configs; import com.alibaba.dashscope.agentstudio.model.Credential; import com.alibaba.dashscope.agentstudio.model.Environment; import com.alibaba.dashscope.agentstudio.model.Session; @@ -56,8 +59,10 @@ import java.nio.file.Paths; import java.util.Arrays; import java.util.Collections; +import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; import okhttp3.mockwebserver.RecordedRequest; +import okio.Buffer; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -123,6 +128,53 @@ public void testAgentCreateHttpBody() { assertEquals("be helpful", body.get("system").getAsString()); } + @Test + public void testAgentCreateMultiagentHttpBody() { + Configs.MultiAgentConfig.RosterEntry self = new Configs.MultiAgentConfig.RosterEntry(); + self.setType("self"); + Configs.MultiAgentConfig.RosterEntry worker = new Configs.MultiAgentConfig.RosterEntry(); + worker.setType("agent"); + worker.setId("agent_worker"); + worker.setVersion(1); + Configs.MultiAgentConfig roster = new Configs.MultiAgentConfig(); + roster.setType("coordinator"); + roster.setAgents(Arrays.asList(self, worker)); + + JsonObject body = + AgentCreateParam.builder() + .name("coordinator") + .model("qwen-max") + .multiagent(roster) + .build() + .getHttpBody(); + JsonObject ma = body.getAsJsonObject("multiagent"); + assertEquals("coordinator", ma.get("type").getAsString()); + assertEquals(2, ma.getAsJsonArray("agents").size()); + assertEquals( + "self", ma.getAsJsonArray("agents").get(0).getAsJsonObject().get("type").getAsString()); + JsonObject w = ma.getAsJsonArray("agents").get(1).getAsJsonObject(); + assertEquals("agent", w.get("type").getAsString()); + assertEquals("agent_worker", w.get("id").getAsString()); + assertEquals(1, w.get("version").getAsInt()); + + // omitted -> not emitted + JsonObject plain = + AgentCreateParam.builder().name("plain").model("qwen-max").build().getHttpBody(); + assertFalse(plain.has("multiagent")); + } + + @Test + public void testThreadEventExposesThreadId() { + String json = + "{\"object\":\"message\",\"type\":\"thread_status\"," + + "\"id\":\"sevt_1\",\"thread_id\":\"sthr_abc\"," + + "\"metadata\":{\"to_agent_name\":\"worker\"}}"; + Message ev = JsonUtils.fromJson(json, Message.class); + assertEquals("sthr_abc", ev.getThreadId()); + assertEquals("thread_status", ev.getType()); + assertEquals("worker", ev.getMetadata().get("to_agent_name")); + } + @Test public void testAgentRetrieve() throws Exception { enqueue("agent_response"); @@ -250,6 +302,32 @@ public void testSessionCreate() throws Exception { assertEquals(42.5, session.getUsage().getSpeed(), 0.01); } + @Test + public void testSessionCreateWithVaultIds() throws Exception { + // vault_ids is forwarded in the request body; omitted -> not emitted. + enqueue("session_response"); + new Sessions(null, null, null) + .create( + SessionCreateParam.builder() + .agent("agent_xyz") + .vaultIds(Arrays.asList("vlt_x")) + .metadata(Collections.singletonMap("biz_ticket_id", "1234")) + .build()); + RecordedRequest req = mockServer.takeRequest(); + assertEquals("POST", req.getMethod()); + assertTrue(req.getPath().endsWith("/sessions")); + JsonObject body = JsonUtils.parse(req.getBody().readUtf8()); + assertTrue(body.has("vault_ids")); + assertEquals("vlt_x", body.getAsJsonArray("vault_ids").get(0).getAsString()); + assertEquals("1234", body.getAsJsonObject("metadata").get("biz_ticket_id").getAsString()); + + // Omitted vault_ids must not produce a null vault_ids in the body. + enqueue("session_response"); + new Sessions(null, null, null).create(SessionCreateParam.builder().agent("agent_xyz").build()); + JsonObject body2 = JsonUtils.parse(mockServer.takeRequest().getBody().readUtf8()); + assertFalse(body2.has("vault_ids")); + } + @Test public void testSessionRetrieve() throws Exception { enqueue("session_response"); @@ -830,6 +908,33 @@ public void testFileList() throws Exception { assertEquals("cursor_file", page.getNextPage()); } + @Test + public void testFileDownload() throws Exception { + String baseUrl = String.format("http://127.0.0.1:%s/api/v1/agentstudio", mockServer.getPort()); + com.alibaba.dashscope.agentstudio.resource.Files filesResource = + new com.alibaba.dashscope.agentstudio.resource.Files(baseUrl, null, null); + try { + byte[] expected = "file content".getBytes(StandardCharsets.UTF_8); + mockServer.enqueue( + new MockResponse().setResponseCode(200).setBody(new Buffer().write(expected))); + com.alibaba.dashscope.agentstudio.resource.FileContent content = + filesResource.download("file_001"); + RecordedRequest req = mockServer.takeRequest(); + assertEquals("GET", req.getMethod()); + assertTrue(req.getPath().endsWith("/files/file_001/content")); + assertEquals("Bearer test-key", req.getHeader("Authorization")); + assertEquals(expected.length, content.length()); + assertArrayEquals(expected, content.getBytes()); + + java.io.File out = java.io.File.createTempFile("download", ".bin"); + out.deleteOnExit(); + content.writeToFile(out.getAbsolutePath()); + assertArrayEquals(expected, java.nio.file.Files.readAllBytes(out.toPath())); + } finally { + filesResource.close(); + } + } + @Test public void testFileDelete() throws Exception { enqueue("delete_response");