From 35de402bdd0af120f92c52ce1597b3b0bfeb8559 Mon Sep 17 00:00:00 2001 From: foleydang Date: Mon, 24 Aug 2026 17:51:58 +0800 Subject: [PATCH 1/2] feat(agentstudio): multiagent roster, file download, vault_ids, thread_id - agents.create/update accept `multiagent` (coordinator roster); Agent responses hydrate it into MultiAgentConfig / MultiAgentRosterEntry. - files.download() returns FileContent, a bytes subclass with write_to_file(); sync and async. - sessions.create accepts vault_ids (create-only per the protocol; the update path does not take it). - Message declares the top-level thread_id the server sends on every thread_* event, so subagent thread ids no longer fall into `extra`. Verified against the pre-maas backend: roster create / update / clear, plus the four server-side rejections (two selfs, >20 entries, Anthropic's string shorthand, unknown entry type), and a live coordinator delegation run emitting 7 thread_* events with the subagent result returned to the coordinator. Co-Authored-By: Claude Fable 5 --- dashscope/agentstudio/resources/agents.py | 8 ++ dashscope/agentstudio/resources/files.py | 94 ++++++++++++- dashscope/agentstudio/resources/sessions.py | 4 + dashscope/agentstudio/types/__init__.py | 2 + dashscope/agentstudio/types/models.py | 41 ++++++ dashscope/agentstudio/types/params.py | 20 ++- tests/unit/test_agentstudio_files.py | 144 ++++++++++++++++++++ tests/unit/test_agentstudio_protocol.py | 92 +++++++++++++ 8 files changed, 402 insertions(+), 3 deletions(-) create mode 100644 tests/unit/test_agentstudio_files.py diff --git a/dashscope/agentstudio/resources/agents.py b/dashscope/agentstudio/resources/agents.py index 2ded152c..91bf1553 100644 --- a/dashscope/agentstudio/resources/agents.py +++ b/dashscope/agentstudio/resources/agents.py @@ -42,6 +42,7 @@ def create( tools: Optional[Sequence[Mapping[str, Any]]] = None, mcp_servers: Optional[Sequence[Mapping[str, Any]]] = None, skills: Optional[Sequence[Mapping[str, Any]]] = None, + multiagent: Optional[Mapping[str, Any]] = None, metadata: Optional[Mapping[str, Any]] = None, ) -> Agent: body = AgentCreateParams( @@ -52,6 +53,7 @@ def create( tools=tools, mcp_servers=mcp_servers, skills=skills, + multiagent=multiagent, metadata=metadata, ).to_dict() resp = self._client.transport.request("POST", _PATH_AGENTS, json=body) @@ -89,6 +91,7 @@ def update( tools: Optional[Sequence[Mapping[str, Any]]] = None, mcp_servers: Optional[Sequence[Mapping[str, Any]]] = None, skills: Optional[Sequence[Mapping[str, Any]]] = None, + multiagent: Optional[Mapping[str, Any]] = None, metadata: Optional[Mapping[str, Any]] = None, ) -> Agent: """Update the latest version of an agent. @@ -106,6 +109,7 @@ def update( tools=tools, mcp_servers=mcp_servers, skills=skills, + multiagent=multiagent, metadata=metadata, ).to_dict() resp = self._client.transport.request( @@ -191,6 +195,7 @@ async def create( tools: Optional[Sequence[Mapping[str, Any]]] = None, mcp_servers: Optional[Sequence[Mapping[str, Any]]] = None, skills: Optional[Sequence[Mapping[str, Any]]] = None, + multiagent: Optional[Mapping[str, Any]] = None, metadata: Optional[Mapping[str, Any]] = None, ) -> Agent: body = AgentCreateParams( @@ -201,6 +206,7 @@ async def create( tools=tools, mcp_servers=mcp_servers, skills=skills, + multiagent=multiagent, metadata=metadata, ).to_dict() resp = await self._client.transport.request( @@ -242,6 +248,7 @@ async def update( tools: Optional[Sequence[Mapping[str, Any]]] = None, mcp_servers: Optional[Sequence[Mapping[str, Any]]] = None, skills: Optional[Sequence[Mapping[str, Any]]] = None, + multiagent: Optional[Mapping[str, Any]] = None, metadata: Optional[Mapping[str, Any]] = None, ) -> Agent: """Update the latest version of an agent. @@ -259,6 +266,7 @@ async def update( tools=tools, mcp_servers=mcp_servers, skills=skills, + multiagent=multiagent, metadata=metadata, ).to_dict() resp = await self._client.transport.request( diff --git a/dashscope/agentstudio/resources/files.py b/dashscope/agentstudio/resources/files.py index 348c3b56..6c52eda6 100644 --- a/dashscope/agentstudio/resources/files.py +++ b/dashscope/agentstudio/resources/files.py @@ -36,6 +36,28 @@ ProgressCallback = Callable[[int, int], None] +class FileContent(bytes): + """Binary content of a downloaded file. + + A ``bytes`` subclass so it drops into any code expecting raw bytes, + with a :meth:`write_to_file` helper that mirrors the Anthropic SDK + (``client.beta.files.download(...).write_to_file(path)``). + """ + + def write_to_file( + self, + path: Union[str, "os.PathLike[str]"], + ) -> Path: + """Write the content to ``path`` and return it. + + Missing parent directories are created. + """ + dest = Path(os.fspath(path)) + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_bytes(self) + return dest + + def _open_file( file: Union[str, "os.PathLike[str]", BinaryIO, Tuple[str, BinaryIO]], ) -> Tuple[str, BinaryIO, bool]: @@ -110,7 +132,7 @@ def _file_size(fileobj: IO[bytes]) -> int: class Files: - """File upload / list / delete.""" + """File upload / download / list / delete.""" def __init__(self, client) -> None: self._client = client @@ -165,6 +187,38 @@ def retrieve(self, file_id: str) -> File: # Alias: get() delegates to retrieve() get = retrieve # type: ignore[assignment] + def _open_content(self, file_id: str, timeout: Optional[float]): + """GET the content endpoint and return the streaming response.""" + return self._client.transport.request( + "GET", + f"{_PATH_FILES}/{file_id}/content", + extra_headers={"Accept": "*/*"}, + stream=True, + timeout=timeout, + ) + + def download( + self, + file_id: str, + *, + timeout: Optional[float] = None, + ) -> FileContent: + """Return the file content as a :class:`FileContent`. + + Only files whose ``downloadable`` flag is true can be fetched; the + service answers 403 otherwise. Use :meth:`FileContent.write_to_file` + to persist the bytes to disk. + + content = client.files.download("file_xxx") + content.write_to_file("output.txt") + """ + resp = self._open_content(file_id, timeout) + try: + resp.read() + return FileContent(resp.content) + finally: + resp.close() + def list( self, *, @@ -207,7 +261,7 @@ def delete(self, file_id: str) -> DeleteResponse: class AsyncFiles: - """Async file upload / list / delete.""" + """Async file upload / download / list / delete.""" def __init__(self, client) -> None: self._client = client @@ -262,6 +316,42 @@ async def retrieve(self, file_id: str) -> File: # Alias: get() delegates to retrieve() get = retrieve # type: ignore[assignment] + async def _open_content(self, file_id: str, timeout: Optional[float]): + """GET the content endpoint and return the streaming response. + + The service either streams the bytes back or answers 302 with a + pre-signed storage URL, so redirects are followed. + """ + return await self._client.transport.request( + "GET", + f"{_PATH_FILES}/{file_id}/content", + extra_headers={"Accept": "*/*"}, + stream=True, + timeout=timeout, + ) + + async def download( + self, + file_id: str, + *, + timeout: Optional[float] = None, + ) -> FileContent: + """Return the file content as a :class:`FileContent`. + + Only files whose ``downloadable`` flag is true can be fetched; the + service answers 403 otherwise. Use :meth:`FileContent.write_to_file` + to persist the bytes to disk. + + content = await client.files.download("file_xxx") + content.write_to_file("output.txt") + """ + resp = await self._open_content(file_id, timeout) + try: + await resp.aread() + return FileContent(resp.content) + finally: + await resp.aclose() + async def list( self, *, diff --git a/dashscope/agentstudio/resources/sessions.py b/dashscope/agentstudio/resources/sessions.py index 3b299c68..22ba90ae 100644 --- a/dashscope/agentstudio/resources/sessions.py +++ b/dashscope/agentstudio/resources/sessions.py @@ -43,6 +43,7 @@ def create( environment_id: Optional[str] = None, title: Optional[str] = None, resources: Optional[Sequence[Mapping[str, Any]]] = None, + vault_ids: Optional[Sequence[str]] = None, metadata: Optional[Mapping[str, Any]] = None, ) -> Session: body = SessionCreateParams( @@ -50,6 +51,7 @@ def create( environment_id=environment_id, title=title, resources=resources, + vault_ids=vault_ids, metadata=metadata, ).to_dict() resp = self._client.transport.request( @@ -166,6 +168,7 @@ async def create( environment_id: Optional[str] = None, title: Optional[str] = None, resources: Optional[Sequence[Mapping[str, Any]]] = None, + vault_ids: Optional[Sequence[str]] = None, metadata: Optional[Mapping[str, Any]] = None, ) -> Session: body = SessionCreateParams( @@ -173,6 +176,7 @@ async def create( environment_id=environment_id, title=title, resources=resources, + vault_ids=vault_ids, metadata=metadata, ).to_dict() resp = await self._client.transport.request( diff --git a/dashscope/agentstudio/types/__init__.py b/dashscope/agentstudio/types/__init__.py index f1a546d4..497f2b70 100644 --- a/dashscope/agentstudio/types/__init__.py +++ b/dashscope/agentstudio/types/__init__.py @@ -28,6 +28,8 @@ parse_content_blocks, Agent, AgentVersion, + MultiAgentConfig, + MultiAgentRosterEntry, Credential, CredentialAuth, Deployment, diff --git a/dashscope/agentstudio/types/models.py b/dashscope/agentstudio/types/models.py index 171d5620..238cba1d 100644 --- a/dashscope/agentstudio/types/models.py +++ b/dashscope/agentstudio/types/models.py @@ -333,6 +333,39 @@ def parse_content_blocks( # =========================================================================== +class MultiAgentRosterEntry(BaseModel): + """One entry in a coordinator agent's multiagent roster. + + ``type`` is ``"agent"`` (reference another agent by ``id`` + optional + ``version``) or ``"self"`` (a copy of the coordinator; at most one). + """ + + _fields = ("type", "id", "version") + + +class MultiAgentConfig(BaseModel): + """Multi-agent coordinator config (the ``multiagent`` field). + + ``type`` is currently always ``"coordinator"``; ``agents`` is the + roster of 1-20 entries. An empty list clears the roster. + """ + + _fields = ("type", "agents") + + def __init__(self, **kwargs: Any) -> None: + agents = kwargs.get("agents") + if isinstance(agents, list): + kwargs["agents"] = [ + ( + MultiAgentRosterEntry(**dict(a)) + if isinstance(a, Mapping) + else a + ) + for a in agents + ] + super().__init__(**kwargs) + + class Agent(BaseModel): _fields = ( "id", @@ -345,6 +378,7 @@ class Agent(BaseModel): "tools", "mcp_servers", "skills", + "multiagent", "metadata", "workspace_id", "archived_at", @@ -353,6 +387,12 @@ class Agent(BaseModel): "request_id", ) + def __init__(self, **kwargs: Any) -> None: + multiagent = kwargs.get("multiagent") + if isinstance(multiagent, Mapping): + kwargs["multiagent"] = MultiAgentConfig(**dict(multiagent)) + super().__init__(**kwargs) + @property def system_prompt(self) -> Optional[str]: """Alias: server field is ``system``, kept for SDK user convenience.""" @@ -653,6 +693,7 @@ class Message(BaseModel): "created_at", "sequence_number", "session_thread_id", + "thread_id", "code", "message", ) diff --git a/dashscope/agentstudio/types/params.py b/dashscope/agentstudio/types/params.py index f8af6aa9..c09c1d79 100644 --- a/dashscope/agentstudio/types/params.py +++ b/dashscope/agentstudio/types/params.py @@ -52,6 +52,7 @@ class AgentCreateParams(BaseModel): "tools", "mcp_servers", "skills", + "multiagent", "metadata", ) @@ -65,6 +66,7 @@ def __init__( tools: Optional[Sequence[Mapping[str, Any]]] = None, mcp_servers: Optional[Sequence[Mapping[str, Any]]] = None, skills: Optional[Sequence[Mapping[str, Any]]] = None, + multiagent: Optional[Mapping[str, Any]] = None, metadata: Optional[Mapping[str, Any]] = None, ) -> None: super().__init__( @@ -79,6 +81,7 @@ def __init__( else None ), skills=([dict(s) for s in skills] if skills is not None else None), + multiagent=(dict(multiagent) if multiagent is not None else None), metadata=(dict(metadata) if metadata is not None else None), ) @@ -95,6 +98,7 @@ class AgentUpdateParams(BaseModel): "tools", "mcp_servers", "skills", + "multiagent", "metadata", ) @@ -109,6 +113,7 @@ def __init__( tools: Optional[Sequence[Mapping[str, Any]]] = None, mcp_servers: Optional[Sequence[Mapping[str, Any]]] = None, skills: Optional[Sequence[Mapping[str, Any]]] = None, + multiagent: Optional[Mapping[str, Any]] = None, metadata: Optional[Mapping[str, Any]] = None, ) -> None: super().__init__( @@ -124,6 +129,7 @@ def __init__( else None ), skills=([dict(s) for s in skills] if skills is not None else None), + multiagent=(dict(multiagent) if multiagent is not None else None), metadata=(dict(metadata) if metadata is not None else None), ) @@ -252,9 +258,19 @@ class SessionCreateParams(BaseModel): ``agent`` is the agent ID string (not the full agent object). ``resources`` is an optional list of file mounts; each item is a mapping with ``type``, ``file_id`` and ``mount_path`` keys. + ``vault_ids`` is create-only — attach vaults (``vlt_*``) whose + credentials are substituted at egress; the session update path does + not accept it. """ - _fields = ("agent", "environment_id", "title", "resources", "metadata") + _fields = ( + "agent", + "environment_id", + "title", + "resources", + "vault_ids", + "metadata", + ) def __init__( # pylint: disable=useless-parent-delegation self, @@ -263,6 +279,7 @@ def __init__( # pylint: disable=useless-parent-delegation environment_id: Optional[str] = None, title: Optional[str] = None, resources: Optional[Sequence[Mapping[str, Any]]] = None, + vault_ids: Optional[Sequence[str]] = None, metadata: Optional[Mapping[str, Any]] = None, ) -> None: super().__init__( @@ -272,6 +289,7 @@ def __init__( # pylint: disable=useless-parent-delegation resources=( [dict(r) for r in resources] if resources is not None else None ), + vault_ids=(list(vault_ids) if vault_ids is not None else None), metadata=(dict(metadata) if metadata is not None else None), ) diff --git a/tests/unit/test_agentstudio_files.py b/tests/unit/test_agentstudio_files.py new file mode 100644 index 00000000..a47c9e17 --- /dev/null +++ b/tests/unit/test_agentstudio_files.py @@ -0,0 +1,144 @@ +# -*- coding: utf-8 -*- +"""File download: content shape, write_to_file, progress. + +Download tests use a recording transport that hands back real +``httpx.Response`` objects, so the resource code exercises the same +``iter_bytes`` / ``close`` surface it sees against the service. The +service streams the bytes back directly (verified end-to-end), so the +fixtures return 200 with a body. +""" + +from __future__ import annotations + +import asyncio +from typing import Any, Dict, List, Optional + +import httpx +import pytest + +from dashscope.agentstudio import AsyncClient, Client +from dashscope.agentstudio.resources.files import FileContent + +_CONTENT = b"0123456789" * 32 + + +def _response( + content: bytes = _CONTENT, + *, + disposition: Optional[str] = None, +) -> httpx.Response: + headers = { + "Content-Type": "application/octet-stream", + "Content-Length": str(len(content)), + } + if disposition is not None: + headers["Content-Disposition"] = disposition + return httpx.Response(200, headers=headers, content=content) + + +class _Tx: + """Recording transport that returns a fresh response per call.""" + + def __init__(self, response_factory=_response): + self.calls: List[Dict[str, Any]] = [] + self._factory = response_factory + + def request(self, method, path, **kwargs): + self.calls.append({"method": method, "path": path, **kwargs}) + return self._factory() + + +class _AsyncTx: + """Async recording transport that returns a fresh response per call.""" + + def __init__(self, response_factory=_response): + self.calls: List[Dict[str, Any]] = [] + self._factory = response_factory + + async def request(self, method, path, **kwargs): + self.calls.append({"method": method, "path": path, **kwargs}) + return self._factory() + + +@pytest.fixture(name="client") +def _client_fixture(): + c = Client(api_key="test-key", base_url="http://test") + c.transport = _Tx() + return c + + +@pytest.fixture(name="async_client") +def _async_client_fixture(): + c = AsyncClient(api_key="test-key", base_url="http://test") + c.transport = _AsyncTx() + return c + + +# --------------------------------------------------------------------------- +# Request shape +# --------------------------------------------------------------------------- + + +def test_download_requests_content_endpoint(client): + client.files.download("file_1") + + call = client.transport.calls[0] + assert call["method"] == "GET" + assert call["path"] == "/files/file_1/content" + # Streamed so large files never buffer at the transport. + assert call["stream"] is True + + +# --------------------------------------------------------------------------- +# Content + write_to_file +# --------------------------------------------------------------------------- + + +def test_download_returns_file_content(client): + content = client.files.download("file_1") + + assert isinstance(content, FileContent) + # FileContent is a bytes subclass, so it drops into any code + # expecting raw bytes. + assert isinstance(content, bytes) + assert content == _CONTENT + + +def test_download_write_to_file(client, tmp_path): + content = client.files.download("file_1") + + written = content.write_to_file(tmp_path / "nested" / "out.bin") + + assert written == tmp_path / "nested" / "out.bin" + assert written.read_bytes() == _CONTENT + + +def test_download_write_to_file_creates_missing_parents(client, tmp_path): + content = client.files.download("file_1") + + written = content.write_to_file(tmp_path / "a" / "b" / "c.bin") + + assert written.read_bytes() == _CONTENT + + +# --------------------------------------------------------------------------- +# Async +# --------------------------------------------------------------------------- + + +def test_async_download(async_client): + async def _run(): + return await async_client.files.download("file_1") + + content = asyncio.run(_run()) + assert isinstance(content, FileContent) + assert content == _CONTENT + + +def test_async_download_write_to_file(async_client, tmp_path): + async def _run(): + content = await async_client.files.download("file_1") + return content.write_to_file(tmp_path / "out.bin") + + written = asyncio.run(_run()) + assert written.read_bytes() == _CONTENT diff --git a/tests/unit/test_agentstudio_protocol.py b/tests/unit/test_agentstudio_protocol.py index a028afc9..510e253e 100644 --- a/tests/unit/test_agentstudio_protocol.py +++ b/tests/unit/test_agentstudio_protocol.py @@ -344,3 +344,95 @@ def test_agents_update_does_not_auto_retrieve(): ) assert len(client.transport.calls) == 1 assert client.transport.calls[0]["method"] == "POST" + + +def test_thread_event_exposes_thread_id(): + """thread_* events carry a top-level thread_id; it must be a real field.""" + from dashscope.agentstudio.types import Message + + ev = Message( + object="message", + type="thread_status", + id="sevt_1", + thread_id="sthr_01M0SG9KZ4TMW0QPEKHHM43QRK", + content=[ + { + "type": "data", + "data": {"agent_name": "worker", "thread_status": "running"}, + }, + ], + ) + assert ev.thread_id == "sthr_01M0SG9KZ4TMW0QPEKHHM43QRK" + # not swallowed into extra + assert "thread_id" not in ev.extra + # thread_status value stays readable from the data block + assert ev.content[0].data["thread_status"] == "running" + + +def test_agent_create_body_includes_multiagent(): + """multiagent roster is forwarded verbatim on create.""" + from dashscope.agentstudio.types.params import AgentCreateParams + + body = AgentCreateParams( + name="coordinator", + model="qwen-max", + multiagent={ + "type": "coordinator", + "agents": [ + {"type": "self"}, + {"type": "agent", "id": "agent_worker", "version": 1}, + ], + }, + ).to_dict() + assert body["multiagent"]["type"] == "coordinator" + assert body["multiagent"]["agents"][0] == {"type": "self"} + assert body["multiagent"]["agents"][1]["id"] == "agent_worker" + assert body["multiagent"]["agents"][1]["version"] == 1 + + # omitted -> not emitted + plain = AgentCreateParams(name="plain", model="qwen-max").to_dict() + assert "multiagent" not in plain + + +def test_agent_update_body_includes_multiagent(): + """multiagent is forwarded on update alongside the required version.""" + from dashscope.agentstudio.types.params import AgentUpdateParams + + body = AgentUpdateParams( + name="coordinator", + version=2, + multiagent={"type": "coordinator", "agents": [{"type": "self"}]}, + ).to_dict() + assert body["version"] == 2 + assert body["multiagent"]["agents"] == [{"type": "self"}] + + # An empty list must not be dropped — it clears the roster server-side. + cleared = AgentUpdateParams( + name="coordinator", + version=3, + multiagent={"type": "coordinator", "agents": []}, + ).to_dict() + assert cleared["multiagent"] == {"type": "coordinator", "agents": []} + + +def test_agent_model_hydrates_multiagent(): + """Agent response hydrates the multiagent dict into typed models.""" + from dashscope.agentstudio.types import ( + Agent, + MultiAgentConfig, + MultiAgentRosterEntry, + ) + + agent = Agent( + id="agent_1", + version=1, + multiagent={ + "type": "coordinator", + "agents": [{"type": "self"}, {"type": "agent", "id": "agent_2"}], + }, + ) + assert isinstance(agent.multiagent, MultiAgentConfig) + assert agent.multiagent.type == "coordinator" + assert isinstance(agent.multiagent.agents[0], MultiAgentRosterEntry) + assert agent.multiagent.agents[0].type == "self" + assert agent.multiagent.agents[1].id == "agent_2" From f40c99d3c91e9772972e032b7dfa74c44225fa51 Mon Sep 17 00:00:00 2001 From: foleydang Date: Tue, 25 Aug 2026 10:53:18 +0800 Subject: [PATCH 2/2] fix(agentstudio): accept a MultiAgentConfig back into the params MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback: `dict(multiagent)` raises TypeError when the caller passes the MultiAgentConfig read off a response, which breaks the natural read-modify-write flow (update already requires the version, so callers retrieve first). Use the file's existing `_to_mapping` helper, already used for the deployment agent/schedule/resources params: it tries to_dict() first, then Mapping. Also drop the async `_open_content` note claiming the service may answer 302 with a pre-signed URL and that redirects are followed. Both halves are wrong: the protocol says §6.3 returns the byte stream directly and does not hand out pre-signed URLs in P1, and the httpx clients are built without follow_redirects (default False), so a 302 would silently yield zero bytes. The two docstrings now match, and match the code. Co-Authored-By: Claude Fable 5 --- dashscope/agentstudio/resources/files.py | 6 +----- dashscope/agentstudio/types/params.py | 8 ++++++-- tests/unit/test_agentstudio_protocol.py | 18 ++++++++++++++++++ 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/dashscope/agentstudio/resources/files.py b/dashscope/agentstudio/resources/files.py index 6c52eda6..e05c7be8 100644 --- a/dashscope/agentstudio/resources/files.py +++ b/dashscope/agentstudio/resources/files.py @@ -317,11 +317,7 @@ async def retrieve(self, file_id: str) -> File: get = retrieve # type: ignore[assignment] async def _open_content(self, file_id: str, timeout: Optional[float]): - """GET the content endpoint and return the streaming response. - - The service either streams the bytes back or answers 302 with a - pre-signed storage URL, so redirects are followed. - """ + """GET the content endpoint and return the streaming response.""" return await self._client.transport.request( "GET", f"{_PATH_FILES}/{file_id}/content", diff --git a/dashscope/agentstudio/types/params.py b/dashscope/agentstudio/types/params.py index c09c1d79..4db590fc 100644 --- a/dashscope/agentstudio/types/params.py +++ b/dashscope/agentstudio/types/params.py @@ -81,7 +81,9 @@ def __init__( else None ), skills=([dict(s) for s in skills] if skills is not None else None), - multiagent=(dict(multiagent) if multiagent is not None else None), + multiagent=( + _to_mapping(multiagent) if multiagent is not None else None + ), metadata=(dict(metadata) if metadata is not None else None), ) @@ -129,7 +131,9 @@ def __init__( else None ), skills=([dict(s) for s in skills] if skills is not None else None), - multiagent=(dict(multiagent) if multiagent is not None else None), + multiagent=( + _to_mapping(multiagent) if multiagent is not None else None + ), metadata=(dict(metadata) if metadata is not None else None), ) diff --git a/tests/unit/test_agentstudio_protocol.py b/tests/unit/test_agentstudio_protocol.py index 510e253e..883e6442 100644 --- a/tests/unit/test_agentstudio_protocol.py +++ b/tests/unit/test_agentstudio_protocol.py @@ -414,6 +414,24 @@ def test_agent_update_body_includes_multiagent(): ).to_dict() assert cleared["multiagent"] == {"type": "coordinator", "agents": []} + # A MultiAgentConfig read off a response must be accepted back as-is, + # so read-modify-write round trips work. + from dashscope.agentstudio.types import Agent + + hydrated = Agent( + id="agent_1", + multiagent={"type": "coordinator", "agents": [{"type": "self"}]}, + ).multiagent + round_tripped = AgentUpdateParams( + name="coordinator", + version=4, + multiagent=hydrated, + ).to_dict() + assert round_tripped["multiagent"] == { + "type": "coordinator", + "agents": [{"type": "self"}], + } + def test_agent_model_hydrates_multiagent(): """Agent response hydrates the multiagent dict into typed models."""