Skip to content

Commit 15efbdc

Browse files
foleydangclaude
andcommitted
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 <noreply@anthropic.com>
1 parent c07e68d commit 15efbdc

8 files changed

Lines changed: 402 additions & 3 deletions

File tree

dashscope/agentstudio/resources/agents.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ def create(
4242
tools: Optional[Sequence[Mapping[str, Any]]] = None,
4343
mcp_servers: Optional[Sequence[Mapping[str, Any]]] = None,
4444
skills: Optional[Sequence[Mapping[str, Any]]] = None,
45+
multiagent: Optional[Mapping[str, Any]] = None,
4546
metadata: Optional[Mapping[str, Any]] = None,
4647
) -> Agent:
4748
body = AgentCreateParams(
@@ -52,6 +53,7 @@ def create(
5253
tools=tools,
5354
mcp_servers=mcp_servers,
5455
skills=skills,
56+
multiagent=multiagent,
5557
metadata=metadata,
5658
).to_dict()
5759
resp = self._client.transport.request("POST", _PATH_AGENTS, json=body)
@@ -89,6 +91,7 @@ def update(
8991
tools: Optional[Sequence[Mapping[str, Any]]] = None,
9092
mcp_servers: Optional[Sequence[Mapping[str, Any]]] = None,
9193
skills: Optional[Sequence[Mapping[str, Any]]] = None,
94+
multiagent: Optional[Mapping[str, Any]] = None,
9295
metadata: Optional[Mapping[str, Any]] = None,
9396
) -> Agent:
9497
"""Update the latest version of an agent.
@@ -106,6 +109,7 @@ def update(
106109
tools=tools,
107110
mcp_servers=mcp_servers,
108111
skills=skills,
112+
multiagent=multiagent,
109113
metadata=metadata,
110114
).to_dict()
111115
resp = self._client.transport.request(
@@ -191,6 +195,7 @@ async def create(
191195
tools: Optional[Sequence[Mapping[str, Any]]] = None,
192196
mcp_servers: Optional[Sequence[Mapping[str, Any]]] = None,
193197
skills: Optional[Sequence[Mapping[str, Any]]] = None,
198+
multiagent: Optional[Mapping[str, Any]] = None,
194199
metadata: Optional[Mapping[str, Any]] = None,
195200
) -> Agent:
196201
body = AgentCreateParams(
@@ -201,6 +206,7 @@ async def create(
201206
tools=tools,
202207
mcp_servers=mcp_servers,
203208
skills=skills,
209+
multiagent=multiagent,
204210
metadata=metadata,
205211
).to_dict()
206212
resp = await self._client.transport.request(
@@ -242,6 +248,7 @@ async def update(
242248
tools: Optional[Sequence[Mapping[str, Any]]] = None,
243249
mcp_servers: Optional[Sequence[Mapping[str, Any]]] = None,
244250
skills: Optional[Sequence[Mapping[str, Any]]] = None,
251+
multiagent: Optional[Mapping[str, Any]] = None,
245252
metadata: Optional[Mapping[str, Any]] = None,
246253
) -> Agent:
247254
"""Update the latest version of an agent.
@@ -259,6 +266,7 @@ async def update(
259266
tools=tools,
260267
mcp_servers=mcp_servers,
261268
skills=skills,
269+
multiagent=multiagent,
262270
metadata=metadata,
263271
).to_dict()
264272
resp = await self._client.transport.request(

dashscope/agentstudio/resources/files.py

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,28 @@
3636
ProgressCallback = Callable[[int, int], None]
3737

3838

39+
class FileContent(bytes):
40+
"""Binary content of a downloaded file.
41+
42+
A ``bytes`` subclass so it drops into any code expecting raw bytes,
43+
with a :meth:`write_to_file` helper that mirrors the Anthropic SDK
44+
(``client.beta.files.download(...).write_to_file(path)``).
45+
"""
46+
47+
def write_to_file(
48+
self,
49+
path: Union[str, "os.PathLike[str]"],
50+
) -> Path:
51+
"""Write the content to ``path`` and return it.
52+
53+
Missing parent directories are created.
54+
"""
55+
dest = Path(os.fspath(path))
56+
dest.parent.mkdir(parents=True, exist_ok=True)
57+
dest.write_bytes(self)
58+
return dest
59+
60+
3961
def _open_file(
4062
file: Union[str, "os.PathLike[str]", BinaryIO, Tuple[str, BinaryIO]],
4163
) -> Tuple[str, BinaryIO, bool]:
@@ -110,7 +132,7 @@ def _file_size(fileobj: IO[bytes]) -> int:
110132

111133

112134
class Files:
113-
"""File upload / list / delete."""
135+
"""File upload / download / list / delete."""
114136

115137
def __init__(self, client) -> None:
116138
self._client = client
@@ -165,6 +187,38 @@ def retrieve(self, file_id: str) -> File:
165187
# Alias: get() delegates to retrieve()
166188
get = retrieve # type: ignore[assignment]
167189

190+
def _open_content(self, file_id: str, timeout: Optional[float]):
191+
"""GET the content endpoint and return the streaming response."""
192+
return self._client.transport.request(
193+
"GET",
194+
f"{_PATH_FILES}/{file_id}/content",
195+
extra_headers={"Accept": "*/*"},
196+
stream=True,
197+
timeout=timeout,
198+
)
199+
200+
def download(
201+
self,
202+
file_id: str,
203+
*,
204+
timeout: Optional[float] = None,
205+
) -> FileContent:
206+
"""Return the file content as a :class:`FileContent`.
207+
208+
Only files whose ``downloadable`` flag is true can be fetched; the
209+
service answers 403 otherwise. Use :meth:`FileContent.write_to_file`
210+
to persist the bytes to disk.
211+
212+
content = client.files.download("file_xxx")
213+
content.write_to_file("output.txt")
214+
"""
215+
resp = self._open_content(file_id, timeout)
216+
try:
217+
resp.read()
218+
return FileContent(resp.content)
219+
finally:
220+
resp.close()
221+
168222
def list(
169223
self,
170224
*,
@@ -207,7 +261,7 @@ def delete(self, file_id: str) -> DeleteResponse:
207261

208262

209263
class AsyncFiles:
210-
"""Async file upload / list / delete."""
264+
"""Async file upload / download / list / delete."""
211265

212266
def __init__(self, client) -> None:
213267
self._client = client
@@ -262,6 +316,42 @@ async def retrieve(self, file_id: str) -> File:
262316
# Alias: get() delegates to retrieve()
263317
get = retrieve # type: ignore[assignment]
264318

319+
async def _open_content(self, file_id: str, timeout: Optional[float]):
320+
"""GET the content endpoint and return the streaming response.
321+
322+
The service either streams the bytes back or answers 302 with a
323+
pre-signed storage URL, so redirects are followed.
324+
"""
325+
return await self._client.transport.request(
326+
"GET",
327+
f"{_PATH_FILES}/{file_id}/content",
328+
extra_headers={"Accept": "*/*"},
329+
stream=True,
330+
timeout=timeout,
331+
)
332+
333+
async def download(
334+
self,
335+
file_id: str,
336+
*,
337+
timeout: Optional[float] = None,
338+
) -> FileContent:
339+
"""Return the file content as a :class:`FileContent`.
340+
341+
Only files whose ``downloadable`` flag is true can be fetched; the
342+
service answers 403 otherwise. Use :meth:`FileContent.write_to_file`
343+
to persist the bytes to disk.
344+
345+
content = await client.files.download("file_xxx")
346+
content.write_to_file("output.txt")
347+
"""
348+
resp = await self._open_content(file_id, timeout)
349+
try:
350+
await resp.aread()
351+
return FileContent(resp.content)
352+
finally:
353+
await resp.aclose()
354+
265355
async def list(
266356
self,
267357
*,

dashscope/agentstudio/resources/sessions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,15 @@ def create(
4343
environment_id: Optional[str] = None,
4444
title: Optional[str] = None,
4545
resources: Optional[Sequence[Mapping[str, Any]]] = None,
46+
vault_ids: Optional[Sequence[str]] = None,
4647
metadata: Optional[Mapping[str, Any]] = None,
4748
) -> Session:
4849
body = SessionCreateParams(
4950
agent=agent,
5051
environment_id=environment_id,
5152
title=title,
5253
resources=resources,
54+
vault_ids=vault_ids,
5355
metadata=metadata,
5456
).to_dict()
5557
resp = self._client.transport.request(
@@ -166,13 +168,15 @@ async def create(
166168
environment_id: Optional[str] = None,
167169
title: Optional[str] = None,
168170
resources: Optional[Sequence[Mapping[str, Any]]] = None,
171+
vault_ids: Optional[Sequence[str]] = None,
169172
metadata: Optional[Mapping[str, Any]] = None,
170173
) -> Session:
171174
body = SessionCreateParams(
172175
agent=agent,
173176
environment_id=environment_id,
174177
title=title,
175178
resources=resources,
179+
vault_ids=vault_ids,
176180
metadata=metadata,
177181
).to_dict()
178182
resp = await self._client.transport.request(

dashscope/agentstudio/types/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828
parse_content_blocks,
2929
Agent,
3030
AgentVersion,
31+
MultiAgentConfig,
32+
MultiAgentRosterEntry,
3133
Credential,
3234
CredentialAuth,
3335
Deployment,

dashscope/agentstudio/types/models.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,39 @@ def parse_content_blocks(
333333
# ===========================================================================
334334

335335

336+
class MultiAgentRosterEntry(BaseModel):
337+
"""One entry in a coordinator agent's multiagent roster.
338+
339+
``type`` is ``"agent"`` (reference another agent by ``id`` + optional
340+
``version``) or ``"self"`` (a copy of the coordinator; at most one).
341+
"""
342+
343+
_fields = ("type", "id", "version")
344+
345+
346+
class MultiAgentConfig(BaseModel):
347+
"""Multi-agent coordinator config (the ``multiagent`` field).
348+
349+
``type`` is currently always ``"coordinator"``; ``agents`` is the
350+
roster of 1-20 entries. An empty list clears the roster.
351+
"""
352+
353+
_fields = ("type", "agents")
354+
355+
def __init__(self, **kwargs: Any) -> None:
356+
agents = kwargs.get("agents")
357+
if isinstance(agents, list):
358+
kwargs["agents"] = [
359+
(
360+
MultiAgentRosterEntry(**dict(a))
361+
if isinstance(a, Mapping)
362+
else a
363+
)
364+
for a in agents
365+
]
366+
super().__init__(**kwargs)
367+
368+
336369
class Agent(BaseModel):
337370
_fields = (
338371
"id",
@@ -345,6 +378,7 @@ class Agent(BaseModel):
345378
"tools",
346379
"mcp_servers",
347380
"skills",
381+
"multiagent",
348382
"metadata",
349383
"workspace_id",
350384
"archived_at",
@@ -353,6 +387,12 @@ class Agent(BaseModel):
353387
"request_id",
354388
)
355389

390+
def __init__(self, **kwargs: Any) -> None:
391+
multiagent = kwargs.get("multiagent")
392+
if isinstance(multiagent, Mapping):
393+
kwargs["multiagent"] = MultiAgentConfig(**dict(multiagent))
394+
super().__init__(**kwargs)
395+
356396
@property
357397
def system_prompt(self) -> Optional[str]:
358398
"""Alias: server field is ``system``, kept for SDK user convenience."""
@@ -653,6 +693,7 @@ class Message(BaseModel):
653693
"created_at",
654694
"sequence_number",
655695
"session_thread_id",
696+
"thread_id",
656697
"code",
657698
"message",
658699
)

dashscope/agentstudio/types/params.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ class AgentCreateParams(BaseModel):
5252
"tools",
5353
"mcp_servers",
5454
"skills",
55+
"multiagent",
5556
"metadata",
5657
)
5758

@@ -65,6 +66,7 @@ def __init__(
6566
tools: Optional[Sequence[Mapping[str, Any]]] = None,
6667
mcp_servers: Optional[Sequence[Mapping[str, Any]]] = None,
6768
skills: Optional[Sequence[Mapping[str, Any]]] = None,
69+
multiagent: Optional[Mapping[str, Any]] = None,
6870
metadata: Optional[Mapping[str, Any]] = None,
6971
) -> None:
7072
super().__init__(
@@ -79,6 +81,7 @@ def __init__(
7981
else None
8082
),
8183
skills=([dict(s) for s in skills] if skills is not None else None),
84+
multiagent=(dict(multiagent) if multiagent is not None else None),
8285
metadata=(dict(metadata) if metadata is not None else None),
8386
)
8487

@@ -95,6 +98,7 @@ class AgentUpdateParams(BaseModel):
9598
"tools",
9699
"mcp_servers",
97100
"skills",
101+
"multiagent",
98102
"metadata",
99103
)
100104

@@ -109,6 +113,7 @@ def __init__(
109113
tools: Optional[Sequence[Mapping[str, Any]]] = None,
110114
mcp_servers: Optional[Sequence[Mapping[str, Any]]] = None,
111115
skills: Optional[Sequence[Mapping[str, Any]]] = None,
116+
multiagent: Optional[Mapping[str, Any]] = None,
112117
metadata: Optional[Mapping[str, Any]] = None,
113118
) -> None:
114119
super().__init__(
@@ -124,6 +129,7 @@ def __init__(
124129
else None
125130
),
126131
skills=([dict(s) for s in skills] if skills is not None else None),
132+
multiagent=(dict(multiagent) if multiagent is not None else None),
127133
metadata=(dict(metadata) if metadata is not None else None),
128134
)
129135

@@ -252,9 +258,19 @@ class SessionCreateParams(BaseModel):
252258
``agent`` is the agent ID string (not the full agent object).
253259
``resources`` is an optional list of file mounts; each item is a
254260
mapping with ``type``, ``file_id`` and ``mount_path`` keys.
261+
``vault_ids`` is create-only — attach vaults (``vlt_*``) whose
262+
credentials are substituted at egress; the session update path does
263+
not accept it.
255264
"""
256265

257-
_fields = ("agent", "environment_id", "title", "resources", "metadata")
266+
_fields = (
267+
"agent",
268+
"environment_id",
269+
"title",
270+
"resources",
271+
"vault_ids",
272+
"metadata",
273+
)
258274

259275
def __init__( # pylint: disable=useless-parent-delegation
260276
self,
@@ -263,6 +279,7 @@ def __init__( # pylint: disable=useless-parent-delegation
263279
environment_id: Optional[str] = None,
264280
title: Optional[str] = None,
265281
resources: Optional[Sequence[Mapping[str, Any]]] = None,
282+
vault_ids: Optional[Sequence[str]] = None,
266283
metadata: Optional[Mapping[str, Any]] = None,
267284
) -> None:
268285
super().__init__(
@@ -272,6 +289,7 @@ def __init__( # pylint: disable=useless-parent-delegation
272289
resources=(
273290
[dict(r) for r in resources] if resources is not None else None
274291
),
292+
vault_ids=(list(vault_ids) if vault_ids is not None else None),
275293
metadata=(dict(metadata) if metadata is not None else None),
276294
)
277295

0 commit comments

Comments
 (0)