diff --git a/dashscope/agentstudio/resources/agents.py b/dashscope/agentstudio/resources/agents.py index 2ded152..91bf155 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 348c3b5..e05c7be 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,38 @@ 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.""" + 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 3b299c6..22ba90a 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 f1a546d..497f2b7 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 171d562..238cba1 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 f8af6aa..4db590f 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,9 @@ def __init__( else None ), skills=([dict(s) for s in skills] if skills 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), ) @@ -95,6 +100,7 @@ class AgentUpdateParams(BaseModel): "tools", "mcp_servers", "skills", + "multiagent", "metadata", ) @@ -109,6 +115,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 +131,9 @@ def __init__( else None ), skills=([dict(s) for s in skills] if skills 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), ) @@ -252,9 +262,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 +283,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 +293,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 0000000..a47c9e1 --- /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 a028afc..883e644 100644 --- a/tests/unit/test_agentstudio_protocol.py +++ b/tests/unit/test_agentstudio_protocol.py @@ -344,3 +344,113 @@ 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": []} + + # 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.""" + 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"