From b1c4be8f707255fe9b4b9f4802e67c08acebb463 Mon Sep 17 00:00:00 2001 From: foleydang Date: Mon, 22 Jun 2026 21:32:13 +0800 Subject: [PATCH 1/8] feat: add AgentStudio sub-SDK for managed agents Add a complete AgentStudio SDK package under dashscope/agentstudio/ that wraps the AgentStudio managed-agent HTTP API with sync and async clients, SSE streaming, automatic pagination, @tool decorator with SessionToolRunner, and comprehensive error handling. Wire-format types (content blocks, stop_reason, session status) are aligned with the AGENTSTUDIO API protocol specification. Includes unit tests for pagination, protocol types, tool runner, skills, and tool decorator, plus a sample script. Co-Authored-By: Claude Opus 4.6 --- dashscope/agentstudio/__init__.py | 138 ++++ dashscope/agentstudio/client.py | 172 ++++ dashscope/agentstudio/constants.py | 79 ++ dashscope/agentstudio/exceptions.py | 226 ++++++ dashscope/agentstudio/pagination.py | 152 ++++ dashscope/agentstudio/resources/__init__.py | 52 ++ dashscope/agentstudio/resources/_helpers.py | 67 ++ dashscope/agentstudio/resources/agents.py | 244 ++++++ .../agentstudio/resources/environments.py | 150 ++++ dashscope/agentstudio/resources/files.py | 238 ++++++ .../agentstudio/resources/session_events.py | 331 ++++++++ dashscope/agentstudio/resources/sessions.py | 178 +++++ dashscope/agentstudio/resources/skills.py | 277 +++++++ dashscope/agentstudio/streaming.py | 130 ++++ dashscope/agentstudio/tools.py | 736 ++++++++++++++++++ dashscope/agentstudio/transport.py | 617 +++++++++++++++ dashscope/agentstudio/types/__init__.py | 39 + dashscope/agentstudio/types/models.py | 694 +++++++++++++++++ dashscope/agentstudio/types/params.py | 457 +++++++++++ requirements.txt | 2 + samples/test_agentstudio.py | 66 ++ tests/unit/test_agentstudio_pagination.py | 94 +++ tests/unit/test_agentstudio_protocol.py | 261 +++++++ tests/unit/test_agentstudio_runner.py | 260 +++++++ tests/unit/test_agentstudio_skills.py | 119 +++ tests/unit/test_agentstudio_tools.py | 192 +++++ 26 files changed, 5971 insertions(+) create mode 100644 dashscope/agentstudio/__init__.py create mode 100644 dashscope/agentstudio/client.py create mode 100644 dashscope/agentstudio/constants.py create mode 100644 dashscope/agentstudio/exceptions.py create mode 100644 dashscope/agentstudio/pagination.py create mode 100644 dashscope/agentstudio/resources/__init__.py create mode 100644 dashscope/agentstudio/resources/_helpers.py create mode 100644 dashscope/agentstudio/resources/agents.py create mode 100644 dashscope/agentstudio/resources/environments.py create mode 100644 dashscope/agentstudio/resources/files.py create mode 100644 dashscope/agentstudio/resources/session_events.py create mode 100644 dashscope/agentstudio/resources/sessions.py create mode 100644 dashscope/agentstudio/resources/skills.py create mode 100644 dashscope/agentstudio/streaming.py create mode 100644 dashscope/agentstudio/tools.py create mode 100644 dashscope/agentstudio/transport.py create mode 100644 dashscope/agentstudio/types/__init__.py create mode 100644 dashscope/agentstudio/types/models.py create mode 100644 dashscope/agentstudio/types/params.py create mode 100644 samples/test_agentstudio.py create mode 100644 tests/unit/test_agentstudio_pagination.py create mode 100644 tests/unit/test_agentstudio_protocol.py create mode 100644 tests/unit/test_agentstudio_runner.py create mode 100644 tests/unit/test_agentstudio_skills.py create mode 100644 tests/unit/test_agentstudio_tools.py diff --git a/dashscope/agentstudio/__init__.py b/dashscope/agentstudio/__init__.py new file mode 100644 index 0000000..dee258a --- /dev/null +++ b/dashscope/agentstudio/__init__.py @@ -0,0 +1,138 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""AgentStudio sub-SDK for the dashscope Python client. + +The ``agentstudio`` package wraps the AgentStudio managed-agent HTTP +API. All resource classes are accessed through a client instance:: + + from dashscope.agentstudio import Client + from dashscope.agentstudio import tool + from dashscope.agentstudio.types import user_message, user_custom_tool_result + +Example +------- +>>> from dashscope.agentstudio import Client +>>> from dashscope.agentstudio.types import user_message +>>> client = Client(api_key="sk-xxx") +>>> agent = client.agents.create(name="demo", model="qwen-plus") +>>> session = client.sessions.create(agent=agent.id) +>>> client.sessions.events.send(session.id, [user_message("Hello!")]) +>>> with client.sessions.events.stream(session.id) as stream: +... for event in stream: +... print(event.type, event.to_dict()) +... if event.type == "session_status": +... break +""" + +# yapf: disable + +from .client import Client, AsyncClient +from .transport import APIResponse, RequestOptions +from . import types +from .exceptions import ( + AgentStudioError, + APIConnectionError, + APIStatusError, + APITimeoutError, + AuthenticationError, + ConflictError, + InternalServerError, + InvalidRequestError, + NotFoundError, + OverloadedError, + PermissionDeniedError, + RateLimitError, + StreamClosedError, + StreamError, +) +from .pagination import ( + AsyncCursorPage, + AsyncIdCursorPage, + CursorPage, + IdCursorPage, +) +from .tools import ( + AsyncSessionToolRunner, + DispatchedTool, + SessionToolRunner, + ToolSpec, + collect_specs, + tool, + tool_from_spec, +) +from .types import ( + Message, + ServerEvent, + user_custom_tool_result, + user_define_outcome, + user_interrupt, + user_message, + user_tool_confirmation, + user_tool_result, +) +from .types.params import ( + AgentCreateParams, + AgentUpdateParams, + SessionCreateParams, + SessionUpdateParams, + EnvironmentCreateParams, + EnvironmentUpdateParams, + SessionEventSendParams, +) + +__all__ = [ + # client + "Client", + "AsyncClient", + # base types + "RequestOptions", + "APIResponse", + # exceptions + "AgentStudioError", + "APIConnectionError", + "APIStatusError", + "APITimeoutError", + "AuthenticationError", + "ConflictError", + "InternalServerError", + "InvalidRequestError", + "NotFoundError", + "OverloadedError", + "PermissionDeniedError", + "RateLimitError", + "StreamError", + "StreamClosedError", + # pagination + "CursorPage", + "AsyncCursorPage", + "IdCursorPage", + "AsyncIdCursorPage", + # tools + "tool", + "tool_from_spec", + "ToolSpec", + "collect_specs", + "SessionToolRunner", + "AsyncSessionToolRunner", + "DispatchedTool", + # unified message type + "Message", + "ServerEvent", + # client event helpers (re-exported for convenience) + "user_message", + "user_interrupt", + "user_tool_confirmation", + "user_custom_tool_result", + "user_tool_result", + "user_define_outcome", + # typing module + "types", + # params (commonly used request-body types) + "AgentCreateParams", + "AgentUpdateParams", + "SessionCreateParams", + "SessionUpdateParams", + "EnvironmentCreateParams", + "EnvironmentUpdateParams", + "SessionEventSendParams", +] diff --git a/dashscope/agentstudio/client.py b/dashscope/agentstudio/client.py new file mode 100644 index 0000000..d479f9b --- /dev/null +++ b/dashscope/agentstudio/client.py @@ -0,0 +1,172 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""AgentStudio client classes.""" + +from __future__ import annotations + +import os +from typing import Any, Optional, Tuple, Union + +import httpx + +from dashscope.agentstudio.resources.agents import Agents, AsyncAgents +from dashscope.agentstudio.resources.environments import Environments, AsyncEnvironments +from dashscope.agentstudio.resources.files import Files, AsyncFiles +from dashscope.agentstudio.resources.sessions import Sessions, AsyncSessions +from dashscope.agentstudio.resources.skills import Skills, AsyncSkills +from dashscope.agentstudio.constants import ( + AGENTSTUDIO_BASE_URL_TEMPLATE, + AGENTSTUDIO_DEFAULT_WORKSPACE, + AGENTSTUDIO_DEFAULT_TIMEOUT, + AGENTSTUDIO_MAX_RETRIES, +) +from dashscope.agentstudio.transport import SyncTransport, AsyncTransport +from dashscope.common.api_key import get_default_api_key + + +def _resolve_base_url(explicit_url: Optional[str], workspace: Optional[str]) -> str: + """Resolve the base URL. + + Priority: + 1. explicit base_url parameter (user provides full URL) + 2. DASHSCOPE_AGENTSTUDIO_URL / AGENTSTUDIO_URL env var + 3. Build from workspace: https://{workspace}.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio + """ + if explicit_url: + return explicit_url + env_url = os.environ.get("DASHSCOPE_AGENTSTUDIO_URL") or os.environ.get("AGENTSTUDIO_URL") + if env_url: + return env_url + # Build from workspace + ws = workspace or os.environ.get("DASHSCOPE_WORKSPACE") or AGENTSTUDIO_DEFAULT_WORKSPACE + return AGENTSTUDIO_BASE_URL_TEMPLATE.format(workspace=ws) + + +def _user_agent(base_url: str) -> str: + try: + from dashscope.version import __version__ + except Exception: + __version__ = "0.0.0" + return f"dashscope-agentstudio-python/{__version__} (+{base_url})" + + +class Client: + """Synchronous AgentStudio client. + + Usage:: + + from dashscope.agentstudio import Client + + client = Client(api_key="sk-xxx", workspace="my-workspace") + # URL: https://my-workspace.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio + agent = client.agents.create(name="demo", model="qwen-max") + session = client.sessions.create(agent=agent.id) + with client.sessions.events.stream(session.id) as stream: + for event in stream: + print(event.type) + """ + + def __init__( + self, + *, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + base_url: Optional[str] = None, + uid: Optional[str] = None, + timeout: Optional[Union[float, httpx.Timeout, Tuple[float, float]]] = None, + max_retries: int = AGENTSTUDIO_MAX_RETRIES, + http_client: Optional[httpx.Client] = None, + ) -> None: + resolved_workspace = workspace or os.environ.get("DASHSCOPE_WORKSPACE") or AGENTSTUDIO_DEFAULT_WORKSPACE + resolved_base = _resolve_base_url(base_url, resolved_workspace) + self._transport = SyncTransport( + base_url=resolved_base, + api_key=api_key or get_default_api_key(), + workspace=resolved_workspace, + uid=uid or os.environ.get("DASHSCOPE_UID"), + user_agent=_user_agent(resolved_base), + timeout=timeout or AGENTSTUDIO_DEFAULT_TIMEOUT, + max_retries=max_retries, + http_client=http_client, + ) + self.agents = Agents(self) + self.sessions = Sessions(self) + self.environments = Environments(self) + self.files = Files(self) + self.skills = Skills(self) + + def close(self) -> None: + self._transport.close() + + def __enter__(self) -> "Client": + return self + + def __exit__(self, *exc: Any) -> None: + self.close() + + def __del__(self) -> None: + try: + self.close() + except Exception: + pass + + +class AsyncClient: + """Asynchronous AgentStudio client. + + Usage:: + + from dashscope.agentstudio import AsyncClient + + client = AsyncClient(api_key="sk-xxx", workspace="my-workspace") + # URL: https://my-workspace.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio + agent = await client.agents.create(name="demo", model="qwen-max") + session = await client.sessions.create(agent=agent.id) + async with client.sessions.events.stream(session.id) as stream: + async for event in stream: + print(event.type) + """ + + def __init__( + self, + *, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + base_url: Optional[str] = None, + uid: Optional[str] = None, + timeout: Optional[Union[float, httpx.Timeout, Tuple[float, float]]] = None, + max_retries: int = AGENTSTUDIO_MAX_RETRIES, + http_client: Optional[httpx.AsyncClient] = None, + ) -> None: + resolved_workspace = workspace or os.environ.get("DASHSCOPE_WORKSPACE") or AGENTSTUDIO_DEFAULT_WORKSPACE + resolved_base = _resolve_base_url(base_url, resolved_workspace) + self._transport = AsyncTransport( + base_url=resolved_base, + api_key=api_key or get_default_api_key(), + workspace=resolved_workspace, + uid=uid or os.environ.get("DASHSCOPE_UID"), + user_agent=_user_agent(resolved_base), + timeout=timeout or AGENTSTUDIO_DEFAULT_TIMEOUT, + max_retries=max_retries, + http_client=http_client, + ) + self.agents = AsyncAgents(self) + self.sessions = AsyncSessions(self) + self.environments = AsyncEnvironments(self) + self.files = AsyncFiles(self) + self.skills = AsyncSkills(self) + + async def aclose(self) -> None: + await self._transport.aclose() + + async def __aenter__(self) -> "AsyncClient": + return self + + async def __aexit__(self, *exc: Any) -> None: + await self.aclose() + + def __del__(self) -> None: + try: + self._transport.close() + except Exception: + pass diff --git a/dashscope/agentstudio/constants.py b/dashscope/agentstudio/constants.py new file mode 100644 index 0000000..97518a8 --- /dev/null +++ b/dashscope/agentstudio/constants.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""AgentStudio wire-protocol and configuration constants.""" + +import httpx +from enum import StrEnum + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +AGENTSTUDIO_BASE_URL_TEMPLATE = "https://{workspace}.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio" +AGENTSTUDIO_DEFAULT_WORKSPACE = "trial" +AGENTSTUDIO_DEFAULT_TIMEOUT = httpx.Timeout(600.0, connect=10.0) +AGENTSTUDIO_MAX_RETRIES = 2 + + +# --------------------------------------------------------------------------- +# Wire-protocol enums +# --------------------------------------------------------------------------- + +class SSEEventType(StrEnum): + """Server-sent event types (the value of ``event.type`` in SSE payloads). + + Client-sendable: MESSAGE, INTERRUPT, TOOL_CONFIRMATION, + FUNCTION_CALL_OUTPUT, TOOL_CALL_OUTPUT, DEFINE_OUTCOME. + Server-emitted: all types. + """ + # Client-sendable + MESSAGE = "message" + INTERRUPT = "interrupt" + TOOL_CONFIRMATION = "tool_confirmation" + FUNCTION_CALL_OUTPUT = "function_call_output" + TOOL_CALL_OUTPUT = "tool_call_output" + DEFINE_OUTCOME = "define_outcome" + + # Server-emitted + FUNCTION_CALL = "function_call" + TOOL_CALL = "tool_call" + REASONING = "reasoning" + MCP_CALL = "mcp_call" + MCP_CALL_OUTPUT = "mcp_call_output" + THREAD_MESSAGE_SENT = "thread_message_sent" + THREAD_MESSAGE_RECEIVED = "thread_message_received" + THREAD_CONTEXT_COMPACTED = "thread_context_compacted" + SESSION_STATUS = "session_status" + ERROR = "error" + SESSION_UPDATED = "session_updated" + THREAD_CREATED = "thread_created" + THREAD_STATUS = "thread_status" + MODEL_REQUEST_START = "model_request_start" + MODEL_REQUEST_END = "model_request_end" + OUTCOME_EVALUATION = "outcome_evaluation" + + +class MessageRole(StrEnum): + """Roles used in message/event payloads.""" + USER = "user" + ASSISTANT = "assistant" + TOOL = "tool" + + +class BlockType(StrEnum): + """Content block types (the value of ``block.type`` in message content arrays).""" + TEXT = "text" + IMAGE = "image" + AUDIO = "audio" + DATA = "data" + FILE = "file" + REFUSAL = "refusal" + ERROR = "error" + + +class SessionStatus(StrEnum): + """Session run-status values (the value of ``data.session_status`` in session_status events).""" + IDLE = "idle" + RUNNING = "running" + RESCHEDULING = "rescheduling" + TERMINATED = "terminated" \ No newline at end of file diff --git a/dashscope/agentstudio/exceptions.py b/dashscope/agentstudio/exceptions.py new file mode 100644 index 0000000..ec99267 --- /dev/null +++ b/dashscope/agentstudio/exceptions.py @@ -0,0 +1,226 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Exception hierarchy for the AgentStudio SDK. + +The AgentStudio service returns errors in the canonical CMA shape:: + + { + "type": "error", + "error": {"code": "invalid_request_error", "message": "..."}, + "request_id": "req_..." + } + +The pre-release backend currently emits ``error_code``/``error_message`` +instead of nested ``error.{code,message}``. We accept both shapes and +normalize to the documented form. The compatibility branch is marked +with ``# TODO(bma-fix)`` so we can remove it once the backend aligns. +""" + +from __future__ import annotations + +from typing import Any, Dict, Mapping, Optional + +from dashscope.common.error import DashScopeException + + +class AgentStudioError(DashScopeException): + """Base exception for all AgentStudio SDK errors. + + Attributes + ---------- + code: str + Machine-readable error code (e.g. ``invalid_request_error``). + message: str + Human-readable error description from the server. + request_id: Optional[str] + Correlation identifier for log lookups (``req_``). + status_code: Optional[int] + HTTP status code if the error originated from a HTTP response. + raw: Optional[Mapping[str, Any]] + Original response payload for debugging. + """ + + code: str = "agentstudio_error" + + def __init__( + self, + message: str, + *, + code: Optional[str] = None, + request_id: Optional[str] = None, + status_code: Optional[int] = None, + raw: Optional[Mapping[str, Any]] = None, + ) -> None: + super().__init__(message) + if code is not None: + self.code = code + self.message = message + self.request_id = request_id + self.status_code = status_code + self.raw = raw + + def __repr__(self) -> str: # pragma: no cover - debug helper + return ( + f"{type(self).__name__}(code={self.code!r}, " + f"message={self.message!r}, request_id={self.request_id!r}, " + f"status_code={self.status_code!r})" + ) + + +# --------------------------------------------------------------------------- +# Connection / transport layer errors (no HTTP response received) +# --------------------------------------------------------------------------- + + +class APIConnectionError(AgentStudioError): + """Raised when the HTTP request fails before a response is read.""" + + code = "api_connection_error" + + +class APITimeoutError(APIConnectionError): + """Raised on connect / read timeouts.""" + + code = "api_timeout_error" + + +# --------------------------------------------------------------------------- +# Server-side errors (HTTP response received) +# --------------------------------------------------------------------------- + + +class APIStatusError(AgentStudioError): + """Raised when the server returns a non-2xx status.""" + + code = "api_status_error" + + +class InvalidRequestError(APIStatusError): + code = "invalid_request_error" + + +class AuthenticationError(APIStatusError): + code = "authentication_error" + + +class PermissionDeniedError(APIStatusError): + code = "permission_denied_error" + + +class NotFoundError(APIStatusError): + code = "not_found_error" + + +class ConflictError(APIStatusError): + code = "conflict_error" + + +class RateLimitError(APIStatusError): + code = "rate_limit_error" + + +class OverloadedError(APIStatusError): + code = "overloaded_error" + + +class InternalServerError(APIStatusError): + code = "api_error" + + +# --------------------------------------------------------------------------- +# Streaming errors +# --------------------------------------------------------------------------- + + +class StreamError(AgentStudioError): + """Raised when an SSE stream encounters a fatal protocol error.""" + + code = "stream_error" + + +class StreamClosedError(StreamError): + """Raised when consumers attempt I/O on an already-closed stream.""" + + code = "stream_closed" + + +# --------------------------------------------------------------------------- +# Dispatcher +# --------------------------------------------------------------------------- + + +_STATUS_TO_DEFAULT: Dict[int, type] = { + 400: InvalidRequestError, + 401: AuthenticationError, + 403: PermissionDeniedError, + 404: NotFoundError, + 409: ConflictError, + 429: RateLimitError, + 500: InternalServerError, + 502: InternalServerError, + 503: OverloadedError, + 504: InternalServerError, +} + +_CODE_TO_CLASS: Dict[str, type] = { + "invalid_request_error": InvalidRequestError, + "authentication_error": AuthenticationError, + "permission_denied_error": PermissionDeniedError, + "not_found_error": NotFoundError, + "conflict_error": ConflictError, + "rate_limit_error": RateLimitError, + "overloaded_error": OverloadedError, + "api_error": InternalServerError, +} + + +def from_response( + *, status_code: int, body: Any, headers: Optional[Mapping[str, str]] = None +) -> AgentStudioError: + """Build an :class:`AgentStudioError` instance from a HTTP response. + + Accepts both the documented ``{type, error:{code,message}, request_id}`` + shape and the pre-release ``{type, error:{error_code, error_message}}`` + shape. Falls back to a Spring default ``{timestamp,status,error,path}`` + when the body is not JSON-serializable. + + The ``x-request-id`` response header is preferred over the body + ``request_id`` field (server-generated IDs are more reliable for tracing). + """ + + code: Optional[str] = None + message: Optional[str] = None + request_id: Optional[str] = None + + # Prefer server-generated request ID from response header. + if headers: + request_id = headers.get("x-request-id") + + if isinstance(body, Mapping): + # Body request_id as fallback (snake_case is canonical, accept camelCase). + if request_id is None: + request_id = body.get("request_id") or body.get("requestId") # TODO(bma-fix) + err = body.get("error") + if isinstance(err, Mapping): + code = err.get("code") or err.get("error_code") # TODO(bma-fix) + message = err.get("message") or err.get("error_message") # TODO(bma-fix) + # Spring default fallback. + if message is None and "error" in body and isinstance(body["error"], str): + message = body["error"] + code = body.get("error") or "api_error" + if message is None: + message = body.get("message") + + if message is None: + message = f"HTTP {status_code}" + if code is None: + code = _STATUS_TO_DEFAULT.get(status_code, APIStatusError).code # type: ignore[attr-defined] + + cls = _CODE_TO_CLASS.get(code) or _STATUS_TO_DEFAULT.get(status_code) or APIStatusError + return cls( + message, + code=code, + request_id=request_id, + status_code=status_code, + raw=body if isinstance(body, Mapping) else {"raw": body}, + ) diff --git a/dashscope/agentstudio/pagination.py b/dashscope/agentstudio/pagination.py new file mode 100644 index 0000000..8ec01cb --- /dev/null +++ b/dashscope/agentstudio/pagination.py @@ -0,0 +1,152 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Cursor-based pagination. + +AgentStudio uses opaque cursor pagination: callers pass ``limit`` and +``page`` (a cursor token returned by the previous page in +``next_page``). The Files endpoint additionally supports ``after_id`` / +``before_id`` for ID-anchored navigation; that variant is exposed via +:class:`IdCursorPage`. +""" + +from __future__ import annotations + +from typing import ( + Any, + AsyncIterator, + Callable, + Dict, + Generic, + Iterator, + List, + Optional, + TypeVar, +) + +T = TypeVar("T") + + +class CursorPage(Generic[T]): + """Synchronous page over an opaque ``page`` cursor.""" + + def __init__( + self, + *, + data: List[T], + next_page: Optional[str], + request_id: Optional[str], + fetch_next: Optional[Callable[[str], "CursorPage[T]"]] = None, + ) -> None: + self.data = data + self.next_page = next_page + self.request_id = request_id + self._fetch_next = fetch_next + + def has_next(self) -> bool: + return bool(self.next_page) and self._fetch_next is not None + + def get_next(self) -> Optional["CursorPage[T]"]: + if not self.has_next(): + return None + assert self._fetch_next is not None and self.next_page is not None + return self._fetch_next(self.next_page) + + def __iter__(self) -> Iterator[T]: + page: Optional[CursorPage[T]] = self + while page is not None: + for item in page.data: + yield item + page = page.get_next() + + def __len__(self) -> int: + """Return the number of items on *this* page only. + + .. note:: + ``__iter__`` auto-paginates across all pages, so ``len(page)`` + may differ from ``sum(1 for _ in page)``. Use + ``len(page.data)`` for an explicit current-page count. + """ + return len(self.data) + + +class AsyncCursorPage(Generic[T]): + """Asynchronous page over an opaque ``page`` cursor.""" + + def __init__( + self, + *, + data: List[T], + next_page: Optional[str], + request_id: Optional[str], + fetch_next: Optional[Callable[[str], "AsyncCursorPage[T]"]] = None, + ) -> None: + self.data = data + self.next_page = next_page + self.request_id = request_id + self._fetch_next = fetch_next + + def has_next(self) -> bool: + return bool(self.next_page) and self._fetch_next is not None + + async def get_next(self) -> Optional["AsyncCursorPage[T]"]: + if not self.has_next(): + return None + assert self._fetch_next is not None and self.next_page is not None + result = self._fetch_next(self.next_page) + if hasattr(result, "__await__"): + result = await result # type: ignore[assignment] + return result # type: ignore[return-value] + + def __aiter__(self) -> AsyncIterator[T]: + return self._aiter() + + async def _aiter(self) -> AsyncIterator[T]: + page: Optional[AsyncCursorPage[T]] = self + while page is not None: + for item in page.data: + yield item + page = await page.get_next() + + def __len__(self) -> int: + """Return the number of items on *this* page only. + + .. note:: + ``__aiter__`` auto-paginates across all pages, so ``len(page)`` + may differ from the total number of items. Use + ``len(page.data)`` for an explicit current-page count. + """ + return len(self.data) + + +class IdCursorPage(CursorPage[T]): + """Files API uses ``after_id`` / ``before_id`` instead of ``page``. + + ``next_page`` is filled with the last item id (or ``None`` when the + list ends), and the SDK injects it as ``after_id`` on the follow-up + request. + """ + + +class AsyncIdCursorPage(AsyncCursorPage[T]): + """Async variant of :class:`IdCursorPage`.""" + + +def build_page( + *, + payload: Dict[str, Any], + item_factory: Callable[[Dict[str, Any]], T], + fetch_next: Optional[Callable[[str], Any]] = None, + request_id: Optional[str] = None, + page_cls: type = CursorPage, +) -> Any: + """Hydrate a page object from a normalized payload.""" + + items_raw = payload.get("data") or [] + items: List[T] = [item_factory(it) for it in items_raw if isinstance(it, dict)] + next_page = payload.get("next_page") + return page_cls( + data=items, + next_page=next_page, + request_id=request_id, + fetch_next=fetch_next, + ) diff --git a/dashscope/agentstudio/resources/__init__.py b/dashscope/agentstudio/resources/__init__.py new file mode 100644 index 0000000..3577fab --- /dev/null +++ b/dashscope/agentstudio/resources/__init__.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Resource classes for the AgentStudio SDK. + +All resource classes are instantiated by the client and accessed as +attributes (e.g. ``client.agents``, ``client.sessions``). + +Usage:: + + from dashscope.agentstudio import Client + + client = Client(api_key="sk-...") + agent = client.agents.create(name="demo", model="qwen-plus") + session = client.sessions.create(agent=agent.id) + client.sessions.events.send(session.id, [user_message("hello")]) +""" + +from dashscope.agentstudio.resources.agents import Agents, AsyncAgents +from dashscope.agentstudio.resources.environments import Environments, AsyncEnvironments +from dashscope.agentstudio.resources.files import Files, AsyncFiles +from dashscope.agentstudio.resources.session_events import ( + AsyncSessionEvents, + SessionEvents, + _AioTypedEventStream, + _TypedEventStream, +) +from dashscope.agentstudio.resources.sessions import Sessions, AsyncSessions +from dashscope.agentstudio.resources.skills import ( + AsyncSkillVersions, + AsyncSkills, + SkillVersions, + Skills, +) + +__all__ = [ + "Agents", + "AsyncAgents", + "Environments", + "AsyncEnvironments", + "Files", + "AsyncFiles", + "Skills", + "AsyncSkills", + "SkillVersions", + "AsyncSkillVersions", + "Sessions", + "AsyncSessions", + "SessionEvents", + "AsyncSessionEvents", + "_TypedEventStream", + "_AioTypedEventStream", +] diff --git a/dashscope/agentstudio/resources/_helpers.py b/dashscope/agentstudio/resources/_helpers.py new file mode 100644 index 0000000..f371cb5 --- /dev/null +++ b/dashscope/agentstudio/resources/_helpers.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Shared helper functions for resource modules.""" + +from __future__ import annotations + +from typing import Any, Mapping, Optional + +from dashscope.agentstudio.types import ( + Agent, + AgentVersion, + Environment, + File, + ServerEvent, + Session, + Skill, + SkillVersion, + parse_server_event, +) + + +# --------------------------------------------------------------------------- +# Coerce helpers +# --------------------------------------------------------------------------- + +def _coerce_agent(payload: Mapping[str, Any]) -> Agent: + return Agent(**dict(payload)) + + +def _coerce_agent_version(payload: Mapping[str, Any]) -> AgentVersion: + return AgentVersion(**dict(payload)) + + +def _coerce_env(payload: Mapping[str, Any]) -> Environment: + return Environment(**dict(payload)) + + +def _coerce_file(payload: Mapping[str, Any]) -> File: + return File(**dict(payload)) + + +def _coerce_skill(payload: Mapping[str, Any]) -> Skill: + return Skill(**dict(payload)) + + +def _coerce_skill_version(payload: Mapping[str, Any]) -> SkillVersion: + return SkillVersion(**dict(payload)) + + +def _coerce_event(payload: Mapping[str, Any]) -> ServerEvent: + return parse_server_event(dict(payload)) + + +def _coerce_session(payload: Mapping[str, Any]) -> Session: + return Session(**dict(payload)) + + +# --------------------------------------------------------------------------- +# Path helpers +# --------------------------------------------------------------------------- + +def _events_path(session_id: str) -> str: + return f"/sessions/{session_id}/events" + + +def _stream_path(session_id: str) -> str: + return f"/sessions/{session_id}/events/stream" diff --git a/dashscope/agentstudio/resources/agents.py b/dashscope/agentstudio/resources/agents.py new file mode 100644 index 0000000..2951a55 --- /dev/null +++ b/dashscope/agentstudio/resources/agents.py @@ -0,0 +1,244 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Agents resource class.""" + +from __future__ import annotations + +from typing import Any, Mapping, Optional, Sequence + +from dashscope.agentstudio.pagination import AsyncCursorPage, CursorPage, build_page +from dashscope.agentstudio.resources._helpers import ( + _coerce_agent, + _coerce_agent_version, +) +from dashscope.agentstudio.types import Agent, AgentVersion +from dashscope.agentstudio.types.params import AgentCreateParams, AgentListParams, AgentUpdateParams, AgentVersionListParams + + +_PATH_AGENTS = "/agents" + + +class Agents: + """Agent CRUD — instance methods backed by a shared client.""" + + def __init__(self, client) -> None: + self._client = client + + def create( + self, + *, + name: str, + model: str, + description: Optional[str] = None, + system_prompt: Optional[str] = None, + tools: Optional[Sequence[Mapping[str, Any]]] = None, + mcp_servers: Optional[Sequence[Mapping[str, Any]]] = None, + skills: Optional[Sequence[Mapping[str, Any]]] = None, + metadata: Optional[Mapping[str, Any]] = None, + ) -> Agent: + body = AgentCreateParams( + name=name, model=model, description=description, + system_prompt=system_prompt, tools=tools, + mcp_servers=mcp_servers, skills=skills, metadata=metadata, + ).to_dict() + resp = self._client._transport.request("POST", _PATH_AGENTS, json=body) + return _coerce_agent(resp.data) + + def retrieve( + self, + agent_id: str, + *, + version: Optional[int] = None, + ) -> Agent: + params = {"version": version} if version is not None else None + resp = self._client._transport.request("GET", f"{_PATH_AGENTS}/{agent_id}", params=params) + return _coerce_agent(resp.data) + + # Alias: get() delegates to retrieve(), matching Assistants.get() pattern + get = retrieve # type: ignore[assignment] + + # Alias: call() delegates to create(), matching Assistants.call() pattern + call = create # type: ignore[assignment] + + def update( + self, + agent_id: str, + *, + name: Optional[str] = None, + description: Optional[str] = None, + model: Optional[str] = None, + system_prompt: Optional[str] = None, + tools: Optional[Sequence[Mapping[str, Any]]] = None, + mcp_servers: Optional[Sequence[Mapping[str, Any]]] = None, + skills: Optional[Sequence[Mapping[str, Any]]] = None, + metadata: Optional[Mapping[str, Any]] = None, + ) -> Agent: + current = self.retrieve(agent_id) + body = AgentUpdateParams( + version=current.version, + name=name, description=description, + model=model, system_prompt=system_prompt, tools=tools, + mcp_servers=mcp_servers, skills=skills, metadata=metadata, + ).to_dict() + resp = self._client._transport.request("POST", f"{_PATH_AGENTS}/{agent_id}", json=body) + return _coerce_agent(resp.data) + + def archive(self, agent_id: str) -> Agent: + resp = self._client._transport.request("POST", f"{_PATH_AGENTS}/{agent_id}/archive") + return _coerce_agent(resp.data) + + def list( + self, + *, + limit: Optional[int] = None, + page: Optional[str] = None, + include_archived: Optional[bool] = None, + ) -> CursorPage[Agent]: + params = AgentListParams(limit=limit, page=page, include_archived=include_archived).to_dict() + resp = self._client._transport.request("GET", _PATH_AGENTS, params=params) + return build_page( + payload=resp.data, + item_factory=_coerce_agent, + request_id=resp.request_id, + fetch_next=lambda nxt: self.list( + limit=limit, page=nxt, include_archived=include_archived, + ), + ) + + def list_versions( + self, + agent_id: str, + *, + limit: Optional[int] = None, + page: Optional[str] = None, + ) -> CursorPage[AgentVersion]: + params = AgentVersionListParams(limit=limit, page=page).to_dict() + resp = self._client._transport.request( + "GET", f"{_PATH_AGENTS}/{agent_id}/versions", params=params + ) + return build_page( + payload=resp.data, + item_factory=_coerce_agent_version, + request_id=resp.request_id, + fetch_next=lambda nxt: self.list_versions( + agent_id, limit=limit, page=nxt, + ), + ) + + +class AsyncAgents: + """Async Agent CRUD — instance methods backed by a shared client.""" + + def __init__(self, client) -> None: + self._client = client + + async def create( + self, + *, + name: str, + model: str, + description: Optional[str] = None, + system_prompt: Optional[str] = None, + tools: Optional[Sequence[Mapping[str, Any]]] = None, + mcp_servers: Optional[Sequence[Mapping[str, Any]]] = None, + skills: Optional[Sequence[Mapping[str, Any]]] = None, + metadata: Optional[Mapping[str, Any]] = None, + ) -> Agent: + body = AgentCreateParams( + name=name, model=model, description=description, + system_prompt=system_prompt, tools=tools, + mcp_servers=mcp_servers, skills=skills, metadata=metadata, + ).to_dict() + resp = await self._client._transport.request("POST", _PATH_AGENTS, json=body) + return _coerce_agent(resp.data) + + async def retrieve( + self, + agent_id: str, + *, + version: Optional[int] = None, + ) -> Agent: + params = {"version": version} if version is not None else None + resp = await self._client._transport.request("GET", f"{_PATH_AGENTS}/{agent_id}", params=params) + return _coerce_agent(resp.data) + + # Alias: get() delegates to retrieve() + get = retrieve # type: ignore[assignment] + + # Alias: call() delegates to create() + call = create # type: ignore[assignment] + + async def update( + self, + agent_id: str, + *, + name: Optional[str] = None, + description: Optional[str] = None, + model: Optional[str] = None, + system_prompt: Optional[str] = None, + tools: Optional[Sequence[Mapping[str, Any]]] = None, + mcp_servers: Optional[Sequence[Mapping[str, Any]]] = None, + skills: Optional[Sequence[Mapping[str, Any]]] = None, + metadata: Optional[Mapping[str, Any]] = None, + ) -> Agent: + current = await self.retrieve(agent_id) + body = AgentUpdateParams( + version=current.version, + name=name, description=description, + model=model, system_prompt=system_prompt, tools=tools, + mcp_servers=mcp_servers, skills=skills, metadata=metadata, + ).to_dict() + resp = await self._client._transport.request("POST", f"{_PATH_AGENTS}/{agent_id}", json=body) + return _coerce_agent(resp.data) + + async def archive(self, agent_id: str) -> Agent: + resp = await self._client._transport.request("POST", f"{_PATH_AGENTS}/{agent_id}/archive") + return _coerce_agent(resp.data) + + async def list( + self, + *, + limit: Optional[int] = None, + page: Optional[str] = None, + include_archived: Optional[bool] = None, + ) -> AsyncCursorPage[Agent]: + params = AgentListParams(limit=limit, page=page, include_archived=include_archived).to_dict() + resp = await self._client._transport.request("GET", _PATH_AGENTS, params=params) + + async def fetch_next(nxt: str) -> AsyncCursorPage[Agent]: + return await self.list( + limit=limit, page=nxt, include_archived=include_archived, + ) + + return build_page( + payload=resp.data, + item_factory=_coerce_agent, + request_id=resp.request_id, + page_cls=AsyncCursorPage, + fetch_next=fetch_next, + ) + + async def list_versions( + self, + agent_id: str, + *, + limit: Optional[int] = None, + page: Optional[str] = None, + ) -> AsyncCursorPage[AgentVersion]: + params = AgentVersionListParams(limit=limit, page=page).to_dict() + resp = await self._client._transport.request( + "GET", f"{_PATH_AGENTS}/{agent_id}/versions", params=params + ) + + async def fetch_next(nxt: str) -> AsyncCursorPage[AgentVersion]: + return await self.list_versions( + agent_id, limit=limit, page=nxt, + ) + + return build_page( + payload=resp.data, + item_factory=_coerce_agent_version, + request_id=resp.request_id, + page_cls=AsyncCursorPage, + fetch_next=fetch_next, + ) diff --git a/dashscope/agentstudio/resources/environments.py b/dashscope/agentstudio/resources/environments.py new file mode 100644 index 0000000..d915608 --- /dev/null +++ b/dashscope/agentstudio/resources/environments.py @@ -0,0 +1,150 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Environments resource class.""" + +from __future__ import annotations + +from typing import Any, Mapping, Optional + +from dashscope.agentstudio.pagination import AsyncCursorPage, CursorPage, build_page +from dashscope.agentstudio.resources._helpers import ( + _coerce_env, +) +from dashscope.agentstudio.types import DeleteResponse, Environment +from dashscope.agentstudio.types.params import EnvironmentCreateParams, EnvironmentListParams, EnvironmentUpdateParams + + + +_PATH_ENVIRONMENTS = "/environments" + + +class Environments: + """Environment CRUD — instance methods backed by a shared client.""" + + def __init__(self, client) -> None: + self._client = client + + def create( + self, *, name: str, config: Mapping[str, Any], + description: Optional[str] = None, + scope: Optional[str] = "organization", + metadata: Optional[Mapping[str, Any]] = None, + extra_headers: Optional[Mapping[str, str]] = None, + ) -> Environment: + body = EnvironmentCreateParams( + name=name, config=config, description=description, + scope=scope, metadata=metadata, + ).to_dict() + resp = self._client._transport.request( + "POST", _PATH_ENVIRONMENTS, json=body, extra_headers=extra_headers + ) + return _coerce_env(resp.data) + + def retrieve(self, environment_id: str) -> Environment: + resp = self._client._transport.request("GET", f"{_PATH_ENVIRONMENTS}/{environment_id}") + return _coerce_env(resp.data) + + # Alias: get() delegates to retrieve() + get = retrieve # type: ignore[assignment] + + def update( + self, environment_id: str, *, + name: Optional[str] = None, description: Optional[str] = None, + config: Optional[Mapping[str, Any]] = None, + metadata: Optional[Mapping[str, Any]] = None, + ) -> Environment: + body = EnvironmentUpdateParams( + name=name, description=description, + config=config, metadata=metadata, + ).to_dict() + resp = self._client._transport.request("POST", f"{_PATH_ENVIRONMENTS}/{environment_id}", json=body) + return _coerce_env(resp.data) + + def list( + self, *, limit: Optional[int] = None, page: Optional[str] = None, + include_archived: Optional[bool] = None, + ) -> CursorPage[Environment]: + params = EnvironmentListParams(limit=limit, page=page, include_archived=include_archived).to_dict() + resp = self._client._transport.request("GET", _PATH_ENVIRONMENTS, params=params) + return build_page( + payload=resp.data, item_factory=_coerce_env, request_id=resp.request_id, + fetch_next=lambda nxt: self.list( + limit=limit, page=nxt, include_archived=include_archived, + ), + ) + + def archive(self, environment_id: str) -> Environment: + resp = self._client._transport.request("POST", f"{_PATH_ENVIRONMENTS}/{environment_id}/archive") + return _coerce_env(resp.data) + + def delete(self, environment_id: str) -> DeleteResponse: + resp = self._client._transport.request("DELETE", f"{_PATH_ENVIRONMENTS}/{environment_id}") + return DeleteResponse(**resp.data) + + +class AsyncEnvironments: + """Async Environment CRUD — instance methods backed by a shared client.""" + + def __init__(self, client) -> None: + self._client = client + + async def create( + self, *, name: str, config: Mapping[str, Any], + description: Optional[str] = None, + scope: Optional[str] = "organization", + metadata: Optional[Mapping[str, Any]] = None, + extra_headers: Optional[Mapping[str, str]] = None, + ) -> Environment: + body = EnvironmentCreateParams( + name=name, config=config, description=description, + scope=scope, metadata=metadata, + ).to_dict() + resp = await self._client._transport.request( + "POST", _PATH_ENVIRONMENTS, json=body, extra_headers=extra_headers + ) + return _coerce_env(resp.data) + + async def retrieve(self, environment_id: str) -> Environment: + resp = await self._client._transport.request("GET", f"{_PATH_ENVIRONMENTS}/{environment_id}") + return _coerce_env(resp.data) + + # Alias: get() delegates to retrieve() + get = retrieve # type: ignore[assignment] + + async def update( + self, environment_id: str, *, + name: Optional[str] = None, description: Optional[str] = None, + config: Optional[Mapping[str, Any]] = None, + metadata: Optional[Mapping[str, Any]] = None, + ) -> Environment: + body = EnvironmentUpdateParams( + name=name, description=description, + config=config, metadata=metadata, + ).to_dict() + resp = await self._client._transport.request("POST", f"{_PATH_ENVIRONMENTS}/{environment_id}", json=body) + return _coerce_env(resp.data) + + async def list( + self, *, limit: Optional[int] = None, page: Optional[str] = None, + include_archived: Optional[bool] = None, + ) -> AsyncCursorPage[Environment]: + params = EnvironmentListParams(limit=limit, page=page, include_archived=include_archived).to_dict() + resp = await self._client._transport.request("GET", _PATH_ENVIRONMENTS, params=params) + + async def fetch_next(nxt: str) -> AsyncCursorPage[Environment]: + return await self.list( + limit=limit, page=nxt, include_archived=include_archived, + ) + + return build_page( + payload=resp.data, item_factory=_coerce_env, request_id=resp.request_id, + page_cls=AsyncCursorPage, fetch_next=fetch_next, + ) + + async def archive(self, environment_id: str) -> Environment: + resp = await self._client._transport.request("POST", f"{_PATH_ENVIRONMENTS}/{environment_id}/archive") + return _coerce_env(resp.data) + + async def delete(self, environment_id: str) -> DeleteResponse: + resp = await self._client._transport.request("DELETE", f"{_PATH_ENVIRONMENTS}/{environment_id}") + return DeleteResponse(**resp.data) diff --git a/dashscope/agentstudio/resources/files.py b/dashscope/agentstudio/resources/files.py new file mode 100644 index 0000000..3f46954 --- /dev/null +++ b/dashscope/agentstudio/resources/files.py @@ -0,0 +1,238 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Files resource class.""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any, BinaryIO, Callable, Dict, IO, Mapping, Optional, Tuple, Union + +from dashscope.agentstudio.pagination import AsyncIdCursorPage, IdCursorPage, build_page +from dashscope.agentstudio.resources._helpers import ( + _coerce_file, +) +from dashscope.agentstudio.types import DeleteResponse, File +from dashscope.agentstudio.types.params import FileListParams + + +_PATH_FILES = "/files" + +ProgressCallback = Callable[[int, int], None] +"""``(bytes_read, total_bytes_or_-1)`` – called after every chunk.""" + + +def _open_file( + file: Union[str, "os.PathLike[str]", BinaryIO, Tuple[str, BinaryIO]], +) -> Tuple[str, BinaryIO, bool]: + """Return ``(filename, fileobj, must_close)``.""" + if isinstance(file, tuple) and len(file) == 2: + name, obj = file + return str(name), obj, False + if isinstance(file, (str, bytes, os.PathLike)): + path = Path(os.fspath(file)) + return path.name, path.open("rb"), True + name = getattr(file, "name", "upload.bin") + return os.path.basename(str(name)), file, False + + +class _ProgressFile: + """Wrap a file-like object so callers can observe upload progress.""" + + def __init__(self, fileobj: IO[bytes], total: int, callback: ProgressCallback) -> None: + self._fileobj = fileobj + self._total = total + self._read = 0 + self._callback = callback + + def __len__(self) -> int: + return self._total + + def read(self, size: int = -1) -> bytes: + chunk = self._fileobj.read(size) + if chunk: + self._read += len(chunk) + try: + self._callback(self._read, self._total) + except Exception: + pass + return chunk + + def seek(self, offset: int, whence: int = 0) -> int: + result = self._fileobj.seek(offset, whence) + if whence == 0: + self._read = offset + elif whence == 1: + self._read += offset + elif whence == 2: + self._read = self._total + offset + return result + + def tell(self) -> int: + return self._fileobj.tell() + + @property + def name(self) -> str: + return getattr(self._fileobj, "name", "upload.bin") + + +def _file_size(fileobj: IO[bytes]) -> int: + try: + cur = fileobj.tell() + except (AttributeError, OSError): + return -1 + try: + fileobj.seek(0, os.SEEK_END) + end = fileobj.tell() + fileobj.seek(cur) + return end - cur + except (AttributeError, OSError): + return -1 + + +class Files: + """File upload / list / delete — instance methods backed by a shared client.""" + + def __init__(self, client) -> None: + self._client = client + + def upload( + self, + file: Union[str, "os.PathLike[str]", BinaryIO, Tuple[str, BinaryIO]], + *, + mime_type: Optional[str] = None, + progress: Optional[ProgressCallback] = None, + extra_headers: Optional[Mapping[str, str]] = None, + ) -> File: + filename, fileobj, must_close = _open_file(file) + try: + payload_obj: Any + if progress is not None: + size = _file_size(fileobj) + if size > 0: + payload_obj = _ProgressFile(fileobj, size, progress) + else: + payload_obj = fileobj + else: + payload_obj = fileobj + multipart: Dict[str, Any] = { + "file": (filename, payload_obj, mime_type or "application/octet-stream") + } + resp = self._client._transport.request( + "POST", _PATH_FILES, files=multipart, extra_headers=extra_headers, + ) + return _coerce_file(resp.data) + finally: + if must_close: + try: + fileobj.close() + except Exception: + pass + + def retrieve(self, file_id: str) -> File: + resp = self._client._transport.request("GET", f"{_PATH_FILES}/{file_id}") + return _coerce_file(resp.data) + + # Alias: get() delegates to retrieve() + get = retrieve # type: ignore[assignment] + + def list( + self, *, limit: Optional[int] = None, + page: Optional[str] = None, + scope_id: Optional[str] = None, + after_id: Optional[str] = None, + before_id: Optional[str] = None, + ) -> IdCursorPage[File]: + params = FileListParams( + limit=limit, page=page, scope_id=scope_id, + after_id=after_id, before_id=before_id, + ).to_dict() + resp = self._client._transport.request("GET", _PATH_FILES, params=params) + + def fetch_next(token: str) -> IdCursorPage[File]: + return self.list( + limit=limit, page=token, scope_id=scope_id, + ) + + return build_page( + payload=resp.data, item_factory=_coerce_file, request_id=resp.request_id, + page_cls=IdCursorPage, fetch_next=fetch_next, + ) + + def delete(self, file_id: str) -> DeleteResponse: + resp = self._client._transport.request("DELETE", f"{_PATH_FILES}/{file_id}") + return DeleteResponse(**resp.data) + + +class AsyncFiles: + """Async File upload / list / delete — instance methods backed by a shared client.""" + + def __init__(self, client) -> None: + self._client = client + + async def upload( + self, + file: Union[str, "os.PathLike[str]", BinaryIO, Tuple[str, BinaryIO]], + *, + mime_type: Optional[str] = None, + progress: Optional[ProgressCallback] = None, + extra_headers: Optional[Mapping[str, str]] = None, + ) -> File: + filename, fileobj, must_close = _open_file(file) + try: + payload_obj: Any + if progress is not None: + size = _file_size(fileobj) + if size > 0: + payload_obj = _ProgressFile(fileobj, size, progress) + else: + payload_obj = fileobj + else: + payload_obj = fileobj + multipart: Dict[str, Any] = { + "file": (filename, payload_obj, mime_type or "application/octet-stream") + } + resp = await self._client._transport.request( + "POST", _PATH_FILES, files=multipart, extra_headers=extra_headers, + ) + return _coerce_file(resp.data) + finally: + if must_close: + try: + fileobj.close() + except Exception: + pass + + async def retrieve(self, file_id: str) -> File: + resp = await self._client._transport.request("GET", f"{_PATH_FILES}/{file_id}") + return _coerce_file(resp.data) + + # Alias: get() delegates to retrieve() + get = retrieve # type: ignore[assignment] + + async def list( + self, *, limit: Optional[int] = None, + page: Optional[str] = None, + scope_id: Optional[str] = None, + after_id: Optional[str] = None, + before_id: Optional[str] = None, + ) -> AsyncIdCursorPage[File]: + params = FileListParams( + limit=limit, page=page, scope_id=scope_id, + after_id=after_id, before_id=before_id, + ).to_dict() + resp = await self._client._transport.request("GET", _PATH_FILES, params=params) + + async def fetch_next(token: str) -> AsyncIdCursorPage[File]: + return await self.list( + limit=limit, page=token, scope_id=scope_id, + ) + + return build_page( + payload=resp.data, item_factory=_coerce_file, request_id=resp.request_id, + page_cls=AsyncIdCursorPage, fetch_next=fetch_next, + ) + + async def delete(self, file_id: str) -> DeleteResponse: + resp = await self._client._transport.request("DELETE", f"{_PATH_FILES}/{file_id}") + return DeleteResponse(**resp.data) diff --git a/dashscope/agentstudio/resources/session_events.py b/dashscope/agentstudio/resources/session_events.py new file mode 100644 index 0000000..bad6a84 --- /dev/null +++ b/dashscope/agentstudio/resources/session_events.py @@ -0,0 +1,331 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""SessionEvents and AsyncSessionEvents resource classes, plus typed stream wrappers.""" + +from __future__ import annotations + +from typing import Any, AsyncIterator, Dict, Iterator, Mapping, Optional, Sequence + +from dashscope.agentstudio import exceptions +from dashscope.agentstudio.pagination import AsyncCursorPage, CursorPage, build_page +from dashscope.agentstudio.resources._helpers import ( + _coerce_event, + _events_path, + _stream_path, +) +from dashscope.agentstudio.streaming import AsyncEventStream, EventStream +from dashscope.agentstudio.tools import AsyncSessionToolRunner, SessionToolRunner +from dashscope.agentstudio.types import ServerEvent +from dashscope.agentstudio.types.params import SessionEventListParams, SessionEventSendParams +from dashscope.agentstudio.constants import AGENTSTUDIO_DEFAULT_TIMEOUT, SSEEventType, BlockType, SessionStatus + + +class SessionEvents: + """Session event send / list / stream — instance methods backed by a shared client.""" + + def __init__(self, client) -> None: + self._client = client + + def send( + self, + session_id: str, + events: Sequence[Mapping[str, Any]], + ) -> Dict[str, Any]: + """Send events to a session. + + Returns the server response dict:: + + result = client.sessions.events.send(session.id, [user_message("hello")]) + """ + if not events: + raise ValueError("events must contain at least 1 entry") + body = SessionEventSendParams(input=events).to_dict() + resp = self._client._transport.request("POST", _events_path(session_id), json=body) + return resp.data + + def list( + self, + session_id: str, + *, + types: Optional[Sequence[str]] = None, + created_at_gt: Optional[str] = None, + created_at_gte: Optional[str] = None, + created_at_lt: Optional[str] = None, + created_at_lte: Optional[str] = None, + limit: Optional[int] = None, + order: Optional[str] = None, + page: Optional[str] = None, + ) -> CursorPage[ServerEvent]: + params = SessionEventListParams( + types=types, created_at_gt=created_at_gt, + created_at_gte=created_at_gte, created_at_lt=created_at_lt, + created_at_lte=created_at_lte, limit=limit, order=order, page=page, + ).to_dict() + resp = self._client._transport.request("GET", _events_path(session_id), params=params) + + def fetch_next(nxt: str) -> CursorPage[ServerEvent]: + return self.list( + session_id, types=types, + created_at_gt=created_at_gt, created_at_gte=created_at_gte, + created_at_lt=created_at_lt, created_at_lte=created_at_lte, + limit=limit, order=order, page=nxt, + ) + + return build_page( + payload=resp.data, item_factory=_coerce_event, request_id=resp.request_id, + fetch_next=fetch_next, + ) + + def stream( + self, + session_id: str, + *, + timeout: Optional[float] = None, + ) -> "_TypedEventStream": + """Open the SSE stream and return an iterator of typed events.""" + + resp = self._client._transport.request( + "GET", _stream_path(session_id), + extra_headers={"Accept": "text/event-stream"}, + stream=True, timeout=timeout or AGENTSTUDIO_DEFAULT_TIMEOUT, + ) + if resp.status_code >= 400: + try: + resp.read() + body = resp.json() + except ValueError: + body = {"raw": resp.text} + try: + raise exceptions.from_response( + status_code=resp.status_code, body=body, headers=resp.headers + ) + finally: + resp.close() + # Transport is shared with the client — stream close only closes the + # response, NOT the transport. + return _TypedEventStream(EventStream(response=resp)) + + def tool_runner( + self, + session_id: str, + *, + tools: Sequence[Any], + events: Optional[Sequence[Mapping[str, Any]]] = None, + max_idle_seconds: int = 300, + max_concurrent: int = 4, + ) -> SessionToolRunner: + """Convenience wrapper around :class:`SessionToolRunner`. + + If ``events`` is provided, they will be sent **after** the SSE stream + is opened (inside ``run()``) to guarantee no events are missed. + """ + return SessionToolRunner( + client=self._client, + session_id=session_id, + tools=list(tools), + events=list(events) if events else None, + max_idle_seconds=max_idle_seconds, + max_concurrent=max_concurrent, + ) + +class _TypedEventStream: + """Wrap :class:`EventStream` so iterating yields :class:`ServerEvent`.""" + + def __init__(self, stream: EventStream) -> None: + self._stream = stream + + def __enter__(self) -> "_TypedEventStream": + return self + + def __exit__(self, *exc: Any) -> None: + self.close() + + def __iter__(self) -> Iterator[ServerEvent]: + for payload in self._stream: + yield _coerce_event(payload) + + @property + def text_stream(self): + """Iterate over text chunks from agent messages. + + Automatically stops when the session reaches ``idle`` or + ``terminated`` status, so callers don't need to handle + ``session_status`` events manually. + """ + for event in self: + if getattr(event, 'type', None) == SSEEventType.MESSAGE: + for block in (event.content or []): + if getattr(block, 'type', None) == BlockType.TEXT: + text = getattr(block, 'text', '') + if text: + yield text + elif getattr(event, 'type', None) == SSEEventType.SESSION_STATUS: + block = event.content[0] if event.content else None + d = getattr(block, 'data', None) or {} + if d.get('session_status') in ( + SessionStatus.IDLE, SessionStatus.TERMINATED, + SessionStatus.RESCHEDULING, + ): + return + + def close(self) -> None: + self._stream.close() + + +class AsyncSessionEvents: + """Async session event operations — instance methods backed by a shared client.""" + + def __init__(self, client) -> None: + self._client = client + + async def send( + self, + session_id: str, + events: Sequence[Mapping[str, Any]], + ) -> Dict[str, Any]: + """Send events to a session. + + Returns the server response dict:: + + result = await client.sessions.events.send(session.id, [user_message("hello")]) + """ + if not events: + raise ValueError("events must contain at least 1 entry") + body = SessionEventSendParams(input=events).to_dict() + resp = await self._client._transport.request("POST", _events_path(session_id), json=body) + return resp.data + + async def list( + self, + session_id: str, + *, + types: Optional[Sequence[str]] = None, + created_at_gt: Optional[str] = None, + created_at_gte: Optional[str] = None, + created_at_lt: Optional[str] = None, + created_at_lte: Optional[str] = None, + limit: Optional[int] = None, + order: Optional[str] = None, + page: Optional[str] = None, + ) -> AsyncCursorPage[ServerEvent]: + params = SessionEventListParams( + types=types, created_at_gt=created_at_gt, + created_at_gte=created_at_gte, created_at_lt=created_at_lt, + created_at_lte=created_at_lte, limit=limit, order=order, page=page, + ).to_dict() + resp = await self._client._transport.request("GET", _events_path(session_id), params=params) + + async def fetch_next(nxt: str) -> AsyncCursorPage[ServerEvent]: + return await self.list( + session_id, types=types, + created_at_gt=created_at_gt, created_at_gte=created_at_gte, + created_at_lt=created_at_lt, created_at_lte=created_at_lte, + limit=limit, order=order, page=nxt, + ) + + return build_page( + payload=resp.data, item_factory=_coerce_event, request_id=resp.request_id, + page_cls=AsyncCursorPage, fetch_next=fetch_next, + ) + + async def stream( + self, + session_id: str, + *, + timeout: Optional[float] = None, + ) -> "_AioTypedEventStream": + """Open the SSE stream and return an async iterator of typed events.""" + + resp = await self._client._transport.request( + "GET", _stream_path(session_id), + extra_headers={"Accept": "text/event-stream"}, + stream=True, timeout=timeout or AGENTSTUDIO_DEFAULT_TIMEOUT, + ) + if resp.status_code >= 400: + try: + await resp.aread() + body = resp.json() + except ValueError: + body = {"raw": resp.text} + try: + raise exceptions.from_response( + status_code=resp.status_code, body=body, headers=resp.headers + ) + finally: + await resp.aclose() + # Transport is shared with the client — stream close only closes the + # response, NOT the transport. + return _AioTypedEventStream(AsyncEventStream(response=resp)) + + def tool_runner( + self, + session_id: str, + *, + tools: Sequence[Any], + events: Optional[Sequence[Mapping[str, Any]]] = None, + max_idle_seconds: int = 300, + max_concurrent: int = 4, + ) -> AsyncSessionToolRunner: + """Convenience wrapper around :class:`AsyncSessionToolRunner`. + + If ``events`` is provided, they will be sent concurrently with + the SSE stream opening (inside ``run()``) to guarantee no events + are missed. + """ + return AsyncSessionToolRunner( + client=self._client, + session_id=session_id, + tools=list(tools), + events=list(events) if events else None, + max_idle_seconds=max_idle_seconds, + max_concurrent=max_concurrent, + ) + + +class _AioTypedEventStream: + def __init__(self, stream: AsyncEventStream) -> None: + self._stream = stream + + async def __aenter__(self) -> "_AioTypedEventStream": + return self + + async def __aexit__(self, *exc: Any) -> None: + await self.aclose() + + def __aiter__(self) -> AsyncIterator[ServerEvent]: + return self._aiter() + + async def _aiter(self) -> AsyncIterator[ServerEvent]: + async for payload in self._stream: + yield _coerce_event(payload) + + @property + def text_stream(self): + """Async iterator over text chunks from agent messages.""" + return self._text_stream() + + async def _text_stream(self): + """Async iterator over text chunks from agent messages. + + Automatically stops when the session reaches ``idle`` or + ``terminated`` status, so callers don't need to handle + ``session_status`` events manually. + """ + async for event in self: + if getattr(event, 'type', None) == SSEEventType.MESSAGE: + for block in (event.content or []): + if getattr(block, 'type', None) == BlockType.TEXT: + text = getattr(block, 'text', '') + if text: + yield text + elif getattr(event, 'type', None) == SSEEventType.SESSION_STATUS: + block = event.content[0] if event.content else None + d = getattr(block, 'data', None) or {} + if d.get('session_status') in ( + SessionStatus.IDLE, SessionStatus.TERMINATED, + SessionStatus.RESCHEDULING, + ): + return + + async def aclose(self) -> None: + await self._stream.aclose() diff --git a/dashscope/agentstudio/resources/sessions.py b/dashscope/agentstudio/resources/sessions.py new file mode 100644 index 0000000..692d4cd --- /dev/null +++ b/dashscope/agentstudio/resources/sessions.py @@ -0,0 +1,178 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Sessions resource class.""" + +from __future__ import annotations + +from typing import Any, Mapping, Optional, Sequence + +from dashscope.agentstudio.pagination import AsyncCursorPage, CursorPage, build_page +from dashscope.agentstudio.resources._helpers import ( + _coerce_session, +) +from dashscope.agentstudio.resources.session_events import SessionEvents, AsyncSessionEvents +from dashscope.agentstudio.types import DeleteResponse, Session +from dashscope.agentstudio.types.params import SessionCreateParams, SessionListParams, SessionUpdateParams + + +_PATH_SESSIONS = "/sessions" + + +class Sessions: + """Session CRUD — instance methods backed by a shared client.""" + + def __init__(self, client) -> None: + self._client = client + self.events = SessionEvents(client) + + def create( + self, *, agent: str, + environment_id: Optional[str] = None, + title: Optional[str] = None, + metadata: Optional[Mapping[str, Any]] = None, + ) -> Session: + body = SessionCreateParams( + agent=agent, environment_id=environment_id, + title=title, metadata=metadata, + ).to_dict() + resp = self._client._transport.request("POST", _PATH_SESSIONS, json=body) + return _coerce_session(resp.data) + + def retrieve(self, session_id: str) -> Session: + resp = self._client._transport.request("GET", f"{_PATH_SESSIONS}/{session_id}") + return _coerce_session(resp.data) + + # Alias: get() delegates to retrieve() + get = retrieve # type: ignore[assignment] + + # Alias: call() delegates to create() + call = create # type: ignore[assignment] + + def update( + self, session_id: str, *, + title: Optional[str] = None, + metadata: Optional[Mapping[str, Any]] = None, + ) -> Session: + body = SessionUpdateParams( + title=title, metadata=metadata, + ).to_dict() + resp = self._client._transport.request("POST", f"{_PATH_SESSIONS}/{session_id}", json=body) + return _coerce_session(resp.data) + + def list( + self, *, + limit: Optional[int] = None, + page: Optional[str] = None, + agent_id: Optional[str] = None, + statuses: Optional[Sequence[str]] = None, + created_at_gt: Optional[str] = None, + created_at_gte: Optional[str] = None, + created_at_lt: Optional[str] = None, + created_at_lte: Optional[str] = None, + ) -> CursorPage[Session]: + params = SessionListParams( + limit=limit, page=page, agent_id=agent_id, statuses=statuses, + created_at_gt=created_at_gt, created_at_gte=created_at_gte, + created_at_lt=created_at_lt, created_at_lte=created_at_lte, + ).to_dict() + resp = self._client._transport.request("GET", _PATH_SESSIONS, params=params) + + def fetch_next(nxt: str) -> CursorPage[Session]: + return self.list( + limit=limit, page=nxt, agent_id=agent_id, statuses=statuses, + created_at_gt=created_at_gt, created_at_gte=created_at_gte, + created_at_lt=created_at_lt, created_at_lte=created_at_lte, + ) + + return build_page( + payload=resp.data, item_factory=_coerce_session, request_id=resp.request_id, + fetch_next=fetch_next, + ) + + def archive(self, session_id: str) -> Session: + resp = self._client._transport.request("POST", f"{_PATH_SESSIONS}/{session_id}/archive") + return _coerce_session(resp.data) + + def delete(self, session_id: str) -> DeleteResponse: + resp = self._client._transport.request("DELETE", f"{_PATH_SESSIONS}/{session_id}") + return DeleteResponse(**resp.data) + + +class AsyncSessions: + """Async Session CRUD — instance methods backed by a shared client.""" + + def __init__(self, client) -> None: + self._client = client + self.events = AsyncSessionEvents(client) + + async def create( + self, *, agent: str, + environment_id: Optional[str] = None, + title: Optional[str] = None, + metadata: Optional[Mapping[str, Any]] = None, + ) -> Session: + body = SessionCreateParams( + agent=agent, environment_id=environment_id, + title=title, metadata=metadata, + ).to_dict() + resp = await self._client._transport.request("POST", _PATH_SESSIONS, json=body) + return _coerce_session(resp.data) + + async def retrieve(self, session_id: str) -> Session: + resp = await self._client._transport.request("GET", f"{_PATH_SESSIONS}/{session_id}") + return _coerce_session(resp.data) + + # Alias: get() delegates to retrieve() + get = retrieve # type: ignore[assignment] + + # Alias: call() delegates to create() + call = create # type: ignore[assignment] + + async def update( + self, session_id: str, *, + title: Optional[str] = None, + metadata: Optional[Mapping[str, Any]] = None, + ) -> Session: + body = SessionUpdateParams( + title=title, metadata=metadata, + ).to_dict() + resp = await self._client._transport.request("POST", f"{_PATH_SESSIONS}/{session_id}", json=body) + return _coerce_session(resp.data) + + async def list( + self, *, + limit: Optional[int] = None, + page: Optional[str] = None, + agent_id: Optional[str] = None, + statuses: Optional[Sequence[str]] = None, + created_at_gt: Optional[str] = None, + created_at_gte: Optional[str] = None, + created_at_lt: Optional[str] = None, + created_at_lte: Optional[str] = None, + ) -> AsyncCursorPage[Session]: + params = SessionListParams( + limit=limit, page=page, agent_id=agent_id, statuses=statuses, + created_at_gt=created_at_gt, created_at_gte=created_at_gte, + created_at_lt=created_at_lt, created_at_lte=created_at_lte, + ).to_dict() + resp = await self._client._transport.request("GET", _PATH_SESSIONS, params=params) + + async def fetch_next(nxt: str) -> AsyncCursorPage[Session]: + return await self.list( + limit=limit, page=nxt, agent_id=agent_id, statuses=statuses, + created_at_gt=created_at_gt, created_at_gte=created_at_gte, + created_at_lt=created_at_lt, created_at_lte=created_at_lte, + ) + + return build_page( + payload=resp.data, item_factory=_coerce_session, request_id=resp.request_id, + page_cls=AsyncCursorPage, fetch_next=fetch_next, + ) + + async def archive(self, session_id: str) -> Session: + resp = await self._client._transport.request("POST", f"{_PATH_SESSIONS}/{session_id}/archive") + return _coerce_session(resp.data) + + async def delete(self, session_id: str) -> DeleteResponse: + resp = await self._client._transport.request("DELETE", f"{_PATH_SESSIONS}/{session_id}") + return DeleteResponse(**resp.data) diff --git a/dashscope/agentstudio/resources/skills.py b/dashscope/agentstudio/resources/skills.py new file mode 100644 index 0000000..6086991 --- /dev/null +++ b/dashscope/agentstudio/resources/skills.py @@ -0,0 +1,277 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Skills and SkillVersions resource classes.""" + +from __future__ import annotations + +import os +from typing import Any, BinaryIO, Dict, Mapping, Optional, Tuple, Union + +from dashscope.agentstudio.pagination import AsyncCursorPage, CursorPage, build_page +from dashscope.agentstudio.resources._helpers import ( + _coerce_skill, + _coerce_skill_version, +) +from dashscope.agentstudio.types import DeleteResponse, Skill, SkillVersion +from dashscope.agentstudio.types.params import SkillCreateParams, SkillListParams, SkillVersionCreateParams, SkillVersionListParams + + + +_PATH_SKILLS = "/skills" + +FileSource = Union[str, "os.PathLike[str]", BinaryIO, Tuple[str, BinaryIO]] + + +class Skills: + """Skill CRUD — instance methods backed by a shared client.""" + + def __init__(self, client) -> None: + self._client = client + self.versions = SkillVersions(client) + + def create( + self, *, file_id: Optional[str] = None, + file: Optional[FileSource] = None, + mime_type: Optional[str] = None, + ) -> Skill: + resolved = _resolve_file_id_sync( + client=self._client, + file_id=file_id, file=file, mime_type=mime_type, + ) + body = SkillCreateParams(file_id=resolved).to_dict() + resp = self._client._transport.request("POST", _PATH_SKILLS, json=body) + return _coerce_skill(resp.data) + + def retrieve(self, skill_id: str) -> Skill: + resp = self._client._transport.request("GET", f"{_PATH_SKILLS}/{skill_id}") + return _coerce_skill(resp.data) + + # Alias: get() delegates to retrieve() + get = retrieve # type: ignore[assignment] + + # Alias: call() delegates to create() + call = create # type: ignore[assignment] + + def list( + self, *, source: Optional[str] = None, + limit: Optional[int] = None, page: Optional[str] = None, + ) -> CursorPage[Skill]: + params = SkillListParams(source=source, limit=limit, page=page).to_dict() + resp = self._client._transport.request("GET", _PATH_SKILLS, params=params) + return build_page( + payload=resp.data, item_factory=_coerce_skill, request_id=resp.request_id, + fetch_next=lambda nxt: self.list( + source=source, limit=limit, page=nxt, + ), + ) + + def delete(self, skill_id: str) -> DeleteResponse: + resp = self._client._transport.request("DELETE", f"{_PATH_SKILLS}/{skill_id}") + return DeleteResponse(**resp.data) + + +class SkillVersions: + """Skill version sub-resource — instance methods backed by a shared client.""" + + def __init__(self, client) -> None: + self._client = client + + def create( + self, skill_id: str, *, + file_id: Optional[str] = None, + file: Optional[FileSource] = None, + mime_type: Optional[str] = None, + ) -> SkillVersion: + resolved = _resolve_file_id_sync( + client=self._client, + file_id=file_id, file=file, mime_type=mime_type, + ) + body = SkillVersionCreateParams(file_id=resolved).to_dict() + resp = self._client._transport.request( + "POST", f"{_PATH_SKILLS}/{skill_id}/versions", + json=body, + ) + return _coerce_skill_version(resp.data) + + def list( + self, skill_id: str, *, + limit: Optional[int] = None, page: Optional[str] = None, + ) -> CursorPage[SkillVersion]: + params = SkillVersionListParams(limit=limit, page=page).to_dict() + resp = self._client._transport.request( + "GET", f"{_PATH_SKILLS}/{skill_id}/versions", params=params + ) + return build_page( + payload=resp.data, item_factory=_coerce_skill_version, request_id=resp.request_id, + fetch_next=lambda nxt: self.list( + skill_id, limit=limit, page=nxt, + ), + ) + + def retrieve(self, skill_id: str, version: str) -> SkillVersion: + resp = self._client._transport.request( + "GET", f"{_PATH_SKILLS}/{skill_id}/versions/{version}" + ) + return _coerce_skill_version(resp.data) + + # Alias: get() delegates to retrieve() + get = retrieve # type: ignore[assignment] + + def download(self, skill_id: str, version: str) -> Dict[str, Any]: + resp = self._client._transport.request( + "GET", f"{_PATH_SKILLS}/{skill_id}/versions/{version}/content" + ) + return dict(resp.data) + + +class AsyncSkills: + """Async Skill CRUD — instance methods backed by a shared client.""" + + def __init__(self, client) -> None: + self._client = client + self.versions = AsyncSkillVersions(client) + + async def create( + self, *, file_id: Optional[str] = None, + file: Optional[FileSource] = None, + mime_type: Optional[str] = None, + ) -> Skill: + resolved = await _resolve_file_id_async( + client=self._client, + file_id=file_id, file=file, mime_type=mime_type, + ) + body = SkillCreateParams(file_id=resolved).to_dict() + resp = await self._client._transport.request("POST", _PATH_SKILLS, json=body) + return _coerce_skill(resp.data) + + async def retrieve(self, skill_id: str) -> Skill: + resp = await self._client._transport.request("GET", f"{_PATH_SKILLS}/{skill_id}") + return _coerce_skill(resp.data) + + # Alias: get() delegates to retrieve() + get = retrieve # type: ignore[assignment] + + # Alias: call() delegates to create() + call = create # type: ignore[assignment] + + async def list( + self, *, source: Optional[str] = None, + limit: Optional[int] = None, page: Optional[str] = None, + ) -> AsyncCursorPage[Skill]: + params = SkillListParams(source=source, limit=limit, page=page).to_dict() + resp = await self._client._transport.request("GET", _PATH_SKILLS, params=params) + + async def fetch_next(nxt: str) -> AsyncCursorPage[Skill]: + return await self.list( + source=source, limit=limit, page=nxt, + ) + + return build_page( + payload=resp.data, item_factory=_coerce_skill, request_id=resp.request_id, + page_cls=AsyncCursorPage, fetch_next=fetch_next, + ) + + async def delete(self, skill_id: str) -> DeleteResponse: + resp = await self._client._transport.request("DELETE", f"{_PATH_SKILLS}/{skill_id}") + return DeleteResponse(**resp.data) + + +class AsyncSkillVersions: + """Async skill version sub-resource — instance methods backed by a shared client.""" + + def __init__(self, client) -> None: + self._client = client + + async def create( + self, skill_id: str, *, + file_id: Optional[str] = None, + file: Optional[FileSource] = None, + mime_type: Optional[str] = None, + ) -> SkillVersion: + resolved = await _resolve_file_id_async( + client=self._client, + file_id=file_id, file=file, mime_type=mime_type, + ) + body = SkillVersionCreateParams(file_id=resolved).to_dict() + resp = await self._client._transport.request( + "POST", f"{_PATH_SKILLS}/{skill_id}/versions", + json=body, + ) + return _coerce_skill_version(resp.data) + + async def list( + self, skill_id: str, *, + limit: Optional[int] = None, page: Optional[str] = None, + ) -> AsyncCursorPage[SkillVersion]: + params = SkillVersionListParams(limit=limit, page=page).to_dict() + resp = await self._client._transport.request( + "GET", f"{_PATH_SKILLS}/{skill_id}/versions", params=params + ) + + async def fetch_next(nxt: str) -> AsyncCursorPage[SkillVersion]: + return await self.list( + skill_id, limit=limit, page=nxt, + ) + + return build_page( + payload=resp.data, item_factory=_coerce_skill_version, request_id=resp.request_id, + page_cls=AsyncCursorPage, fetch_next=fetch_next, + ) + + async def retrieve(self, skill_id: str, version: str) -> SkillVersion: + resp = await self._client._transport.request( + "GET", f"{_PATH_SKILLS}/{skill_id}/versions/{version}" + ) + return _coerce_skill_version(resp.data) + + # Alias: get() delegates to retrieve() + get = retrieve # type: ignore[assignment] + + async def download(self, skill_id: str, version: str) -> Dict[str, Any]: + resp = await self._client._transport.request( + "GET", f"{_PATH_SKILLS}/{skill_id}/versions/{version}/content" + ) + return dict(resp.data) + + +# --------------------------------------------------------------------------- +# File-ID resolution helpers (internal) +# --------------------------------------------------------------------------- + +def _resolve_file_id_sync( + *, + client: Any, + file_id: Optional[str], + file: Optional[FileSource], + mime_type: Optional[str], +) -> str: + if (file_id is None) == (file is None): + raise TypeError( + "skills upload requires exactly one of file_id=... or file=..." + ) + if file_id is not None: + return file_id + # Use the client's files resource to upload + uploaded = client.files.upload( + file, mime_type=mime_type or "application/zip", + ) + return uploaded.id + + +async def _resolve_file_id_async( + *, + client: Any, + file_id: Optional[str], + file: Optional[FileSource], + mime_type: Optional[str], +) -> str: + if (file_id is None) == (file is None): + raise TypeError( + "skills upload requires exactly one of file_id=... or file=..." + ) + if file_id is not None: + return file_id + uploaded = await client.files.upload( + file, mime_type=mime_type or "application/zip", + ) + return uploaded.id diff --git a/dashscope/agentstudio/streaming.py b/dashscope/agentstudio/streaming.py new file mode 100644 index 0000000..0a20c56 --- /dev/null +++ b/dashscope/agentstudio/streaming.py @@ -0,0 +1,130 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""SSE (Server-Sent Events) stream iterators backed by ``httpx-sse``. + +The AgentStudio stream is documented as:: + + event: message + data: + + : keepalive + +The ``event:`` field is always the literal ``"message"`` and never used +for routing – the actual event type lives in ``data.type``. The server +does not emit ``id:`` lines; reconnection is performed via +``GET /sessions/{id}/events`` with ``created_at[gt]=...`` plus +client-side dedup by ``data.id``. + +This module wraps ``httpx_sse.EventSource`` in iterator classes that +yield parsed JSON dicts for consumption by the typed event stream layer. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from typing import Any, AsyncIterator, Dict, Iterator, Optional + +import httpx +from httpx_sse import EventSource + +from dashscope.agentstudio import exceptions +from dashscope.common.logging import logger + + +# --------------------------------------------------------------------------- +# Iterators +# --------------------------------------------------------------------------- + + +@dataclass +class EventStream: + """Synchronous iterator over an SSE response backed by ``httpx-sse``. + + Emits dict payloads (the parsed ``data`` JSON of each frame). Comment + / keepalive frames are silently skipped. The underlying response is + closed when iteration completes or :meth:`close` is invoked. + """ + + response: httpx.Response + _event_source: EventSource = field(init=False) + _closed: bool = False + + def __post_init__(self) -> None: + self._event_source = EventSource(self.response) + + def __enter__(self) -> "EventStream": + return self + + def __exit__(self, exc_type, exc, tb) -> None: + self.close() + + def __iter__(self) -> Iterator[Dict[str, Any]]: + return self._iter() + + def _iter(self) -> Iterator[Dict[str, Any]]: + if self._closed: + raise exceptions.StreamClosedError("stream already closed") + try: + for sse in self._event_source.iter_sse(): + if sse.data: + try: + yield json.loads(sse.data) + except json.JSONDecodeError: + logger.warning("SSE frame contains invalid JSON") + yield {"_raw": sse.data} + finally: + self.close() + + def close(self) -> None: + if self._closed: + return + self._closed = True + try: + self.response.close() + except Exception: # pragma: no cover + pass + + +@dataclass +class AsyncEventStream: + """Async iterator over an SSE response backed by ``httpx-sse``.""" + + response: httpx.Response + _event_source: EventSource = field(init=False) + _closed: bool = False + + def __post_init__(self) -> None: + self._event_source = EventSource(self.response) + + async def __aenter__(self) -> "AsyncEventStream": + return self + + async def __aexit__(self, exc_type, exc, tb) -> None: + await self.aclose() + + def __aiter__(self) -> AsyncIterator[Dict[str, Any]]: + return self._aiter() + + async def _aiter(self) -> AsyncIterator[Dict[str, Any]]: + if self._closed: + raise exceptions.StreamClosedError("stream already closed") + try: + async for sse in self._event_source.aiter_sse(): + if sse.data: + try: + yield json.loads(sse.data) + except json.JSONDecodeError: + logger.warning("SSE frame contains invalid JSON") + yield {"_raw": sse.data} + finally: + await self.aclose() + + async def aclose(self) -> None: + if self._closed: + return + self._closed = True + try: + await self.response.aclose() + except Exception: # pragma: no cover + pass \ No newline at end of file diff --git a/dashscope/agentstudio/tools.py b/dashscope/agentstudio/tools.py new file mode 100644 index 0000000..26bd37d --- /dev/null +++ b/dashscope/agentstudio/tools.py @@ -0,0 +1,736 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""@tool decorator, ToolSpec, and session tool runners.""" + +from __future__ import annotations + +import inspect +import json +import re +import threading +import time +import typing +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass, field +from typing import ( + Any, + Callable, + Dict, + AsyncIterator, + Iterator, + List, + Mapping, + Optional, + Sequence, + Tuple, + Union, + get_args, + get_origin, +) + +from dashscope.agentstudio.types import ( + user_custom_tool_result, + AgentCustomToolUseEvent, +) +from dashscope.agentstudio.constants import SSEEventType, SessionStatus +from dashscope.common.logging import logger + +# --------------------------------------------------------------------------- +# Schema helpers +# --------------------------------------------------------------------------- + +_PRIMITIVE_MAP: Dict[Any, str] = { + str: "string", + int: "integer", + float: "number", + bool: "boolean", + list: "array", + dict: "object", + type(None): "null", +} + + +def _python_type_to_schema(py_type: Any) -> Dict[str, Any]: + if py_type is inspect.Parameter.empty or py_type is Any: + return {} + origin = get_origin(py_type) + if origin is Union: + non_none = [a for a in get_args(py_type) if a is not type(None)] + if len(non_none) == 1: + return _python_type_to_schema(non_none[0]) + return {"oneOf": [_python_type_to_schema(a) for a in non_none]} + if origin in (list, List): + args = get_args(py_type) + if args: + return {"type": "array", "items": _python_type_to_schema(args[0])} + return {"type": "array"} + if origin in (dict, Dict): + return {"type": "object"} + mapped = _PRIMITIVE_MAP.get(py_type) + if mapped is not None: + return {"type": mapped} + return {"type": "object"} + + +def _parse_docstring(doc: str) -> Tuple[str, Dict[str, str]]: + """Split a docstring into (summary, {param: description}).""" + if not doc: + return "", {} + raw = inspect.cleandoc(doc) + lines = raw.splitlines() + descs: Dict[str, str] = {} + + # Sphinx / NumPy ``:param name:`` style + summary_end = len(lines) + sphinx_re = re.compile(r"^:param\s+([a-zA-Z_][\w]*)\s*:\s*(.*)$") + current: Optional[str] = None + for idx, line in enumerate(lines): + m = sphinx_re.match(line) + if m: + if current is None: + summary_end = min(summary_end, idx) + current = m.group(1) + descs[current] = m.group(2).strip() + elif current and line.startswith((" ", "\t")) and line.strip(): + descs[current] = (descs[current] + " " + line.strip()).strip() + elif line.strip() == "" and current: + current = None + if descs: + return "\n".join(lines[:summary_end]).strip(), descs + + # Google / Napoleon ``Args:`` style + section_re = re.compile(r"^\s*(Args|Arguments|Parameters)\s*:\s*$", flags=re.IGNORECASE) + section_idx: Optional[int] = None + for idx, line in enumerate(lines): + if section_re.match(line): + section_idx = idx + break + if section_idx is None: + return raw.strip(), {} + + summary = "\n".join(lines[:section_idx]).strip() + entry_re = re.compile(r"^\s+([a-zA-Z_][\w]*)\s*(?:\([^)]*\))?\s*:\s*(.*)$") + current = None + for line in lines[section_idx + 1 :]: + if not line.strip(): + current = None + continue + if re.match(r"^\S+\s*:\s*$", line): + break + m = entry_re.match(line) + if m: + current = m.group(1) + descs[current] = m.group(2).strip() + elif current and line.startswith((" ", "\t")): + descs[current] = (descs[current] + " " + line.strip()).strip() + return summary, descs + + +# --------------------------------------------------------------------------- +# ToolSpec +# --------------------------------------------------------------------------- + +@dataclass +class ToolSpec: + """Server-facing description of a client-executed tool.""" + + name: str + description: str + func: Optional[Callable[..., Any]] = None + is_async: bool = False + parameters: Dict[str, Any] = field(default_factory=dict) + + def to_descriptor(self) -> Dict[str, Any]: + """Serialize to the wire format for ``POST /agents``. + + The BMA backend does not accept a ``type`` field for custom tools, + so we omit it (only ``name``, ``description``, ``input_schema``). + """ + return { + "name": self.name, + "description": self.description, + "input_schema": self.parameters, + } + + @classmethod + def from_dict( + cls, + descriptor: Mapping[str, Any], + *, + func: Optional[Callable[..., Any]] = None, + ) -> "ToolSpec": + """Construct from a hand-written descriptor.""" + if "name" not in descriptor or "input_schema" not in descriptor: + raise ValueError("ToolSpec.from_dict requires at least 'name' and 'input_schema'") + return cls( + name=str(descriptor["name"]), + description=str(descriptor.get("description", "")), + func=func, + is_async=inspect.iscoroutinefunction(func) if func else False, + parameters=dict(descriptor["input_schema"]), + ) + + +def tool( + _func: Optional[Callable[..., Any]] = None, + *, + name: Optional[str] = None, + description: Optional[str] = None, +) -> Any: + """Decorate a callable to expose it as a custom tool.""" + + def _wrap(func: Callable[..., Any]) -> Callable[..., Any]: + sig = inspect.signature(func) + type_hints = typing.get_type_hints(func) + summary, param_docs = _parse_docstring(inspect.getdoc(func) or "") + properties: Dict[str, Any] = {} + required: List[str] = [] + for pname, param in sig.parameters.items(): + if pname in ("self", "cls"): + continue + if param.kind in ( + inspect.Parameter.VAR_POSITIONAL, + inspect.Parameter.VAR_KEYWORD, + ): + continue + schema = _python_type_to_schema(type_hints.get(pname, param.annotation)) + schema = dict(schema or {"type": "string"}) + doc = param_docs.get(pname) + if doc: + schema["description"] = doc + if param.default is not inspect.Parameter.empty: + # Include default=None in schema so the API knows it is optional + schema["default"] = param.default + properties[pname] = schema + if param.default is inspect.Parameter.empty: + required.append(pname) + spec = ToolSpec( + name=name or func.__name__, + description=description or summary, + func=func, + is_async=inspect.iscoroutinefunction(func), + parameters={ + "type": "object", + "properties": properties, + "required": required, + "additionalProperties": False, + }, + ) + func.__tool_spec__ = spec # type: ignore[attr-defined] + return func + + if _func is not None and callable(_func): + return _wrap(_func) + return _wrap + + +def tool_from_spec( + descriptor: Mapping[str, Any], +) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + """Decorator factory that binds a hand-written descriptor to a callable.""" + + def _wrap(func: Callable[..., Any]) -> Callable[..., Any]: + spec = ToolSpec.from_dict(descriptor, func=func) + func.__tool_spec__ = spec # type: ignore[attr-defined] + return func + + return _wrap + + +def collect_specs(items: Any) -> List[ToolSpec]: + """Resolve ``@tool``-decorated callables into a list of ToolSpec.""" + specs: List[ToolSpec] = [] + for it in items: + spec = getattr(it, "__tool_spec__", None) + if isinstance(spec, ToolSpec): + specs.append(spec) + elif isinstance(it, ToolSpec): + specs.append(it) + elif isinstance(it, Mapping): + specs.append(ToolSpec.from_dict(it)) + elif callable(it): + wrapped = tool(it) + specs.append(getattr(wrapped, "__tool_spec__")) + else: + raise TypeError(f"Unsupported tool entry: {it!r}") + return specs + + +# --------------------------------------------------------------------------- +# Runners +# --------------------------------------------------------------------------- + +@dataclass +class DispatchedTool: + """Bookkeeping record emitted by a runner.""" + + name: str + custom_tool_use_id: str + arguments: Dict[str, Any] + output: Any + is_error: bool + duration_ms: int + + +def _coerce_args(raw: Any) -> Dict[str, Any]: + if isinstance(raw, str): + try: + return json.loads(raw) + except json.JSONDecodeError: + raise ValueError( + f"Tool arguments must be valid JSON, got: {raw!r}" + ) from None + return raw or {} + + +def _extract_tool_call(event: AgentCustomToolUseEvent) -> Optional[Dict[str, Any]]: + content = event.content or [] + for block in content: + block_dict = block.to_dict() if hasattr(block, "to_dict") else dict(block) + # function_call events: data is nested inside a "data" key + inner = block_dict.get("data") or {} + if isinstance(inner, dict) and inner.get("name") and inner.get("call_id"): + return { + "name": inner["name"], + "custom_tool_use_id": inner["call_id"], + "arguments": _coerce_args(inner.get("arguments")), + } + if "name" in block_dict and ( + "custom_tool_use_id" in block_dict or "tool_use_id" in block_dict + ): + return { + "name": block_dict["name"], + "custom_tool_use_id": block_dict.get("custom_tool_use_id") + or block_dict.get("tool_use_id"), + "arguments": _coerce_args(block_dict.get("arguments") or block_dict.get("input")), + } + meta = event.metadata or {} + if isinstance(meta, dict) and meta.get("name") and meta.get("custom_tool_use_id"): + return { + "name": meta["name"], + "custom_tool_use_id": meta["custom_tool_use_id"], + "arguments": _coerce_args(meta.get("arguments")), + } + return None + + +def _format_output(value: Any) -> Any: + if isinstance(value, str): + return value + try: + return json.dumps(value, ensure_ascii=False) + except TypeError: + return repr(value) + + +def _extract_session_status(event: Any) -> Optional[str]: + """Extract session_status string from a session_status event. + + The status lives in ``content[0].data.session_status``. Returns ``None`` + when the event does not carry the expected structure. + """ + content = event.content or [] + for block in content: + block_dict = block.to_dict() if hasattr(block, "to_dict") else dict(block) + data = block_dict.get("data") + if isinstance(data, dict): + return data.get("session_status") + return None + + +class SessionToolRunner: + """Sync runner; iterate to receive :class:`DispatchedTool` records.""" + + def __init__( + self, + *, + client: Any, + session_id: str, + tools: Sequence[Any], + events: Optional[Sequence[Any]] = None, + max_idle_seconds: int = 300, + max_concurrent: int = 4, + ) -> None: + self._client = client + self._session_id = session_id + self._specs: Dict[str, ToolSpec] = { + spec.name: spec for spec in collect_specs(tools) + } + self._events = events + self._max_idle = max_idle_seconds + self._executor = ThreadPoolExecutor( + max_workers=max(1, int(max_concurrent)), + thread_name_prefix="agentstudio-tool", + ) + self._stopped = False + self._last_activity = time.monotonic() + self._lock = threading.Lock() + + def __enter__(self) -> "SessionToolRunner": + return self + + def __exit__(self, *exc) -> None: + self.close() + + def close(self) -> None: + if self._stopped: + return + self._stopped = True + self._executor.shutdown(wait=False) + + def __iter__(self) -> Iterator[DispatchedTool]: + return self.run() + + def run(self) -> Iterator[DispatchedTool]: + """Stream events and yield each completed tool dispatch. + + If ``events`` were provided at construction time, they are sent + before opening the stream. The server immediately returns HTTP + headers on the SSE connection, so the stream can be opened after + sending without blocking. + + Tool functions are submitted to a ``ThreadPoolExecutor`` so that + multiple tools can execute concurrently while the event stream + continues to be consumed. + """ + from concurrent.futures import Future + + pending: Dict[Future, Dict[str, Any]] = {} + + # Send events first (server now flushes headers immediately). + if self._events: + self._client.sessions.events.send( + self._session_id, events=self._events, + ) + self._events = None + + try: + with self._client.sessions.events.stream( + self._session_id, + timeout=self._max_idle, + ) as stream: + for event in stream: + if event.type in (SSEEventType.TOOL_CALL, SSEEventType.FUNCTION_CALL): + self._last_activity = time.monotonic() + call = _extract_tool_call(event) + if call is None: + logger.warning("custom_tool_use without identifiable name/id; skipping") + continue + future = self._executor.submit(self._dispatch_call, call) + pending[future] = call + elif event.type == SSEEventType.SESSION_STATUS: + session_status = _extract_session_status(event) + if session_status in (SessionStatus.IDLE, SessionStatus.TERMINATED): + yield from self._drain(pending) + if session_status == SessionStatus.TERMINATED: + return + if self._idle_exceeded(): + logger.debug("session idle for too long, stopping runner") + return + return + yield from self._harvest(pending) + finally: + self.close() + + def _harvest(self, pending: Dict) -> Iterator[DispatchedTool]: + """Yield records for any futures that have completed so far.""" + from concurrent.futures import Future + + done = [f for f in list(pending) if f.done()] + for future in done: + pending.pop(future) + try: + record = future.result() + except Exception: # pragma: no cover + logger.exception("tool dispatch future raised") + continue + if record is not None: + yield record + + def _drain(self, pending: Dict) -> Iterator[DispatchedTool]: + """Block until all pending futures complete and yield their records.""" + from concurrent.futures import wait, FIRST_COMPLETED + + while pending: + done_set, _ = wait(list(pending), return_when=FIRST_COMPLETED) + for future in done_set: + pending.pop(future, None) + try: + record = future.result() + except Exception: # pragma: no cover + logger.exception("tool dispatch future raised") + continue + if record is not None: + yield record + + def _idle_exceeded(self) -> bool: + return (time.monotonic() - self._last_activity) >= self._max_idle + + def _dispatch(self, event: Any) -> Optional[DispatchedTool]: + """Legacy single-dispatch entry point (kept for backward compat).""" + call = _extract_tool_call(event) + if call is None: + return None + return self._dispatch_call(call) + + def _dispatch_call(self, call: Dict[str, Any]) -> Optional[DispatchedTool]: + """Execute a single tool call extracted from *call* dict.""" + name = call["name"] + spec = self._specs.get(name) + if spec is None: + self._post_result( + custom_tool_use_id=call["custom_tool_use_id"], + output=f"Tool '{name}' not registered with this runner.", + is_error=True, + ) + return DispatchedTool( + name=name, + custom_tool_use_id=call["custom_tool_use_id"], + arguments=call["arguments"], + output=None, + is_error=True, + duration_ms=0, + ) + + started = time.monotonic() + try: + if spec.func is None: + result = f"Tool '{name}' is a descriptor-only tool (no callable registered)" + is_error = True + else: + result = spec.func(**call["arguments"]) + is_error = False + except Exception as exc: # noqa: BLE001 + logger.exception("tool %r raised", name) + result = f"{type(exc).__name__}: {exc}" + is_error = True + duration_ms = int((time.monotonic() - started) * 1000) + + formatted = _format_output(result) + self._post_result( + custom_tool_use_id=call["custom_tool_use_id"], + output=formatted, + is_error=is_error, + ) + return DispatchedTool( + name=name, + custom_tool_use_id=call["custom_tool_use_id"], + arguments=call["arguments"], + output=formatted, + is_error=is_error, + duration_ms=duration_ms, + ) + + def _post_result(self, *, custom_tool_use_id: str, output: Any, is_error: bool) -> None: + evt = user_custom_tool_result( + custom_tool_use_id=custom_tool_use_id, + content=output, + is_error=is_error, + ) + try: + self._client.sessions.events.send( + self._session_id, [evt], + ) + except Exception: # pragma: no cover + logger.exception("failed to post tool result") + + +class AsyncSessionToolRunner: + """Async counterpart of :class:`SessionToolRunner`.""" + + def __init__( + self, + *, + client: Any, + session_id: str, + tools: Sequence[Any], + events: Optional[Sequence[Any]] = None, + max_idle_seconds: int = 300, + max_concurrent: int = 4, + ) -> None: + self._client = client + self._session_id = session_id + self._specs: Dict[str, ToolSpec] = { + spec.name: spec for spec in collect_specs(tools) + } + self._events = events + self._max_idle = max_idle_seconds + self._max_concurrent = max(1, int(max_concurrent)) + self._pending_tasks: set = set() + + async def __aenter__(self) -> "AsyncSessionToolRunner": + return self + + async def __aexit__(self, *exc) -> None: + await self.aclose() + + async def aclose(self) -> None: + """Cancel any pending tool dispatch tasks.""" + for task in self._pending_tasks: + task.cancel() + self._pending_tasks.clear() + + def __aiter__(self): + return self.run() + + async def run(self) -> AsyncIterator[DispatchedTool]: + """Async generator yielding :class:`DispatchedTool` records. + + If ``events`` were provided at construction time, they are sent + before opening the stream. The server immediately returns HTTP + headers on the SSE connection, so the stream can be opened after + sending without blocking. + + Tool dispatches are spawned as ``asyncio.Task`` objects so that + multiple tools can execute concurrently while the event stream + continues to be consumed. A semaphore bounds the number of + concurrent dispatches to ``max_concurrent``. + """ + import asyncio + + loop = asyncio.get_running_loop() + last_activity = loop.time() + sem = asyncio.Semaphore(self._max_concurrent) + self._pending_tasks.clear() + + async def _guarded_dispatch(call: Dict[str, Any]) -> Optional[DispatchedTool]: + async with sem: + return await self._dispatch_call(call) + + # Send events first (server now flushes headers immediately). + if self._events: + await self._client.sessions.events.send( + self._session_id, events=self._events, + ) + self._events = None + + async with await self._client.sessions.events.stream( + self._session_id, + timeout=self._max_idle, + ) as stream: + async for event in stream: + if event.type in (SSEEventType.TOOL_CALL, SSEEventType.FUNCTION_CALL): + last_activity = loop.time() + call = _extract_tool_call(event) + if call is None: + continue + task = asyncio.create_task(_guarded_dispatch(call)) + self._pending_tasks.add(task) + elif event.type == SSEEventType.SESSION_STATUS: + session_status = _extract_session_status(event) + if session_status in (SessionStatus.IDLE, SessionStatus.TERMINATED): + # Drain pending tasks before exiting. + async for record in self._drain(self._pending_tasks): + yield record + if session_status == SessionStatus.TERMINATED: + return + if loop.time() - last_activity >= self._max_idle: + return + return + # Yield completed dispatches (non-blocking). + async for record in self._harvest(self._pending_tasks): + yield record + + async def _harvest(self, pending: set) -> AsyncIterator[DispatchedTool]: + """Yield records for any tasks that have completed so far.""" + done = {t for t in pending if t.done()} + for task in done: + pending.discard(task) + try: + record = task.result() + except Exception: # pragma: no cover + logger.exception("tool dispatch task raised") + continue + if record is not None: + yield record + + async def _drain(self, pending: set) -> AsyncIterator[DispatchedTool]: + """Wait for all pending tasks to complete and yield their records.""" + import asyncio + + while pending: + done, still_pending = await asyncio.wait( + pending, return_when=asyncio.FIRST_COMPLETED, + ) + for task in done: + pending.discard(task) + try: + record = task.result() + except Exception: # pragma: no cover + logger.exception("tool dispatch task raised") + continue + if record is not None: + yield record + + async def _dispatch(self, event: Any) -> Optional[DispatchedTool]: + """Legacy single-dispatch entry point (kept for backward compat).""" + call = _extract_tool_call(event) + if call is None: + return None + return await self._dispatch_call(call) + + async def _dispatch_call(self, call: Dict[str, Any]) -> Optional[DispatchedTool]: + import asyncio + + spec = self._specs.get(call["name"]) + if spec is None: + await self._post_result( + call["custom_tool_use_id"], + output=f"Tool '{call['name']}' not registered with this runner.", + is_error=True, + ) + return DispatchedTool( + name=call["name"], + custom_tool_use_id=call["custom_tool_use_id"], + arguments=call["arguments"], + output=None, + is_error=True, + duration_ms=0, + ) + + loop = asyncio.get_running_loop() + started = loop.time() + try: + if spec.func is None: + result = f"Tool '{call['name']}' is a descriptor-only tool (no callable registered)" + is_error = True + elif spec.is_async: + result = await spec.func(**call["arguments"]) + is_error = False + else: + result = await loop.run_in_executor( + None, lambda: spec.func(**call["arguments"]) + ) + is_error = False + except Exception as exc: # noqa: BLE001 + logger.exception("tool %r raised", call["name"]) + result = f"{type(exc).__name__}: {exc}" + is_error = True + duration_ms = int((loop.time() - started) * 1000) + formatted = _format_output(result) + await self._post_result( + call["custom_tool_use_id"], output=formatted, is_error=is_error + ) + return DispatchedTool( + name=call["name"], + custom_tool_use_id=call["custom_tool_use_id"], + arguments=call["arguments"], + output=formatted, + is_error=is_error, + duration_ms=duration_ms, + ) + + async def _post_result( + self, custom_tool_use_id: str, *, output: Any, is_error: bool + ) -> None: + evt = user_custom_tool_result( + custom_tool_use_id=custom_tool_use_id, + content=output, + is_error=is_error, + ) + try: + await self._client.sessions.events.send( + self._session_id, [evt], + ) + except Exception: # pragma: no cover + logger.exception("failed to post tool result") diff --git a/dashscope/agentstudio/transport.py b/dashscope/agentstudio/transport.py new file mode 100644 index 0000000..b291ea8 --- /dev/null +++ b/dashscope/agentstudio/transport.py @@ -0,0 +1,617 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""HTTP transport for the AgentStudio SDK. + +This module wraps ``httpx.Client`` (sync) and ``httpx.AsyncClient`` +(async) so the rest of the SDK can speak in plain Python data structures. +The transport layer is responsible for: + +* injecting auth / workspace / uid / x-request-id headers, +* JSON encoding / decoding the request and response bodies, +* converting non-2xx responses into :class:`AgentStudioError`, +* retry-on-network-error with bounded exponential backoff. + +It deliberately does *not* implement model marshalling – that lives in +the resource modules so each endpoint can hydrate its own typed shapes. +""" + +from __future__ import annotations + +import asyncio +import json as _json +import random +import socket +import time +import uuid +from dataclasses import dataclass, field +from typing import Any, Dict, IO, Mapping, Optional, Tuple, Union + +import httpx + +from dashscope.agentstudio import exceptions +from dashscope.agentstudio.constants import ( + AGENTSTUDIO_DEFAULT_TIMEOUT, + AGENTSTUDIO_MAX_RETRIES, +) +from dashscope.common.logging import logger + + +# --------------------------------------------------------------------------- +# Base data classes (merged from _base.py) +# --------------------------------------------------------------------------- + + +@dataclass +class RequestOptions: + """Typed HTTP request options. + + Encapsulates all parameters needed to issue an HTTP request through + the transport layer, replacing bare keyword arguments for better + type safety and testability. + """ + + method: str + path: str + json: Optional[Dict[str, Any]] = None + params: Optional[Dict[str, Any]] = None + data: Optional[Union[bytes, IO]] = None + files: Optional[Dict[str, Any]] = None + headers: Optional[Dict[str, str]] = None + timeout: Optional[float] = None + max_retries: Optional[int] = None + stream: bool = False + + +@dataclass +class APIResponse: + """Typed API response. + + Wraps the parsed response data together with metadata (request_id, + status_code) so callers no longer need to unpack anonymous tuples. + """ + + data: Dict[str, Any] + request_id: Optional[str] = None + status_code: int = 200 + +# --------------------------------------------------------------------------- +# Constants (merged from _constants.py) +# --------------------------------------------------------------------------- + +HEADER_AUTHORIZATION = "Authorization" +HEADER_REQUEST_ID = "x-request-id" +HEADER_UID = "x-dashscope-uid" +HEADER_USER_AGENT = "User-Agent" + +# --------------------------------------------------------------------------- +# Response normalization helpers (merged from _response.py) +# --------------------------------------------------------------------------- + + +def unwrap(payload: Any) -> Tuple[Dict[str, Any], Optional[str]]: + """Normalize a JSON response body. + + Returns + ------- + (data, request_id) + ``data`` is the canonical resource payload with ``requestId`` + rewritten to ``request_id``. ``request_id`` is also returned as a + separate value for downstream metadata recording. + """ + + if not isinstance(payload, dict): + return ({"value": payload}, None) + + data = dict(payload) + request_id = data.pop("requestId", None) + if request_id is not None and "request_id" not in data: + data["request_id"] = request_id + rid = data.get("request_id") + return data, rid if isinstance(rid, str) else None + + +def is_error_payload(payload: Any) -> bool: + """Identify error bodies for both documented and pre-release shapes.""" + + if not isinstance(payload, dict): + return False + if payload.get("type") == "error": + return True + err = payload.get("error") + if isinstance(err, dict): + return any(k in err for k in ("code", "message", "error_code", "error_message")) + return False + + +def _new_request_id() -> str: + """Generate ``req_<26-char ULID-ish>``. + + We use a UUID4 substring rather than an actual ULID to avoid an extra + runtime dependency – the backend treats this header opaquely. + """ + + return "req_" + uuid.uuid4().hex[:26] + + +def _resolve_timeout(timeout: Union[float, httpx.Timeout, Tuple[float, float], None]) -> httpx.Timeout: + if timeout is None: + return AGENTSTUDIO_DEFAULT_TIMEOUT + if isinstance(timeout, httpx.Timeout): + return timeout + if isinstance(timeout, tuple): + return httpx.Timeout(timeout[1], connect=timeout[0]) + return httpx.Timeout(float(timeout), connect=AGENTSTUDIO_DEFAULT_TIMEOUT.connect) + + +def _backoff(attempt: int) -> float: + """Capped jittered exponential backoff.""" + + base = min(2 ** attempt, 8.0) + return base + random.random() * 0.25 + + +def _get_retry_after(headers: Mapping[str, str]) -> Optional[float]: + """Parse retry delay from response headers. + + Checks ``retry-after-ms`` (milliseconds), then ``retry-after`` (seconds). + Returns seconds or None. + """ + try: + ms = headers.get("retry-after-ms") + if ms is not None: + return float(ms) / 1000 + except (TypeError, ValueError): + pass + try: + sec = headers.get("retry-after") + if sec is not None: + return float(sec) + except (TypeError, ValueError): + pass + return None + + +def _should_retry(status_code: int, headers: Mapping[str, str]) -> bool: + """Decide whether an HTTP response should be retried.""" + should_retry_header = headers.get("x-should-retry") + if should_retry_header == "true": + return True + if should_retry_header == "false": + return False + return status_code in (408, 409, 429) or status_code >= 500 + + +def _should_retry_exception(err: BaseException) -> Tuple[bool, Optional[Any]]: + """Walk the ``__cause__`` chain to decide if an exception is retryable. + + Returns ``(should_retry, response_or_none)``. + """ + seen: set[int] = set() + current: Optional[BaseException] = err + while current is not None and id(current) not in seen: + seen.add(id(current)) + if isinstance(current, exceptions.AgentStudioError): + return False, None + if isinstance(current, httpx.TimeoutException): + return True, None + if isinstance(current, httpx.NetworkError): + return True, None + current = current.__cause__ + return False, None + + +# --------------------------------------------------------------------------- +# Header builder (shared by sync + async transports) +# --------------------------------------------------------------------------- + + +def build_headers( + *, + api_key: Optional[str], + uid: Optional[str], + user_agent: str, + extra: Optional[Mapping[str, str]] = None, + json_body: bool = True, +) -> Dict[str, str]: + """Compose the canonical AgentStudio request headers.""" + + if not api_key: + raise exceptions.AuthenticationError( + "api_key is required. Pass it via Client(api_key=...) or " + "the DASHSCOPE_API_KEY environment variable.", + code="authentication_error", + ) + + headers: Dict[str, str] = { + HEADER_AUTHORIZATION: f"Bearer {api_key}", + HEADER_USER_AGENT: user_agent, + } + if json_body: + headers["Content-Type"] = "application/json" + headers["Accept"] = "application/json" + if uid: + headers[HEADER_UID] = uid + if extra: + for k, v in extra.items(): + if v is not None: + headers[k] = v + return headers + + +# --------------------------------------------------------------------------- +# Sync transport (httpx) +# --------------------------------------------------------------------------- + + +class SyncTransport: + """Thin ``httpx.Client`` wrapper used by the sync client.""" + + def __init__( + self, + *, + base_url: str, + api_key: Optional[str], + workspace: Optional[str], + uid: Optional[str], + user_agent: str, + timeout: Union[float, httpx.Timeout, Tuple[float, float], None], + max_retries: int = AGENTSTUDIO_MAX_RETRIES, + http_client: Optional[httpx.Client] = None, + ) -> None: + self.base_url = base_url.rstrip("/") + self.api_key = api_key + self.workspace = workspace + self.uid = uid + self.user_agent = user_agent + self.timeout = timeout + self.max_retries = max_retries + self._owns_client = http_client is None + if http_client is not None: + self._client = http_client + else: + socket_options = [(socket.SOL_SOCKET, socket.SO_KEEPALIVE, True)] + if hasattr(socket, 'TCP_KEEPINTVL'): + socket_options.append((socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 60)) + if hasattr(socket, 'TCP_KEEPIDLE'): + socket_options.append((socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 60)) + if hasattr(socket, 'TCP_KEEPCNT'): + socket_options.append((socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 5)) + transport = httpx.HTTPTransport(socket_options=socket_options) + self._client = httpx.Client( + timeout=_resolve_timeout(timeout), + limits=httpx.Limits(max_connections=1000, max_keepalive_connections=100), + transport=transport, + ) + + def _absolute_url(self, path: str) -> str: + if path.startswith(("http://", "https://")): + return path + if not path.startswith("/"): + path = "/" + path + return self.base_url + path + + def request( + self, + method: str, + path: str, + *, + params: Optional[Mapping[str, Any]] = None, + json: Any = None, + data: Any = None, + files: Any = None, + extra_headers: Optional[Mapping[str, str]] = None, + timeout: Union[float, httpx.Timeout, Tuple[float, float], None] = None, + stream: bool = False, + ) -> Any: + """Issue an HTTP request and return an :class:`APIResponse`. + + For ``stream=True`` returns the raw :class:`httpx.Response` so + the streaming module can iterate over it. + """ + + headers = build_headers( + api_key=self.api_key, + uid=self.uid, + user_agent=self.user_agent, + extra=extra_headers, + json_body=files is None and method.upper() not in ("GET", "HEAD", "DELETE"), + ) + if files is not None: + headers.pop("Content-Type", None) + + url = self._absolute_url(path) + req_timeout = _resolve_timeout(timeout if timeout is not None else self.timeout) + + attempt = 0 + last_exc: Optional[Exception] = None + while attempt <= self.max_retries: + try: + req = self._client.build_request( + method=method.upper(), + url=url, + params=params, + json=json, + content=data, + files=files, + headers=headers, + timeout=req_timeout, + ) + resp = self._client.send(req, stream=stream) + except httpx.TimeoutException as exc: + last_exc = exc + if attempt >= self.max_retries: + raise exceptions.APITimeoutError(str(exc)) from exc + except Exception as exc: + if isinstance(exc, exceptions.AgentStudioError): + raise + should_retry, _ = _should_retry_exception(exc) + last_exc = exc + if attempt >= self.max_retries or not should_retry: + raise exceptions.APIConnectionError(str(exc)) from exc + else: + if not stream and _should_retry(resp.status_code, resp.headers): + last_exc = exceptions.APIStatusError( + f"HTTP {resp.status_code}", + status_code=resp.status_code, + ) + if attempt >= self.max_retries: + try: + return self._parse(resp) + finally: + resp.close() + wait = _get_retry_after(resp.headers) or _backoff(attempt) + resp.close() + time.sleep(wait) + attempt += 1 + continue + if stream: + return resp + try: + return self._parse(resp) + finally: + if not stream: + resp.close() + time.sleep(_backoff(attempt)) + attempt += 1 + raise exceptions.APIConnectionError(str(last_exc) if last_exc else "unknown") + + def _parse(self, resp: httpx.Response) -> APIResponse: + header_rid = resp.headers.get("x-request-id") + ctype = resp.headers.get("Content-Type", "") + + if not ctype.startswith("application/json") and resp.status_code < 400: + payload: Any = {"_binary": resp.content, "_content_type": ctype} + data, rid = unwrap(payload) + return APIResponse(data=data, request_id=rid or header_rid, status_code=resp.status_code) + + try: + payload = resp.json() + except ValueError: + payload = {"raw": resp.text} + + if resp.status_code >= 400 or is_error_payload(payload): + raise exceptions.from_response( + status_code=resp.status_code, body=payload, headers=resp.headers + ) + + data, rid = unwrap(payload) + return APIResponse(data=data, request_id=rid or header_rid, status_code=resp.status_code) + + def close(self) -> None: + if self._owns_client: + self._client.close() + + @property + def is_closed(self) -> bool: + return self._client.is_closed + + def __enter__(self) -> "SyncTransport": + return self + + def __exit__( + self, + exc_type: Optional[type], + exc: Optional[BaseException], + exc_tb: Any, + ) -> None: + self.close() + + def __del__(self) -> None: + if self.is_closed: + return + try: + self.close() + except Exception: + pass + + +# --------------------------------------------------------------------------- +# Async transport (httpx) +# --------------------------------------------------------------------------- + + +class AsyncTransport: + """``httpx.AsyncClient`` transport with built-in connection pooling.""" + + def __init__( + self, + *, + base_url: str, + api_key: Optional[str], + workspace: Optional[str], + uid: Optional[str], + user_agent: str, + timeout: Union[float, httpx.Timeout, Tuple[float, float], None], + max_retries: int = AGENTSTUDIO_MAX_RETRIES, + http_client: Optional[httpx.AsyncClient] = None, + ) -> None: + self.base_url = base_url.rstrip("/") + self.api_key = api_key + self.workspace = workspace + self.uid = uid + self.user_agent = user_agent + self.timeout = timeout + self.max_retries = max_retries + self._owns_client = http_client is None + if http_client is not None: + self._client = http_client + else: + socket_options = [(socket.SOL_SOCKET, socket.SO_KEEPALIVE, True)] + if hasattr(socket, 'TCP_KEEPINTVL'): + socket_options.append((socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 60)) + if hasattr(socket, 'TCP_KEEPIDLE'): + socket_options.append((socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 60)) + if hasattr(socket, 'TCP_KEEPCNT'): + socket_options.append((socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 5)) + transport = httpx.AsyncHTTPTransport(socket_options=socket_options) + self._client = httpx.AsyncClient( + timeout=_resolve_timeout(timeout), + limits=httpx.Limits(max_connections=1000, max_keepalive_connections=100), + transport=transport, + ) + + def _absolute_url(self, path: str) -> str: + if path.startswith(("http://", "https://")): + return path + if not path.startswith("/"): + path = "/" + path + return self.base_url + path + + async def request( + self, + method: str, + path: str, + *, + params: Optional[Mapping[str, Any]] = None, + json: Any = None, + data: Any = None, + files: Any = None, + extra_headers: Optional[Mapping[str, str]] = None, + timeout: Union[float, httpx.Timeout, Tuple[float, float], None] = None, + stream: bool = False, + ) -> Any: + """Issue an HTTP request. For ``stream=True`` returns raw ``httpx.Response``.""" + + headers = build_headers( + api_key=self.api_key, + uid=self.uid, + user_agent=self.user_agent, + extra=extra_headers, + json_body=files is None and method.upper() not in ("GET", "HEAD", "DELETE"), + ) + if files is not None: + headers.pop("Content-Type", None) + req_timeout = _resolve_timeout(timeout if timeout is not None else self.timeout) + url = self._absolute_url(path) + + attempt = 0 + last_exc: Optional[Exception] = None + while attempt <= self.max_retries: + try: + req = self._client.build_request( + method=method.upper(), + url=url, + params=params, + json=json, + content=data, + files=files, + headers=headers, + timeout=req_timeout, + ) + resp = await self._client.send(req, stream=stream) + except httpx.TimeoutException as exc: + last_exc = exc + if attempt >= self.max_retries: + raise exceptions.APITimeoutError(str(exc)) from exc + except Exception as exc: + if isinstance(exc, exceptions.AgentStudioError): + raise + should_retry, _ = _should_retry_exception(exc) + last_exc = exc + if attempt >= self.max_retries or not should_retry: + raise exceptions.APIConnectionError(str(exc)) from exc + else: + if not stream and _should_retry(resp.status_code, resp.headers): + last_exc = exceptions.APIStatusError( + f"HTTP {resp.status_code}", + status_code=resp.status_code, + ) + if attempt >= self.max_retries: + try: + return await self._parse(resp) + finally: + await resp.aclose() + wait = _get_retry_after(resp.headers) or _backoff(attempt) + await resp.aclose() + await asyncio.sleep(wait) + attempt += 1 + continue + if stream: + return resp + try: + return await self._parse(resp) + finally: + if not stream: + await resp.aclose() + await asyncio.sleep(_backoff(attempt)) + attempt += 1 + raise exceptions.APIConnectionError(str(last_exc) if last_exc else "unknown") + + async def _parse(self, resp: httpx.Response) -> APIResponse: + header_rid = resp.headers.get("x-request-id") + ctype = resp.headers.get("Content-Type", "") + + if not ctype.startswith("application/json") and resp.status_code < 400: + content = await resp.aread() + data, rid = unwrap({"_binary": content, "_content_type": ctype}) + return APIResponse(data=data, request_id=rid or header_rid, status_code=resp.status_code) + + try: + payload = resp.json() + except ValueError: + payload = {"raw": resp.text} + + if resp.status_code >= 400 or is_error_payload(payload): + raise exceptions.from_response( + status_code=resp.status_code, body=payload, headers=resp.headers + ) + + data, rid = unwrap(payload) + return APIResponse(data=data, request_id=rid or header_rid, status_code=resp.status_code) + + def close(self) -> None: + """Best-effort synchronous close (for __del__ / GC safety).""" + if self._owns_client: + try: + self._client.close() + except Exception: + pass + + async def aclose(self) -> None: + if self._owns_client: + await self._client.aclose() + + @property + def is_closed(self) -> bool: + return self._client.is_closed + + async def __aenter__(self) -> "AsyncTransport": + return self + + async def __aexit__( + self, + exc_type: Optional[type], + exc: Optional[BaseException], + exc_tb: Any, + ) -> None: + await self.aclose() + + def __del__(self) -> None: + if self.is_closed: + return + try: + asyncio.get_running_loop().create_task(self.aclose()) + except RuntimeError: + self.close() + except Exception: + pass \ No newline at end of file diff --git a/dashscope/agentstudio/types/__init__.py b/dashscope/agentstudio/types/__init__.py new file mode 100644 index 0000000..d9af404 --- /dev/null +++ b/dashscope/agentstudio/types/__init__.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +"""AgentStudio type definitions. + +Re-exports all public symbols from sub-modules for backward compatibility: + from dashscope.agentstudio.types import Agent, Message, user_message +""" +from dashscope.agentstudio.types.models import * # noqa: F401,F403 +from dashscope.agentstudio.types.models import ( + BaseModel, + Metadata, Scope, Mount, Networking, Packages, EnvironmentConfig, + StopReason, Usage, + TextBlock, ImageBlock, AudioBlock, DataBlock, FileBlock, RefusalBlock, ErrorBlock, + ContentBlock, parse_content_block, parse_content_blocks, + Agent, AgentVersion, Environment, File, Skill, SkillVersion, + Session, SessionThread, DeleteResponse, + Message, parse_message, parse_server_event, + user_message, user_interrupt, user_tool_confirmation, + user_custom_tool_result, user_tool_result, user_define_outcome, + ServerEvent, UnknownServerEvent, + AgentMessageEvent, AgentThinkingEvent, AgentToolUseEvent, + AgentToolResultEvent, AgentCustomToolUseEvent, AgentCustomToolResultEvent, + AgentMcpToolUseEvent, AgentMcpToolResultEvent, + AgentThreadMessageSentEvent, AgentThreadMessageReceivedEvent, + AgentThreadContextCompactedEvent, + SessionStatusRunningEvent, SessionStatusIdleEvent, + SessionStatusReschedulingEvent, SessionStatusTerminatedEvent, + SessionErrorEvent, SessionUpdatedEvent, + SessionThreadCreatedEvent, SessionThreadStatusEvent, + SpanModelRequestStartEvent, SpanModelRequestEndEvent, +) +from dashscope.agentstudio.types.params import ( + AgentCreateParams, AgentUpdateParams, AgentListParams, + EnvironmentCreateParams, EnvironmentUpdateParams, EnvironmentListParams, + SessionCreateParams, SessionUpdateParams, SessionListParams, + SessionEventSendParams, SessionEventListParams, + FileListParams, + SkillCreateParams, SkillListParams, + SkillVersionCreateParams, SkillVersionListParams, +) diff --git a/dashscope/agentstudio/types/models.py b/dashscope/agentstudio/types/models.py new file mode 100644 index 0000000..e548475 --- /dev/null +++ b/dashscope/agentstudio/types/models.py @@ -0,0 +1,694 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Unified type definitions for the AgentStudio SDK. + +This module merges all types from the ``types/`` sub-package into a single +file organized into six sections: + + 1. Common types (EnvironmentConfig, Usage, Metadata, …) + 2. Content blocks (TextBlock, ImageBlock, … ContentBlock) + 3. Resource objects (Agent, Session, File, Skill, …) + 4. Unified Message (replaces 20+ individual server-event classes) + 5. Client event helpers (user_message, user_interrupt, …) + 6. Backward compatibility aliases +""" + +from __future__ import annotations + +from typing import Any, ClassVar, Dict, Iterable, List, Mapping, Optional, Sequence, Union + +from dashscope.agentstudio.constants import SSEEventType, MessageRole, BlockType, SessionStatus + + +# =========================================================================== +# Section 0 – Base model +# =========================================================================== + + +class BaseModel: + """Lightweight typed container. + + Subclasses declare the attributes they expect via :attr:`_fields` so + :meth:`to_dict` can emit a deterministic ordering. Extra fields are + preserved in :attr:`extra` to maintain forward compatibility when the + server adds new fields. + """ + + _fields: ClassVar[Iterable[str]] = () + + def __init__(self, **kwargs: Any) -> None: + self._raw: Dict[str, Any] = dict(kwargs) + for name in self._fields: + setattr(self, name, kwargs.get(name)) + self.extra: Dict[str, Any] = { + k: v for k, v in kwargs.items() if k not in set(self._fields) + } + + @classmethod + def from_dict(cls, payload: Optional[Mapping[str, Any]]) -> Optional["BaseModel"]: + if payload is None: + return None + return cls(**dict(payload)) + + def to_dict(self) -> Dict[str, Any]: + out: Dict[str, Any] = {} + for name in self._fields: + value = getattr(self, name, None) + if value is None: + continue + out[name] = _serialize(value) + for k, v in self.extra.items(): + if v is None: + continue + out[k] = _serialize(v) + return out + + def __repr__(self) -> str: # pragma: no cover - debug helper + body = ", ".join( + f"{name}={getattr(self, name)!r}" + for name in self._fields + if getattr(self, name, None) is not None + ) + return f"{type(self).__name__}({body})" + + def __eq__(self, other: Any) -> bool: + if not isinstance(other, BaseModel): + return NotImplemented + return self.to_dict() == other.to_dict() + + +def _serialize(value: Any) -> Any: + if isinstance(value, BaseModel): + return value.to_dict() + if isinstance(value, list): + return [_serialize(v) for v in value] + if isinstance(value, dict): + return {k: _serialize(v) for k, v in value.items()} + return value + + +# =========================================================================== +# Section 1 – Common types +# =========================================================================== + + +class Metadata(BaseModel): + """Free-form metadata bag attached to most AgentStudio resources. + + The server stores ``metadata`` as a flat string-keyed dictionary + (max 16 keys, key length <= 64, value length <= 512). The SDK does + not validate these limits client-side – the server returns + ``invalid_request_error`` if they are exceeded. + """ + + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + + def to_dict(self) -> Dict[str, Any]: + return dict(self._raw) + + +class Scope(BaseModel): + """Resource scope (organization / session).""" + + _fields = ("type", "id") + + +class Mount(BaseModel): + """Session resource mount descriptor.""" + + _fields = ("type", "file_id", "skill_id", "mount_path") + + +class Networking(BaseModel): + _fields = ("type",) # "unrestricted" | "restricted" + + +class Packages(BaseModel): + _fields = ("apt", "gem", "pip", "cargo", "go", "npm") + + +class EnvironmentConfig(BaseModel): + _fields = ("type", "networking", "packages") + + def __init__(self, **kwargs: Any) -> None: + if "networking" in kwargs and isinstance(kwargs["networking"], dict): + kwargs["networking"] = Networking(**kwargs["networking"]) + if "packages" in kwargs and isinstance(kwargs["packages"], dict): + kwargs["packages"] = Packages(**kwargs["packages"]) + super().__init__(**kwargs) + + +class StopReason(BaseModel): + """Carried by ``session_status`` idle events.""" + + _fields = ("type", "event_ids") + + +class Usage(BaseModel): + """Token usage carried by ``span.model_request_end`` events.""" + + _fields = ( + "input_tokens", + "output_tokens", + "cache_creation_input_tokens", + "cache_read_input_tokens", + "speed", + ) + + +# =========================================================================== +# Section 2 – Content blocks +# =========================================================================== + + +class TextBlock(BaseModel): + _fields = ("type", "text", "citations") + + def __init__(self, **kwargs: Any) -> None: + kwargs.setdefault("type", BlockType.TEXT) + super().__init__(**kwargs) + + def __str__(self) -> str: + return getattr(self, "text", "") or "" + + +class ImageBlock(BaseModel): + _fields = ("type", "image_url", "file_id", "image_data", "media_type") + + def __init__(self, **kwargs: Any) -> None: + kwargs.setdefault("type", "image") + super().__init__(**kwargs) + + def __str__(self) -> str: + return f"[image] {getattr(self, 'image_url', '') or getattr(self, 'file_id', '') or ''}" + + +class AudioBlock(BaseModel): + _fields = ("type", "data", "format", "file_id") + + def __init__(self, **kwargs: Any) -> None: + kwargs.setdefault("type", "audio") + super().__init__(**kwargs) + + def __str__(self) -> str: + return f"[audio] {getattr(self, 'file_id', '') or ''}" + + +class DataBlock(BaseModel): + _fields = ("type", "data", "name", "title", "context") + + def __init__(self, **kwargs: Any) -> None: + kwargs.setdefault("type", "data") + super().__init__(**kwargs) + + def __str__(self) -> str: + d = getattr(self, "data", None) or {} + if not isinstance(d, dict): + return str(d) + name = d.get("name", "") + args = d.get("arguments", "") + output = d.get("output", "") + status = d.get("session_status", "") + if name and args: + return f"{name}({args})" + if output: + return str(output) + if status: + return status + return str(d) if d else "" + + +class FileBlock(BaseModel): + _fields = ("type", "file_url", "file_id", "file_data", "media_type", "filename") + + def __init__(self, **kwargs: Any) -> None: + kwargs.setdefault("type", "file") + super().__init__(**kwargs) + + def __str__(self) -> str: + return f"[file] {getattr(self, 'filename', '') or getattr(self, 'file_id', '') or ''}" + + +class RefusalBlock(BaseModel): + _fields = ("type", "refusal") + + def __init__(self, **kwargs: Any) -> None: + kwargs.setdefault("type", "refusal") + super().__init__(**kwargs) + + def __str__(self) -> str: + return f"[refusal] {getattr(self, 'refusal', '')}" + + +class ErrorBlock(BaseModel): + _fields = ("type", "error_code", "message") + + def __init__(self, **kwargs: Any) -> None: + kwargs.setdefault("type", BlockType.ERROR) + super().__init__(**kwargs) + + def __str__(self) -> str: + return f"[error] {getattr(self, 'message', '') or getattr(self, 'error_code', '')}" + + +_CONTENT_REGISTRY: Dict[str, type] = { + BlockType.TEXT: TextBlock, + BlockType.IMAGE: ImageBlock, + BlockType.AUDIO: AudioBlock, + BlockType.DATA: DataBlock, + BlockType.FILE: FileBlock, + BlockType.REFUSAL: RefusalBlock, + BlockType.ERROR: ErrorBlock, + SSEEventType.TOOL_CALL: DataBlock, + SSEEventType.TOOL_CALL_OUTPUT: DataBlock, + SSEEventType.SESSION_STATUS: DataBlock, + SSEEventType.REASONING: DataBlock, + SSEEventType.MCP_CALL: DataBlock, + SSEEventType.MCP_CALL_OUTPUT: DataBlock, + SSEEventType.FUNCTION_CALL: DataBlock, + SSEEventType.FUNCTION_CALL_OUTPUT: DataBlock, +} + +ContentBlock = Union[ + TextBlock, ImageBlock, AudioBlock, DataBlock, FileBlock, RefusalBlock, ErrorBlock +] + + +def parse_content_block(payload: Mapping[str, Any]) -> ContentBlock: + cls = _CONTENT_REGISTRY.get(payload.get("type", ""), TextBlock) + return cls(**dict(payload)) + + +def parse_content_blocks( + items: Optional[List[Mapping[str, Any]]], +) -> List[ContentBlock]: + if not items: + return [] + return [parse_content_block(it) for it in items if isinstance(it, Mapping)] + + +# =========================================================================== +# Section 3 – Resource objects +# =========================================================================== + + +class Agent(BaseModel): + _fields = ( + "id", + "type", + "version", + "name", + "description", + "model", + "system", + "tools", + "mcp_servers", + "skills", + "metadata", + "archived_at", + "created_at", + "updated_at", + "request_id", + ) + + @property + def system_prompt(self) -> Optional[str]: + """Alias: server field is ``system``, kept for SDK user convenience.""" + return self.system + + +class AgentVersion(BaseModel): + _fields = ("id", "version", "name", "description", "created_at") + + +class Environment(BaseModel): + _fields = ( + "id", + "type", + "name", + "description", + "config", + "metadata", + "scope", + "archived_at", + "created_at", + "updated_at", + "request_id", + ) + + def __init__(self, **kwargs: Any) -> None: + cfg = kwargs.get("config") + if isinstance(cfg, Mapping): + kwargs["config"] = EnvironmentConfig(**dict(cfg)) + super().__init__(**kwargs) + + +class File(BaseModel): + _fields = ( + "id", + "type", + "filename", + "downloadable", + "mime_type", + "size_bytes", + "scope", + "created_at", + "request_id", + ) + + def __init__(self, **kwargs: Any) -> None: + scope = kwargs.get("scope") + if isinstance(scope, Mapping): + kwargs["scope"] = Scope(**dict(scope)) + super().__init__(**kwargs) + + +class Skill(BaseModel): + _fields = ( + "id", + "type", + "name", + "description", + "source", + "status", + "latest_version", + "version", # backward compat alias + "file_id", + "scope", + "created_at", + "updated_at", + "request_id", + ) + + +class SkillVersion(BaseModel): + _fields = ( + "id", + "type", + "skill_id", + "name", + "description", + "version", + "status", + "additional_properties", + "created_at", + "updated_at", + ) + + +class Session(BaseModel): + _fields = ( + "id", + "type", + "title", + "agent", + "agent_id", + "agent_version", + "environment_id", + "status", + "stop_reason", + "resources", + "metadata", + "stats", + "usage", + "archived_at", + "created_at", + "updated_at", + "request_id", + ) + +class SessionThread(BaseModel): + _fields = ( + "id", + "session_id", + "parent_thread_id", + "title", + "status", + "created_at", + "updated_at", + ) + + +class DeleteResponse(BaseModel): + _fields = ("id", "type", "request_id") + + +# =========================================================================== +# Section 4 – Unified Message class (replaces server_events.py) +# =========================================================================== + + +class Message(BaseModel): + """Unified event/message model corresponding to the AgentStudio API + protocol Message structure. + + All SSE stream events and history events share this structure. + The ``type`` field discriminates between event kinds:: + + message, tool_call, tool_call_output, function_call, + function_call_output, mcp_call, mcp_call_output, reasoning, + session_status, error, session_updated, thread_created, + thread_status, thread_message_sent, thread_message_received, + thread_context_compacted, model_request_start, model_request_end, + outcome_evaluation, interrupt, tool_confirmation, define_outcome + + Legacy ``agent.*`` / ``session.*`` / ``span.*`` type strings are also + accepted transparently for backward compatibility. + """ + + _fields = ( + "object", + "status", + "id", + "type", + "role", + "content", + "metadata", + "is_error", + "created_at", + "sequence_number", + "session_thread_id", + "code", + "message", + ) + + def __init__(self, **kwargs: Any) -> None: + content = kwargs.get("content") + if isinstance(content, list): + kwargs["content"] = parse_content_blocks(content) + super().__init__(**kwargs) + + def __str__(self) -> str: + etype = getattr(self, "type", "unknown") + content = self.content or [] + if content: + return f"[{etype}] " + " ".join(str(b) for b in content) + return f"[{etype}]" + + +def parse_message(payload: Mapping[str, Any]) -> Message: + """Turn a parsed SSE ``data`` dict into a :class:`Message` instance.""" + return Message(**dict(payload)) + + +# Keep the original function name as an alias so existing code that calls +# ``parse_server_event`` continues to work unchanged. +def parse_server_event(payload: Mapping[str, Any]) -> Message: + """Alias for :func:`parse_message`; kept for backward compatibility.""" + return parse_message(payload) + + +# =========================================================================== +# Section 5 – Client event helpers (AGENTSTUDIO wire format) +# =========================================================================== + + +def user_message( + text_or_blocks: Any, + *, + session_thread_id: Optional[str] = None, + metadata: Optional[Mapping[str, Any]] = None, +) -> Dict[str, Any]: + """Build a ``message`` client event (AGENTSTUDIO wire format). + + ``text_or_blocks`` may be a plain string (wrapped as a text block) + or a list of pre-built content block dicts. + """ + + if isinstance(text_or_blocks, str): + content: List[Dict[str, Any]] = [{"type": BlockType.TEXT, "text": text_or_blocks}] + else: + content = list(text_or_blocks) + evt: Dict[str, Any] = {"role": MessageRole.USER, "type": SSEEventType.MESSAGE, "content": content} + if session_thread_id: + evt["session_thread_id"] = session_thread_id + if metadata: + evt["metadata"] = dict(metadata) + return evt + + +def user_interrupt( + *, + session_thread_id: Optional[str] = None, + metadata: Optional[Mapping[str, Any]] = None, +) -> Dict[str, Any]: + """Cancel the agent's current turn (best-effort).""" + + evt: Dict[str, Any] = {"role": MessageRole.USER, "type": SSEEventType.INTERRUPT} + if session_thread_id: + evt["session_thread_id"] = session_thread_id + if metadata: + evt["metadata"] = dict(metadata) + return evt + + +def user_tool_confirmation( + *, + tool_use_id: str, + result: str, + deny_message: Optional[str] = None, + session_thread_id: Optional[str] = None, +) -> Dict[str, Any]: + """Approve or deny a built-in tool invocation. + + ``result`` must be ``"allow"`` or ``"deny"``. ``deny_message`` is + only meaningful when denying. + """ + + if result not in ("allow", "deny"): + raise ValueError("tool_confirmation result must be 'allow' or 'deny'") + data: Dict[str, Any] = { + "call_id": tool_use_id, + "result": result, + } + if deny_message and result == "deny": + data["deny_message"] = deny_message + evt: Dict[str, Any] = { + "role": MessageRole.USER, + "type": SSEEventType.TOOL_CONFIRMATION, + "content": [{"type": "data", "data": data}], + } + if session_thread_id: + evt["session_thread_id"] = session_thread_id + return evt + + +def user_custom_tool_result( + *, + custom_tool_use_id: str, + content: Any, + is_error: bool = False, + session_thread_id: Optional[str] = None, +) -> Dict[str, Any]: + """Reply to an :class:`AgentCustomToolUseEvent` with a tool result. + + Emits a ``function_call_output`` event in the AGENTSTUDIO wire format. + """ + + if isinstance(content, str): + output = content + elif isinstance(content, Sequence) and not isinstance(content, str): + output = list(content) + else: + output = content + evt: Dict[str, Any] = { + "role": MessageRole.TOOL, + "type": SSEEventType.FUNCTION_CALL_OUTPUT, + "content": [{"type": "data", "data": { + "call_id": custom_tool_use_id, + "output": output, + }}], + "is_error": bool(is_error), + } + if session_thread_id: + evt["session_thread_id"] = session_thread_id + return evt + + +def user_tool_result( + *, + tool_use_id: str, + content: Any, + is_error: bool = False, + session_thread_id: Optional[str] = None, +) -> Dict[str, Any]: + """Self-hosted tool result (advanced; mirrors built-in tool execution). + + Emits a ``tool_call_output`` event in the AGENTSTUDIO wire format. + """ + + if isinstance(content, str): + output = content + elif isinstance(content, Sequence) and not isinstance(content, str): + output = list(content) + else: + output = content + evt: Dict[str, Any] = { + "role": MessageRole.TOOL, + "type": SSEEventType.TOOL_CALL_OUTPUT, + "content": [{"type": "data", "data": { + "call_id": tool_use_id, + "output": output, + }}], + "is_error": bool(is_error), + } + if session_thread_id: + evt["session_thread_id"] = session_thread_id + return evt + + +def user_define_outcome( + *, + description: str, + rubric: Optional[str] = None, + max_iterations: Optional[int] = None, + session_thread_id: Optional[str] = None, +) -> Dict[str, Any]: + """Steer the agent towards a measurable outcome.""" + + data: Dict[str, Any] = {"description": description} + if rubric is not None: + data["rubric"] = rubric + if max_iterations is not None: + data["max_iterations"] = int(max_iterations) + evt: Dict[str, Any] = { + "role": MessageRole.USER, + "type": SSEEventType.DEFINE_OUTCOME, + "content": [{"type": "data", "data": data}], + } + if session_thread_id: + evt["session_thread_id"] = session_thread_id + return evt + + +# =========================================================================== +# Section 6 – Backward compatibility aliases +# =========================================================================== + +# server_events.py ── ServerEvent was the base class; now it's Message +ServerEvent = Message +UnknownServerEvent = Message + +# All the concrete subclasses from server_events.py map to Message +AgentMessageEvent = Message +AgentThinkingEvent = Message +AgentToolUseEvent = Message +AgentToolResultEvent = Message +AgentCustomToolUseEvent = Message +AgentCustomToolResultEvent = Message +AgentMcpToolUseEvent = Message +AgentMcpToolResultEvent = Message +AgentThreadMessageSentEvent = Message +AgentThreadMessageReceivedEvent = Message +AgentThreadContextCompactedEvent = Message +SessionStatusRunningEvent = Message +SessionStatusIdleEvent = Message +SessionStatusReschedulingEvent = Message +SessionStatusTerminatedEvent = Message +SessionErrorEvent = Message +SessionUpdatedEvent = Message +SessionThreadCreatedEvent = Message +SessionThreadStatusEvent = Message +SpanModelRequestStartEvent = Message +SpanModelRequestEndEvent = Message diff --git a/dashscope/agentstudio/types/params.py b/dashscope/agentstudio/types/params.py new file mode 100644 index 0000000..16c47b9 --- /dev/null +++ b/dashscope/agentstudio/types/params.py @@ -0,0 +1,457 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Per-resource API request parameter definitions. + +Each resource group (Agents, Environments, Sessions, …) defines the +request-body structures for its write endpoints. All classes inherit +from :class:`~dashscope.agentstudio.types.models.BaseModel` so that +``.to_dict()`` produces a ready-to-send wire-format dict. + +The ``__init__`` signature uses **user-friendly** parameter names +(e.g. ``system_prompt``) while the ``super().__init__()`` call passes +**wire-format** field names (e.g. ``system``). + +Python 3.8+ compatible. +""" + +from __future__ import annotations + +from typing import Any, Mapping, Optional, Sequence + +from dashscope.agentstudio.types.models import BaseModel + + +# =========================================================================== +# Agents +# =========================================================================== + + +class AgentCreateParams(BaseModel): + """Request body for ``POST /agents``. + + Maps to :meth:`Agents.create`. The SDK method exposes + ``model`` as a plain string and ``system_prompt`` as a keyword arg, + but the wire format uses ``model: {"id": "…"}`` and ``system``. + """ + + _fields = ("name", "model", "description", "system", "tools", + "mcp_servers", "skills", "metadata") + + def __init__( + self, *, + name: str, + model: str, + description: Optional[str] = None, + system_prompt: Optional[str] = None, + tools: Optional[Sequence[Mapping[str, Any]]] = None, + mcp_servers: Optional[Sequence[Mapping[str, Any]]] = None, + skills: Optional[Sequence[Mapping[str, Any]]] = None, + metadata: Optional[Mapping[str, Any]] = None, + ) -> None: + super().__init__( + name=name, + model={"id": model}, + description=description, + system=system_prompt, + tools=[dict(t) for t in tools] if tools is not None else None, + mcp_servers=[dict(s) for s in mcp_servers] if mcp_servers is not None else None, + skills=[dict(s) for s in skills] if skills is not None else None, + metadata=dict(metadata) if metadata is not None else None, + ) + + +class AgentUpdateParams(BaseModel): + """Request body for ``POST /agents/{id}`` (update latest version).""" + + _fields = ("version", "name", "model", "description", "system", + "tools", "mcp_servers", "skills", "metadata") + + def __init__( + self, *, + version: int, + name: Optional[str] = None, + model: Optional[str] = None, + description: Optional[str] = None, + system_prompt: Optional[str] = None, + tools: Optional[Sequence[Mapping[str, Any]]] = None, + mcp_servers: Optional[Sequence[Mapping[str, Any]]] = None, + skills: Optional[Sequence[Mapping[str, Any]]] = None, + metadata: Optional[Mapping[str, Any]] = None, + ) -> None: + super().__init__( + version=version, + name=name, + model={"id": model} if model is not None else None, + description=description, + system=system_prompt, + tools=[dict(t) for t in tools] if tools is not None else None, + mcp_servers=[dict(s) for s in mcp_servers] if mcp_servers is not None else None, + skills=[dict(s) for s in skills] if skills is not None else None, + metadata=dict(metadata) if metadata is not None else None, + ) + + +class AgentListParams(BaseModel): + """Query params for ``GET /agents``. + + ``include_archived`` is a Python bool mapped to the wire-format + string ``"true"`` / ``"false"``. + """ + + _fields = ("limit", "page", "include_archived") + + def __init__( + self, *, + limit: Optional[int] = None, + page: Optional[str] = None, + include_archived: Optional[bool] = None, + ) -> None: + super().__init__( + limit=limit, + page=page, + include_archived=str(include_archived).lower() if include_archived is not None else None, + ) + + +class AgentVersionListParams(BaseModel): + """Query params for ``GET /agents/{id}/versions``.""" + + _fields = ("limit", "page") + + def __init__( + self, *, + limit: Optional[int] = None, + page: Optional[str] = None, + ) -> None: + super().__init__( + limit=limit, + page=page, + ) + + +# =========================================================================== +# Environments +# =========================================================================== + + +class EnvironmentCreateParams(BaseModel): + """Request body for ``POST /environments``.""" + + _fields = ("name", "config", "description", "scope", "metadata") + + def __init__( + self, *, + name: str, + config: Mapping[str, Any], + description: Optional[str] = None, + scope: Optional[str] = None, + metadata: Optional[Mapping[str, Any]] = None, + ) -> None: + super().__init__( + name=name, + config=dict(config), + description=description, + scope=scope, + metadata=dict(metadata) if metadata is not None else None, + ) + + +class EnvironmentUpdateParams(BaseModel): + """Request body for ``POST /environments/{id}``.""" + + _fields = ("name", "description", "config", "scope", "metadata") + + def __init__( + self, *, + name: Optional[str] = None, + description: Optional[str] = None, + config: Optional[Mapping[str, Any]] = None, + scope: Optional[str] = None, + metadata: Optional[Mapping[str, Any]] = None, + ) -> None: + super().__init__( + name=name, + description=description, + config=dict(config) if config is not None else None, + scope=scope, + metadata=dict(metadata) if metadata is not None else None, + ) + + +class EnvironmentListParams(BaseModel): + """Query params for ``GET /environments``. + + ``include_archived`` is a Python bool mapped to the wire-format + string ``"true"`` / ``"false"``. + """ + + _fields = ("limit", "page", "include_archived") + + def __init__( + self, *, + limit: Optional[int] = None, + page: Optional[str] = None, + include_archived: Optional[bool] = None, + ) -> None: + super().__init__( + limit=limit, + page=page, + include_archived=str(include_archived).lower() if include_archived is not None else None, + ) + + +# =========================================================================== +# Sessions +# =========================================================================== + + +class SessionCreateParams(BaseModel): + """Request body for ``POST /sessions``. + + ``agent`` is the agent ID string (not the full agent object). + """ + + _fields = ("agent", "environment_id", "title", "metadata") + + def __init__( + self, *, + agent: str, + environment_id: Optional[str] = None, + title: Optional[str] = None, + metadata: Optional[Mapping[str, Any]] = None, + ) -> None: + super().__init__( + agent=agent, + environment_id=environment_id, + title=title, + metadata=dict(metadata) if metadata is not None else None, + ) + + +class SessionUpdateParams(BaseModel): + """Request body for ``POST /sessions/{id}``.""" + + _fields = ("title", "metadata") + + def __init__( + self, *, + title: Optional[str] = None, + metadata: Optional[Mapping[str, Any]] = None, + ) -> None: + super().__init__( + title=title, + metadata=dict(metadata) if metadata is not None else None, + ) + + +class SessionListParams(BaseModel): + """Query params for ``GET /sessions``. + + ``statuses`` is a list of session status strings such as + ``"idle"``, ``"running"``, ``"terminated"``. + + Python-friendly parameter names (``statuses``, ``created_at_gt``, …) + are mapped to wire-format keys (``statuses[]``, ``created_at[gt]``, …) + inside ``__init__``. + """ + + _fields = ("limit", "page", "agent_id", "statuses[]", + "created_at[gt]", "created_at[gte]", + "created_at[lt]", "created_at[lte]") + + def __init__( + self, *, + limit: Optional[int] = None, + page: Optional[str] = None, + agent_id: Optional[str] = None, + statuses: Optional[Sequence[str]] = None, + created_at_gt: Optional[str] = None, + created_at_gte: Optional[str] = None, + created_at_lt: Optional[str] = None, + created_at_lte: Optional[str] = None, + ) -> None: + kwargs = {} + kwargs["limit"] = limit + kwargs["page"] = page + kwargs["agent_id"] = agent_id + if statuses is not None: + kwargs["statuses[]"] = list(statuses) + if created_at_gt is not None: + kwargs["created_at[gt]"] = created_at_gt + if created_at_gte is not None: + kwargs["created_at[gte]"] = created_at_gte + if created_at_lt is not None: + kwargs["created_at[lt]"] = created_at_lt + if created_at_lte is not None: + kwargs["created_at[lte]"] = created_at_lte + super().__init__(**kwargs) + + +# =========================================================================== +# SessionEvents +# =========================================================================== + + +class SessionEventSendParams(BaseModel): + """Request body for ``POST /sessions/{id}/events``. + + The ``input`` array must contain at least one event. + """ + + _fields = ("input",) + + def __init__( + self, *, + input: Sequence[Mapping[str, Any]], + ) -> None: + super().__init__( + input=[dict(e) for e in input], + ) + + +class SessionEventListParams(BaseModel): + """Query params for ``GET /sessions/{id}/events``. + + ``types`` is a list of event type strings joined by comma in the + wire format. ``created_at_*`` parameters are mapped to the + bracket-based wire keys ``created_at[gt]`` etc. + """ + + _fields = ("types", "created_at[gt]", "created_at[gte]", + "created_at[lt]", "created_at[lte]", "limit", "order", "page") + + def __init__( + self, *, + types: Optional[Sequence[str]] = None, + created_at_gt: Optional[str] = None, + created_at_gte: Optional[str] = None, + created_at_lt: Optional[str] = None, + created_at_lte: Optional[str] = None, + limit: Optional[int] = None, + order: Optional[str] = None, + page: Optional[str] = None, + ) -> None: + kwargs = {} + if types is not None: + kwargs["types"] = ",".join(types) + if created_at_gt is not None: + kwargs["created_at[gt]"] = created_at_gt + if created_at_gte is not None: + kwargs["created_at[gte]"] = created_at_gte + if created_at_lt is not None: + kwargs["created_at[lt]"] = created_at_lt + if created_at_lte is not None: + kwargs["created_at[lte]"] = created_at_lte + kwargs["limit"] = limit + kwargs["order"] = order + kwargs["page"] = page + super().__init__(**kwargs) + + +# =========================================================================== +# Files +# =========================================================================== + + +class FileListParams(BaseModel): + """Query params for ``GET /files``. + + File upload uses multipart form data and is therefore not + represented as a param class. + """ + + _fields = ("limit", "page", "scope_id", "after_id", "before_id") + + def __init__( + self, *, + limit: Optional[int] = None, + page: Optional[str] = None, + scope_id: Optional[str] = None, + after_id: Optional[str] = None, + before_id: Optional[str] = None, + ) -> None: + super().__init__( + limit=limit, + page=page, + scope_id=scope_id, + after_id=after_id, + before_id=before_id, + ) + + +# =========================================================================== +# Skills +# =========================================================================== + + +class SkillCreateParams(BaseModel): + """Request body for ``POST /skills``. + + ``file_id`` references a file previously uploaded via the Files API. + The server extracts ``name`` and ``description`` from the zip's + ``SKILL.md``; they are not client-settable. + """ + + _fields = ("file_id",) + + def __init__( + self, *, + file_id: str, + ) -> None: + super().__init__(file_id=file_id) + + +class SkillListParams(BaseModel): + """Query params for ``GET /skills``.""" + + _fields = ("source", "limit", "page") + + def __init__( + self, *, + source: Optional[str] = None, + limit: Optional[int] = None, + page: Optional[str] = None, + ) -> None: + super().__init__( + source=source, + limit=limit, + page=page, + ) + + +# =========================================================================== +# SkillVersions +# =========================================================================== + + +class SkillVersionCreateParams(BaseModel): + """Request body for ``POST /skills/{skill_id}/versions``.""" + + _fields = ("file_id",) + + def __init__( + self, *, + file_id: str, + ) -> None: + super().__init__( + file_id=file_id, + ) + + +class SkillVersionListParams(BaseModel): + """Query params for ``GET /skills/{skill_id}/versions``.""" + + _fields = ("limit", "page") + + def __init__( + self, *, + limit: Optional[int] = None, + page: Optional[str] = None, + ) -> None: + super().__init__( + limit=limit, + page=page, + ) + + + diff --git a/requirements.txt b/requirements.txt index b3366fa..cf0a7ce 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,5 @@ cryptography certifi typer>=0.9.0 rich>=13.0.0 +httpx>=0.25.0 +httpx-sse>=0.4.0 diff --git a/samples/test_agentstudio.py b/samples/test_agentstudio.py new file mode 100644 index 0000000..03d77f4 --- /dev/null +++ b/samples/test_agentstudio.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. + +import os + +from dashscope.agentstudio import Client, user_message + + +class TestAgentStudio: + """Test cases for AgentStudio synchronous API.""" + + @staticmethod + def test_quickstart(): + """Test end-to-end flow: create env -> agent -> session + -> send message -> stream events -> archive session.""" + api_key = os.environ["DASHSCOPE_API_KEY"] + workspace = os.environ.get("DASHSCOPE_WORKSPACE", "trial") + client = Client(api_key=api_key, workspace=workspace) + + print("==> creating environment") + env = client.environments.create( + name="quickstart-env", + config={ + "type": "cloud", + "networking": {"type": "unrestricted"}, + }, + ) + print(f" environment_id={env.id}") + + print("==> creating agent") + agent = client.agents.create( + name="quickstart-agent", + system_prompt="You are a friendly demo agent. Reply briefly.", + model="qwen-max", + tools=[{"type": "builtin_toolkit", "default_config": {"enabled": False}, + "configs": [{"name": "bash", "enabled": True}]}], + ) + print(f" agent_id={agent.id}") + + print("==> creating session") + session = client.sessions.create( + agent=agent.id, + environment_id=env.id, + title="quickstart", + ) + print(f" session_id={session.id}") + + print("==> sending user message") + client.sessions.events.send( + session.id, + [user_message("Hello! Tell me one fun fact about Hangzhou.")], + ) + + print("==> streaming events") + with client.sessions.events.stream(session.id) as stream: + for text in stream.text_stream: + print(text, end="", flush=True) + print() # newline after streaming + + print("==> archiving session") + client.sessions.archive(session.id) + print("done.") + + +if __name__ == "__main__": + TestAgentStudio.test_quickstart() diff --git a/tests/unit/test_agentstudio_pagination.py b/tests/unit/test_agentstudio_pagination.py new file mode 100644 index 0000000..e3421b7 --- /dev/null +++ b/tests/unit/test_agentstudio_pagination.py @@ -0,0 +1,94 @@ +# -*- coding: utf-8 -*- +"""Tests for cursor-based pagination.""" + +from __future__ import annotations + +from typing import List + +import pytest + +from dashscope.agentstudio.pagination import ( + AsyncCursorPage, + CursorPage, + build_page, +) + + +def test_build_page(): + page = build_page( + payload={"data": [{"id": "a"}, {"id": "b"}], "next_page": "token_2"}, + item_factory=lambda x: x, + request_id="req_1", + ) + assert len(page.data) == 2 + assert page.next_page == "token_2" + assert page.request_id == "req_1" + + +def test_build_page_empty(): + page = build_page(payload={"data": None}, item_factory=lambda x: x) + assert page.data == [] + + +def test_cursor_page_has_next_and_get_next(): + pages: List[CursorPage[int]] = [ + CursorPage(data=[1, 2], next_page="p2", request_id=None), + CursorPage(data=[3], next_page=None, request_id=None), + ] + call_idx = iter(range(1, 3)) + + def _fetch(token: str) -> CursorPage[int]: + return pages[next(call_idx)] + + pages[0]._fetch_next = _fetch + assert pages[0].has_next() is True + nxt = pages[0].get_next() + assert nxt is not None + assert nxt.data == [3] + assert nxt.has_next() is False + + +def test_cursor_page_iter_auto_paginates(): + pages: List[CursorPage[int]] = [ + CursorPage(data=[1, 2], next_page="p2", request_id=None), + CursorPage(data=[3], next_page=None, request_id=None), + ] + call_idx = iter(range(1, 3)) + + def _fetch(token: str) -> CursorPage[int]: + return pages[next(call_idx)] + + pages[0]._fetch_next = _fetch + assert list(pages[0]) == [1, 2, 3] + + +def test_cursor_page_no_next(): + page = CursorPage(data=[1], next_page=None, request_id=None) + assert page.has_next() is False + assert page.get_next() is None + assert list(page) == [1] + + +@pytest.mark.asyncio +async def test_async_cursor_page_iter(): + pages: List[AsyncCursorPage[int]] = [ + AsyncCursorPage(data=[1], next_page="p2", request_id=None), + AsyncCursorPage(data=[2], next_page=None, request_id=None), + ] + call_idx = iter(range(1, 3)) + + async def _fetch(token: str) -> AsyncCursorPage[int]: + return pages[next(call_idx)] + + pages[0]._fetch_next = _fetch + result = [item async for item in pages[0]] + assert result == [1, 2] + + +@pytest.mark.asyncio +async def test_async_cursor_page_no_next(): + page = AsyncCursorPage(data=[1], next_page=None, request_id=None) + assert page.has_next() is False + assert await page.get_next() is None + result = [item async for item in page] + assert result == [1] \ No newline at end of file diff --git a/tests/unit/test_agentstudio_protocol.py b/tests/unit/test_agentstudio_protocol.py new file mode 100644 index 0000000..086d884 --- /dev/null +++ b/tests/unit/test_agentstudio_protocol.py @@ -0,0 +1,261 @@ +# -*- coding: utf-8 -*- +"""Protocol-alignment tests: error / snake_case / data flatten. + +These three contracts were agreed on with the backend team: + +1. Error envelope uses ``error.code`` / ``error.message`` (the + documented shape). Legacy ``error_code`` / ``error_message`` is + still tolerated for compatibility. +2. Wire format is snake_case throughout — both request bodies emitted + by the SDK and response bodies returned by the server. The only + defensive translation is ``requestId`` → ``request_id`` because + some legacy gateways still emit camelCase for that single field. +3. Responses come back as flat bare JSON; the SDK exposes the resource + at the top level via :func:`unwrap`. +""" + +import json + +import pytest + +from dashscope.agentstudio import exceptions +from dashscope.agentstudio.transport import is_error_payload, unwrap +from dashscope.agentstudio.types import ( + user_message, + user_interrupt, + user_tool_confirmation, + user_custom_tool_result, + user_define_outcome, +) + + +# --------------------------------------------------------------------------- +# 1. error.{code, message} +# --------------------------------------------------------------------------- + + +def test_error_uses_nested_code_and_message(): + body = { + "type": "error", + "error": {"code": "invalid_request_error", "message": "bad arg"}, + "request_id": "req_001", + } + err = exceptions.from_response(status_code=400, body=body) + assert isinstance(err, exceptions.InvalidRequestError) + assert err.code == "invalid_request_error" + assert err.message == "bad arg" + assert err.request_id == "req_001" + + +def test_error_legacy_underscored_fields_still_parsed(): + body = { + "type": "error", + "error": {"error_code": "rate_limit_error", "error_message": "slow"}, + } + err = exceptions.from_response(status_code=429, body=body) + assert isinstance(err, exceptions.RateLimitError) + assert err.code == "rate_limit_error" + + +def test_is_error_payload_detects_both_shapes(): + assert is_error_payload( + {"type": "error", "error": {"code": "x", "message": "y"}} + ) + assert is_error_payload( + {"type": "error", "error": {"error_code": "x", "error_message": "y"}} + ) + + +# --------------------------------------------------------------------------- +# 2. snake_case wire format +# --------------------------------------------------------------------------- + + +def test_client_event_keys_are_snake_case(): + """Every key the SDK emits in user.* events must be snake_case.""" + samples = [ + user_message( + "hi", session_thread_id="th_1", metadata={"k": "v"} + ), + user_tool_confirmation( + tool_use_id="t_1", result="allow" + ), + user_tool_confirmation( + tool_use_id="t_1", result="deny", deny_message="nope" + ), + user_custom_tool_result( + custom_tool_use_id="ctu_1", content="ok", is_error=False + ), + user_define_outcome( + description="desc", rubric="r", max_iterations=3 + ), + ] + + def _walk_keys(obj): + if isinstance(obj, dict): + for k, v in obj.items(): + assert "_" in k or k.islower(), f"non-snake_case key: {k}" + # No camel-case (i.e. no internal uppercase letters) + assert not any(c.isupper() for c in k), f"camelCase leak: {k}" + _walk_keys(v) + elif isinstance(obj, list): + for it in obj: + _walk_keys(it) + + for evt in samples: + # Re-serialize through json to make sure no funny key types slipped in + _walk_keys(json.loads(json.dumps(evt))) + + +def test_unwrap_keeps_snake_case_payloads_unchanged(): + payload = { + "id": "agt_1", + "name": "demo", + "system_prompt": "you are helpful", + "created_at": "2026-06-16T10:00:00Z", + "request_id": "req_1", + } + data, rid = unwrap(payload) + assert data == payload + assert rid == "req_1" + + +def test_unwrap_only_camelcase_translation_is_request_id(): + """Other camelCase keys are left as-is; SDK trusts the backend + to send snake_case per the agreed wire contract. + """ + payload = {"id": "agt_1", "fooBar": 1, "requestId": "req_x"} + data, rid = unwrap(payload) + assert data["fooBar"] == 1 # not auto-converted + assert "request_id" in data and "requestId" not in data + assert rid == "req_x" + + +# --------------------------------------------------------------------------- +# 3. flat response +# --------------------------------------------------------------------------- + + +def test_flat_response_unchanged(): + payload = {"id": "agt_1", "request_id": "req_1"} + data, rid = unwrap(payload) + assert data == {"id": "agt_1", "request_id": "req_1"} + assert rid == "req_1" + + +# --------------------------------------------------------------------------- +# Tests from test_agentstudio_client_events.py +# --------------------------------------------------------------------------- + + +def test_user_message_string_wraps_to_text_block(): + evt = user_message("hello") + assert evt["type"] == "message" + assert evt["role"] == "user" + assert evt["content"] == [{"type": "text", "text": "hello"}] + + +def test_user_message_list_passthrough(): + blocks = [{"type": "image", "source": "..."}] + evt = user_message(blocks, session_thread_id="th_1", metadata={"k": "v"}) + assert evt["type"] == "message" + assert evt["role"] == "user" + assert evt["content"] == blocks + assert evt["session_thread_id"] == "th_1" + assert evt["metadata"] == {"k": "v"} + + +def test_user_interrupt(): + evt = user_interrupt() + assert evt == {"role": "user", "type": "interrupt"} + + +def test_user_tool_confirmation_validates_result(): + with pytest.raises(ValueError): + user_tool_confirmation(tool_use_id="t_1", result="MAYBE") + deny = user_tool_confirmation( + tool_use_id="t_1", result="deny", deny_message="nope" + ) + assert deny["role"] == "user" + assert deny["type"] == "tool_confirmation" + data_block = deny["content"][0] + assert data_block["type"] == "data" + assert data_block["data"]["call_id"] == "t_1" + assert data_block["data"]["result"] == "deny" + assert data_block["data"]["deny_message"] == "nope" + + +def test_user_custom_tool_result_string_to_text(): + evt = user_custom_tool_result(custom_tool_use_id="ctu_1", content="42") + assert evt["role"] == "tool" + assert evt["type"] == "function_call_output" + data_block = evt["content"][0] + assert data_block["type"] == "data" + assert data_block["data"]["call_id"] == "ctu_1" + assert data_block["data"]["output"] == "42" + assert evt["is_error"] is False + + +def test_user_custom_tool_result_dict_to_data_block(): + evt = user_custom_tool_result( + custom_tool_use_id="ctu_1", content={"x": 1}, is_error=True + ) + assert evt["role"] == "tool" + assert evt["type"] == "function_call_output" + data_block = evt["content"][0] + assert data_block["type"] == "data" + assert data_block["data"]["call_id"] == "ctu_1" + assert data_block["data"]["output"] == {"x": 1} + assert evt["is_error"] is True + + +def test_user_define_outcome(): + evt = user_define_outcome( + description="task A", rubric="must be JSON", max_iterations=3 + ) + assert evt["role"] == "user" + assert evt["type"] == "define_outcome" + data_block = evt["content"][0] + assert data_block["type"] == "data" + assert data_block["data"]["description"] == "task A" + assert data_block["data"]["rubric"] == "must be JSON" + assert data_block["data"]["max_iterations"] == 3 + + +# --------------------------------------------------------------------------- +# Tests for Agent/Session type field mapping +# --------------------------------------------------------------------------- + + +def test_agent_system_field_and_property(): + """Server returns ``system`` field; SDK exposes it both as + ``agent.system`` and the convenience ``agent.system_prompt`` property. + """ + from dashscope.agentstudio.types import Agent + + agent = Agent(id="agt_1", system="You are helpful.", name="Test") + assert agent.system == "You are helpful." + assert agent.system_prompt == "You are helpful." + + +def test_session_stats_and_usage_fields(): + """Session now parses ``stats`` and ``usage`` from server response.""" + from dashscope.agentstudio.types import Session + + stats = {"total_events": 42, "total_turns": 5} + usage = {"input_tokens": 100, "output_tokens": 200} + s = Session(id="sesn_1", status="idle", stats=stats, usage=usage) + assert s.stats == stats + assert s.usage == usage + + +def test_from_response_spring_default(): + """Spring Boot default error page is coerced to the right error type.""" + body = { + "timestamp": "...", + "status": 404, + "error": "Not Found", + "path": "/api/v1/agentstudio/agents", + } + err = exceptions.from_response(status_code=404, body=body) + assert isinstance(err, exceptions.NotFoundError) diff --git a/tests/unit/test_agentstudio_runner.py b/tests/unit/test_agentstudio_runner.py new file mode 100644 index 0000000..e4cdb7f --- /dev/null +++ b/tests/unit/test_agentstudio_runner.py @@ -0,0 +1,260 @@ +# -*- coding: utf-8 -*- +"""Tests for SessionToolRunner and AsyncSessionToolRunner using mocked client.""" + +from __future__ import annotations + +import asyncio +from typing import Any, Dict, List +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from dashscope.agentstudio import Client, AsyncClient +from dashscope.agentstudio.tools import SessionToolRunner, AsyncSessionToolRunner, tool +from dashscope.agentstudio.types import ( + AgentCustomToolUseEvent, + SessionStatusIdleEvent, +) + + +# --------------------------------------------------------------------------- +# Sync helpers +# --------------------------------------------------------------------------- + +class _FakeStream: + def __init__(self, events: List[Any]): + self._events = events + + def __enter__(self): + return self + + def __exit__(self, *exc): + pass + + def __iter__(self): + return iter(self._events) + + def close(self): + pass + + +def _make_client(): + return Client(api_key="test-key") + + +# --------------------------------------------------------------------------- +# Async helpers +# --------------------------------------------------------------------------- + +class _FakeAsyncStream: + def __init__(self, events: List[Any]): + self._events = events + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + pass + + def __aiter__(self): + return self._aiter() + + async def _aiter(self): + for evt in self._events: + yield evt + + async def aclose(self): + pass + + +def _make_async_client(): + return AsyncClient(api_key="test-key") + + +# --------------------------------------------------------------------------- +# Sync runner tests +# --------------------------------------------------------------------------- + +def test_runner_dispatches_custom_tool_and_posts_result(): + @tool + def add(a: int, b: int) -> int: + """Sum two integers.""" + return a + b + + server_events = [ + AgentCustomToolUseEvent( + id="evt_1", type="tool_call", + content=[{"type": "data", "name": "add", "custom_tool_use_id": "ctu_1", "arguments": {"a": 2, "b": 3}}], + ), + SessionStatusIdleEvent( + id="evt_2", type="session_status", + content=[{"type": "data", "data": {"session_status": "idle", "stop_reason": "end_turn"}}], + ), + ] + + sent: List[Dict[str, Any]] = [] + client = _make_client() + client.sessions.events.stream = MagicMock(return_value=_FakeStream(server_events)) + client.sessions.events.send = MagicMock(side_effect=lambda sid, evts, **kw: sent.extend(evts)) + + runner = SessionToolRunner(client=client, session_id="sesn_1", tools=[add], max_idle_seconds=0) + records = list(runner) + + assert len(records) == 1 + assert records[0].name == "add" + assert records[0].is_error is False + assert records[0].output in ("5", 5) + assert len(sent) == 1 + posted = sent[0] + assert posted["type"] == "function_call_output" + assert posted["role"] == "tool" + assert posted["content"][0]["data"]["call_id"] == "ctu_1" + + +def test_runner_reports_unknown_tool_as_error(): + server_events = [ + AgentCustomToolUseEvent( + id="evt_1", type="tool_call", + content=[{"type": "data", "name": "missing", "custom_tool_use_id": "ctu_2", "arguments": {}}], + ), + SessionStatusIdleEvent( + id="evt_2", type="session_status", + content=[{"type": "data", "data": {"session_status": "idle", "stop_reason": "end_turn"}}], + ), + ] + + sent: List[Dict[str, Any]] = [] + client = _make_client() + client.sessions.events.stream = MagicMock(return_value=_FakeStream(server_events)) + client.sessions.events.send = MagicMock(side_effect=lambda sid, evts, **kw: sent.extend(evts)) + + runner = SessionToolRunner(client=client, session_id="sesn_2", tools=[], max_idle_seconds=0) + records = list(runner) + + assert len(records) == 1 + assert records[0].is_error is True + assert len(sent) == 1 + assert sent[0]["is_error"] is True + + +# --------------------------------------------------------------------------- +# Async runner tests +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_async_runner_dispatches_custom_tool_and_posts_result(): + @tool + def add(a: int, b: int) -> int: + """Sum two integers.""" + return a + b + + server_events = [ + AgentCustomToolUseEvent( + id="evt_1", type="tool_call", + content=[{"type": "data", "name": "add", "custom_tool_use_id": "ctu_1", "arguments": {"a": 2, "b": 3}}], + ), + SessionStatusIdleEvent( + id="evt_2", type="session_status", + content=[{"type": "data", "data": {"session_status": "idle", "stop_reason": "end_turn"}}], + ), + ] + + sent: List[Dict[str, Any]] = [] + client = _make_async_client() + client.sessions.events.stream = AsyncMock(return_value=_FakeAsyncStream(server_events)) + client.sessions.events.send = AsyncMock(side_effect=lambda sid, evts, **kw: sent.extend(evts)) + + runner = AsyncSessionToolRunner(client=client, session_id="sesn_1", tools=[add], max_idle_seconds=0) + records = [record async for record in runner] + + assert len(records) == 1 + assert records[0].name == "add" + assert records[0].is_error is False + assert records[0].output in ("5", 5) + assert len(sent) == 1 + posted = sent[0] + assert posted["type"] == "function_call_output" + assert posted["role"] == "tool" + assert posted["content"][0]["data"]["call_id"] == "ctu_1" + + +@pytest.mark.asyncio +async def test_async_runner_reports_unknown_tool_as_error(): + server_events = [ + AgentCustomToolUseEvent( + id="evt_1", type="tool_call", + content=[{"type": "data", "name": "missing", "custom_tool_use_id": "ctu_2", "arguments": {}}], + ), + SessionStatusIdleEvent( + id="evt_2", type="session_status", + content=[{"type": "data", "data": {"session_status": "idle", "stop_reason": "end_turn"}}], + ), + ] + + sent: List[Dict[str, Any]] = [] + client = _make_async_client() + client.sessions.events.stream = AsyncMock(return_value=_FakeAsyncStream(server_events)) + client.sessions.events.send = AsyncMock(side_effect=lambda sid, evts, **kw: sent.extend(evts)) + + runner = AsyncSessionToolRunner(client=client, session_id="sesn_2", tools=[], max_idle_seconds=0) + records = [record async for record in runner] + + assert len(records) == 1 + assert records[0].is_error is True + assert len(sent) == 1 + assert sent[0]["is_error"] is True + + +@pytest.mark.asyncio +async def test_async_runner_parallel_send_and_stream(): + stream_opened = asyncio.Event() + send_started = asyncio.Event() + + async def _fake_stream(*args, **kwargs): + stream_opened.set() + await asyncio.sleep(0.1) + return _FakeAsyncStream([]) + + async def _fake_send(*args, **kwargs): + send_started.set() + + client = _make_async_client() + client.sessions.events.stream = _fake_stream + client.sessions.events.send = _fake_send + + runner = AsyncSessionToolRunner( + client=client, session_id="sesn_3", tools=[], + events=[{"role": "user", "type": "message", "content": [{"type": "text", "text": "hi"}]}], + max_idle_seconds=0, + ) + async for _ in runner: + pass + + assert stream_opened.is_set() + assert send_started.is_set() + + +@pytest.mark.asyncio +async def test_async_runner_with_events_parameter(): + captured_events: List[Any] = [] + + async def _fake_stream(*args, **kwargs): + return _FakeAsyncStream([]) + + async def _fake_send(session_id, events, **kwargs): + captured_events.extend(list(events)) + + client = _make_async_client() + client.sessions.events.stream = _fake_stream + client.sessions.events.send = _fake_send + + runner = AsyncSessionToolRunner( + client=client, session_id="sesn_4", tools=[], + events=[{"role": "user", "type": "message", "content": [{"type": "text", "text": "hello"}]}], + max_idle_seconds=0, + ) + async for _ in runner: + pass + + assert len(captured_events) == 1 + assert captured_events[0]["type"] == "message" \ No newline at end of file diff --git a/tests/unit/test_agentstudio_skills.py b/tests/unit/test_agentstudio_skills.py new file mode 100644 index 0000000..e837afa --- /dev/null +++ b/tests/unit/test_agentstudio_skills.py @@ -0,0 +1,119 @@ +# -*- coding: utf-8 -*- +"""Skill resource shape & auto-upload compatibility. + +Tests create a client with a mocked transport and patch Files.upload +on the client's files instance since the SDK uses client instance mode. +""" + +from __future__ import annotations + +from typing import Any, Dict, List + +import pytest + +from dashscope.agentstudio import Client +from dashscope.agentstudio.transport import APIResponse +from dashscope.agentstudio.resources.skills import SkillVersions + + +class _Tx: + def __init__(self): + self.calls: List[Dict[str, Any]] = [] + + def request(self, method, path, **kwargs): + self.calls.append({"method": method, "path": path, **kwargs}) + if "/versions" in path: + return APIResponse(data={"id": "skv_1", "skill_id": "skl_1", "version": 1}, request_id="req_2") + if path == "/skills": + return APIResponse(data={"id": "skl_1", "name": "demo"}, request_id="req_1") + return APIResponse(data={}, request_id=None) + + +@pytest.fixture +def client(): + """Create a client with a recording transport.""" + c = Client(api_key="test-key") + c._transport = _Tx() + return c + + +@pytest.fixture +def fake_upload(client, monkeypatch): + """Patch Files.upload on the client's files instance to record calls.""" + calls: List[Dict[str, Any]] = [] + file_id_holder = {"id": "file_auto"} + + def _upload(self_files, file, *, mime_type=None, **kwargs): + calls.append({"file": file, "mime_type": mime_type}) + + class _F: + id = file_id_holder["id"] + + return _F() + + monkeypatch.setattr(type(client.files), "upload", _upload) + return calls, file_id_holder + + +def test_skill_create_with_file_id_does_not_call_upload(client, fake_upload): + upload_calls, _ = fake_upload + client.skills.create(file_id="file_existing", name="demo") + + assert upload_calls == [] + body = client._transport.calls[0]["json"] + assert body == {"file_id": "file_existing", "name": "demo"} + + +def test_skill_create_with_file_path_auto_uploads_first(client, fake_upload): + upload_calls, holder = fake_upload + holder["id"] = "file_uploaded" + + client.skills.create(file="/tmp/skill.zip", name="demo") + + assert len(upload_calls) == 1 + assert upload_calls[0]["file"] == "/tmp/skill.zip" + assert upload_calls[0]["mime_type"] == "application/zip" + body = client._transport.calls[0]["json"] + assert body["file_id"] == "file_uploaded" + + +def test_skill_create_rejects_both_file_and_file_id(client): + with pytest.raises(TypeError): + client.skills.create(file_id="x", file="/tmp/y.zip") + + +def test_skill_create_rejects_neither_file_nor_file_id(client): + with pytest.raises(TypeError): + client.skills.create(name="demo") + + +def test_skill_versions_create_auto_upload(client, fake_upload): + upload_calls, holder = fake_upload + holder["id"] = "file_v2" + + sv = SkillVersions(client) + sv.create("skl_1", file="/tmp/v2.zip") + + assert len(upload_calls) == 1 + assert upload_calls[0]["file"] == "/tmp/v2.zip" + body = client._transport.calls[0]["json"] + assert body == {"file_id": "file_v2"} + + +def test_skill_versions_create_with_file_id_no_upload(client, fake_upload): + upload_calls, _ = fake_upload + + sv = SkillVersions(client) + sv.create("skl_1", file_id="file_existing") + + assert upload_calls == [] + body = client._transport.calls[0]["json"] + assert body == {"file_id": "file_existing"} + + +def test_skill_versions_create_explicit_mime_type_overrides(client, fake_upload): + upload_calls, _ = fake_upload + + sv = SkillVersions(client) + sv.create("skl_1", file="/tmp/v.tar.gz", mime_type="application/gzip") + assert upload_calls[0]["mime_type"] == "application/gzip" diff --git a/tests/unit/test_agentstudio_tools.py b/tests/unit/test_agentstudio_tools.py new file mode 100644 index 0000000..4154910 --- /dev/null +++ b/tests/unit/test_agentstudio_tools.py @@ -0,0 +1,192 @@ +# -*- coding: utf-8 -*- +"""Tests for the @tool decorator schema synthesis and JSON Schema validation.""" + +from typing import List, Optional + +import pytest + +from dashscope.agentstudio.tools import ToolSpec, tool + +jsonschema = pytest.importorskip("jsonschema") + + +def _validate(schema): + # Validator factory raises if the schema itself is invalid. + jsonschema.Draft7Validator.check_schema(schema) + + +# --------------------------------------------------------------------------- +# Tests from test_agentstudio_tools_decorator.py +# --------------------------------------------------------------------------- + + +def test_tool_decorator_basic_schema(): + @tool + def get_weather(city: str, unit: str = "C") -> str: + """Look up the weather for a city.""" + + return f"sunny {city} {unit}" + + spec: ToolSpec = get_weather.__tool_spec__ + assert spec.name == "get_weather" + assert spec.description == "Look up the weather for a city." + schema = spec.parameters + assert schema["type"] == "object" + assert schema["properties"]["city"] == {"type": "string"} + # ``unit`` has a default so the schema records it. + assert schema["properties"]["unit"]["type"] == "string" + assert schema["properties"]["unit"]["default"] == "C" + assert schema["required"] == ["city"] + + +def test_tool_decorator_optional_and_list(): + @tool(name="search", description="Custom desc") + def _search(q: str, limit: Optional[int] = None, tags: List[str] = None): + return [] + + spec = _search.__tool_spec__ + assert spec.name == "search" + assert spec.description == "Custom desc" + props = spec.parameters["properties"] + assert props["q"] == {"type": "string"} + # Optional[int] -> integer with default=None (API needs to know it's optional) + assert props["limit"] == {"type": "integer", "default": None} + # List[str] -> array of strings + assert props["tags"] == {"type": "array", "items": {"type": "string"}, "default": None} + assert spec.parameters["required"] == ["q"] + + +def test_tool_descriptor_round_trip(): + @tool + def identity(text: str) -> str: + """Echo.""" + return text + + desc = identity.__tool_spec__.to_descriptor() + assert "type" not in desc, "BMA backend does not accept 'type' field for custom tools" + assert desc["name"] == "identity" + assert desc["input_schema"]["properties"]["text"] == {"type": "string"} + + +# --------------------------------------------------------------------------- +# Tests from test_agentstudio_tools_schema.py +# --------------------------------------------------------------------------- + + +def test_simple_tool_schema_is_valid_draft7(): + @tool + def get_weather(city: str) -> str: + """Look up the weather for a city.""" + return f"sunny in {city}" + + spec = get_weather.__tool_spec__ + _validate(spec.parameters) + assert spec.parameters["type"] == "object" + assert spec.parameters["properties"]["city"]["type"] == "string" + assert spec.parameters["required"] == ["city"] + assert spec.parameters["additionalProperties"] is False + + +def test_default_value_is_emitted_into_schema(): + @tool + def search(q: str, limit: int = 10) -> str: + """Search the index.""" + return "" + + schema = search.__tool_spec__.parameters + _validate(schema) + assert schema["properties"]["limit"]["default"] == 10 + assert "limit" not in schema["required"] + + +def test_optional_strips_none_keeps_inner_type(): + @tool + def lookup(name: str, hint: Optional[int] = None) -> str: + """Look something up.""" + return "" + + schema = lookup.__tool_spec__.parameters + _validate(schema) + assert schema["properties"]["hint"]["type"] == "integer" + assert schema["properties"]["hint"]["default"] is None # None default is preserved + assert "hint" not in schema["required"] + + +def test_list_inner_type_is_validated(): + @tool + def tag(names: List[str]) -> str: + """Tag inputs.""" + return "" + + schema = tag.__tool_spec__.parameters + _validate(schema) + assert schema["properties"]["names"] == { + "type": "array", + "items": {"type": "string"}, + } + + +def test_args_block_populates_per_param_descriptions(): + @tool + def make_order(item: str, qty: int = 1) -> str: + """Place a supply order. + + Args: + item: SKU identifier of the product to order. + qty: How many units to request. Defaults to 1. + """ + return "" + + schema = make_order.__tool_spec__.parameters + _validate(schema) + assert schema["properties"]["item"]["description"] == ( + "SKU identifier of the product to order." + ) + assert "How many units to request" in schema["properties"]["qty"]["description"] + # Top-level summary trims the Args section + assert make_order.__tool_spec__.description == "Place a supply order." + + +def test_sphinx_style_param_descriptions(): + @tool + def fetch(url: str, timeout: float = 5.0) -> str: + """Fetch a URL. + + :param url: The fully qualified URL to download. + :param timeout: Per-request timeout in seconds. + """ + return "" + + schema = fetch.__tool_spec__.parameters + _validate(schema) + assert ( + schema["properties"]["url"]["description"] + == "The fully qualified URL to download." + ) + assert ( + schema["properties"]["timeout"]["description"] + == "Per-request timeout in seconds." + ) + assert fetch.__tool_spec__.description == "Fetch a URL." + + +def test_descriptor_round_trip_validates_arguments(): + @tool + def ping(host: str, count: int = 1) -> str: + """Ping a host.""" + return "" + + schema = ping.__tool_spec__.to_descriptor()["input_schema"] + _validate(schema) + # valid args + jsonschema.validate({"host": "example.com"}, schema) + jsonschema.validate({"host": "example.com", "count": 3}, schema) + # invalid args -- additionalProperties=False + with pytest.raises(jsonschema.ValidationError): + jsonschema.validate({"host": "x", "extra": True}, schema) + # missing required + with pytest.raises(jsonschema.ValidationError): + jsonschema.validate({"count": 3}, schema) + # type mismatch + with pytest.raises(jsonschema.ValidationError): + jsonschema.validate({"host": 123}, schema) From f37afa94724e63ee51eb201df278cd7e0668688b Mon Sep 17 00:00:00 2001 From: foleydang Date: Mon, 22 Jun 2026 23:22:39 +0800 Subject: [PATCH 2/8] fix: resolve pylint warnings in agentstudio test files Co-Authored-By: Claude Opus 4.6 --- tests/unit/test_agentstudio_skills.py | 24 +++++++++++------ tests/unit/test_agentstudio_tools.py | 37 +++++++++++++++++---------- 2 files changed, 40 insertions(+), 21 deletions(-) diff --git a/tests/unit/test_agentstudio_skills.py b/tests/unit/test_agentstudio_skills.py index e837afa..18610a4 100644 --- a/tests/unit/test_agentstudio_skills.py +++ b/tests/unit/test_agentstudio_skills.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +# pylint: disable=protected-access """Skill resource shape & auto-upload compatibility. Tests create a client with a mocked transport and patch Files.upload @@ -23,27 +24,32 @@ def __init__(self): def request(self, method, path, **kwargs): self.calls.append({"method": method, "path": path, **kwargs}) if "/versions" in path: - return APIResponse(data={"id": "skv_1", "skill_id": "skl_1", "version": 1}, request_id="req_2") + return APIResponse( + data={"id": "skv_1", "skill_id": "skl_1", "version": 1}, + request_id="req_2", + ) if path == "/skills": - return APIResponse(data={"id": "skl_1", "name": "demo"}, request_id="req_1") + return APIResponse( + data={"id": "skl_1", "name": "demo"}, request_id="req_1" + ) return APIResponse(data={}, request_id=None) -@pytest.fixture -def client(): +@pytest.fixture(name="client") +def _client_fixture(): """Create a client with a recording transport.""" c = Client(api_key="test-key") c._transport = _Tx() return c -@pytest.fixture -def fake_upload(client, monkeypatch): +@pytest.fixture(name="fake_upload") +def _fake_upload_fixture(client, monkeypatch): """Patch Files.upload on the client's files instance to record calls.""" calls: List[Dict[str, Any]] = [] file_id_holder = {"id": "file_auto"} - def _upload(self_files, file, *, mime_type=None, **kwargs): + def _upload(_self_files, file, *, mime_type=None, **_kwargs): calls.append({"file": file, "mime_type": mime_type}) class _F: @@ -111,7 +117,9 @@ def test_skill_versions_create_with_file_id_no_upload(client, fake_upload): assert body == {"file_id": "file_existing"} -def test_skill_versions_create_explicit_mime_type_overrides(client, fake_upload): +def test_skill_versions_create_explicit_mime_type_overrides( + client, fake_upload +): upload_calls, _ = fake_upload sv = SkillVersions(client) diff --git a/tests/unit/test_agentstudio_tools.py b/tests/unit/test_agentstudio_tools.py index 4154910..5486f46 100644 --- a/tests/unit/test_agentstudio_tools.py +++ b/tests/unit/test_agentstudio_tools.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -"""Tests for the @tool decorator schema synthesis and JSON Schema validation.""" +"""@tool decorator schema synthesis and JSON Schema validation.""" from typing import List, Optional @@ -42,17 +42,21 @@ def get_weather(city: str, unit: str = "C") -> str: def test_tool_decorator_optional_and_list(): @tool(name="search", description="Custom desc") def _search(q: str, limit: Optional[int] = None, tags: List[str] = None): - return [] + return [q, limit, tags] spec = _search.__tool_spec__ assert spec.name == "search" assert spec.description == "Custom desc" props = spec.parameters["properties"] assert props["q"] == {"type": "string"} - # Optional[int] -> integer with default=None (API needs to know it's optional) + # Optional[int] -> integer with default=None assert props["limit"] == {"type": "integer", "default": None} # List[str] -> array of strings - assert props["tags"] == {"type": "array", "items": {"type": "string"}, "default": None} + assert props["tags"] == { + "type": "array", + "items": {"type": "string"}, + "default": None, + } assert spec.parameters["required"] == ["q"] @@ -63,7 +67,9 @@ def identity(text: str) -> str: return text desc = identity.__tool_spec__.to_descriptor() - assert "type" not in desc, "BMA backend does not accept 'type' field for custom tools" + assert ( + "type" not in desc + ), "BMA backend does not accept 'type' field for custom tools" assert desc["name"] == "identity" assert desc["input_schema"]["properties"]["text"] == {"type": "string"} @@ -91,7 +97,7 @@ def test_default_value_is_emitted_into_schema(): @tool def search(q: str, limit: int = 10) -> str: """Search the index.""" - return "" + return f"{q}{limit}" schema = search.__tool_spec__.parameters _validate(schema) @@ -103,12 +109,14 @@ def test_optional_strips_none_keeps_inner_type(): @tool def lookup(name: str, hint: Optional[int] = None) -> str: """Look something up.""" - return "" + return f"{name}{hint}" schema = lookup.__tool_spec__.parameters _validate(schema) assert schema["properties"]["hint"]["type"] == "integer" - assert schema["properties"]["hint"]["default"] is None # None default is preserved + assert ( + schema["properties"]["hint"]["default"] is None + ) # None default is preserved assert "hint" not in schema["required"] @@ -116,7 +124,7 @@ def test_list_inner_type_is_validated(): @tool def tag(names: List[str]) -> str: """Tag inputs.""" - return "" + return str(names) schema = tag.__tool_spec__.parameters _validate(schema) @@ -135,14 +143,17 @@ def make_order(item: str, qty: int = 1) -> str: item: SKU identifier of the product to order. qty: How many units to request. Defaults to 1. """ - return "" + return f"{item}{qty}" schema = make_order.__tool_spec__.parameters _validate(schema) assert schema["properties"]["item"]["description"] == ( "SKU identifier of the product to order." ) - assert "How many units to request" in schema["properties"]["qty"]["description"] + assert ( + "How many units to request" + in schema["properties"]["qty"]["description"] + ) # Top-level summary trims the Args section assert make_order.__tool_spec__.description == "Place a supply order." @@ -155,7 +166,7 @@ def fetch(url: str, timeout: float = 5.0) -> str: :param url: The fully qualified URL to download. :param timeout: Per-request timeout in seconds. """ - return "" + return f"{url}{timeout}" schema = fetch.__tool_spec__.parameters _validate(schema) @@ -174,7 +185,7 @@ def test_descriptor_round_trip_validates_arguments(): @tool def ping(host: str, count: int = 1) -> str: """Ping a host.""" - return "" + return f"{host}{count}" schema = ping.__tool_spec__.to_descriptor()["input_schema"] _validate(schema) From 5a815acc831971dbc5461389af6882709741c578 Mon Sep 17 00:00:00 2001 From: foleydang Date: Tue, 23 Jun 2026 00:43:33 +0800 Subject: [PATCH 3/8] fix: resolve all pre-commit lint issues in agentstudio module - Rename _transport to transport (remove protected-access pattern) - Fix E501 line-too-long across all resource/transport/type files - Remove unused imports (Optional, json, field, logger, Mapping, Future) - Fix duplicate docstring in files.py, shorten long docstrings - Replace lambda with functools.partial in tools.py - Remove purely-passthrough __init__ methods in params.py - Add StrEnum compatibility shim for Python < 3.11 in constants.py - Fix pytest fixtures with name= parameter to avoid W0621 - Reference @tool params in return values to avoid W0613 - Add noqa: F401 for re-exports in types/__init__.py Co-Authored-By: Claude Opus 4.6 --- dashscope/agentstudio/__init__.py | 4 +- dashscope/agentstudio/client.py | 68 ++++-- dashscope/agentstudio/constants.py | 26 +- dashscope/agentstudio/exceptions.py | 34 ++- dashscope/agentstudio/pagination.py | 4 +- dashscope/agentstudio/resources/__init__.py | 5 +- dashscope/agentstudio/resources/_helpers.py | 4 +- dashscope/agentstudio/resources/agents.py | 151 +++++++++--- .../agentstudio/resources/environments.py | 172 +++++++++---- dashscope/agentstudio/resources/files.py | 133 ++++++++--- .../agentstudio/resources/session_events.py | 209 +++++++++++----- dashscope/agentstudio/resources/sessions.py | 178 ++++++++++---- dashscope/agentstudio/resources/skills.py | 225 +++++++++++++----- dashscope/agentstudio/streaming.py | 4 +- dashscope/agentstudio/tools.py | 166 ++++++++++--- dashscope/agentstudio/transport.py | 168 +++++++++---- dashscope/agentstudio/types/__init__.py | 102 +++++--- dashscope/agentstudio/types/models.py | 101 ++++++-- dashscope/agentstudio/types/params.py | 216 +++++++++-------- tests/unit/test_agentstudio_pagination.py | 9 +- tests/unit/test_agentstudio_protocol.py | 35 ++- tests/unit/test_agentstudio_runner.py | 206 +++++++++++++--- tests/unit/test_agentstudio_skills.py | 17 +- 23 files changed, 1623 insertions(+), 614 deletions(-) diff --git a/dashscope/agentstudio/__init__.py b/dashscope/agentstudio/__init__.py index dee258a..05912d5 100644 --- a/dashscope/agentstudio/__init__.py +++ b/dashscope/agentstudio/__init__.py @@ -7,7 +7,9 @@ from dashscope.agentstudio import Client from dashscope.agentstudio import tool - from dashscope.agentstudio.types import user_message, user_custom_tool_result + from dashscope.agentstudio.types import ( + user_message, user_custom_tool_result, + ) Example ------- diff --git a/dashscope/agentstudio/client.py b/dashscope/agentstudio/client.py index d479f9b..5f84534 100644 --- a/dashscope/agentstudio/client.py +++ b/dashscope/agentstudio/client.py @@ -10,7 +10,10 @@ import httpx from dashscope.agentstudio.resources.agents import Agents, AsyncAgents -from dashscope.agentstudio.resources.environments import Environments, AsyncEnvironments +from dashscope.agentstudio.resources.environments import ( + Environments, + AsyncEnvironments, +) from dashscope.agentstudio.resources.files import Files, AsyncFiles from dashscope.agentstudio.resources.sessions import Sessions, AsyncSessions from dashscope.agentstudio.resources.skills import Skills, AsyncSkills @@ -24,21 +27,30 @@ from dashscope.common.api_key import get_default_api_key -def _resolve_base_url(explicit_url: Optional[str], workspace: Optional[str]) -> str: +def _resolve_base_url( + explicit_url: Optional[str], + workspace: Optional[str], +) -> str: """Resolve the base URL. - + Priority: - 1. explicit base_url parameter (user provides full URL) - 2. DASHSCOPE_AGENTSTUDIO_URL / AGENTSTUDIO_URL env var - 3. Build from workspace: https://{workspace}.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio + 1. explicit base_url parameter (full URL) + 2. DASHSCOPE_AGENTSTUDIO_URL / AGENTSTUDIO_URL env + 3. Build from workspace template """ if explicit_url: return explicit_url - env_url = os.environ.get("DASHSCOPE_AGENTSTUDIO_URL") or os.environ.get("AGENTSTUDIO_URL") + env_url = os.environ.get("DASHSCOPE_AGENTSTUDIO_URL") or os.environ.get( + "AGENTSTUDIO_URL", + ) if env_url: return env_url # Build from workspace - ws = workspace or os.environ.get("DASHSCOPE_WORKSPACE") or AGENTSTUDIO_DEFAULT_WORKSPACE + ws = ( + workspace + or os.environ.get("DASHSCOPE_WORKSPACE") + or AGENTSTUDIO_DEFAULT_WORKSPACE + ) return AGENTSTUDIO_BASE_URL_TEMPLATE.format(workspace=ws) @@ -57,8 +69,7 @@ class Client: from dashscope.agentstudio import Client - client = Client(api_key="sk-xxx", workspace="my-workspace") - # URL: https://my-workspace.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio + client = Client(api_key="sk-xxx", workspace="my-ws") agent = client.agents.create(name="demo", model="qwen-max") session = client.sessions.create(agent=agent.id) with client.sessions.events.stream(session.id) as stream: @@ -73,13 +84,19 @@ def __init__( workspace: Optional[str] = None, base_url: Optional[str] = None, uid: Optional[str] = None, - timeout: Optional[Union[float, httpx.Timeout, Tuple[float, float]]] = None, + timeout: Optional[ + Union[float, httpx.Timeout, Tuple[float, float]] + ] = None, max_retries: int = AGENTSTUDIO_MAX_RETRIES, http_client: Optional[httpx.Client] = None, ) -> None: - resolved_workspace = workspace or os.environ.get("DASHSCOPE_WORKSPACE") or AGENTSTUDIO_DEFAULT_WORKSPACE + resolved_workspace = ( + workspace + or os.environ.get("DASHSCOPE_WORKSPACE") + or AGENTSTUDIO_DEFAULT_WORKSPACE + ) resolved_base = _resolve_base_url(base_url, resolved_workspace) - self._transport = SyncTransport( + self.transport = SyncTransport( base_url=resolved_base, api_key=api_key or get_default_api_key(), workspace=resolved_workspace, @@ -96,7 +113,7 @@ def __init__( self.skills = Skills(self) def close(self) -> None: - self._transport.close() + self.transport.close() def __enter__(self) -> "Client": return self @@ -118,9 +135,10 @@ class AsyncClient: from dashscope.agentstudio import AsyncClient - client = AsyncClient(api_key="sk-xxx", workspace="my-workspace") - # URL: https://my-workspace.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio - agent = await client.agents.create(name="demo", model="qwen-max") + client = AsyncClient(api_key="sk-xxx", workspace="my-ws") + agent = await client.agents.create( + name="demo", model="qwen-max", + ) session = await client.sessions.create(agent=agent.id) async with client.sessions.events.stream(session.id) as stream: async for event in stream: @@ -134,13 +152,19 @@ def __init__( workspace: Optional[str] = None, base_url: Optional[str] = None, uid: Optional[str] = None, - timeout: Optional[Union[float, httpx.Timeout, Tuple[float, float]]] = None, + timeout: Optional[ + Union[float, httpx.Timeout, Tuple[float, float]] + ] = None, max_retries: int = AGENTSTUDIO_MAX_RETRIES, http_client: Optional[httpx.AsyncClient] = None, ) -> None: - resolved_workspace = workspace or os.environ.get("DASHSCOPE_WORKSPACE") or AGENTSTUDIO_DEFAULT_WORKSPACE + resolved_workspace = ( + workspace + or os.environ.get("DASHSCOPE_WORKSPACE") + or AGENTSTUDIO_DEFAULT_WORKSPACE + ) resolved_base = _resolve_base_url(base_url, resolved_workspace) - self._transport = AsyncTransport( + self.transport = AsyncTransport( base_url=resolved_base, api_key=api_key or get_default_api_key(), workspace=resolved_workspace, @@ -157,7 +181,7 @@ def __init__( self.skills = AsyncSkills(self) async def aclose(self) -> None: - await self._transport.aclose() + await self.transport.aclose() async def __aenter__(self) -> "AsyncClient": return self @@ -167,6 +191,6 @@ async def __aexit__(self, *exc: Any) -> None: def __del__(self) -> None: try: - self._transport.close() + self.transport.close() except Exception: pass diff --git a/dashscope/agentstudio/constants.py b/dashscope/agentstudio/constants.py index 97518a8..d81ac02 100644 --- a/dashscope/agentstudio/constants.py +++ b/dashscope/agentstudio/constants.py @@ -2,14 +2,25 @@ # Copyright (c) Alibaba, Inc. and its affiliates. """AgentStudio wire-protocol and configuration constants.""" +import enum +import sys + import httpx -from enum import StrEnum + +if sys.version_info >= (3, 11): + from enum import StrEnum +else: + + class StrEnum(str, enum.Enum): # type: ignore[no-redef] + """Minimal StrEnum shim for Python < 3.11.""" + # --------------------------------------------------------------------------- # Configuration # --------------------------------------------------------------------------- -AGENTSTUDIO_BASE_URL_TEMPLATE = "https://{workspace}.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio" +_HOST = "https://{workspace}.cn-beijing.maas.aliyuncs.com" +AGENTSTUDIO_BASE_URL_TEMPLATE = _HOST + "/api/v1/agentstudio" AGENTSTUDIO_DEFAULT_WORKSPACE = "trial" AGENTSTUDIO_DEFAULT_TIMEOUT = httpx.Timeout(600.0, connect=10.0) AGENTSTUDIO_MAX_RETRIES = 2 @@ -19,6 +30,7 @@ # Wire-protocol enums # --------------------------------------------------------------------------- + class SSEEventType(StrEnum): """Server-sent event types (the value of ``event.type`` in SSE payloads). @@ -26,6 +38,7 @@ class SSEEventType(StrEnum): FUNCTION_CALL_OUTPUT, TOOL_CALL_OUTPUT, DEFINE_OUTCOME. Server-emitted: all types. """ + # Client-sendable MESSAGE = "message" INTERRUPT = "interrupt" @@ -55,13 +68,15 @@ class SSEEventType(StrEnum): class MessageRole(StrEnum): """Roles used in message/event payloads.""" + USER = "user" ASSISTANT = "assistant" TOOL = "tool" class BlockType(StrEnum): - """Content block types (the value of ``block.type`` in message content arrays).""" + """Content block types (``block.type`` values).""" + TEXT = "text" IMAGE = "image" AUDIO = "audio" @@ -72,8 +87,9 @@ class BlockType(StrEnum): class SessionStatus(StrEnum): - """Session run-status values (the value of ``data.session_status`` in session_status events).""" + """Session run-status values (``session_status``).""" + IDLE = "idle" RUNNING = "running" RESCHEDULING = "rescheduling" - TERMINATED = "terminated" \ No newline at end of file + TERMINATED = "terminated" diff --git a/dashscope/agentstudio/exceptions.py b/dashscope/agentstudio/exceptions.py index ec99267..124ab5e 100644 --- a/dashscope/agentstudio/exceptions.py +++ b/dashscope/agentstudio/exceptions.py @@ -175,7 +175,10 @@ class StreamClosedError(StreamError): def from_response( - *, status_code: int, body: Any, headers: Optional[Mapping[str, str]] = None + *, + status_code: int, + body: Any, + headers: Optional[Mapping[str, str]] = None, ) -> AgentStudioError: """Build an :class:`AgentStudioError` instance from a HTTP response. @@ -197,15 +200,23 @@ def from_response( request_id = headers.get("x-request-id") if isinstance(body, Mapping): - # Body request_id as fallback (snake_case is canonical, accept camelCase). + # Body request_id as fallback (snake_case canonical). if request_id is None: - request_id = body.get("request_id") or body.get("requestId") # TODO(bma-fix) + request_id = body.get("request_id") or body.get( + "requestId", + ) # TODO(bma-fix) err = body.get("error") if isinstance(err, Mapping): code = err.get("code") or err.get("error_code") # TODO(bma-fix) - message = err.get("message") or err.get("error_message") # TODO(bma-fix) + message = err.get("message") or err.get( + "error_message", + ) # TODO(bma-fix) # Spring default fallback. - if message is None and "error" in body and isinstance(body["error"], str): + if ( + message is None + and "error" in body + and isinstance(body["error"], str) + ): message = body["error"] code = body.get("error") or "api_error" if message is None: @@ -214,9 +225,16 @@ def from_response( if message is None: message = f"HTTP {status_code}" if code is None: - code = _STATUS_TO_DEFAULT.get(status_code, APIStatusError).code # type: ignore[attr-defined] - - cls = _CODE_TO_CLASS.get(code) or _STATUS_TO_DEFAULT.get(status_code) or APIStatusError + code = _STATUS_TO_DEFAULT.get( # type: ignore[attr-defined] + status_code, + APIStatusError, + ).code + + cls = ( + _CODE_TO_CLASS.get(code) + or _STATUS_TO_DEFAULT.get(status_code) + or APIStatusError + ) return cls( message, code=code, diff --git a/dashscope/agentstudio/pagination.py b/dashscope/agentstudio/pagination.py index 8ec01cb..9dbe481 100644 --- a/dashscope/agentstudio/pagination.py +++ b/dashscope/agentstudio/pagination.py @@ -142,7 +142,9 @@ def build_page( """Hydrate a page object from a normalized payload.""" items_raw = payload.get("data") or [] - items: List[T] = [item_factory(it) for it in items_raw if isinstance(it, dict)] + items: List[T] = [ + item_factory(it) for it in items_raw if isinstance(it, dict) + ] next_page = payload.get("next_page") return page_cls( data=items, diff --git a/dashscope/agentstudio/resources/__init__.py b/dashscope/agentstudio/resources/__init__.py index 3577fab..65a4051 100644 --- a/dashscope/agentstudio/resources/__init__.py +++ b/dashscope/agentstudio/resources/__init__.py @@ -16,7 +16,10 @@ """ from dashscope.agentstudio.resources.agents import Agents, AsyncAgents -from dashscope.agentstudio.resources.environments import Environments, AsyncEnvironments +from dashscope.agentstudio.resources.environments import ( + Environments, + AsyncEnvironments, +) from dashscope.agentstudio.resources.files import Files, AsyncFiles from dashscope.agentstudio.resources.session_events import ( AsyncSessionEvents, diff --git a/dashscope/agentstudio/resources/_helpers.py b/dashscope/agentstudio/resources/_helpers.py index f371cb5..7edb14c 100644 --- a/dashscope/agentstudio/resources/_helpers.py +++ b/dashscope/agentstudio/resources/_helpers.py @@ -4,7 +4,7 @@ from __future__ import annotations -from typing import Any, Mapping, Optional +from typing import Any, Mapping from dashscope.agentstudio.types import ( Agent, @@ -23,6 +23,7 @@ # Coerce helpers # --------------------------------------------------------------------------- + def _coerce_agent(payload: Mapping[str, Any]) -> Agent: return Agent(**dict(payload)) @@ -59,6 +60,7 @@ def _coerce_session(payload: Mapping[str, Any]) -> Session: # Path helpers # --------------------------------------------------------------------------- + def _events_path(session_id: str) -> str: return f"/sessions/{session_id}/events" diff --git a/dashscope/agentstudio/resources/agents.py b/dashscope/agentstudio/resources/agents.py index 2951a55..6576403 100644 --- a/dashscope/agentstudio/resources/agents.py +++ b/dashscope/agentstudio/resources/agents.py @@ -6,13 +6,22 @@ from typing import Any, Mapping, Optional, Sequence -from dashscope.agentstudio.pagination import AsyncCursorPage, CursorPage, build_page +from dashscope.agentstudio.pagination import ( + AsyncCursorPage, + CursorPage, + build_page, +) from dashscope.agentstudio.resources._helpers import ( _coerce_agent, _coerce_agent_version, ) from dashscope.agentstudio.types import Agent, AgentVersion -from dashscope.agentstudio.types.params import AgentCreateParams, AgentListParams, AgentUpdateParams, AgentVersionListParams +from dashscope.agentstudio.types.params import ( + AgentCreateParams, + AgentListParams, + AgentUpdateParams, + AgentVersionListParams, +) _PATH_AGENTS = "/agents" @@ -37,11 +46,16 @@ def create( metadata: Optional[Mapping[str, Any]] = None, ) -> Agent: body = AgentCreateParams( - name=name, model=model, description=description, - system_prompt=system_prompt, tools=tools, - mcp_servers=mcp_servers, skills=skills, metadata=metadata, + name=name, + model=model, + description=description, + system_prompt=system_prompt, + tools=tools, + mcp_servers=mcp_servers, + skills=skills, + metadata=metadata, ).to_dict() - resp = self._client._transport.request("POST", _PATH_AGENTS, json=body) + resp = self._client.transport.request("POST", _PATH_AGENTS, json=body) return _coerce_agent(resp.data) def retrieve( @@ -51,7 +65,11 @@ def retrieve( version: Optional[int] = None, ) -> Agent: params = {"version": version} if version is not None else None - resp = self._client._transport.request("GET", f"{_PATH_AGENTS}/{agent_id}", params=params) + resp = self._client.transport.request( + "GET", + f"{_PATH_AGENTS}/{agent_id}", + params=params, + ) return _coerce_agent(resp.data) # Alias: get() delegates to retrieve(), matching Assistants.get() pattern @@ -76,15 +94,27 @@ def update( current = self.retrieve(agent_id) body = AgentUpdateParams( version=current.version, - name=name, description=description, - model=model, system_prompt=system_prompt, tools=tools, - mcp_servers=mcp_servers, skills=skills, metadata=metadata, + name=name, + description=description, + model=model, + system_prompt=system_prompt, + tools=tools, + mcp_servers=mcp_servers, + skills=skills, + metadata=metadata, ).to_dict() - resp = self._client._transport.request("POST", f"{_PATH_AGENTS}/{agent_id}", json=body) + resp = self._client.transport.request( + "POST", + f"{_PATH_AGENTS}/{agent_id}", + json=body, + ) return _coerce_agent(resp.data) def archive(self, agent_id: str) -> Agent: - resp = self._client._transport.request("POST", f"{_PATH_AGENTS}/{agent_id}/archive") + resp = self._client.transport.request( + "POST", + f"{_PATH_AGENTS}/{agent_id}/archive", + ) return _coerce_agent(resp.data) def list( @@ -94,14 +124,24 @@ def list( page: Optional[str] = None, include_archived: Optional[bool] = None, ) -> CursorPage[Agent]: - params = AgentListParams(limit=limit, page=page, include_archived=include_archived).to_dict() - resp = self._client._transport.request("GET", _PATH_AGENTS, params=params) + params = AgentListParams( + limit=limit, + page=page, + include_archived=include_archived, + ).to_dict() + resp = self._client.transport.request( + "GET", + _PATH_AGENTS, + params=params, + ) return build_page( payload=resp.data, item_factory=_coerce_agent, request_id=resp.request_id, fetch_next=lambda nxt: self.list( - limit=limit, page=nxt, include_archived=include_archived, + limit=limit, + page=nxt, + include_archived=include_archived, ), ) @@ -113,15 +153,19 @@ def list_versions( page: Optional[str] = None, ) -> CursorPage[AgentVersion]: params = AgentVersionListParams(limit=limit, page=page).to_dict() - resp = self._client._transport.request( - "GET", f"{_PATH_AGENTS}/{agent_id}/versions", params=params + resp = self._client.transport.request( + "GET", + f"{_PATH_AGENTS}/{agent_id}/versions", + params=params, ) return build_page( payload=resp.data, item_factory=_coerce_agent_version, request_id=resp.request_id, fetch_next=lambda nxt: self.list_versions( - agent_id, limit=limit, page=nxt, + agent_id, + limit=limit, + page=nxt, ), ) @@ -145,11 +189,20 @@ async def create( metadata: Optional[Mapping[str, Any]] = None, ) -> Agent: body = AgentCreateParams( - name=name, model=model, description=description, - system_prompt=system_prompt, tools=tools, - mcp_servers=mcp_servers, skills=skills, metadata=metadata, + name=name, + model=model, + description=description, + system_prompt=system_prompt, + tools=tools, + mcp_servers=mcp_servers, + skills=skills, + metadata=metadata, ).to_dict() - resp = await self._client._transport.request("POST", _PATH_AGENTS, json=body) + resp = await self._client.transport.request( + "POST", + _PATH_AGENTS, + json=body, + ) return _coerce_agent(resp.data) async def retrieve( @@ -159,7 +212,11 @@ async def retrieve( version: Optional[int] = None, ) -> Agent: params = {"version": version} if version is not None else None - resp = await self._client._transport.request("GET", f"{_PATH_AGENTS}/{agent_id}", params=params) + resp = await self._client.transport.request( + "GET", + f"{_PATH_AGENTS}/{agent_id}", + params=params, + ) return _coerce_agent(resp.data) # Alias: get() delegates to retrieve() @@ -184,15 +241,27 @@ async def update( current = await self.retrieve(agent_id) body = AgentUpdateParams( version=current.version, - name=name, description=description, - model=model, system_prompt=system_prompt, tools=tools, - mcp_servers=mcp_servers, skills=skills, metadata=metadata, + name=name, + description=description, + model=model, + system_prompt=system_prompt, + tools=tools, + mcp_servers=mcp_servers, + skills=skills, + metadata=metadata, ).to_dict() - resp = await self._client._transport.request("POST", f"{_PATH_AGENTS}/{agent_id}", json=body) + resp = await self._client.transport.request( + "POST", + f"{_PATH_AGENTS}/{agent_id}", + json=body, + ) return _coerce_agent(resp.data) async def archive(self, agent_id: str) -> Agent: - resp = await self._client._transport.request("POST", f"{_PATH_AGENTS}/{agent_id}/archive") + resp = await self._client.transport.request( + "POST", + f"{_PATH_AGENTS}/{agent_id}/archive", + ) return _coerce_agent(resp.data) async def list( @@ -202,12 +271,22 @@ async def list( page: Optional[str] = None, include_archived: Optional[bool] = None, ) -> AsyncCursorPage[Agent]: - params = AgentListParams(limit=limit, page=page, include_archived=include_archived).to_dict() - resp = await self._client._transport.request("GET", _PATH_AGENTS, params=params) + params = AgentListParams( + limit=limit, + page=page, + include_archived=include_archived, + ).to_dict() + resp = await self._client.transport.request( + "GET", + _PATH_AGENTS, + params=params, + ) async def fetch_next(nxt: str) -> AsyncCursorPage[Agent]: return await self.list( - limit=limit, page=nxt, include_archived=include_archived, + limit=limit, + page=nxt, + include_archived=include_archived, ) return build_page( @@ -226,13 +305,17 @@ async def list_versions( page: Optional[str] = None, ) -> AsyncCursorPage[AgentVersion]: params = AgentVersionListParams(limit=limit, page=page).to_dict() - resp = await self._client._transport.request( - "GET", f"{_PATH_AGENTS}/{agent_id}/versions", params=params + resp = await self._client.transport.request( + "GET", + f"{_PATH_AGENTS}/{agent_id}/versions", + params=params, ) async def fetch_next(nxt: str) -> AsyncCursorPage[AgentVersion]: return await self.list_versions( - agent_id, limit=limit, page=nxt, + agent_id, + limit=limit, + page=nxt, ) return build_page( diff --git a/dashscope/agentstudio/resources/environments.py b/dashscope/agentstudio/resources/environments.py index d915608..8aed2ee 100644 --- a/dashscope/agentstudio/resources/environments.py +++ b/dashscope/agentstudio/resources/environments.py @@ -6,13 +6,20 @@ from typing import Any, Mapping, Optional -from dashscope.agentstudio.pagination import AsyncCursorPage, CursorPage, build_page +from dashscope.agentstudio.pagination import ( + AsyncCursorPage, + CursorPage, + build_page, +) from dashscope.agentstudio.resources._helpers import ( _coerce_env, ) from dashscope.agentstudio.types import DeleteResponse, Environment -from dashscope.agentstudio.types.params import EnvironmentCreateParams, EnvironmentListParams, EnvironmentUpdateParams - +from dashscope.agentstudio.types.params import ( + EnvironmentCreateParams, + EnvironmentListParams, + EnvironmentUpdateParams, +) _PATH_ENVIRONMENTS = "/environments" @@ -25,60 +32,102 @@ def __init__(self, client) -> None: self._client = client def create( - self, *, name: str, config: Mapping[str, Any], + self, + *, + name: str, + config: Mapping[str, Any], description: Optional[str] = None, scope: Optional[str] = "organization", metadata: Optional[Mapping[str, Any]] = None, extra_headers: Optional[Mapping[str, str]] = None, ) -> Environment: body = EnvironmentCreateParams( - name=name, config=config, description=description, - scope=scope, metadata=metadata, + name=name, + config=config, + description=description, + scope=scope, + metadata=metadata, ).to_dict() - resp = self._client._transport.request( - "POST", _PATH_ENVIRONMENTS, json=body, extra_headers=extra_headers + resp = self._client.transport.request( + "POST", + _PATH_ENVIRONMENTS, + json=body, + extra_headers=extra_headers, ) return _coerce_env(resp.data) def retrieve(self, environment_id: str) -> Environment: - resp = self._client._transport.request("GET", f"{_PATH_ENVIRONMENTS}/{environment_id}") + resp = self._client.transport.request( + "GET", + f"{_PATH_ENVIRONMENTS}/{environment_id}", + ) return _coerce_env(resp.data) # Alias: get() delegates to retrieve() get = retrieve # type: ignore[assignment] def update( - self, environment_id: str, *, - name: Optional[str] = None, description: Optional[str] = None, + self, + environment_id: str, + *, + name: Optional[str] = None, + description: Optional[str] = None, config: Optional[Mapping[str, Any]] = None, metadata: Optional[Mapping[str, Any]] = None, ) -> Environment: body = EnvironmentUpdateParams( - name=name, description=description, - config=config, metadata=metadata, + name=name, + description=description, + config=config, + metadata=metadata, ).to_dict() - resp = self._client._transport.request("POST", f"{_PATH_ENVIRONMENTS}/{environment_id}", json=body) + resp = self._client.transport.request( + "POST", + f"{_PATH_ENVIRONMENTS}/{environment_id}", + json=body, + ) return _coerce_env(resp.data) def list( - self, *, limit: Optional[int] = None, page: Optional[str] = None, + self, + *, + limit: Optional[int] = None, + page: Optional[str] = None, include_archived: Optional[bool] = None, ) -> CursorPage[Environment]: - params = EnvironmentListParams(limit=limit, page=page, include_archived=include_archived).to_dict() - resp = self._client._transport.request("GET", _PATH_ENVIRONMENTS, params=params) + params = EnvironmentListParams( + limit=limit, + page=page, + include_archived=include_archived, + ).to_dict() + resp = self._client.transport.request( + "GET", + _PATH_ENVIRONMENTS, + params=params, + ) return build_page( - payload=resp.data, item_factory=_coerce_env, request_id=resp.request_id, + payload=resp.data, + item_factory=_coerce_env, + request_id=resp.request_id, fetch_next=lambda nxt: self.list( - limit=limit, page=nxt, include_archived=include_archived, + limit=limit, + page=nxt, + include_archived=include_archived, ), ) def archive(self, environment_id: str) -> Environment: - resp = self._client._transport.request("POST", f"{_PATH_ENVIRONMENTS}/{environment_id}/archive") + resp = self._client.transport.request( + "POST", + f"{_PATH_ENVIRONMENTS}/{environment_id}/archive", + ) return _coerce_env(resp.data) def delete(self, environment_id: str) -> DeleteResponse: - resp = self._client._transport.request("DELETE", f"{_PATH_ENVIRONMENTS}/{environment_id}") + resp = self._client.transport.request( + "DELETE", + f"{_PATH_ENVIRONMENTS}/{environment_id}", + ) return DeleteResponse(**resp.data) @@ -89,62 +138,105 @@ def __init__(self, client) -> None: self._client = client async def create( - self, *, name: str, config: Mapping[str, Any], + self, + *, + name: str, + config: Mapping[str, Any], description: Optional[str] = None, scope: Optional[str] = "organization", metadata: Optional[Mapping[str, Any]] = None, extra_headers: Optional[Mapping[str, str]] = None, ) -> Environment: body = EnvironmentCreateParams( - name=name, config=config, description=description, - scope=scope, metadata=metadata, + name=name, + config=config, + description=description, + scope=scope, + metadata=metadata, ).to_dict() - resp = await self._client._transport.request( - "POST", _PATH_ENVIRONMENTS, json=body, extra_headers=extra_headers + resp = await self._client.transport.request( + "POST", + _PATH_ENVIRONMENTS, + json=body, + extra_headers=extra_headers, ) return _coerce_env(resp.data) async def retrieve(self, environment_id: str) -> Environment: - resp = await self._client._transport.request("GET", f"{_PATH_ENVIRONMENTS}/{environment_id}") + resp = await self._client.transport.request( + "GET", + f"{_PATH_ENVIRONMENTS}/{environment_id}", + ) return _coerce_env(resp.data) # Alias: get() delegates to retrieve() get = retrieve # type: ignore[assignment] async def update( - self, environment_id: str, *, - name: Optional[str] = None, description: Optional[str] = None, + self, + environment_id: str, + *, + name: Optional[str] = None, + description: Optional[str] = None, config: Optional[Mapping[str, Any]] = None, metadata: Optional[Mapping[str, Any]] = None, ) -> Environment: body = EnvironmentUpdateParams( - name=name, description=description, - config=config, metadata=metadata, + name=name, + description=description, + config=config, + metadata=metadata, ).to_dict() - resp = await self._client._transport.request("POST", f"{_PATH_ENVIRONMENTS}/{environment_id}", json=body) + resp = await self._client.transport.request( + "POST", + f"{_PATH_ENVIRONMENTS}/{environment_id}", + json=body, + ) return _coerce_env(resp.data) async def list( - self, *, limit: Optional[int] = None, page: Optional[str] = None, + self, + *, + limit: Optional[int] = None, + page: Optional[str] = None, include_archived: Optional[bool] = None, ) -> AsyncCursorPage[Environment]: - params = EnvironmentListParams(limit=limit, page=page, include_archived=include_archived).to_dict() - resp = await self._client._transport.request("GET", _PATH_ENVIRONMENTS, params=params) + params = EnvironmentListParams( + limit=limit, + page=page, + include_archived=include_archived, + ).to_dict() + resp = await self._client.transport.request( + "GET", + _PATH_ENVIRONMENTS, + params=params, + ) async def fetch_next(nxt: str) -> AsyncCursorPage[Environment]: return await self.list( - limit=limit, page=nxt, include_archived=include_archived, + limit=limit, + page=nxt, + include_archived=include_archived, ) return build_page( - payload=resp.data, item_factory=_coerce_env, request_id=resp.request_id, - page_cls=AsyncCursorPage, fetch_next=fetch_next, + payload=resp.data, + item_factory=_coerce_env, + request_id=resp.request_id, + page_cls=AsyncCursorPage, + fetch_next=fetch_next, ) async def archive(self, environment_id: str) -> Environment: - resp = await self._client._transport.request("POST", f"{_PATH_ENVIRONMENTS}/{environment_id}/archive") + resp = await self._client.transport.request( + "POST", + f"{_PATH_ENVIRONMENTS}/{environment_id}/archive", + ) return _coerce_env(resp.data) async def delete(self, environment_id: str) -> DeleteResponse: - resp = await self._client._transport.request("DELETE", f"{_PATH_ENVIRONMENTS}/{environment_id}") + resp = await self._client.transport.request( + "DELETE", + f"{_PATH_ENVIRONMENTS}/{environment_id}", + ) return DeleteResponse(**resp.data) diff --git a/dashscope/agentstudio/resources/files.py b/dashscope/agentstudio/resources/files.py index 3f46954..22c6c1d 100644 --- a/dashscope/agentstudio/resources/files.py +++ b/dashscope/agentstudio/resources/files.py @@ -6,9 +6,23 @@ import os from pathlib import Path -from typing import Any, BinaryIO, Callable, Dict, IO, Mapping, Optional, Tuple, Union +from typing import ( + Any, + BinaryIO, + Callable, + Dict, + IO, + Mapping, + Optional, + Tuple, + Union, +) -from dashscope.agentstudio.pagination import AsyncIdCursorPage, IdCursorPage, build_page +from dashscope.agentstudio.pagination import ( + AsyncIdCursorPage, + IdCursorPage, + build_page, +) from dashscope.agentstudio.resources._helpers import ( _coerce_file, ) @@ -18,8 +32,8 @@ _PATH_FILES = "/files" +# (bytes_read, total_bytes_or_-1) -- called after every chunk. ProgressCallback = Callable[[int, int], None] -"""``(bytes_read, total_bytes_or_-1)`` – called after every chunk.""" def _open_file( @@ -39,7 +53,12 @@ def _open_file( class _ProgressFile: """Wrap a file-like object so callers can observe upload progress.""" - def __init__(self, fileobj: IO[bytes], total: int, callback: ProgressCallback) -> None: + def __init__( + self, + fileobj: IO[bytes], + total: int, + callback: ProgressCallback, + ) -> None: self._fileobj = fileobj self._total = total self._read = 0 @@ -91,7 +110,7 @@ def _file_size(fileobj: IO[bytes]) -> int: class Files: - """File upload / list / delete — instance methods backed by a shared client.""" + """File upload / list / delete.""" def __init__(self, client) -> None: self._client = client @@ -116,10 +135,17 @@ def upload( else: payload_obj = fileobj multipart: Dict[str, Any] = { - "file": (filename, payload_obj, mime_type or "application/octet-stream") + "file": ( + filename, + payload_obj, + mime_type or "application/octet-stream", + ), } - resp = self._client._transport.request( - "POST", _PATH_FILES, files=multipart, extra_headers=extra_headers, + resp = self._client.transport.request( + "POST", + _PATH_FILES, + files=multipart, + extra_headers=extra_headers, ) return _coerce_file(resp.data) finally: @@ -130,42 +156,62 @@ def upload( pass def retrieve(self, file_id: str) -> File: - resp = self._client._transport.request("GET", f"{_PATH_FILES}/{file_id}") + resp = self._client.transport.request( + "GET", + f"{_PATH_FILES}/{file_id}", + ) return _coerce_file(resp.data) # Alias: get() delegates to retrieve() get = retrieve # type: ignore[assignment] def list( - self, *, limit: Optional[int] = None, + self, + *, + limit: Optional[int] = None, page: Optional[str] = None, scope_id: Optional[str] = None, after_id: Optional[str] = None, before_id: Optional[str] = None, ) -> IdCursorPage[File]: params = FileListParams( - limit=limit, page=page, scope_id=scope_id, - after_id=after_id, before_id=before_id, + limit=limit, + page=page, + scope_id=scope_id, + after_id=after_id, + before_id=before_id, ).to_dict() - resp = self._client._transport.request("GET", _PATH_FILES, params=params) + resp = self._client.transport.request( + "GET", + _PATH_FILES, + params=params, + ) def fetch_next(token: str) -> IdCursorPage[File]: return self.list( - limit=limit, page=token, scope_id=scope_id, + limit=limit, + page=token, + scope_id=scope_id, ) return build_page( - payload=resp.data, item_factory=_coerce_file, request_id=resp.request_id, - page_cls=IdCursorPage, fetch_next=fetch_next, + payload=resp.data, + item_factory=_coerce_file, + request_id=resp.request_id, + page_cls=IdCursorPage, + fetch_next=fetch_next, ) def delete(self, file_id: str) -> DeleteResponse: - resp = self._client._transport.request("DELETE", f"{_PATH_FILES}/{file_id}") + resp = self._client.transport.request( + "DELETE", + f"{_PATH_FILES}/{file_id}", + ) return DeleteResponse(**resp.data) class AsyncFiles: - """Async File upload / list / delete — instance methods backed by a shared client.""" + """Async file upload / list / delete.""" def __init__(self, client) -> None: self._client = client @@ -190,10 +236,17 @@ async def upload( else: payload_obj = fileobj multipart: Dict[str, Any] = { - "file": (filename, payload_obj, mime_type or "application/octet-stream") + "file": ( + filename, + payload_obj, + mime_type or "application/octet-stream", + ), } - resp = await self._client._transport.request( - "POST", _PATH_FILES, files=multipart, extra_headers=extra_headers, + resp = await self._client.transport.request( + "POST", + _PATH_FILES, + files=multipart, + extra_headers=extra_headers, ) return _coerce_file(resp.data) finally: @@ -204,35 +257,55 @@ async def upload( pass async def retrieve(self, file_id: str) -> File: - resp = await self._client._transport.request("GET", f"{_PATH_FILES}/{file_id}") + resp = await self._client.transport.request( + "GET", + f"{_PATH_FILES}/{file_id}", + ) return _coerce_file(resp.data) # Alias: get() delegates to retrieve() get = retrieve # type: ignore[assignment] async def list( - self, *, limit: Optional[int] = None, + self, + *, + limit: Optional[int] = None, page: Optional[str] = None, scope_id: Optional[str] = None, after_id: Optional[str] = None, before_id: Optional[str] = None, ) -> AsyncIdCursorPage[File]: params = FileListParams( - limit=limit, page=page, scope_id=scope_id, - after_id=after_id, before_id=before_id, + limit=limit, + page=page, + scope_id=scope_id, + after_id=after_id, + before_id=before_id, ).to_dict() - resp = await self._client._transport.request("GET", _PATH_FILES, params=params) + resp = await self._client.transport.request( + "GET", + _PATH_FILES, + params=params, + ) async def fetch_next(token: str) -> AsyncIdCursorPage[File]: return await self.list( - limit=limit, page=token, scope_id=scope_id, + limit=limit, + page=token, + scope_id=scope_id, ) return build_page( - payload=resp.data, item_factory=_coerce_file, request_id=resp.request_id, - page_cls=AsyncIdCursorPage, fetch_next=fetch_next, + payload=resp.data, + item_factory=_coerce_file, + request_id=resp.request_id, + page_cls=AsyncIdCursorPage, + fetch_next=fetch_next, ) async def delete(self, file_id: str) -> DeleteResponse: - resp = await self._client._transport.request("DELETE", f"{_PATH_FILES}/{file_id}") + resp = await self._client.transport.request( + "DELETE", + f"{_PATH_FILES}/{file_id}", + ) return DeleteResponse(**resp.data) diff --git a/dashscope/agentstudio/resources/session_events.py b/dashscope/agentstudio/resources/session_events.py index bad6a84..927232e 100644 --- a/dashscope/agentstudio/resources/session_events.py +++ b/dashscope/agentstudio/resources/session_events.py @@ -1,27 +1,50 @@ # -*- coding: utf-8 -*- # Copyright (c) Alibaba, Inc. and its affiliates. -"""SessionEvents and AsyncSessionEvents resource classes, plus typed stream wrappers.""" +"""SessionEvents and AsyncSessionEvents resource classes.""" from __future__ import annotations -from typing import Any, AsyncIterator, Dict, Iterator, Mapping, Optional, Sequence +from typing import ( + Any, + AsyncIterator, + Dict, + Iterator, + Mapping, + Optional, + Sequence, +) from dashscope.agentstudio import exceptions -from dashscope.agentstudio.pagination import AsyncCursorPage, CursorPage, build_page +from dashscope.agentstudio.pagination import ( + AsyncCursorPage, + CursorPage, + build_page, +) from dashscope.agentstudio.resources._helpers import ( _coerce_event, _events_path, _stream_path, ) from dashscope.agentstudio.streaming import AsyncEventStream, EventStream -from dashscope.agentstudio.tools import AsyncSessionToolRunner, SessionToolRunner +from dashscope.agentstudio.tools import ( + AsyncSessionToolRunner, + SessionToolRunner, +) from dashscope.agentstudio.types import ServerEvent -from dashscope.agentstudio.types.params import SessionEventListParams, SessionEventSendParams -from dashscope.agentstudio.constants import AGENTSTUDIO_DEFAULT_TIMEOUT, SSEEventType, BlockType, SessionStatus +from dashscope.agentstudio.types.params import ( + SessionEventListParams, + SessionEventSendParams, +) +from dashscope.agentstudio.constants import ( + AGENTSTUDIO_DEFAULT_TIMEOUT, + BlockType, + SessionStatus, + SSEEventType, +) class SessionEvents: - """Session event send / list / stream — instance methods backed by a shared client.""" + """Session event send / list / stream.""" def __init__(self, client) -> None: self._client = client @@ -35,12 +58,18 @@ def send( Returns the server response dict:: - result = client.sessions.events.send(session.id, [user_message("hello")]) + result = client.sessions.events.send( + session.id, [user_message("hello")], + ) """ if not events: raise ValueError("events must contain at least 1 entry") body = SessionEventSendParams(input=events).to_dict() - resp = self._client._transport.request("POST", _events_path(session_id), json=body) + resp = self._client.transport.request( + "POST", + _events_path(session_id), + json=body, + ) return resp.data def list( @@ -57,22 +86,38 @@ def list( page: Optional[str] = None, ) -> CursorPage[ServerEvent]: params = SessionEventListParams( - types=types, created_at_gt=created_at_gt, - created_at_gte=created_at_gte, created_at_lt=created_at_lt, - created_at_lte=created_at_lte, limit=limit, order=order, page=page, + types=types, + created_at_gt=created_at_gt, + created_at_gte=created_at_gte, + created_at_lt=created_at_lt, + created_at_lte=created_at_lte, + limit=limit, + order=order, + page=page, ).to_dict() - resp = self._client._transport.request("GET", _events_path(session_id), params=params) + resp = self._client.transport.request( + "GET", + _events_path(session_id), + params=params, + ) def fetch_next(nxt: str) -> CursorPage[ServerEvent]: return self.list( - session_id, types=types, - created_at_gt=created_at_gt, created_at_gte=created_at_gte, - created_at_lt=created_at_lt, created_at_lte=created_at_lte, - limit=limit, order=order, page=nxt, + session_id, + types=types, + created_at_gt=created_at_gt, + created_at_gte=created_at_gte, + created_at_lt=created_at_lt, + created_at_lte=created_at_lte, + limit=limit, + order=order, + page=nxt, ) return build_page( - payload=resp.data, item_factory=_coerce_event, request_id=resp.request_id, + payload=resp.data, + item_factory=_coerce_event, + request_id=resp.request_id, fetch_next=fetch_next, ) @@ -84,10 +129,12 @@ def stream( ) -> "_TypedEventStream": """Open the SSE stream and return an iterator of typed events.""" - resp = self._client._transport.request( - "GET", _stream_path(session_id), + resp = self._client.transport.request( + "GET", + _stream_path(session_id), extra_headers={"Accept": "text/event-stream"}, - stream=True, timeout=timeout or AGENTSTUDIO_DEFAULT_TIMEOUT, + stream=True, + timeout=timeout or AGENTSTUDIO_DEFAULT_TIMEOUT, ) if resp.status_code >= 400: try: @@ -97,13 +144,17 @@ def stream( body = {"raw": resp.text} try: raise exceptions.from_response( - status_code=resp.status_code, body=body, headers=resp.headers + status_code=resp.status_code, + body=body, + headers=resp.headers, ) finally: resp.close() - # Transport is shared with the client — stream close only closes the - # response, NOT the transport. - return _TypedEventStream(EventStream(response=resp)) + # Transport is shared -- stream close only closes + # the response, NOT the transport. + return _TypedEventStream( + EventStream(response=resp), + ) def tool_runner( self, @@ -128,6 +179,7 @@ def tool_runner( max_concurrent=max_concurrent, ) + class _TypedEventStream: """Wrap :class:`EventStream` so iterating yields :class:`ServerEvent`.""" @@ -153,17 +205,18 @@ def text_stream(self): ``session_status`` events manually. """ for event in self: - if getattr(event, 'type', None) == SSEEventType.MESSAGE: - for block in (event.content or []): - if getattr(block, 'type', None) == BlockType.TEXT: - text = getattr(block, 'text', '') + if getattr(event, "type", None) == SSEEventType.MESSAGE: + for block in event.content or []: + if getattr(block, "type", None) == BlockType.TEXT: + text = getattr(block, "text", "") if text: yield text - elif getattr(event, 'type', None) == SSEEventType.SESSION_STATUS: + elif getattr(event, "type", None) == SSEEventType.SESSION_STATUS: block = event.content[0] if event.content else None - d = getattr(block, 'data', None) or {} - if d.get('session_status') in ( - SessionStatus.IDLE, SessionStatus.TERMINATED, + d = getattr(block, "data", None) or {} + if d.get("session_status") in ( + SessionStatus.IDLE, + SessionStatus.TERMINATED, SessionStatus.RESCHEDULING, ): return @@ -173,7 +226,7 @@ def close(self) -> None: class AsyncSessionEvents: - """Async session event operations — instance methods backed by a shared client.""" + """Async session event operations.""" def __init__(self, client) -> None: self._client = client @@ -187,12 +240,18 @@ async def send( Returns the server response dict:: - result = await client.sessions.events.send(session.id, [user_message("hello")]) + result = await client.sessions.events.send( + session.id, [user_message("hello")], + ) """ if not events: raise ValueError("events must contain at least 1 entry") body = SessionEventSendParams(input=events).to_dict() - resp = await self._client._transport.request("POST", _events_path(session_id), json=body) + resp = await self._client.transport.request( + "POST", + _events_path(session_id), + json=body, + ) return resp.data async def list( @@ -209,23 +268,42 @@ async def list( page: Optional[str] = None, ) -> AsyncCursorPage[ServerEvent]: params = SessionEventListParams( - types=types, created_at_gt=created_at_gt, - created_at_gte=created_at_gte, created_at_lt=created_at_lt, - created_at_lte=created_at_lte, limit=limit, order=order, page=page, + types=types, + created_at_gt=created_at_gt, + created_at_gte=created_at_gte, + created_at_lt=created_at_lt, + created_at_lte=created_at_lte, + limit=limit, + order=order, + page=page, ).to_dict() - resp = await self._client._transport.request("GET", _events_path(session_id), params=params) + resp = await self._client.transport.request( + "GET", + _events_path(session_id), + params=params, + ) - async def fetch_next(nxt: str) -> AsyncCursorPage[ServerEvent]: + async def fetch_next( + nxt: str, + ) -> AsyncCursorPage[ServerEvent]: return await self.list( - session_id, types=types, - created_at_gt=created_at_gt, created_at_gte=created_at_gte, - created_at_lt=created_at_lt, created_at_lte=created_at_lte, - limit=limit, order=order, page=nxt, + session_id, + types=types, + created_at_gt=created_at_gt, + created_at_gte=created_at_gte, + created_at_lt=created_at_lt, + created_at_lte=created_at_lte, + limit=limit, + order=order, + page=nxt, ) return build_page( - payload=resp.data, item_factory=_coerce_event, request_id=resp.request_id, - page_cls=AsyncCursorPage, fetch_next=fetch_next, + payload=resp.data, + item_factory=_coerce_event, + request_id=resp.request_id, + page_cls=AsyncCursorPage, + fetch_next=fetch_next, ) async def stream( @@ -236,10 +314,12 @@ async def stream( ) -> "_AioTypedEventStream": """Open the SSE stream and return an async iterator of typed events.""" - resp = await self._client._transport.request( - "GET", _stream_path(session_id), + resp = await self._client.transport.request( + "GET", + _stream_path(session_id), extra_headers={"Accept": "text/event-stream"}, - stream=True, timeout=timeout or AGENTSTUDIO_DEFAULT_TIMEOUT, + stream=True, + timeout=timeout or AGENTSTUDIO_DEFAULT_TIMEOUT, ) if resp.status_code >= 400: try: @@ -249,13 +329,17 @@ async def stream( body = {"raw": resp.text} try: raise exceptions.from_response( - status_code=resp.status_code, body=body, headers=resp.headers + status_code=resp.status_code, + body=body, + headers=resp.headers, ) finally: await resp.aclose() - # Transport is shared with the client — stream close only closes the - # response, NOT the transport. - return _AioTypedEventStream(AsyncEventStream(response=resp)) + # Transport is shared -- stream close only closes + # the response, NOT the transport. + return _AioTypedEventStream( + AsyncEventStream(response=resp), + ) def tool_runner( self, @@ -312,17 +396,18 @@ async def _text_stream(self): ``session_status`` events manually. """ async for event in self: - if getattr(event, 'type', None) == SSEEventType.MESSAGE: - for block in (event.content or []): - if getattr(block, 'type', None) == BlockType.TEXT: - text = getattr(block, 'text', '') + if getattr(event, "type", None) == SSEEventType.MESSAGE: + for block in event.content or []: + if getattr(block, "type", None) == BlockType.TEXT: + text = getattr(block, "text", "") if text: yield text - elif getattr(event, 'type', None) == SSEEventType.SESSION_STATUS: + elif getattr(event, "type", None) == SSEEventType.SESSION_STATUS: block = event.content[0] if event.content else None - d = getattr(block, 'data', None) or {} - if d.get('session_status') in ( - SessionStatus.IDLE, SessionStatus.TERMINATED, + d = getattr(block, "data", None) or {} + if d.get("session_status") in ( + SessionStatus.IDLE, + SessionStatus.TERMINATED, SessionStatus.RESCHEDULING, ): return diff --git a/dashscope/agentstudio/resources/sessions.py b/dashscope/agentstudio/resources/sessions.py index 692d4cd..a0c9914 100644 --- a/dashscope/agentstudio/resources/sessions.py +++ b/dashscope/agentstudio/resources/sessions.py @@ -6,13 +6,24 @@ from typing import Any, Mapping, Optional, Sequence -from dashscope.agentstudio.pagination import AsyncCursorPage, CursorPage, build_page +from dashscope.agentstudio.pagination import ( + AsyncCursorPage, + CursorPage, + build_page, +) from dashscope.agentstudio.resources._helpers import ( _coerce_session, ) -from dashscope.agentstudio.resources.session_events import SessionEvents, AsyncSessionEvents +from dashscope.agentstudio.resources.session_events import ( + SessionEvents, + AsyncSessionEvents, +) from dashscope.agentstudio.types import DeleteResponse, Session -from dashscope.agentstudio.types.params import SessionCreateParams, SessionListParams, SessionUpdateParams +from dashscope.agentstudio.types.params import ( + SessionCreateParams, + SessionListParams, + SessionUpdateParams, +) _PATH_SESSIONS = "/sessions" @@ -26,20 +37,31 @@ def __init__(self, client) -> None: self.events = SessionEvents(client) def create( - self, *, agent: str, + self, + *, + agent: str, environment_id: Optional[str] = None, title: Optional[str] = None, metadata: Optional[Mapping[str, Any]] = None, ) -> Session: body = SessionCreateParams( - agent=agent, environment_id=environment_id, - title=title, metadata=metadata, + agent=agent, + environment_id=environment_id, + title=title, + metadata=metadata, ).to_dict() - resp = self._client._transport.request("POST", _PATH_SESSIONS, json=body) + resp = self._client.transport.request( + "POST", + _PATH_SESSIONS, + json=body, + ) return _coerce_session(resp.data) def retrieve(self, session_id: str) -> Session: - resp = self._client._transport.request("GET", f"{_PATH_SESSIONS}/{session_id}") + resp = self._client.transport.request( + "GET", + f"{_PATH_SESSIONS}/{session_id}", + ) return _coerce_session(resp.data) # Alias: get() delegates to retrieve() @@ -49,18 +71,26 @@ def retrieve(self, session_id: str) -> Session: call = create # type: ignore[assignment] def update( - self, session_id: str, *, + self, + session_id: str, + *, title: Optional[str] = None, metadata: Optional[Mapping[str, Any]] = None, ) -> Session: body = SessionUpdateParams( - title=title, metadata=metadata, + title=title, + metadata=metadata, ).to_dict() - resp = self._client._transport.request("POST", f"{_PATH_SESSIONS}/{session_id}", json=body) + resp = self._client.transport.request( + "POST", + f"{_PATH_SESSIONS}/{session_id}", + json=body, + ) return _coerce_session(resp.data) def list( - self, *, + self, + *, limit: Optional[int] = None, page: Optional[str] = None, agent_id: Optional[str] = None, @@ -71,30 +101,52 @@ def list( created_at_lte: Optional[str] = None, ) -> CursorPage[Session]: params = SessionListParams( - limit=limit, page=page, agent_id=agent_id, statuses=statuses, - created_at_gt=created_at_gt, created_at_gte=created_at_gte, - created_at_lt=created_at_lt, created_at_lte=created_at_lte, + limit=limit, + page=page, + agent_id=agent_id, + statuses=statuses, + created_at_gt=created_at_gt, + created_at_gte=created_at_gte, + created_at_lt=created_at_lt, + created_at_lte=created_at_lte, ).to_dict() - resp = self._client._transport.request("GET", _PATH_SESSIONS, params=params) + resp = self._client.transport.request( + "GET", + _PATH_SESSIONS, + params=params, + ) def fetch_next(nxt: str) -> CursorPage[Session]: return self.list( - limit=limit, page=nxt, agent_id=agent_id, statuses=statuses, - created_at_gt=created_at_gt, created_at_gte=created_at_gte, - created_at_lt=created_at_lt, created_at_lte=created_at_lte, + limit=limit, + page=nxt, + agent_id=agent_id, + statuses=statuses, + created_at_gt=created_at_gt, + created_at_gte=created_at_gte, + created_at_lt=created_at_lt, + created_at_lte=created_at_lte, ) return build_page( - payload=resp.data, item_factory=_coerce_session, request_id=resp.request_id, + payload=resp.data, + item_factory=_coerce_session, + request_id=resp.request_id, fetch_next=fetch_next, ) def archive(self, session_id: str) -> Session: - resp = self._client._transport.request("POST", f"{_PATH_SESSIONS}/{session_id}/archive") + resp = self._client.transport.request( + "POST", + f"{_PATH_SESSIONS}/{session_id}/archive", + ) return _coerce_session(resp.data) def delete(self, session_id: str) -> DeleteResponse: - resp = self._client._transport.request("DELETE", f"{_PATH_SESSIONS}/{session_id}") + resp = self._client.transport.request( + "DELETE", + f"{_PATH_SESSIONS}/{session_id}", + ) return DeleteResponse(**resp.data) @@ -106,20 +158,31 @@ def __init__(self, client) -> None: self.events = AsyncSessionEvents(client) async def create( - self, *, agent: str, + self, + *, + agent: str, environment_id: Optional[str] = None, title: Optional[str] = None, metadata: Optional[Mapping[str, Any]] = None, ) -> Session: body = SessionCreateParams( - agent=agent, environment_id=environment_id, - title=title, metadata=metadata, + agent=agent, + environment_id=environment_id, + title=title, + metadata=metadata, ).to_dict() - resp = await self._client._transport.request("POST", _PATH_SESSIONS, json=body) + resp = await self._client.transport.request( + "POST", + _PATH_SESSIONS, + json=body, + ) return _coerce_session(resp.data) async def retrieve(self, session_id: str) -> Session: - resp = await self._client._transport.request("GET", f"{_PATH_SESSIONS}/{session_id}") + resp = await self._client.transport.request( + "GET", + f"{_PATH_SESSIONS}/{session_id}", + ) return _coerce_session(resp.data) # Alias: get() delegates to retrieve() @@ -129,18 +192,26 @@ async def retrieve(self, session_id: str) -> Session: call = create # type: ignore[assignment] async def update( - self, session_id: str, *, + self, + session_id: str, + *, title: Optional[str] = None, metadata: Optional[Mapping[str, Any]] = None, ) -> Session: body = SessionUpdateParams( - title=title, metadata=metadata, + title=title, + metadata=metadata, ).to_dict() - resp = await self._client._transport.request("POST", f"{_PATH_SESSIONS}/{session_id}", json=body) + resp = await self._client.transport.request( + "POST", + f"{_PATH_SESSIONS}/{session_id}", + json=body, + ) return _coerce_session(resp.data) async def list( - self, *, + self, + *, limit: Optional[int] = None, page: Optional[str] = None, agent_id: Optional[str] = None, @@ -151,28 +222,51 @@ async def list( created_at_lte: Optional[str] = None, ) -> AsyncCursorPage[Session]: params = SessionListParams( - limit=limit, page=page, agent_id=agent_id, statuses=statuses, - created_at_gt=created_at_gt, created_at_gte=created_at_gte, - created_at_lt=created_at_lt, created_at_lte=created_at_lte, + limit=limit, + page=page, + agent_id=agent_id, + statuses=statuses, + created_at_gt=created_at_gt, + created_at_gte=created_at_gte, + created_at_lt=created_at_lt, + created_at_lte=created_at_lte, ).to_dict() - resp = await self._client._transport.request("GET", _PATH_SESSIONS, params=params) + resp = await self._client.transport.request( + "GET", + _PATH_SESSIONS, + params=params, + ) async def fetch_next(nxt: str) -> AsyncCursorPage[Session]: return await self.list( - limit=limit, page=nxt, agent_id=agent_id, statuses=statuses, - created_at_gt=created_at_gt, created_at_gte=created_at_gte, - created_at_lt=created_at_lt, created_at_lte=created_at_lte, + limit=limit, + page=nxt, + agent_id=agent_id, + statuses=statuses, + created_at_gt=created_at_gt, + created_at_gte=created_at_gte, + created_at_lt=created_at_lt, + created_at_lte=created_at_lte, ) return build_page( - payload=resp.data, item_factory=_coerce_session, request_id=resp.request_id, - page_cls=AsyncCursorPage, fetch_next=fetch_next, + payload=resp.data, + item_factory=_coerce_session, + request_id=resp.request_id, + page_cls=AsyncCursorPage, + fetch_next=fetch_next, ) async def archive(self, session_id: str) -> Session: - resp = await self._client._transport.request("POST", f"{_PATH_SESSIONS}/{session_id}/archive") + resp = await self._client.transport.request( + "POST", + f"{_PATH_SESSIONS}/{session_id}/archive", + ) return _coerce_session(resp.data) async def delete(self, session_id: str) -> DeleteResponse: - resp = await self._client._transport.request("DELETE", f"{_PATH_SESSIONS}/{session_id}") + resp = await self._client.transport.request( + "DELETE", + f"{_PATH_SESSIONS}/{session_id}", + ) return DeleteResponse(**resp.data) diff --git a/dashscope/agentstudio/resources/skills.py b/dashscope/agentstudio/resources/skills.py index 6086991..c9d9833 100644 --- a/dashscope/agentstudio/resources/skills.py +++ b/dashscope/agentstudio/resources/skills.py @@ -4,17 +4,25 @@ from __future__ import annotations -import os -from typing import Any, BinaryIO, Dict, Mapping, Optional, Tuple, Union +import os # pylint: disable=unused-import # noqa: F401 +from typing import Any, BinaryIO, Dict, Optional, Tuple, Union -from dashscope.agentstudio.pagination import AsyncCursorPage, CursorPage, build_page +from dashscope.agentstudio.pagination import ( + AsyncCursorPage, + CursorPage, + build_page, +) from dashscope.agentstudio.resources._helpers import ( _coerce_skill, _coerce_skill_version, ) from dashscope.agentstudio.types import DeleteResponse, Skill, SkillVersion -from dashscope.agentstudio.types.params import SkillCreateParams, SkillListParams, SkillVersionCreateParams, SkillVersionListParams - +from dashscope.agentstudio.types.params import ( + SkillCreateParams, + SkillListParams, + SkillVersionCreateParams, + SkillVersionListParams, +) _PATH_SKILLS = "/skills" @@ -30,20 +38,27 @@ def __init__(self, client) -> None: self.versions = SkillVersions(client) def create( - self, *, file_id: Optional[str] = None, + self, + *, + file_id: Optional[str] = None, file: Optional[FileSource] = None, mime_type: Optional[str] = None, ) -> Skill: resolved = _resolve_file_id_sync( client=self._client, - file_id=file_id, file=file, mime_type=mime_type, + file_id=file_id, + file=file, + mime_type=mime_type, ) body = SkillCreateParams(file_id=resolved).to_dict() - resp = self._client._transport.request("POST", _PATH_SKILLS, json=body) + resp = self._client.transport.request("POST", _PATH_SKILLS, json=body) return _coerce_skill(resp.data) def retrieve(self, skill_id: str) -> Skill: - resp = self._client._transport.request("GET", f"{_PATH_SKILLS}/{skill_id}") + resp = self._client.transport.request( + "GET", + f"{_PATH_SKILLS}/{skill_id}", + ) return _coerce_skill(resp.data) # Alias: get() delegates to retrieve() @@ -53,64 +68,97 @@ def retrieve(self, skill_id: str) -> Skill: call = create # type: ignore[assignment] def list( - self, *, source: Optional[str] = None, - limit: Optional[int] = None, page: Optional[str] = None, + self, + *, + source: Optional[str] = None, + limit: Optional[int] = None, + page: Optional[str] = None, ) -> CursorPage[Skill]: - params = SkillListParams(source=source, limit=limit, page=page).to_dict() - resp = self._client._transport.request("GET", _PATH_SKILLS, params=params) + params = SkillListParams( + source=source, + limit=limit, + page=page, + ).to_dict() + resp = self._client.transport.request( + "GET", + _PATH_SKILLS, + params=params, + ) return build_page( - payload=resp.data, item_factory=_coerce_skill, request_id=resp.request_id, + payload=resp.data, + item_factory=_coerce_skill, + request_id=resp.request_id, fetch_next=lambda nxt: self.list( - source=source, limit=limit, page=nxt, + source=source, + limit=limit, + page=nxt, ), ) def delete(self, skill_id: str) -> DeleteResponse: - resp = self._client._transport.request("DELETE", f"{_PATH_SKILLS}/{skill_id}") + resp = self._client.transport.request( + "DELETE", + f"{_PATH_SKILLS}/{skill_id}", + ) return DeleteResponse(**resp.data) class SkillVersions: - """Skill version sub-resource — instance methods backed by a shared client.""" + """Skill version sub-resource.""" def __init__(self, client) -> None: self._client = client def create( - self, skill_id: str, *, + self, + skill_id: str, + *, file_id: Optional[str] = None, file: Optional[FileSource] = None, mime_type: Optional[str] = None, ) -> SkillVersion: resolved = _resolve_file_id_sync( client=self._client, - file_id=file_id, file=file, mime_type=mime_type, + file_id=file_id, + file=file, + mime_type=mime_type, ) body = SkillVersionCreateParams(file_id=resolved).to_dict() - resp = self._client._transport.request( - "POST", f"{_PATH_SKILLS}/{skill_id}/versions", + resp = self._client.transport.request( + "POST", + f"{_PATH_SKILLS}/{skill_id}/versions", json=body, ) return _coerce_skill_version(resp.data) def list( - self, skill_id: str, *, - limit: Optional[int] = None, page: Optional[str] = None, + self, + skill_id: str, + *, + limit: Optional[int] = None, + page: Optional[str] = None, ) -> CursorPage[SkillVersion]: params = SkillVersionListParams(limit=limit, page=page).to_dict() - resp = self._client._transport.request( - "GET", f"{_PATH_SKILLS}/{skill_id}/versions", params=params + resp = self._client.transport.request( + "GET", + f"{_PATH_SKILLS}/{skill_id}/versions", + params=params, ) return build_page( - payload=resp.data, item_factory=_coerce_skill_version, request_id=resp.request_id, + payload=resp.data, + item_factory=_coerce_skill_version, + request_id=resp.request_id, fetch_next=lambda nxt: self.list( - skill_id, limit=limit, page=nxt, + skill_id, + limit=limit, + page=nxt, ), ) def retrieve(self, skill_id: str, version: str) -> SkillVersion: - resp = self._client._transport.request( - "GET", f"{_PATH_SKILLS}/{skill_id}/versions/{version}" + resp = self._client.transport.request( + "GET", + f"{_PATH_SKILLS}/{skill_id}/versions/{version}", ) return _coerce_skill_version(resp.data) @@ -118,8 +166,9 @@ def retrieve(self, skill_id: str, version: str) -> SkillVersion: get = retrieve # type: ignore[assignment] def download(self, skill_id: str, version: str) -> Dict[str, Any]: - resp = self._client._transport.request( - "GET", f"{_PATH_SKILLS}/{skill_id}/versions/{version}/content" + resp = self._client.transport.request( + "GET", + f"{_PATH_SKILLS}/{skill_id}/versions/{version}/content", ) return dict(resp.data) @@ -132,20 +181,31 @@ def __init__(self, client) -> None: self.versions = AsyncSkillVersions(client) async def create( - self, *, file_id: Optional[str] = None, + self, + *, + file_id: Optional[str] = None, file: Optional[FileSource] = None, mime_type: Optional[str] = None, ) -> Skill: resolved = await _resolve_file_id_async( client=self._client, - file_id=file_id, file=file, mime_type=mime_type, + file_id=file_id, + file=file, + mime_type=mime_type, ) body = SkillCreateParams(file_id=resolved).to_dict() - resp = await self._client._transport.request("POST", _PATH_SKILLS, json=body) + resp = await self._client.transport.request( + "POST", + _PATH_SKILLS, + json=body, + ) return _coerce_skill(resp.data) async def retrieve(self, skill_id: str) -> Skill: - resp = await self._client._transport.request("GET", f"{_PATH_SKILLS}/{skill_id}") + resp = await self._client.transport.request( + "GET", + f"{_PATH_SKILLS}/{skill_id}", + ) return _coerce_skill(resp.data) # Alias: get() delegates to retrieve() @@ -155,72 +215,107 @@ async def retrieve(self, skill_id: str) -> Skill: call = create # type: ignore[assignment] async def list( - self, *, source: Optional[str] = None, - limit: Optional[int] = None, page: Optional[str] = None, + self, + *, + source: Optional[str] = None, + limit: Optional[int] = None, + page: Optional[str] = None, ) -> AsyncCursorPage[Skill]: - params = SkillListParams(source=source, limit=limit, page=page).to_dict() - resp = await self._client._transport.request("GET", _PATH_SKILLS, params=params) + params = SkillListParams( + source=source, + limit=limit, + page=page, + ).to_dict() + resp = await self._client.transport.request( + "GET", + _PATH_SKILLS, + params=params, + ) async def fetch_next(nxt: str) -> AsyncCursorPage[Skill]: return await self.list( - source=source, limit=limit, page=nxt, + source=source, + limit=limit, + page=nxt, ) return build_page( - payload=resp.data, item_factory=_coerce_skill, request_id=resp.request_id, - page_cls=AsyncCursorPage, fetch_next=fetch_next, + payload=resp.data, + item_factory=_coerce_skill, + request_id=resp.request_id, + page_cls=AsyncCursorPage, + fetch_next=fetch_next, ) async def delete(self, skill_id: str) -> DeleteResponse: - resp = await self._client._transport.request("DELETE", f"{_PATH_SKILLS}/{skill_id}") + resp = await self._client.transport.request( + "DELETE", + f"{_PATH_SKILLS}/{skill_id}", + ) return DeleteResponse(**resp.data) class AsyncSkillVersions: - """Async skill version sub-resource — instance methods backed by a shared client.""" + """Async skill version sub-resource.""" def __init__(self, client) -> None: self._client = client async def create( - self, skill_id: str, *, + self, + skill_id: str, + *, file_id: Optional[str] = None, file: Optional[FileSource] = None, mime_type: Optional[str] = None, ) -> SkillVersion: resolved = await _resolve_file_id_async( client=self._client, - file_id=file_id, file=file, mime_type=mime_type, + file_id=file_id, + file=file, + mime_type=mime_type, ) body = SkillVersionCreateParams(file_id=resolved).to_dict() - resp = await self._client._transport.request( - "POST", f"{_PATH_SKILLS}/{skill_id}/versions", + resp = await self._client.transport.request( + "POST", + f"{_PATH_SKILLS}/{skill_id}/versions", json=body, ) return _coerce_skill_version(resp.data) async def list( - self, skill_id: str, *, - limit: Optional[int] = None, page: Optional[str] = None, + self, + skill_id: str, + *, + limit: Optional[int] = None, + page: Optional[str] = None, ) -> AsyncCursorPage[SkillVersion]: params = SkillVersionListParams(limit=limit, page=page).to_dict() - resp = await self._client._transport.request( - "GET", f"{_PATH_SKILLS}/{skill_id}/versions", params=params + resp = await self._client.transport.request( + "GET", + f"{_PATH_SKILLS}/{skill_id}/versions", + params=params, ) async def fetch_next(nxt: str) -> AsyncCursorPage[SkillVersion]: return await self.list( - skill_id, limit=limit, page=nxt, + skill_id, + limit=limit, + page=nxt, ) return build_page( - payload=resp.data, item_factory=_coerce_skill_version, request_id=resp.request_id, - page_cls=AsyncCursorPage, fetch_next=fetch_next, + payload=resp.data, + item_factory=_coerce_skill_version, + request_id=resp.request_id, + page_cls=AsyncCursorPage, + fetch_next=fetch_next, ) async def retrieve(self, skill_id: str, version: str) -> SkillVersion: - resp = await self._client._transport.request( - "GET", f"{_PATH_SKILLS}/{skill_id}/versions/{version}" + resp = await self._client.transport.request( + "GET", + f"{_PATH_SKILLS}/{skill_id}/versions/{version}", ) return _coerce_skill_version(resp.data) @@ -228,8 +323,9 @@ async def retrieve(self, skill_id: str, version: str) -> SkillVersion: get = retrieve # type: ignore[assignment] async def download(self, skill_id: str, version: str) -> Dict[str, Any]: - resp = await self._client._transport.request( - "GET", f"{_PATH_SKILLS}/{skill_id}/versions/{version}/content" + resp = await self._client.transport.request( + "GET", + f"{_PATH_SKILLS}/{skill_id}/versions/{version}/content", ) return dict(resp.data) @@ -238,6 +334,7 @@ async def download(self, skill_id: str, version: str) -> Dict[str, Any]: # File-ID resolution helpers (internal) # --------------------------------------------------------------------------- + def _resolve_file_id_sync( *, client: Any, @@ -247,13 +344,14 @@ def _resolve_file_id_sync( ) -> str: if (file_id is None) == (file is None): raise TypeError( - "skills upload requires exactly one of file_id=... or file=..." + "skills upload requires exactly one of file_id=... or file=...", ) if file_id is not None: return file_id # Use the client's files resource to upload uploaded = client.files.upload( - file, mime_type=mime_type or "application/zip", + file, + mime_type=mime_type or "application/zip", ) return uploaded.id @@ -267,11 +365,12 @@ async def _resolve_file_id_async( ) -> str: if (file_id is None) == (file is None): raise TypeError( - "skills upload requires exactly one of file_id=... or file=..." + "skills upload requires exactly one of file_id=... or file=...", ) if file_id is not None: return file_id uploaded = await client.files.upload( - file, mime_type=mime_type or "application/zip", + file, + mime_type=mime_type or "application/zip", ) return uploaded.id diff --git a/dashscope/agentstudio/streaming.py b/dashscope/agentstudio/streaming.py index 0a20c56..c6485be 100644 --- a/dashscope/agentstudio/streaming.py +++ b/dashscope/agentstudio/streaming.py @@ -23,7 +23,7 @@ import json from dataclasses import dataclass, field -from typing import Any, AsyncIterator, Dict, Iterator, Optional +from typing import Any, AsyncIterator, Dict, Iterator import httpx from httpx_sse import EventSource @@ -127,4 +127,4 @@ async def aclose(self) -> None: try: await self.response.aclose() except Exception: # pragma: no cover - pass \ No newline at end of file + pass diff --git a/dashscope/agentstudio/tools.py b/dashscope/agentstudio/tools.py index 26bd37d..19f6278 100644 --- a/dashscope/agentstudio/tools.py +++ b/dashscope/agentstudio/tools.py @@ -4,6 +4,7 @@ from __future__ import annotations +import functools import inspect import json import re @@ -50,7 +51,9 @@ } -def _python_type_to_schema(py_type: Any) -> Dict[str, Any]: +def _python_type_to_schema( # pylint: disable=too-many-return-statements + py_type: Any, +) -> Dict[str, Any]: if py_type is inspect.Parameter.empty or py_type is Any: return {} origin = get_origin(py_type) @@ -72,7 +75,9 @@ def _python_type_to_schema(py_type: Any) -> Dict[str, Any]: return {"type": "object"} -def _parse_docstring(doc: str) -> Tuple[str, Dict[str, str]]: +def _parse_docstring( # pylint: disable=too-many-branches + doc: str, +) -> Tuple[str, Dict[str, str]]: """Split a docstring into (summary, {param: description}).""" if not doc: return "", {} @@ -99,7 +104,10 @@ def _parse_docstring(doc: str) -> Tuple[str, Dict[str, str]]: return "\n".join(lines[:summary_end]).strip(), descs # Google / Napoleon ``Args:`` style - section_re = re.compile(r"^\s*(Args|Arguments|Parameters)\s*:\s*$", flags=re.IGNORECASE) + section_re = re.compile( + r"^\s*(Args|Arguments|Parameters)\s*:\s*$", + flags=re.IGNORECASE, + ) section_idx: Optional[int] = None for idx, line in enumerate(lines): if section_re.match(line): @@ -130,6 +138,7 @@ def _parse_docstring(doc: str) -> Tuple[str, Dict[str, str]]: # ToolSpec # --------------------------------------------------------------------------- + @dataclass class ToolSpec: """Server-facing description of a client-executed tool.""" @@ -161,7 +170,10 @@ def from_dict( ) -> "ToolSpec": """Construct from a hand-written descriptor.""" if "name" not in descriptor or "input_schema" not in descriptor: - raise ValueError("ToolSpec.from_dict requires at least 'name' and 'input_schema'") + raise ValueError( + "ToolSpec.from_dict requires at least" + " 'name' and 'input_schema'", + ) return cls( name=str(descriptor["name"]), description=str(descriptor.get("description", "")), @@ -193,13 +205,16 @@ def _wrap(func: Callable[..., Any]) -> Callable[..., Any]: inspect.Parameter.VAR_KEYWORD, ): continue - schema = _python_type_to_schema(type_hints.get(pname, param.annotation)) + schema = _python_type_to_schema( + type_hints.get(pname, param.annotation), + ) schema = dict(schema or {"type": "string"}) doc = param_docs.get(pname) if doc: schema["description"] = doc if param.default is not inspect.Parameter.empty: - # Include default=None in schema so the API knows it is optional + # Include default=None so the API knows + # it is optional schema["default"] = param.default properties[pname] = schema if param.default is inspect.Parameter.empty: @@ -260,6 +275,7 @@ def collect_specs(items: Any) -> List[ToolSpec]: # Runners # --------------------------------------------------------------------------- + @dataclass class DispatchedTool: """Bookkeeping record emitted by a runner.""" @@ -278,18 +294,26 @@ def _coerce_args(raw: Any) -> Dict[str, Any]: return json.loads(raw) except json.JSONDecodeError: raise ValueError( - f"Tool arguments must be valid JSON, got: {raw!r}" + f"Tool arguments must be valid JSON, got: {raw!r}", ) from None return raw or {} -def _extract_tool_call(event: AgentCustomToolUseEvent) -> Optional[Dict[str, Any]]: +def _extract_tool_call( + event: AgentCustomToolUseEvent, +) -> Optional[Dict[str, Any]]: content = event.content or [] for block in content: - block_dict = block.to_dict() if hasattr(block, "to_dict") else dict(block) + block_dict = ( + block.to_dict() if hasattr(block, "to_dict") else dict(block) + ) # function_call events: data is nested inside a "data" key inner = block_dict.get("data") or {} - if isinstance(inner, dict) and inner.get("name") and inner.get("call_id"): + if ( + isinstance(inner, dict) + and inner.get("name") + and inner.get("call_id") + ): return { "name": inner["name"], "custom_tool_use_id": inner["call_id"], @@ -302,10 +326,16 @@ def _extract_tool_call(event: AgentCustomToolUseEvent) -> Optional[Dict[str, Any "name": block_dict["name"], "custom_tool_use_id": block_dict.get("custom_tool_use_id") or block_dict.get("tool_use_id"), - "arguments": _coerce_args(block_dict.get("arguments") or block_dict.get("input")), + "arguments": _coerce_args( + block_dict.get("arguments") or block_dict.get("input"), + ), } meta = event.metadata or {} - if isinstance(meta, dict) and meta.get("name") and meta.get("custom_tool_use_id"): + if ( + isinstance(meta, dict) + and meta.get("name") + and meta.get("custom_tool_use_id") + ): return { "name": meta["name"], "custom_tool_use_id": meta["custom_tool_use_id"], @@ -331,7 +361,9 @@ def _extract_session_status(event: Any) -> Optional[str]: """ content = event.content or [] for block in content: - block_dict = block.to_dict() if hasattr(block, "to_dict") else dict(block) + block_dict = ( + block.to_dict() if hasattr(block, "to_dict") else dict(block) + ) data = block_dict.get("data") if isinstance(data, dict): return data.get("session_status") @@ -400,7 +432,8 @@ def run(self) -> Iterator[DispatchedTool]: # Send events first (server now flushes headers immediately). if self._events: self._client.sessions.events.send( - self._session_id, events=self._events, + self._session_id, + events=self._events, ) self._events = None @@ -410,22 +443,39 @@ def run(self) -> Iterator[DispatchedTool]: timeout=self._max_idle, ) as stream: for event in stream: - if event.type in (SSEEventType.TOOL_CALL, SSEEventType.FUNCTION_CALL): + if event.type in ( + SSEEventType.TOOL_CALL, + SSEEventType.FUNCTION_CALL, + ): self._last_activity = time.monotonic() call = _extract_tool_call(event) if call is None: - logger.warning("custom_tool_use without identifiable name/id; skipping") + logger.warning( + "custom_tool_use without" + " identifiable name/id;" + " skipping", + ) continue - future = self._executor.submit(self._dispatch_call, call) + future = self._executor.submit( + self._dispatch_call, + call, + ) pending[future] = call elif event.type == SSEEventType.SESSION_STATUS: session_status = _extract_session_status(event) - if session_status in (SessionStatus.IDLE, SessionStatus.TERMINATED): + if session_status in ( + SessionStatus.IDLE, + SessionStatus.TERMINATED, + ): yield from self._drain(pending) if session_status == SessionStatus.TERMINATED: return if self._idle_exceeded(): - logger.debug("session idle for too long, stopping runner") + logger.debug( + "session idle for" + " too long," + " stopping runner", + ) return return yield from self._harvest(pending) @@ -434,8 +484,6 @@ def run(self) -> Iterator[DispatchedTool]: def _harvest(self, pending: Dict) -> Iterator[DispatchedTool]: """Yield records for any futures that have completed so far.""" - from concurrent.futures import Future - done = [f for f in list(pending) if f.done()] for future in done: pending.pop(future) @@ -495,7 +543,11 @@ def _dispatch_call(self, call: Dict[str, Any]) -> Optional[DispatchedTool]: started = time.monotonic() try: if spec.func is None: - result = f"Tool '{name}' is a descriptor-only tool (no callable registered)" + result = ( + f"Tool '{name}' is a" + " descriptor-only tool" + " (no callable registered)" + ) is_error = True else: result = spec.func(**call["arguments"]) @@ -521,7 +573,13 @@ def _dispatch_call(self, call: Dict[str, Any]) -> Optional[DispatchedTool]: duration_ms=duration_ms, ) - def _post_result(self, *, custom_tool_use_id: str, output: Any, is_error: bool) -> None: + def _post_result( + self, + *, + custom_tool_use_id: str, + output: Any, + is_error: bool, + ) -> None: evt = user_custom_tool_result( custom_tool_use_id=custom_tool_use_id, content=output, @@ -529,7 +587,8 @@ def _post_result(self, *, custom_tool_use_id: str, output: Any, is_error: bool) ) try: self._client.sessions.events.send( - self._session_id, [evt], + self._session_id, + [evt], ) except Exception: # pragma: no cover logger.exception("failed to post tool result") @@ -593,14 +652,17 @@ async def run(self) -> AsyncIterator[DispatchedTool]: sem = asyncio.Semaphore(self._max_concurrent) self._pending_tasks.clear() - async def _guarded_dispatch(call: Dict[str, Any]) -> Optional[DispatchedTool]: + async def _guarded_dispatch( + call: Dict[str, Any], + ) -> Optional[DispatchedTool]: async with sem: return await self._dispatch_call(call) # Send events first (server now flushes headers immediately). if self._events: await self._client.sessions.events.send( - self._session_id, events=self._events, + self._session_id, + events=self._events, ) self._events = None @@ -609,7 +671,10 @@ async def _guarded_dispatch(call: Dict[str, Any]) -> Optional[DispatchedTool]: timeout=self._max_idle, ) as stream: async for event in stream: - if event.type in (SSEEventType.TOOL_CALL, SSEEventType.FUNCTION_CALL): + if event.type in ( + SSEEventType.TOOL_CALL, + SSEEventType.FUNCTION_CALL, + ): last_activity = loop.time() call = _extract_tool_call(event) if call is None: @@ -618,7 +683,10 @@ async def _guarded_dispatch(call: Dict[str, Any]) -> Optional[DispatchedTool]: self._pending_tasks.add(task) elif event.type == SSEEventType.SESSION_STATUS: session_status = _extract_session_status(event) - if session_status in (SessionStatus.IDLE, SessionStatus.TERMINATED): + if session_status in ( + SessionStatus.IDLE, + SessionStatus.TERMINATED, + ): # Drain pending tasks before exiting. async for record in self._drain(self._pending_tasks): yield record @@ -649,8 +717,9 @@ async def _drain(self, pending: set) -> AsyncIterator[DispatchedTool]: import asyncio while pending: - done, still_pending = await asyncio.wait( - pending, return_when=asyncio.FIRST_COMPLETED, + done, _still_pending = await asyncio.wait( + pending, + return_when=asyncio.FIRST_COMPLETED, ) for task in done: pending.discard(task) @@ -669,14 +738,21 @@ async def _dispatch(self, event: Any) -> Optional[DispatchedTool]: return None return await self._dispatch_call(call) - async def _dispatch_call(self, call: Dict[str, Any]) -> Optional[DispatchedTool]: + async def _dispatch_call( + self, + call: Dict[str, Any], + ) -> Optional[DispatchedTool]: import asyncio spec = self._specs.get(call["name"]) if spec is None: await self._post_result( call["custom_tool_use_id"], - output=f"Tool '{call['name']}' not registered with this runner.", + output=( + f"Tool '{call['name']}'" + " not registered with" + " this runner." + ), is_error=True, ) return DispatchedTool( @@ -692,14 +768,23 @@ async def _dispatch_call(self, call: Dict[str, Any]) -> Optional[DispatchedTool] started = loop.time() try: if spec.func is None: - result = f"Tool '{call['name']}' is a descriptor-only tool (no callable registered)" + result = ( + f"Tool '{call['name']}'" + " is a descriptor-only" + " tool (no callable" + " registered)" + ) is_error = True elif spec.is_async: result = await spec.func(**call["arguments"]) is_error = False else: result = await loop.run_in_executor( - None, lambda: spec.func(**call["arguments"]) + None, + functools.partial( + spec.func, + **call["arguments"], + ), ) is_error = False except Exception as exc: # noqa: BLE001 @@ -709,7 +794,9 @@ async def _dispatch_call(self, call: Dict[str, Any]) -> Optional[DispatchedTool] duration_ms = int((loop.time() - started) * 1000) formatted = _format_output(result) await self._post_result( - call["custom_tool_use_id"], output=formatted, is_error=is_error + call["custom_tool_use_id"], + output=formatted, + is_error=is_error, ) return DispatchedTool( name=call["name"], @@ -721,7 +808,11 @@ async def _dispatch_call(self, call: Dict[str, Any]) -> Optional[DispatchedTool] ) async def _post_result( - self, custom_tool_use_id: str, *, output: Any, is_error: bool + self, + custom_tool_use_id: str, + *, + output: Any, + is_error: bool, ) -> None: evt = user_custom_tool_result( custom_tool_use_id=custom_tool_use_id, @@ -730,7 +821,8 @@ async def _post_result( ) try: await self._client.sessions.events.send( - self._session_id, [evt], + self._session_id, + [evt], ) except Exception: # pragma: no cover logger.exception("failed to post tool result") diff --git a/dashscope/agentstudio/transport.py b/dashscope/agentstudio/transport.py index b291ea8..e4ce52e 100644 --- a/dashscope/agentstudio/transport.py +++ b/dashscope/agentstudio/transport.py @@ -18,12 +18,11 @@ from __future__ import annotations import asyncio -import json as _json import random import socket import time import uuid -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import Any, Dict, IO, Mapping, Optional, Tuple, Union import httpx @@ -33,8 +32,6 @@ AGENTSTUDIO_DEFAULT_TIMEOUT, AGENTSTUDIO_MAX_RETRIES, ) -from dashscope.common.logging import logger - # --------------------------------------------------------------------------- # Base data classes (merged from _base.py) @@ -74,6 +71,7 @@ class APIResponse: request_id: Optional[str] = None status_code: int = 200 + # --------------------------------------------------------------------------- # Constants (merged from _constants.py) # --------------------------------------------------------------------------- @@ -119,7 +117,15 @@ def is_error_payload(payload: Any) -> bool: return True err = payload.get("error") if isinstance(err, dict): - return any(k in err for k in ("code", "message", "error_code", "error_message")) + return any( + k in err + for k in ( + "code", + "message", + "error_code", + "error_message", + ) + ) return False @@ -133,20 +139,25 @@ def _new_request_id() -> str: return "req_" + uuid.uuid4().hex[:26] -def _resolve_timeout(timeout: Union[float, httpx.Timeout, Tuple[float, float], None]) -> httpx.Timeout: +def _resolve_timeout( + timeout: Union[float, httpx.Timeout, Tuple[float, float], None], +) -> httpx.Timeout: if timeout is None: return AGENTSTUDIO_DEFAULT_TIMEOUT if isinstance(timeout, httpx.Timeout): return timeout if isinstance(timeout, tuple): return httpx.Timeout(timeout[1], connect=timeout[0]) - return httpx.Timeout(float(timeout), connect=AGENTSTUDIO_DEFAULT_TIMEOUT.connect) + return httpx.Timeout( + float(timeout), + connect=AGENTSTUDIO_DEFAULT_TIMEOUT.connect, + ) def _backoff(attempt: int) -> float: """Capped jittered exponential backoff.""" - base = min(2 ** attempt, 8.0) + base = min(2**attempt, 8.0) return base + random.random() * 0.25 @@ -269,17 +280,30 @@ def __init__( if http_client is not None: self._client = http_client else: - socket_options = [(socket.SOL_SOCKET, socket.SO_KEEPALIVE, True)] - if hasattr(socket, 'TCP_KEEPINTVL'): - socket_options.append((socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 60)) - if hasattr(socket, 'TCP_KEEPIDLE'): - socket_options.append((socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 60)) - if hasattr(socket, 'TCP_KEEPCNT'): - socket_options.append((socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 5)) - transport = httpx.HTTPTransport(socket_options=socket_options) + socket_options = [ + (socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1), + ] + if hasattr(socket, "TCP_KEEPINTVL"): + socket_options.append( + (socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 60), + ) + if hasattr(socket, "TCP_KEEPIDLE"): + socket_options.append( + (socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 60), + ) + if hasattr(socket, "TCP_KEEPCNT"): + socket_options.append( + (socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 5), + ) + transport = httpx.HTTPTransport( + socket_options=socket_options, + ) self._client = httpx.Client( timeout=_resolve_timeout(timeout), - limits=httpx.Limits(max_connections=1000, max_keepalive_connections=100), + limits=httpx.Limits( + max_connections=1000, + max_keepalive_connections=100, + ), transport=transport, ) @@ -290,7 +314,7 @@ def _absolute_url(self, path: str) -> str: path = "/" + path return self.base_url + path - def request( + def request( # pylint: disable=too-many-branches self, method: str, path: str, @@ -314,13 +338,18 @@ def request( uid=self.uid, user_agent=self.user_agent, extra=extra_headers, - json_body=files is None and method.upper() not in ("GET", "HEAD", "DELETE"), + json_body=( + files is None + and method.upper() not in ("GET", "HEAD", "DELETE") + ), ) if files is not None: headers.pop("Content-Type", None) url = self._absolute_url(path) - req_timeout = _resolve_timeout(timeout if timeout is not None else self.timeout) + req_timeout = _resolve_timeout( + timeout if timeout is not None else self.timeout, + ) attempt = 0 last_exc: Optional[Exception] = None @@ -349,7 +378,10 @@ def request( if attempt >= self.max_retries or not should_retry: raise exceptions.APIConnectionError(str(exc)) from exc else: - if not stream and _should_retry(resp.status_code, resp.headers): + if not stream and _should_retry( + resp.status_code, + resp.headers, + ): last_exc = exceptions.APIStatusError( f"HTTP {resp.status_code}", status_code=resp.status_code, @@ -373,7 +405,9 @@ def request( resp.close() time.sleep(_backoff(attempt)) attempt += 1 - raise exceptions.APIConnectionError(str(last_exc) if last_exc else "unknown") + raise exceptions.APIConnectionError( + str(last_exc) if last_exc else "unknown", + ) def _parse(self, resp: httpx.Response) -> APIResponse: header_rid = resp.headers.get("x-request-id") @@ -382,7 +416,11 @@ def _parse(self, resp: httpx.Response) -> APIResponse: if not ctype.startswith("application/json") and resp.status_code < 400: payload: Any = {"_binary": resp.content, "_content_type": ctype} data, rid = unwrap(payload) - return APIResponse(data=data, request_id=rid or header_rid, status_code=resp.status_code) + return APIResponse( + data=data, + request_id=rid or header_rid, + status_code=resp.status_code, + ) try: payload = resp.json() @@ -391,11 +429,17 @@ def _parse(self, resp: httpx.Response) -> APIResponse: if resp.status_code >= 400 or is_error_payload(payload): raise exceptions.from_response( - status_code=resp.status_code, body=payload, headers=resp.headers + status_code=resp.status_code, + body=payload, + headers=resp.headers, ) data, rid = unwrap(payload) - return APIResponse(data=data, request_id=rid or header_rid, status_code=resp.status_code) + return APIResponse( + data=data, + request_id=rid or header_rid, + status_code=resp.status_code, + ) def close(self) -> None: if self._owns_client: @@ -456,17 +500,30 @@ def __init__( if http_client is not None: self._client = http_client else: - socket_options = [(socket.SOL_SOCKET, socket.SO_KEEPALIVE, True)] - if hasattr(socket, 'TCP_KEEPINTVL'): - socket_options.append((socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 60)) - if hasattr(socket, 'TCP_KEEPIDLE'): - socket_options.append((socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 60)) - if hasattr(socket, 'TCP_KEEPCNT'): - socket_options.append((socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 5)) - transport = httpx.AsyncHTTPTransport(socket_options=socket_options) + socket_options = [ + (socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1), + ] + if hasattr(socket, "TCP_KEEPINTVL"): + socket_options.append( + (socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 60), + ) + if hasattr(socket, "TCP_KEEPIDLE"): + socket_options.append( + (socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 60), + ) + if hasattr(socket, "TCP_KEEPCNT"): + socket_options.append( + (socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 5), + ) + transport = httpx.AsyncHTTPTransport( + socket_options=socket_options, + ) self._client = httpx.AsyncClient( timeout=_resolve_timeout(timeout), - limits=httpx.Limits(max_connections=1000, max_keepalive_connections=100), + limits=httpx.Limits( + max_connections=1000, + max_keepalive_connections=100, + ), transport=transport, ) @@ -477,7 +534,7 @@ def _absolute_url(self, path: str) -> str: path = "/" + path return self.base_url + path - async def request( + async def request( # pylint: disable=too-many-branches self, method: str, path: str, @@ -490,18 +547,26 @@ async def request( timeout: Union[float, httpx.Timeout, Tuple[float, float], None] = None, stream: bool = False, ) -> Any: - """Issue an HTTP request. For ``stream=True`` returns raw ``httpx.Response``.""" + """Issue an HTTP request. + + For ``stream=True`` returns raw ``httpx.Response``. + """ headers = build_headers( api_key=self.api_key, uid=self.uid, user_agent=self.user_agent, extra=extra_headers, - json_body=files is None and method.upper() not in ("GET", "HEAD", "DELETE"), + json_body=( + files is None + and method.upper() not in ("GET", "HEAD", "DELETE") + ), ) if files is not None: headers.pop("Content-Type", None) - req_timeout = _resolve_timeout(timeout if timeout is not None else self.timeout) + req_timeout = _resolve_timeout( + timeout if timeout is not None else self.timeout, + ) url = self._absolute_url(path) attempt = 0 @@ -531,7 +596,10 @@ async def request( if attempt >= self.max_retries or not should_retry: raise exceptions.APIConnectionError(str(exc)) from exc else: - if not stream and _should_retry(resp.status_code, resp.headers): + if not stream and _should_retry( + resp.status_code, + resp.headers, + ): last_exc = exceptions.APIStatusError( f"HTTP {resp.status_code}", status_code=resp.status_code, @@ -555,7 +623,9 @@ async def request( await resp.aclose() await asyncio.sleep(_backoff(attempt)) attempt += 1 - raise exceptions.APIConnectionError(str(last_exc) if last_exc else "unknown") + raise exceptions.APIConnectionError( + str(last_exc) if last_exc else "unknown", + ) async def _parse(self, resp: httpx.Response) -> APIResponse: header_rid = resp.headers.get("x-request-id") @@ -564,7 +634,11 @@ async def _parse(self, resp: httpx.Response) -> APIResponse: if not ctype.startswith("application/json") and resp.status_code < 400: content = await resp.aread() data, rid = unwrap({"_binary": content, "_content_type": ctype}) - return APIResponse(data=data, request_id=rid or header_rid, status_code=resp.status_code) + return APIResponse( + data=data, + request_id=rid or header_rid, + status_code=resp.status_code, + ) try: payload = resp.json() @@ -573,11 +647,17 @@ async def _parse(self, resp: httpx.Response) -> APIResponse: if resp.status_code >= 400 or is_error_payload(payload): raise exceptions.from_response( - status_code=resp.status_code, body=payload, headers=resp.headers + status_code=resp.status_code, + body=payload, + headers=resp.headers, ) data, rid = unwrap(payload) - return APIResponse(data=data, request_id=rid or header_rid, status_code=resp.status_code) + return APIResponse( + data=data, + request_id=rid or header_rid, + status_code=resp.status_code, + ) def close(self) -> None: """Best-effort synchronous close (for __del__ / GC safety).""" @@ -614,4 +694,4 @@ def __del__(self) -> None: except RuntimeError: self.close() except Exception: - pass \ No newline at end of file + pass diff --git a/dashscope/agentstudio/types/__init__.py b/dashscope/agentstudio/types/__init__.py index d9af404..2eb57f3 100644 --- a/dashscope/agentstudio/types/__init__.py +++ b/dashscope/agentstudio/types/__init__.py @@ -5,35 +5,83 @@ from dashscope.agentstudio.types import Agent, Message, user_message """ from dashscope.agentstudio.types.models import * # noqa: F401,F403 -from dashscope.agentstudio.types.models import ( +from dashscope.agentstudio.types.models import ( # noqa: F401 BaseModel, - Metadata, Scope, Mount, Networking, Packages, EnvironmentConfig, - StopReason, Usage, - TextBlock, ImageBlock, AudioBlock, DataBlock, FileBlock, RefusalBlock, ErrorBlock, - ContentBlock, parse_content_block, parse_content_blocks, - Agent, AgentVersion, Environment, File, Skill, SkillVersion, - Session, SessionThread, DeleteResponse, - Message, parse_message, parse_server_event, - user_message, user_interrupt, user_tool_confirmation, - user_custom_tool_result, user_tool_result, user_define_outcome, - ServerEvent, UnknownServerEvent, - AgentMessageEvent, AgentThinkingEvent, AgentToolUseEvent, - AgentToolResultEvent, AgentCustomToolUseEvent, AgentCustomToolResultEvent, - AgentMcpToolUseEvent, AgentMcpToolResultEvent, - AgentThreadMessageSentEvent, AgentThreadMessageReceivedEvent, + Metadata, + Scope, + Mount, + Networking, + Packages, + EnvironmentConfig, + StopReason, + Usage, + TextBlock, + ImageBlock, + AudioBlock, + DataBlock, + FileBlock, + RefusalBlock, + ErrorBlock, + ContentBlock, + parse_content_block, + parse_content_blocks, + Agent, + AgentVersion, + Environment, + File, + Skill, + SkillVersion, + Session, + SessionThread, + DeleteResponse, + Message, + parse_message, + parse_server_event, + user_message, + user_interrupt, + user_tool_confirmation, + user_custom_tool_result, + user_tool_result, + user_define_outcome, + ServerEvent, + UnknownServerEvent, + AgentMessageEvent, + AgentThinkingEvent, + AgentToolUseEvent, + AgentToolResultEvent, + AgentCustomToolUseEvent, + AgentCustomToolResultEvent, + AgentMcpToolUseEvent, + AgentMcpToolResultEvent, + AgentThreadMessageSentEvent, + AgentThreadMessageReceivedEvent, AgentThreadContextCompactedEvent, - SessionStatusRunningEvent, SessionStatusIdleEvent, - SessionStatusReschedulingEvent, SessionStatusTerminatedEvent, - SessionErrorEvent, SessionUpdatedEvent, - SessionThreadCreatedEvent, SessionThreadStatusEvent, - SpanModelRequestStartEvent, SpanModelRequestEndEvent, + SessionStatusRunningEvent, + SessionStatusIdleEvent, + SessionStatusReschedulingEvent, + SessionStatusTerminatedEvent, + SessionErrorEvent, + SessionUpdatedEvent, + SessionThreadCreatedEvent, + SessionThreadStatusEvent, + SpanModelRequestStartEvent, + SpanModelRequestEndEvent, ) -from dashscope.agentstudio.types.params import ( - AgentCreateParams, AgentUpdateParams, AgentListParams, - EnvironmentCreateParams, EnvironmentUpdateParams, EnvironmentListParams, - SessionCreateParams, SessionUpdateParams, SessionListParams, - SessionEventSendParams, SessionEventListParams, +from dashscope.agentstudio.types.params import ( # noqa: F401 + AgentCreateParams, + AgentUpdateParams, + AgentListParams, + EnvironmentCreateParams, + EnvironmentUpdateParams, + EnvironmentListParams, + SessionCreateParams, + SessionUpdateParams, + SessionListParams, + SessionEventSendParams, + SessionEventListParams, FileListParams, - SkillCreateParams, SkillListParams, - SkillVersionCreateParams, SkillVersionListParams, + SkillCreateParams, + SkillListParams, + SkillVersionCreateParams, + SkillVersionListParams, ) diff --git a/dashscope/agentstudio/types/models.py b/dashscope/agentstudio/types/models.py index e548475..138fdfb 100644 --- a/dashscope/agentstudio/types/models.py +++ b/dashscope/agentstudio/types/models.py @@ -15,9 +15,23 @@ from __future__ import annotations -from typing import Any, ClassVar, Dict, Iterable, List, Mapping, Optional, Sequence, Union - -from dashscope.agentstudio.constants import SSEEventType, MessageRole, BlockType, SessionStatus +from typing import ( + Any, + ClassVar, + Dict, + Iterable, + List, + Mapping, + Optional, + Sequence, + Union, +) + +from dashscope.agentstudio.constants import ( + SSEEventType, + MessageRole, + BlockType, +) # =========================================================================== @@ -45,7 +59,10 @@ def __init__(self, **kwargs: Any) -> None: } @classmethod - def from_dict(cls, payload: Optional[Mapping[str, Any]]) -> Optional["BaseModel"]: + def from_dict( + cls, + payload: Optional[Mapping[str, Any]], + ) -> Optional["BaseModel"]: if payload is None: return None return cls(**dict(payload)) @@ -101,9 +118,6 @@ class Metadata(BaseModel): ``invalid_request_error`` if they are exceeded. """ - def __init__(self, **kwargs: Any) -> None: - super().__init__(**kwargs) - def to_dict(self) -> Dict[str, Any]: return dict(self._raw) @@ -181,7 +195,9 @@ def __init__(self, **kwargs: Any) -> None: super().__init__(**kwargs) def __str__(self) -> str: - return f"[image] {getattr(self, 'image_url', '') or getattr(self, 'file_id', '') or ''}" + url = getattr(self, "image_url", "") or "" + fid = getattr(self, "file_id", "") or "" + return f"[image] {url or fid}" class AudioBlock(BaseModel): @@ -220,14 +236,23 @@ def __str__(self) -> str: class FileBlock(BaseModel): - _fields = ("type", "file_url", "file_id", "file_data", "media_type", "filename") + _fields = ( + "type", + "file_url", + "file_id", + "file_data", + "media_type", + "filename", + ) def __init__(self, **kwargs: Any) -> None: kwargs.setdefault("type", "file") super().__init__(**kwargs) def __str__(self) -> str: - return f"[file] {getattr(self, 'filename', '') or getattr(self, 'file_id', '') or ''}" + name = getattr(self, "filename", "") or "" + fid = getattr(self, "file_id", "") or "" + return f"[file] {name or fid}" class RefusalBlock(BaseModel): @@ -249,7 +274,9 @@ def __init__(self, **kwargs: Any) -> None: super().__init__(**kwargs) def __str__(self) -> str: - return f"[error] {getattr(self, 'message', '') or getattr(self, 'error_code', '')}" + msg = getattr(self, "message", "") or "" + code = getattr(self, "error_code", "") or "" + return f"[error] {msg or code}" _CONTENT_REGISTRY: Dict[str, type] = { @@ -271,7 +298,13 @@ def __str__(self) -> str: } ContentBlock = Union[ - TextBlock, ImageBlock, AudioBlock, DataBlock, FileBlock, RefusalBlock, ErrorBlock + TextBlock, + ImageBlock, + AudioBlock, + DataBlock, + FileBlock, + RefusalBlock, + ErrorBlock, ] @@ -373,7 +406,7 @@ class Skill(BaseModel): "source", "status", "latest_version", - "version", # backward compat alias + "version", # backward compat alias "file_id", "scope", "created_at", @@ -418,6 +451,7 @@ class Session(BaseModel): "request_id", ) + class SessionThread(BaseModel): _fields = ( "id", @@ -517,10 +551,16 @@ def user_message( """ if isinstance(text_or_blocks, str): - content: List[Dict[str, Any]] = [{"type": BlockType.TEXT, "text": text_or_blocks}] + content: List[Dict[str, Any]] = [ + {"type": BlockType.TEXT, "text": text_or_blocks}, + ] else: content = list(text_or_blocks) - evt: Dict[str, Any] = {"role": MessageRole.USER, "type": SSEEventType.MESSAGE, "content": content} + evt: Dict[str, Any] = { + "role": MessageRole.USER, + "type": SSEEventType.MESSAGE, + "content": content, + } if session_thread_id: evt["session_thread_id"] = session_thread_id if metadata: @@ -535,7 +575,10 @@ def user_interrupt( ) -> Dict[str, Any]: """Cancel the agent's current turn (best-effort).""" - evt: Dict[str, Any] = {"role": MessageRole.USER, "type": SSEEventType.INTERRUPT} + evt: Dict[str, Any] = { + "role": MessageRole.USER, + "type": SSEEventType.INTERRUPT, + } if session_thread_id: evt["session_thread_id"] = session_thread_id if metadata: @@ -595,10 +638,15 @@ def user_custom_tool_result( evt: Dict[str, Any] = { "role": MessageRole.TOOL, "type": SSEEventType.FUNCTION_CALL_OUTPUT, - "content": [{"type": "data", "data": { - "call_id": custom_tool_use_id, - "output": output, - }}], + "content": [ + { + "type": "data", + "data": { + "call_id": custom_tool_use_id, + "output": output, + }, + }, + ], "is_error": bool(is_error), } if session_thread_id: @@ -627,10 +675,15 @@ def user_tool_result( evt: Dict[str, Any] = { "role": MessageRole.TOOL, "type": SSEEventType.TOOL_CALL_OUTPUT, - "content": [{"type": "data", "data": { - "call_id": tool_use_id, - "output": output, - }}], + "content": [ + { + "type": "data", + "data": { + "call_id": tool_use_id, + "output": output, + }, + }, + ], "is_error": bool(is_error), } if session_thread_id: diff --git a/dashscope/agentstudio/types/params.py b/dashscope/agentstudio/types/params.py index 16c47b9..8f41864 100644 --- a/dashscope/agentstudio/types/params.py +++ b/dashscope/agentstudio/types/params.py @@ -34,11 +34,20 @@ class AgentCreateParams(BaseModel): but the wire format uses ``model: {"id": "…"}`` and ``system``. """ - _fields = ("name", "model", "description", "system", "tools", - "mcp_servers", "skills", "metadata") + _fields = ( + "name", + "model", + "description", + "system", + "tools", + "mcp_servers", + "skills", + "metadata", + ) def __init__( - self, *, + self, + *, name: str, model: str, description: Optional[str] = None, @@ -53,21 +62,35 @@ def __init__( model={"id": model}, description=description, system=system_prompt, - tools=[dict(t) for t in tools] if tools is not None else None, - mcp_servers=[dict(s) for s in mcp_servers] if mcp_servers is not None else None, - skills=[dict(s) for s in skills] if skills is not None else None, - metadata=dict(metadata) if metadata is not None else None, + tools=([dict(t) for t in tools] if tools is not None else None), + mcp_servers=( + [dict(s) for s in mcp_servers] + if mcp_servers is not None + else None + ), + skills=([dict(s) for s in skills] if skills is not None else None), + metadata=(dict(metadata) if metadata is not None else None), ) class AgentUpdateParams(BaseModel): """Request body for ``POST /agents/{id}`` (update latest version).""" - _fields = ("version", "name", "model", "description", "system", - "tools", "mcp_servers", "skills", "metadata") + _fields = ( + "version", + "name", + "model", + "description", + "system", + "tools", + "mcp_servers", + "skills", + "metadata", + ) def __init__( - self, *, + self, + *, version: int, name: Optional[str] = None, model: Optional[str] = None, @@ -81,13 +104,17 @@ def __init__( super().__init__( version=version, name=name, - model={"id": model} if model is not None else None, + model=({"id": model} if model is not None else None), description=description, system=system_prompt, - tools=[dict(t) for t in tools] if tools is not None else None, - mcp_servers=[dict(s) for s in mcp_servers] if mcp_servers is not None else None, - skills=[dict(s) for s in skills] if skills is not None else None, - metadata=dict(metadata) if metadata is not None else None, + tools=([dict(t) for t in tools] if tools is not None else None), + mcp_servers=( + [dict(s) for s in mcp_servers] + if mcp_servers is not None + else None + ), + skills=([dict(s) for s in skills] if skills is not None else None), + metadata=(dict(metadata) if metadata is not None else None), ) @@ -100,16 +127,22 @@ class AgentListParams(BaseModel): _fields = ("limit", "page", "include_archived") - def __init__( - self, *, + def __init__( # pylint: disable=useless-parent-delegation + self, + *, limit: Optional[int] = None, page: Optional[str] = None, include_archived: Optional[bool] = None, ) -> None: + archived = ( + str(include_archived).lower() + if include_archived is not None + else None + ) super().__init__( limit=limit, page=page, - include_archived=str(include_archived).lower() if include_archived is not None else None, + include_archived=archived, ) @@ -118,16 +151,6 @@ class AgentVersionListParams(BaseModel): _fields = ("limit", "page") - def __init__( - self, *, - limit: Optional[int] = None, - page: Optional[str] = None, - ) -> None: - super().__init__( - limit=limit, - page=page, - ) - # =========================================================================== # Environments @@ -139,8 +162,9 @@ class EnvironmentCreateParams(BaseModel): _fields = ("name", "config", "description", "scope", "metadata") - def __init__( - self, *, + def __init__( # pylint: disable=useless-parent-delegation + self, + *, name: str, config: Mapping[str, Any], description: Optional[str] = None, @@ -152,7 +176,7 @@ def __init__( config=dict(config), description=description, scope=scope, - metadata=dict(metadata) if metadata is not None else None, + metadata=(dict(metadata) if metadata is not None else None), ) @@ -161,8 +185,9 @@ class EnvironmentUpdateParams(BaseModel): _fields = ("name", "description", "config", "scope", "metadata") - def __init__( - self, *, + def __init__( # pylint: disable=useless-parent-delegation + self, + *, name: Optional[str] = None, description: Optional[str] = None, config: Optional[Mapping[str, Any]] = None, @@ -172,9 +197,9 @@ def __init__( super().__init__( name=name, description=description, - config=dict(config) if config is not None else None, + config=(dict(config) if config is not None else None), scope=scope, - metadata=dict(metadata) if metadata is not None else None, + metadata=(dict(metadata) if metadata is not None else None), ) @@ -187,16 +212,22 @@ class EnvironmentListParams(BaseModel): _fields = ("limit", "page", "include_archived") - def __init__( - self, *, + def __init__( # pylint: disable=useless-parent-delegation + self, + *, limit: Optional[int] = None, page: Optional[str] = None, include_archived: Optional[bool] = None, ) -> None: + archived = ( + str(include_archived).lower() + if include_archived is not None + else None + ) super().__init__( limit=limit, page=page, - include_archived=str(include_archived).lower() if include_archived is not None else None, + include_archived=archived, ) @@ -213,8 +244,9 @@ class SessionCreateParams(BaseModel): _fields = ("agent", "environment_id", "title", "metadata") - def __init__( - self, *, + def __init__( # pylint: disable=useless-parent-delegation + self, + *, agent: str, environment_id: Optional[str] = None, title: Optional[str] = None, @@ -224,7 +256,7 @@ def __init__( agent=agent, environment_id=environment_id, title=title, - metadata=dict(metadata) if metadata is not None else None, + metadata=(dict(metadata) if metadata is not None else None), ) @@ -233,14 +265,15 @@ class SessionUpdateParams(BaseModel): _fields = ("title", "metadata") - def __init__( - self, *, + def __init__( # pylint: disable=useless-parent-delegation + self, + *, title: Optional[str] = None, metadata: Optional[Mapping[str, Any]] = None, ) -> None: super().__init__( title=title, - metadata=dict(metadata) if metadata is not None else None, + metadata=(dict(metadata) if metadata is not None else None), ) @@ -255,12 +288,20 @@ class SessionListParams(BaseModel): inside ``__init__``. """ - _fields = ("limit", "page", "agent_id", "statuses[]", - "created_at[gt]", "created_at[gte]", - "created_at[lt]", "created_at[lte]") + _fields = ( + "limit", + "page", + "agent_id", + "statuses[]", + "created_at[gt]", + "created_at[gte]", + "created_at[lt]", + "created_at[lte]", + ) def __init__( - self, *, + self, + *, limit: Optional[int] = None, page: Optional[str] = None, agent_id: Optional[str] = None, @@ -300,9 +341,12 @@ class SessionEventSendParams(BaseModel): _fields = ("input",) - def __init__( - self, *, - input: Sequence[Mapping[str, Any]], + def __init__( # pylint: disable=useless-parent-delegation + self, + *, + input: Sequence[ # pylint: disable=redefined-builtin + Mapping[str, Any] + ], ) -> None: super().__init__( input=[dict(e) for e in input], @@ -317,11 +361,20 @@ class SessionEventListParams(BaseModel): bracket-based wire keys ``created_at[gt]`` etc. """ - _fields = ("types", "created_at[gt]", "created_at[gte]", - "created_at[lt]", "created_at[lte]", "limit", "order", "page") + _fields = ( + "types", + "created_at[gt]", + "created_at[gte]", + "created_at[lt]", + "created_at[lte]", + "limit", + "order", + "page", + ) def __init__( - self, *, + self, + *, types: Optional[Sequence[str]] = None, created_at_gt: Optional[str] = None, created_at_gte: Optional[str] = None, @@ -362,22 +415,6 @@ class FileListParams(BaseModel): _fields = ("limit", "page", "scope_id", "after_id", "before_id") - def __init__( - self, *, - limit: Optional[int] = None, - page: Optional[str] = None, - scope_id: Optional[str] = None, - after_id: Optional[str] = None, - before_id: Optional[str] = None, - ) -> None: - super().__init__( - limit=limit, - page=page, - scope_id=scope_id, - after_id=after_id, - before_id=before_id, - ) - # =========================================================================== # Skills @@ -394,30 +431,12 @@ class SkillCreateParams(BaseModel): _fields = ("file_id",) - def __init__( - self, *, - file_id: str, - ) -> None: - super().__init__(file_id=file_id) - class SkillListParams(BaseModel): """Query params for ``GET /skills``.""" _fields = ("source", "limit", "page") - def __init__( - self, *, - source: Optional[str] = None, - limit: Optional[int] = None, - page: Optional[str] = None, - ) -> None: - super().__init__( - source=source, - limit=limit, - page=page, - ) - # =========================================================================== # SkillVersions @@ -429,29 +448,8 @@ class SkillVersionCreateParams(BaseModel): _fields = ("file_id",) - def __init__( - self, *, - file_id: str, - ) -> None: - super().__init__( - file_id=file_id, - ) - class SkillVersionListParams(BaseModel): """Query params for ``GET /skills/{skill_id}/versions``.""" _fields = ("limit", "page") - - def __init__( - self, *, - limit: Optional[int] = None, - page: Optional[str] = None, - ) -> None: - super().__init__( - limit=limit, - page=page, - ) - - - diff --git a/tests/unit/test_agentstudio_pagination.py b/tests/unit/test_agentstudio_pagination.py index e3421b7..e81e189 100644 --- a/tests/unit/test_agentstudio_pagination.py +++ b/tests/unit/test_agentstudio_pagination.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +# pylint: disable=protected-access """Tests for cursor-based pagination.""" from __future__ import annotations @@ -37,7 +38,7 @@ def test_cursor_page_has_next_and_get_next(): ] call_idx = iter(range(1, 3)) - def _fetch(token: str) -> CursorPage[int]: + def _fetch(_token: str) -> CursorPage[int]: return pages[next(call_idx)] pages[0]._fetch_next = _fetch @@ -55,7 +56,7 @@ def test_cursor_page_iter_auto_paginates(): ] call_idx = iter(range(1, 3)) - def _fetch(token: str) -> CursorPage[int]: + def _fetch(_token: str) -> CursorPage[int]: return pages[next(call_idx)] pages[0]._fetch_next = _fetch @@ -77,7 +78,7 @@ async def test_async_cursor_page_iter(): ] call_idx = iter(range(1, 3)) - async def _fetch(token: str) -> AsyncCursorPage[int]: + async def _fetch(_token: str) -> AsyncCursorPage[int]: return pages[next(call_idx)] pages[0]._fetch_next = _fetch @@ -91,4 +92,4 @@ async def test_async_cursor_page_no_next(): assert page.has_next() is False assert await page.get_next() is None result = [item async for item in page] - assert result == [1] \ No newline at end of file + assert result == [1] diff --git a/tests/unit/test_agentstudio_protocol.py b/tests/unit/test_agentstudio_protocol.py index 086d884..bad951b 100644 --- a/tests/unit/test_agentstudio_protocol.py +++ b/tests/unit/test_agentstudio_protocol.py @@ -59,10 +59,10 @@ def test_error_legacy_underscored_fields_still_parsed(): def test_is_error_payload_detects_both_shapes(): assert is_error_payload( - {"type": "error", "error": {"code": "x", "message": "y"}} + {"type": "error", "error": {"code": "x", "message": "y"}}, ) assert is_error_payload( - {"type": "error", "error": {"error_code": "x", "error_message": "y"}} + {"type": "error", "error": {"error_code": "x", "error_message": "y"}}, ) @@ -75,19 +75,28 @@ def test_client_event_keys_are_snake_case(): """Every key the SDK emits in user.* events must be snake_case.""" samples = [ user_message( - "hi", session_thread_id="th_1", metadata={"k": "v"} + "hi", + session_thread_id="th_1", + metadata={"k": "v"}, ), user_tool_confirmation( - tool_use_id="t_1", result="allow" + tool_use_id="t_1", + result="allow", ), user_tool_confirmation( - tool_use_id="t_1", result="deny", deny_message="nope" + tool_use_id="t_1", + result="deny", + deny_message="nope", ), user_custom_tool_result( - custom_tool_use_id="ctu_1", content="ok", is_error=False + custom_tool_use_id="ctu_1", + content="ok", + is_error=False, ), user_define_outcome( - description="desc", rubric="r", max_iterations=3 + description="desc", + rubric="r", + max_iterations=3, ), ] @@ -174,7 +183,9 @@ def test_user_tool_confirmation_validates_result(): with pytest.raises(ValueError): user_tool_confirmation(tool_use_id="t_1", result="MAYBE") deny = user_tool_confirmation( - tool_use_id="t_1", result="deny", deny_message="nope" + tool_use_id="t_1", + result="deny", + deny_message="nope", ) assert deny["role"] == "user" assert deny["type"] == "tool_confirmation" @@ -198,7 +209,9 @@ def test_user_custom_tool_result_string_to_text(): def test_user_custom_tool_result_dict_to_data_block(): evt = user_custom_tool_result( - custom_tool_use_id="ctu_1", content={"x": 1}, is_error=True + custom_tool_use_id="ctu_1", + content={"x": 1}, + is_error=True, ) assert evt["role"] == "tool" assert evt["type"] == "function_call_output" @@ -211,7 +224,9 @@ def test_user_custom_tool_result_dict_to_data_block(): def test_user_define_outcome(): evt = user_define_outcome( - description="task A", rubric="must be JSON", max_iterations=3 + description="task A", + rubric="must be JSON", + max_iterations=3, ) assert evt["role"] == "user" assert evt["type"] == "define_outcome" diff --git a/tests/unit/test_agentstudio_runner.py b/tests/unit/test_agentstudio_runner.py index e4cdb7f..a2512ed 100644 --- a/tests/unit/test_agentstudio_runner.py +++ b/tests/unit/test_agentstudio_runner.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -"""Tests for SessionToolRunner and AsyncSessionToolRunner using mocked client.""" +"""Tests for SessionToolRunner and AsyncSessionToolRunner.""" from __future__ import annotations @@ -10,7 +10,11 @@ import pytest from dashscope.agentstudio import Client, AsyncClient -from dashscope.agentstudio.tools import SessionToolRunner, AsyncSessionToolRunner, tool +from dashscope.agentstudio.tools import ( + SessionToolRunner, + AsyncSessionToolRunner, + tool, +) from dashscope.agentstudio.types import ( AgentCustomToolUseEvent, SessionStatusIdleEvent, @@ -21,6 +25,7 @@ # Sync helpers # --------------------------------------------------------------------------- + class _FakeStream: def __init__(self, events: List[Any]): self._events = events @@ -46,6 +51,7 @@ def _make_client(): # Async helpers # --------------------------------------------------------------------------- + class _FakeAsyncStream: def __init__(self, events: List[Any]): self._events = events @@ -75,6 +81,7 @@ def _make_async_client(): # Sync runner tests # --------------------------------------------------------------------------- + def test_runner_dispatches_custom_tool_and_posts_result(): @tool def add(a: int, b: int) -> int: @@ -83,21 +90,47 @@ def add(a: int, b: int) -> int: server_events = [ AgentCustomToolUseEvent( - id="evt_1", type="tool_call", - content=[{"type": "data", "name": "add", "custom_tool_use_id": "ctu_1", "arguments": {"a": 2, "b": 3}}], + id="evt_1", + type="tool_call", + content=[ + { + "type": "data", + "name": "add", + "custom_tool_use_id": "ctu_1", + "arguments": {"a": 2, "b": 3}, + }, + ], ), SessionStatusIdleEvent( - id="evt_2", type="session_status", - content=[{"type": "data", "data": {"session_status": "idle", "stop_reason": "end_turn"}}], + id="evt_2", + type="session_status", + content=[ + { + "type": "data", + "data": { + "session_status": "idle", + "stop_reason": "end_turn", + }, + }, + ], ), ] sent: List[Dict[str, Any]] = [] client = _make_client() - client.sessions.events.stream = MagicMock(return_value=_FakeStream(server_events)) - client.sessions.events.send = MagicMock(side_effect=lambda sid, evts, **kw: sent.extend(evts)) + client.sessions.events.stream = MagicMock( + return_value=_FakeStream(server_events), + ) + client.sessions.events.send = MagicMock( + side_effect=lambda sid, evts, **kw: sent.extend(evts), + ) - runner = SessionToolRunner(client=client, session_id="sesn_1", tools=[add], max_idle_seconds=0) + runner = SessionToolRunner( + client=client, + session_id="sesn_1", + tools=[add], + max_idle_seconds=0, + ) records = list(runner) assert len(records) == 1 @@ -114,21 +147,47 @@ def add(a: int, b: int) -> int: def test_runner_reports_unknown_tool_as_error(): server_events = [ AgentCustomToolUseEvent( - id="evt_1", type="tool_call", - content=[{"type": "data", "name": "missing", "custom_tool_use_id": "ctu_2", "arguments": {}}], + id="evt_1", + type="tool_call", + content=[ + { + "type": "data", + "name": "missing", + "custom_tool_use_id": "ctu_2", + "arguments": {}, + }, + ], ), SessionStatusIdleEvent( - id="evt_2", type="session_status", - content=[{"type": "data", "data": {"session_status": "idle", "stop_reason": "end_turn"}}], + id="evt_2", + type="session_status", + content=[ + { + "type": "data", + "data": { + "session_status": "idle", + "stop_reason": "end_turn", + }, + }, + ], ), ] sent: List[Dict[str, Any]] = [] client = _make_client() - client.sessions.events.stream = MagicMock(return_value=_FakeStream(server_events)) - client.sessions.events.send = MagicMock(side_effect=lambda sid, evts, **kw: sent.extend(evts)) + client.sessions.events.stream = MagicMock( + return_value=_FakeStream(server_events), + ) + client.sessions.events.send = MagicMock( + side_effect=lambda sid, evts, **kw: sent.extend(evts), + ) - runner = SessionToolRunner(client=client, session_id="sesn_2", tools=[], max_idle_seconds=0) + runner = SessionToolRunner( + client=client, + session_id="sesn_2", + tools=[], + max_idle_seconds=0, + ) records = list(runner) assert len(records) == 1 @@ -141,6 +200,7 @@ def test_runner_reports_unknown_tool_as_error(): # Async runner tests # --------------------------------------------------------------------------- + @pytest.mark.asyncio async def test_async_runner_dispatches_custom_tool_and_posts_result(): @tool @@ -150,21 +210,47 @@ def add(a: int, b: int) -> int: server_events = [ AgentCustomToolUseEvent( - id="evt_1", type="tool_call", - content=[{"type": "data", "name": "add", "custom_tool_use_id": "ctu_1", "arguments": {"a": 2, "b": 3}}], + id="evt_1", + type="tool_call", + content=[ + { + "type": "data", + "name": "add", + "custom_tool_use_id": "ctu_1", + "arguments": {"a": 2, "b": 3}, + }, + ], ), SessionStatusIdleEvent( - id="evt_2", type="session_status", - content=[{"type": "data", "data": {"session_status": "idle", "stop_reason": "end_turn"}}], + id="evt_2", + type="session_status", + content=[ + { + "type": "data", + "data": { + "session_status": "idle", + "stop_reason": "end_turn", + }, + }, + ], ), ] sent: List[Dict[str, Any]] = [] client = _make_async_client() - client.sessions.events.stream = AsyncMock(return_value=_FakeAsyncStream(server_events)) - client.sessions.events.send = AsyncMock(side_effect=lambda sid, evts, **kw: sent.extend(evts)) + client.sessions.events.stream = AsyncMock( + return_value=_FakeAsyncStream(server_events), + ) + client.sessions.events.send = AsyncMock( + side_effect=lambda sid, evts, **kw: sent.extend(evts), + ) - runner = AsyncSessionToolRunner(client=client, session_id="sesn_1", tools=[add], max_idle_seconds=0) + runner = AsyncSessionToolRunner( + client=client, + session_id="sesn_1", + tools=[add], + max_idle_seconds=0, + ) records = [record async for record in runner] assert len(records) == 1 @@ -182,21 +268,47 @@ def add(a: int, b: int) -> int: async def test_async_runner_reports_unknown_tool_as_error(): server_events = [ AgentCustomToolUseEvent( - id="evt_1", type="tool_call", - content=[{"type": "data", "name": "missing", "custom_tool_use_id": "ctu_2", "arguments": {}}], + id="evt_1", + type="tool_call", + content=[ + { + "type": "data", + "name": "missing", + "custom_tool_use_id": "ctu_2", + "arguments": {}, + }, + ], ), SessionStatusIdleEvent( - id="evt_2", type="session_status", - content=[{"type": "data", "data": {"session_status": "idle", "stop_reason": "end_turn"}}], + id="evt_2", + type="session_status", + content=[ + { + "type": "data", + "data": { + "session_status": "idle", + "stop_reason": "end_turn", + }, + }, + ], ), ] sent: List[Dict[str, Any]] = [] client = _make_async_client() - client.sessions.events.stream = AsyncMock(return_value=_FakeAsyncStream(server_events)) - client.sessions.events.send = AsyncMock(side_effect=lambda sid, evts, **kw: sent.extend(evts)) + client.sessions.events.stream = AsyncMock( + return_value=_FakeAsyncStream(server_events), + ) + client.sessions.events.send = AsyncMock( + side_effect=lambda sid, evts, **kw: sent.extend(evts), + ) - runner = AsyncSessionToolRunner(client=client, session_id="sesn_2", tools=[], max_idle_seconds=0) + runner = AsyncSessionToolRunner( + client=client, + session_id="sesn_2", + tools=[], + max_idle_seconds=0, + ) records = [record async for record in runner] assert len(records) == 1 @@ -210,12 +322,12 @@ async def test_async_runner_parallel_send_and_stream(): stream_opened = asyncio.Event() send_started = asyncio.Event() - async def _fake_stream(*args, **kwargs): + async def _fake_stream(*_args, **_kwargs): stream_opened.set() await asyncio.sleep(0.1) return _FakeAsyncStream([]) - async def _fake_send(*args, **kwargs): + async def _fake_send(*_args, **_kwargs): send_started.set() client = _make_async_client() @@ -223,8 +335,16 @@ async def _fake_send(*args, **kwargs): client.sessions.events.send = _fake_send runner = AsyncSessionToolRunner( - client=client, session_id="sesn_3", tools=[], - events=[{"role": "user", "type": "message", "content": [{"type": "text", "text": "hi"}]}], + client=client, + session_id="sesn_3", + tools=[], + events=[ + { + "role": "user", + "type": "message", + "content": [{"type": "text", "text": "hi"}], + }, + ], max_idle_seconds=0, ) async for _ in runner: @@ -238,10 +358,10 @@ async def _fake_send(*args, **kwargs): async def test_async_runner_with_events_parameter(): captured_events: List[Any] = [] - async def _fake_stream(*args, **kwargs): + async def _fake_stream(*_args, **_kwargs): return _FakeAsyncStream([]) - async def _fake_send(session_id, events, **kwargs): + async def _fake_send(_session_id, events, **_kwargs): captured_events.extend(list(events)) client = _make_async_client() @@ -249,12 +369,20 @@ async def _fake_send(session_id, events, **kwargs): client.sessions.events.send = _fake_send runner = AsyncSessionToolRunner( - client=client, session_id="sesn_4", tools=[], - events=[{"role": "user", "type": "message", "content": [{"type": "text", "text": "hello"}]}], + client=client, + session_id="sesn_4", + tools=[], + events=[ + { + "role": "user", + "type": "message", + "content": [{"type": "text", "text": "hello"}], + }, + ], max_idle_seconds=0, ) async for _ in runner: pass assert len(captured_events) == 1 - assert captured_events[0]["type"] == "message" \ No newline at end of file + assert captured_events[0]["type"] == "message" diff --git a/tests/unit/test_agentstudio_skills.py b/tests/unit/test_agentstudio_skills.py index 18610a4..1ccc5d5 100644 --- a/tests/unit/test_agentstudio_skills.py +++ b/tests/unit/test_agentstudio_skills.py @@ -1,5 +1,4 @@ # -*- coding: utf-8 -*- -# pylint: disable=protected-access """Skill resource shape & auto-upload compatibility. Tests create a client with a mocked transport and patch Files.upload @@ -30,7 +29,8 @@ def request(self, method, path, **kwargs): ) if path == "/skills": return APIResponse( - data={"id": "skl_1", "name": "demo"}, request_id="req_1" + data={"id": "skl_1", "name": "demo"}, + request_id="req_1", ) return APIResponse(data={}, request_id=None) @@ -39,7 +39,7 @@ def request(self, method, path, **kwargs): def _client_fixture(): """Create a client with a recording transport.""" c = Client(api_key="test-key") - c._transport = _Tx() + c.transport = _Tx() return c @@ -66,7 +66,7 @@ def test_skill_create_with_file_id_does_not_call_upload(client, fake_upload): client.skills.create(file_id="file_existing", name="demo") assert upload_calls == [] - body = client._transport.calls[0]["json"] + body = client.transport.calls[0]["json"] assert body == {"file_id": "file_existing", "name": "demo"} @@ -79,7 +79,7 @@ def test_skill_create_with_file_path_auto_uploads_first(client, fake_upload): assert len(upload_calls) == 1 assert upload_calls[0]["file"] == "/tmp/skill.zip" assert upload_calls[0]["mime_type"] == "application/zip" - body = client._transport.calls[0]["json"] + body = client.transport.calls[0]["json"] assert body["file_id"] == "file_uploaded" @@ -102,7 +102,7 @@ def test_skill_versions_create_auto_upload(client, fake_upload): assert len(upload_calls) == 1 assert upload_calls[0]["file"] == "/tmp/v2.zip" - body = client._transport.calls[0]["json"] + body = client.transport.calls[0]["json"] assert body == {"file_id": "file_v2"} @@ -113,12 +113,13 @@ def test_skill_versions_create_with_file_id_no_upload(client, fake_upload): sv.create("skl_1", file_id="file_existing") assert upload_calls == [] - body = client._transport.calls[0]["json"] + body = client.transport.calls[0]["json"] assert body == {"file_id": "file_existing"} def test_skill_versions_create_explicit_mime_type_overrides( - client, fake_upload + client, + fake_upload, ): upload_calls, _ = fake_upload From 55d6cf31dc7a989b0d85bce9a74c4a51302db2e1 Mon Sep 17 00:00:00 2001 From: foleydang Date: Tue, 30 Jun 2026 11:32:40 +0800 Subject: [PATCH 4/8] refactor: remove custom tools, add vaults/credentials, region param, stop_reason/session_status properties - Remove tools.py (@tool decorator, SessionToolRunner, DispatchedTool) server ToolConfigType only supports builtin_toolkit/mcp_toolkit - Restore user_custom_tool_result event constructor (function_call_output) - Add Vaults & Credentials resources (CRUD + archive) - Add region parameter to Client (default cn-beijing, was hardcoded) - Remove AGENTSTUDIO_DEFAULT_WORKSPACE="trial" fallback, workspace is now required when base_url is not provided - Add Message.stop_reason and Message.session_status properties - Add logging to transport (logger.warning on HTTP errors) - Add test_agentstudio_models.py (14 tests for vault/credential/agent) - Remove test_agentstudio_tools.py and test_agentstudio_runner.py - Fix test_agentstudio_protocol.py and test_agentstudio_skills.py Co-Authored-By: Claude Opus 4.7 --- dashscope/agentstudio/__init__.py | 22 +- dashscope/agentstudio/client.py | 48 +- dashscope/agentstudio/constants.py | 4 +- dashscope/agentstudio/resources/__init__.py | 10 + dashscope/agentstudio/resources/_helpers.py | 10 + .../agentstudio/resources/session_events.py | 51 -- dashscope/agentstudio/resources/vaults.py | 476 ++++++++++ dashscope/agentstudio/streaming.py | 12 +- dashscope/agentstudio/tools.py | 828 ------------------ dashscope/agentstudio/transport.py | 15 + dashscope/agentstudio/types/__init__.py | 10 + dashscope/agentstudio/types/models.py | 143 ++- dashscope/agentstudio/types/params.py | 90 ++ tests/unit/test_agentstudio_models.py | 222 +++++ tests/unit/test_agentstudio_protocol.py | 8 +- tests/unit/test_agentstudio_runner.py | 388 -------- tests/unit/test_agentstudio_skills.py | 10 +- tests/unit/test_agentstudio_tools.py | 203 ----- 18 files changed, 1019 insertions(+), 1531 deletions(-) create mode 100644 dashscope/agentstudio/resources/vaults.py delete mode 100644 dashscope/agentstudio/tools.py create mode 100644 tests/unit/test_agentstudio_models.py delete mode 100644 tests/unit/test_agentstudio_runner.py delete mode 100644 tests/unit/test_agentstudio_tools.py diff --git a/dashscope/agentstudio/__init__.py b/dashscope/agentstudio/__init__.py index 05912d5..17bda3f 100644 --- a/dashscope/agentstudio/__init__.py +++ b/dashscope/agentstudio/__init__.py @@ -6,9 +6,8 @@ API. All resource classes are accessed through a client instance:: from dashscope.agentstudio import Client - from dashscope.agentstudio import tool from dashscope.agentstudio.types import ( - user_message, user_custom_tool_result, + user_message, user_tool_result, ) Example @@ -53,23 +52,14 @@ CursorPage, IdCursorPage, ) -from .tools import ( - AsyncSessionToolRunner, - DispatchedTool, - SessionToolRunner, - ToolSpec, - collect_specs, - tool, - tool_from_spec, -) from .types import ( Message, ServerEvent, - user_custom_tool_result, user_define_outcome, user_interrupt, user_message, user_tool_confirmation, + user_custom_tool_result, user_tool_result, ) from .types.params import ( @@ -109,14 +99,6 @@ "AsyncCursorPage", "IdCursorPage", "AsyncIdCursorPage", - # tools - "tool", - "tool_from_spec", - "ToolSpec", - "collect_specs", - "SessionToolRunner", - "AsyncSessionToolRunner", - "DispatchedTool", # unified message type "Message", "ServerEvent", diff --git a/dashscope/agentstudio/client.py b/dashscope/agentstudio/client.py index 5f84534..ce22cc2 100644 --- a/dashscope/agentstudio/client.py +++ b/dashscope/agentstudio/client.py @@ -17,9 +17,10 @@ from dashscope.agentstudio.resources.files import Files, AsyncFiles from dashscope.agentstudio.resources.sessions import Sessions, AsyncSessions from dashscope.agentstudio.resources.skills import Skills, AsyncSkills +from dashscope.agentstudio.resources.vaults import Vaults, AsyncVaults from dashscope.agentstudio.constants import ( AGENTSTUDIO_BASE_URL_TEMPLATE, - AGENTSTUDIO_DEFAULT_WORKSPACE, + AGENTSTUDIO_DEFAULT_REGION, AGENTSTUDIO_DEFAULT_TIMEOUT, AGENTSTUDIO_MAX_RETRIES, ) @@ -30,13 +31,14 @@ def _resolve_base_url( explicit_url: Optional[str], workspace: Optional[str], + region: Optional[str] = None, ) -> str: """Resolve the base URL. Priority: 1. explicit base_url parameter (full URL) 2. DASHSCOPE_AGENTSTUDIO_URL / AGENTSTUDIO_URL env - 3. Build from workspace template + 3. Build from workspace + region template """ if explicit_url: return explicit_url @@ -45,13 +47,19 @@ def _resolve_base_url( ) if env_url: return env_url - # Build from workspace - ws = ( - workspace - or os.environ.get("DASHSCOPE_WORKSPACE") - or AGENTSTUDIO_DEFAULT_WORKSPACE + # Build from workspace + region + ws = workspace or os.environ.get("DASHSCOPE_WORKSPACE") + if not ws: + raise ValueError( + "workspace is required when base_url is not provided " + "(pass workspace='ws_xxx' or set DASHSCOPE_WORKSPACE env var " + "or use base_url=... to override)", + ) + rgn = region or AGENTSTUDIO_DEFAULT_REGION + return AGENTSTUDIO_BASE_URL_TEMPLATE.format( + workspace=ws, + region=rgn, ) - return AGENTSTUDIO_BASE_URL_TEMPLATE.format(workspace=ws) def _user_agent(base_url: str) -> str: @@ -82,6 +90,7 @@ def __init__( *, api_key: Optional[str] = None, workspace: Optional[str] = None, + region: Optional[str] = None, base_url: Optional[str] = None, uid: Optional[str] = None, timeout: Optional[ @@ -90,12 +99,12 @@ def __init__( max_retries: int = AGENTSTUDIO_MAX_RETRIES, http_client: Optional[httpx.Client] = None, ) -> None: - resolved_workspace = ( - workspace - or os.environ.get("DASHSCOPE_WORKSPACE") - or AGENTSTUDIO_DEFAULT_WORKSPACE + resolved_workspace = workspace or os.environ.get("DASHSCOPE_WORKSPACE") + resolved_base = _resolve_base_url( + base_url, + resolved_workspace, + region, ) - resolved_base = _resolve_base_url(base_url, resolved_workspace) self.transport = SyncTransport( base_url=resolved_base, api_key=api_key or get_default_api_key(), @@ -111,6 +120,7 @@ def __init__( self.environments = Environments(self) self.files = Files(self) self.skills = Skills(self) + self.vaults = Vaults(self) def close(self) -> None: self.transport.close() @@ -150,6 +160,7 @@ def __init__( *, api_key: Optional[str] = None, workspace: Optional[str] = None, + region: Optional[str] = None, base_url: Optional[str] = None, uid: Optional[str] = None, timeout: Optional[ @@ -158,12 +169,12 @@ def __init__( max_retries: int = AGENTSTUDIO_MAX_RETRIES, http_client: Optional[httpx.AsyncClient] = None, ) -> None: - resolved_workspace = ( - workspace - or os.environ.get("DASHSCOPE_WORKSPACE") - or AGENTSTUDIO_DEFAULT_WORKSPACE + resolved_workspace = workspace or os.environ.get("DASHSCOPE_WORKSPACE") + resolved_base = _resolve_base_url( + base_url, + resolved_workspace, + region, ) - resolved_base = _resolve_base_url(base_url, resolved_workspace) self.transport = AsyncTransport( base_url=resolved_base, api_key=api_key or get_default_api_key(), @@ -179,6 +190,7 @@ def __init__( self.environments = AsyncEnvironments(self) self.files = AsyncFiles(self) self.skills = AsyncSkills(self) + self.vaults = AsyncVaults(self) async def aclose(self) -> None: await self.transport.aclose() diff --git a/dashscope/agentstudio/constants.py b/dashscope/agentstudio/constants.py index d81ac02..f589155 100644 --- a/dashscope/agentstudio/constants.py +++ b/dashscope/agentstudio/constants.py @@ -19,9 +19,9 @@ class StrEnum(str, enum.Enum): # type: ignore[no-redef] # Configuration # --------------------------------------------------------------------------- -_HOST = "https://{workspace}.cn-beijing.maas.aliyuncs.com" +_HOST = "https://{workspace}.{region}.maas.aliyuncs.com" AGENTSTUDIO_BASE_URL_TEMPLATE = _HOST + "/api/v1/agentstudio" -AGENTSTUDIO_DEFAULT_WORKSPACE = "trial" +AGENTSTUDIO_DEFAULT_REGION = "cn-beijing" AGENTSTUDIO_DEFAULT_TIMEOUT = httpx.Timeout(600.0, connect=10.0) AGENTSTUDIO_MAX_RETRIES = 2 diff --git a/dashscope/agentstudio/resources/__init__.py b/dashscope/agentstudio/resources/__init__.py index 65a4051..853db58 100644 --- a/dashscope/agentstudio/resources/__init__.py +++ b/dashscope/agentstudio/resources/__init__.py @@ -34,10 +34,18 @@ SkillVersions, Skills, ) +from dashscope.agentstudio.resources.vaults import ( + AsyncCredentials, + AsyncVaults, + Credentials, + Vaults, +) __all__ = [ "Agents", "AsyncAgents", + "Credentials", + "AsyncCredentials", "Environments", "AsyncEnvironments", "Files", @@ -50,6 +58,8 @@ "AsyncSessions", "SessionEvents", "AsyncSessionEvents", + "Vaults", + "AsyncVaults", "_TypedEventStream", "_AioTypedEventStream", ] diff --git a/dashscope/agentstudio/resources/_helpers.py b/dashscope/agentstudio/resources/_helpers.py index 7edb14c..e7ee3c4 100644 --- a/dashscope/agentstudio/resources/_helpers.py +++ b/dashscope/agentstudio/resources/_helpers.py @@ -9,12 +9,14 @@ from dashscope.agentstudio.types import ( Agent, AgentVersion, + Credential, Environment, File, ServerEvent, Session, Skill, SkillVersion, + Vault, parse_server_event, ) @@ -56,6 +58,14 @@ def _coerce_session(payload: Mapping[str, Any]) -> Session: return Session(**dict(payload)) +def _coerce_vault(payload: Mapping[str, Any]) -> Vault: + return Vault(**dict(payload)) + + +def _coerce_credential(payload: Mapping[str, Any]) -> Credential: + return Credential(**dict(payload)) + + # --------------------------------------------------------------------------- # Path helpers # --------------------------------------------------------------------------- diff --git a/dashscope/agentstudio/resources/session_events.py b/dashscope/agentstudio/resources/session_events.py index 927232e..6e04b31 100644 --- a/dashscope/agentstudio/resources/session_events.py +++ b/dashscope/agentstudio/resources/session_events.py @@ -26,10 +26,6 @@ _stream_path, ) from dashscope.agentstudio.streaming import AsyncEventStream, EventStream -from dashscope.agentstudio.tools import ( - AsyncSessionToolRunner, - SessionToolRunner, -) from dashscope.agentstudio.types import ServerEvent from dashscope.agentstudio.types.params import ( SessionEventListParams, @@ -156,29 +152,6 @@ def stream( EventStream(response=resp), ) - def tool_runner( - self, - session_id: str, - *, - tools: Sequence[Any], - events: Optional[Sequence[Mapping[str, Any]]] = None, - max_idle_seconds: int = 300, - max_concurrent: int = 4, - ) -> SessionToolRunner: - """Convenience wrapper around :class:`SessionToolRunner`. - - If ``events`` is provided, they will be sent **after** the SSE stream - is opened (inside ``run()``) to guarantee no events are missed. - """ - return SessionToolRunner( - client=self._client, - session_id=session_id, - tools=list(tools), - events=list(events) if events else None, - max_idle_seconds=max_idle_seconds, - max_concurrent=max_concurrent, - ) - class _TypedEventStream: """Wrap :class:`EventStream` so iterating yields :class:`ServerEvent`.""" @@ -341,30 +314,6 @@ async def stream( AsyncEventStream(response=resp), ) - def tool_runner( - self, - session_id: str, - *, - tools: Sequence[Any], - events: Optional[Sequence[Mapping[str, Any]]] = None, - max_idle_seconds: int = 300, - max_concurrent: int = 4, - ) -> AsyncSessionToolRunner: - """Convenience wrapper around :class:`AsyncSessionToolRunner`. - - If ``events`` is provided, they will be sent concurrently with - the SSE stream opening (inside ``run()``) to guarantee no events - are missed. - """ - return AsyncSessionToolRunner( - client=self._client, - session_id=session_id, - tools=list(tools), - events=list(events) if events else None, - max_idle_seconds=max_idle_seconds, - max_concurrent=max_concurrent, - ) - class _AioTypedEventStream: def __init__(self, stream: AsyncEventStream) -> None: diff --git a/dashscope/agentstudio/resources/vaults.py b/dashscope/agentstudio/resources/vaults.py new file mode 100644 index 0000000..c68e492 --- /dev/null +++ b/dashscope/agentstudio/resources/vaults.py @@ -0,0 +1,476 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Vaults and Credentials resource classes.""" + +from __future__ import annotations + +from typing import Any, Mapping, Optional + +from dashscope.agentstudio.pagination import ( + AsyncCursorPage, + CursorPage, + build_page, +) +from dashscope.agentstudio.resources._helpers import ( + _coerce_credential, + _coerce_vault, +) +from dashscope.agentstudio.types import ( + Credential, + DeleteResponse, + Vault, +) +from dashscope.agentstudio.types.params import ( + CredentialCreateParams, + CredentialListParams, + CredentialUpdateParams, + VaultCreateParams, + VaultListParams, + VaultUpdateParams, +) + + +_PATH_VAULTS = "/vaults" + + +# --------------------------------------------------------------------------- +# Credentials (sub-resource of Vaults) +# --------------------------------------------------------------------------- + + +class Credentials: + def __init__(self, client) -> None: + self._client = client + + def create( + self, + vault_id: str, + *, + auth: Any, + display_name: Optional[str] = None, + metadata: Optional[Mapping[str, Any]] = None, + ) -> Credential: + body = CredentialCreateParams( + auth=auth, + display_name=display_name, + metadata=metadata, + ).to_dict() + resp = self._client.transport.request( + "POST", + f"{_PATH_VAULTS}/{vault_id}/credentials", + json=body, + ) + return _coerce_credential(resp.data) + + def retrieve( + self, + vault_id: str, + credential_id: str, + ) -> Credential: + resp = self._client.transport.request( + "GET", + f"{_PATH_VAULTS}/{vault_id}/credentials" f"/{credential_id}", + ) + return _coerce_credential(resp.data) + + get = retrieve # type: ignore[assignment] + + def update( + self, + vault_id: str, + credential_id: str, + *, + auth: Any = None, + display_name: Optional[str] = None, + metadata: Optional[Mapping[str, Any]] = None, + ) -> Credential: + body = CredentialUpdateParams( + auth=auth, + display_name=display_name, + metadata=metadata, + ).to_dict() + resp = self._client.transport.request( + "POST", + f"{_PATH_VAULTS}/{vault_id}/credentials" f"/{credential_id}", + json=body, + ) + return _coerce_credential(resp.data) + + def list( + self, + vault_id: str, + *, + include_archived: Optional[bool] = None, + limit: Optional[int] = None, + page: Optional[str] = None, + ) -> CursorPage[Credential]: + params = CredentialListParams( + include_archived=include_archived, + limit=limit, + page=page, + ).to_dict() + resp = self._client.transport.request( + "GET", + f"{_PATH_VAULTS}/{vault_id}/credentials", + params=params, + ) + return build_page( + payload=resp.data, + item_factory=_coerce_credential, + request_id=resp.request_id, + fetch_next=lambda nxt: self.list( + vault_id, + include_archived=include_archived, + limit=limit, + page=nxt, + ), + ) + + def delete( + self, + vault_id: str, + credential_id: str, + ) -> DeleteResponse: + resp = self._client.transport.request( + "DELETE", + f"{_PATH_VAULTS}/{vault_id}/credentials" f"/{credential_id}", + ) + return DeleteResponse(**resp.data) + + def archive( + self, + vault_id: str, + credential_id: str, + ) -> Credential: + resp = self._client.transport.request( + "POST", + f"{_PATH_VAULTS}/{vault_id}/credentials" + f"/{credential_id}/archive", + ) + return _coerce_credential(resp.data) + + +# --------------------------------------------------------------------------- +# Vaults (parent resource) +# --------------------------------------------------------------------------- + + +class Vaults: + def __init__(self, client) -> None: + self._client = client + self.credentials = Credentials(client) + + def create( + self, + *, + display_name: str, + metadata: Optional[Mapping[str, Any]] = None, + ) -> Vault: + body = VaultCreateParams( + display_name=display_name, + metadata=metadata, + ).to_dict() + resp = self._client.transport.request( + "POST", + _PATH_VAULTS, + json=body, + ) + return _coerce_vault(resp.data) + + def retrieve(self, vault_id: str) -> Vault: + resp = self._client.transport.request( + "GET", + f"{_PATH_VAULTS}/{vault_id}", + ) + return _coerce_vault(resp.data) + + get = retrieve # type: ignore[assignment] + + def update( + self, + vault_id: str, + *, + display_name: Optional[str] = None, + metadata: Optional[Mapping[str, Any]] = None, + ) -> Vault: + body = VaultUpdateParams( + display_name=display_name, + metadata=metadata, + ).to_dict() + resp = self._client.transport.request( + "POST", + f"{_PATH_VAULTS}/{vault_id}", + json=body, + ) + return _coerce_vault(resp.data) + + def list( + self, + *, + keyword: Optional[str] = None, + include_archived: Optional[bool] = None, + limit: Optional[int] = None, + page: Optional[str] = None, + ) -> CursorPage[Vault]: + params = VaultListParams( + keyword=keyword, + include_archived=include_archived, + limit=limit, + page=page, + ).to_dict() + resp = self._client.transport.request( + "GET", + _PATH_VAULTS, + params=params, + ) + return build_page( + payload=resp.data, + item_factory=_coerce_vault, + request_id=resp.request_id, + fetch_next=lambda nxt: self.list( + keyword=keyword, + include_archived=include_archived, + limit=limit, + page=nxt, + ), + ) + + def delete(self, vault_id: str) -> DeleteResponse: + resp = self._client.transport.request( + "DELETE", + f"{_PATH_VAULTS}/{vault_id}", + ) + return DeleteResponse(**resp.data) + + def archive(self, vault_id: str) -> Vault: + resp = self._client.transport.request( + "POST", + f"{_PATH_VAULTS}/{vault_id}/archive", + ) + return _coerce_vault(resp.data) + + +# --------------------------------------------------------------------------- +# Async variants +# --------------------------------------------------------------------------- + + +class AsyncCredentials: + def __init__(self, client) -> None: + self._client = client + + async def create( + self, + vault_id: str, + *, + auth: Any, + display_name: Optional[str] = None, + metadata: Optional[Mapping[str, Any]] = None, + ) -> Credential: + body = CredentialCreateParams( + auth=auth, + display_name=display_name, + metadata=metadata, + ).to_dict() + resp = await self._client.transport.request( + "POST", + f"{_PATH_VAULTS}/{vault_id}/credentials", + json=body, + ) + return _coerce_credential(resp.data) + + async def retrieve( + self, + vault_id: str, + credential_id: str, + ) -> Credential: + resp = await self._client.transport.request( + "GET", + f"{_PATH_VAULTS}/{vault_id}/credentials" f"/{credential_id}", + ) + return _coerce_credential(resp.data) + + get = retrieve # type: ignore[assignment] + + async def update( + self, + vault_id: str, + credential_id: str, + *, + auth: Any = None, + display_name: Optional[str] = None, + metadata: Optional[Mapping[str, Any]] = None, + ) -> Credential: + body = CredentialUpdateParams( + auth=auth, + display_name=display_name, + metadata=metadata, + ).to_dict() + resp = await self._client.transport.request( + "POST", + f"{_PATH_VAULTS}/{vault_id}/credentials" f"/{credential_id}", + json=body, + ) + return _coerce_credential(resp.data) + + async def list( + self, + vault_id: str, + *, + include_archived: Optional[bool] = None, + limit: Optional[int] = None, + page: Optional[str] = None, + ) -> AsyncCursorPage[Credential]: + params = CredentialListParams( + include_archived=include_archived, + limit=limit, + page=page, + ).to_dict() + resp = await self._client.transport.request( + "GET", + f"{_PATH_VAULTS}/{vault_id}/credentials", + params=params, + ) + + async def fetch_next( + nxt: str, + ) -> AsyncCursorPage[Credential]: + return await self.list( + vault_id, + include_archived=include_archived, + limit=limit, + page=nxt, + ) + + return build_page( + payload=resp.data, + item_factory=_coerce_credential, + request_id=resp.request_id, + page_cls=AsyncCursorPage, + fetch_next=fetch_next, + ) + + async def delete( + self, + vault_id: str, + credential_id: str, + ) -> DeleteResponse: + resp = await self._client.transport.request( + "DELETE", + f"{_PATH_VAULTS}/{vault_id}/credentials" f"/{credential_id}", + ) + return DeleteResponse(**resp.data) + + async def archive( + self, + vault_id: str, + credential_id: str, + ) -> Credential: + resp = await self._client.transport.request( + "POST", + f"{_PATH_VAULTS}/{vault_id}/credentials" + f"/{credential_id}/archive", + ) + return _coerce_credential(resp.data) + + +class AsyncVaults: + def __init__(self, client) -> None: + self._client = client + self.credentials = AsyncCredentials(client) + + async def create( + self, + *, + display_name: str, + metadata: Optional[Mapping[str, Any]] = None, + ) -> Vault: + body = VaultCreateParams( + display_name=display_name, + metadata=metadata, + ).to_dict() + resp = await self._client.transport.request( + "POST", + _PATH_VAULTS, + json=body, + ) + return _coerce_vault(resp.data) + + async def retrieve(self, vault_id: str) -> Vault: + resp = await self._client.transport.request( + "GET", + f"{_PATH_VAULTS}/{vault_id}", + ) + return _coerce_vault(resp.data) + + get = retrieve # type: ignore[assignment] + + async def update( + self, + vault_id: str, + *, + display_name: Optional[str] = None, + metadata: Optional[Mapping[str, Any]] = None, + ) -> Vault: + body = VaultUpdateParams( + display_name=display_name, + metadata=metadata, + ).to_dict() + resp = await self._client.transport.request( + "POST", + f"{_PATH_VAULTS}/{vault_id}", + json=body, + ) + return _coerce_vault(resp.data) + + async def list( + self, + *, + keyword: Optional[str] = None, + include_archived: Optional[bool] = None, + limit: Optional[int] = None, + page: Optional[str] = None, + ) -> AsyncCursorPage[Vault]: + params = VaultListParams( + keyword=keyword, + include_archived=include_archived, + limit=limit, + page=page, + ).to_dict() + resp = await self._client.transport.request( + "GET", + _PATH_VAULTS, + params=params, + ) + + async def fetch_next( + nxt: str, + ) -> AsyncCursorPage[Vault]: + return await self.list( + keyword=keyword, + include_archived=include_archived, + limit=limit, + page=nxt, + ) + + return build_page( + payload=resp.data, + item_factory=_coerce_vault, + request_id=resp.request_id, + page_cls=AsyncCursorPage, + fetch_next=fetch_next, + ) + + async def delete(self, vault_id: str) -> DeleteResponse: + resp = await self._client.transport.request( + "DELETE", + f"{_PATH_VAULTS}/{vault_id}", + ) + return DeleteResponse(**resp.data) + + async def archive(self, vault_id: str) -> Vault: + resp = await self._client.transport.request( + "POST", + f"{_PATH_VAULTS}/{vault_id}/archive", + ) + return _coerce_vault(resp.data) diff --git a/dashscope/agentstudio/streaming.py b/dashscope/agentstudio/streaming.py index c6485be..1283a67 100644 --- a/dashscope/agentstudio/streaming.py +++ b/dashscope/agentstudio/streaming.py @@ -69,10 +69,14 @@ def _iter(self) -> Iterator[Dict[str, Any]]: for sse in self._event_source.iter_sse(): if sse.data: try: - yield json.loads(sse.data) + payload = json.loads(sse.data) except json.JSONDecodeError: logger.warning("SSE frame contains invalid JSON") yield {"_raw": sse.data} + continue + if not payload: + continue + yield payload finally: self.close() @@ -113,10 +117,14 @@ async def _aiter(self) -> AsyncIterator[Dict[str, Any]]: async for sse in self._event_source.aiter_sse(): if sse.data: try: - yield json.loads(sse.data) + payload = json.loads(sse.data) except json.JSONDecodeError: logger.warning("SSE frame contains invalid JSON") yield {"_raw": sse.data} + continue + if not payload: + continue + yield payload finally: await self.aclose() diff --git a/dashscope/agentstudio/tools.py b/dashscope/agentstudio/tools.py deleted file mode 100644 index 19f6278..0000000 --- a/dashscope/agentstudio/tools.py +++ /dev/null @@ -1,828 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) Alibaba, Inc. and its affiliates. -"""@tool decorator, ToolSpec, and session tool runners.""" - -from __future__ import annotations - -import functools -import inspect -import json -import re -import threading -import time -import typing -from concurrent.futures import ThreadPoolExecutor -from dataclasses import dataclass, field -from typing import ( - Any, - Callable, - Dict, - AsyncIterator, - Iterator, - List, - Mapping, - Optional, - Sequence, - Tuple, - Union, - get_args, - get_origin, -) - -from dashscope.agentstudio.types import ( - user_custom_tool_result, - AgentCustomToolUseEvent, -) -from dashscope.agentstudio.constants import SSEEventType, SessionStatus -from dashscope.common.logging import logger - -# --------------------------------------------------------------------------- -# Schema helpers -# --------------------------------------------------------------------------- - -_PRIMITIVE_MAP: Dict[Any, str] = { - str: "string", - int: "integer", - float: "number", - bool: "boolean", - list: "array", - dict: "object", - type(None): "null", -} - - -def _python_type_to_schema( # pylint: disable=too-many-return-statements - py_type: Any, -) -> Dict[str, Any]: - if py_type is inspect.Parameter.empty or py_type is Any: - return {} - origin = get_origin(py_type) - if origin is Union: - non_none = [a for a in get_args(py_type) if a is not type(None)] - if len(non_none) == 1: - return _python_type_to_schema(non_none[0]) - return {"oneOf": [_python_type_to_schema(a) for a in non_none]} - if origin in (list, List): - args = get_args(py_type) - if args: - return {"type": "array", "items": _python_type_to_schema(args[0])} - return {"type": "array"} - if origin in (dict, Dict): - return {"type": "object"} - mapped = _PRIMITIVE_MAP.get(py_type) - if mapped is not None: - return {"type": mapped} - return {"type": "object"} - - -def _parse_docstring( # pylint: disable=too-many-branches - doc: str, -) -> Tuple[str, Dict[str, str]]: - """Split a docstring into (summary, {param: description}).""" - if not doc: - return "", {} - raw = inspect.cleandoc(doc) - lines = raw.splitlines() - descs: Dict[str, str] = {} - - # Sphinx / NumPy ``:param name:`` style - summary_end = len(lines) - sphinx_re = re.compile(r"^:param\s+([a-zA-Z_][\w]*)\s*:\s*(.*)$") - current: Optional[str] = None - for idx, line in enumerate(lines): - m = sphinx_re.match(line) - if m: - if current is None: - summary_end = min(summary_end, idx) - current = m.group(1) - descs[current] = m.group(2).strip() - elif current and line.startswith((" ", "\t")) and line.strip(): - descs[current] = (descs[current] + " " + line.strip()).strip() - elif line.strip() == "" and current: - current = None - if descs: - return "\n".join(lines[:summary_end]).strip(), descs - - # Google / Napoleon ``Args:`` style - section_re = re.compile( - r"^\s*(Args|Arguments|Parameters)\s*:\s*$", - flags=re.IGNORECASE, - ) - section_idx: Optional[int] = None - for idx, line in enumerate(lines): - if section_re.match(line): - section_idx = idx - break - if section_idx is None: - return raw.strip(), {} - - summary = "\n".join(lines[:section_idx]).strip() - entry_re = re.compile(r"^\s+([a-zA-Z_][\w]*)\s*(?:\([^)]*\))?\s*:\s*(.*)$") - current = None - for line in lines[section_idx + 1 :]: - if not line.strip(): - current = None - continue - if re.match(r"^\S+\s*:\s*$", line): - break - m = entry_re.match(line) - if m: - current = m.group(1) - descs[current] = m.group(2).strip() - elif current and line.startswith((" ", "\t")): - descs[current] = (descs[current] + " " + line.strip()).strip() - return summary, descs - - -# --------------------------------------------------------------------------- -# ToolSpec -# --------------------------------------------------------------------------- - - -@dataclass -class ToolSpec: - """Server-facing description of a client-executed tool.""" - - name: str - description: str - func: Optional[Callable[..., Any]] = None - is_async: bool = False - parameters: Dict[str, Any] = field(default_factory=dict) - - def to_descriptor(self) -> Dict[str, Any]: - """Serialize to the wire format for ``POST /agents``. - - The BMA backend does not accept a ``type`` field for custom tools, - so we omit it (only ``name``, ``description``, ``input_schema``). - """ - return { - "name": self.name, - "description": self.description, - "input_schema": self.parameters, - } - - @classmethod - def from_dict( - cls, - descriptor: Mapping[str, Any], - *, - func: Optional[Callable[..., Any]] = None, - ) -> "ToolSpec": - """Construct from a hand-written descriptor.""" - if "name" not in descriptor or "input_schema" not in descriptor: - raise ValueError( - "ToolSpec.from_dict requires at least" - " 'name' and 'input_schema'", - ) - return cls( - name=str(descriptor["name"]), - description=str(descriptor.get("description", "")), - func=func, - is_async=inspect.iscoroutinefunction(func) if func else False, - parameters=dict(descriptor["input_schema"]), - ) - - -def tool( - _func: Optional[Callable[..., Any]] = None, - *, - name: Optional[str] = None, - description: Optional[str] = None, -) -> Any: - """Decorate a callable to expose it as a custom tool.""" - - def _wrap(func: Callable[..., Any]) -> Callable[..., Any]: - sig = inspect.signature(func) - type_hints = typing.get_type_hints(func) - summary, param_docs = _parse_docstring(inspect.getdoc(func) or "") - properties: Dict[str, Any] = {} - required: List[str] = [] - for pname, param in sig.parameters.items(): - if pname in ("self", "cls"): - continue - if param.kind in ( - inspect.Parameter.VAR_POSITIONAL, - inspect.Parameter.VAR_KEYWORD, - ): - continue - schema = _python_type_to_schema( - type_hints.get(pname, param.annotation), - ) - schema = dict(schema or {"type": "string"}) - doc = param_docs.get(pname) - if doc: - schema["description"] = doc - if param.default is not inspect.Parameter.empty: - # Include default=None so the API knows - # it is optional - schema["default"] = param.default - properties[pname] = schema - if param.default is inspect.Parameter.empty: - required.append(pname) - spec = ToolSpec( - name=name or func.__name__, - description=description or summary, - func=func, - is_async=inspect.iscoroutinefunction(func), - parameters={ - "type": "object", - "properties": properties, - "required": required, - "additionalProperties": False, - }, - ) - func.__tool_spec__ = spec # type: ignore[attr-defined] - return func - - if _func is not None and callable(_func): - return _wrap(_func) - return _wrap - - -def tool_from_spec( - descriptor: Mapping[str, Any], -) -> Callable[[Callable[..., Any]], Callable[..., Any]]: - """Decorator factory that binds a hand-written descriptor to a callable.""" - - def _wrap(func: Callable[..., Any]) -> Callable[..., Any]: - spec = ToolSpec.from_dict(descriptor, func=func) - func.__tool_spec__ = spec # type: ignore[attr-defined] - return func - - return _wrap - - -def collect_specs(items: Any) -> List[ToolSpec]: - """Resolve ``@tool``-decorated callables into a list of ToolSpec.""" - specs: List[ToolSpec] = [] - for it in items: - spec = getattr(it, "__tool_spec__", None) - if isinstance(spec, ToolSpec): - specs.append(spec) - elif isinstance(it, ToolSpec): - specs.append(it) - elif isinstance(it, Mapping): - specs.append(ToolSpec.from_dict(it)) - elif callable(it): - wrapped = tool(it) - specs.append(getattr(wrapped, "__tool_spec__")) - else: - raise TypeError(f"Unsupported tool entry: {it!r}") - return specs - - -# --------------------------------------------------------------------------- -# Runners -# --------------------------------------------------------------------------- - - -@dataclass -class DispatchedTool: - """Bookkeeping record emitted by a runner.""" - - name: str - custom_tool_use_id: str - arguments: Dict[str, Any] - output: Any - is_error: bool - duration_ms: int - - -def _coerce_args(raw: Any) -> Dict[str, Any]: - if isinstance(raw, str): - try: - return json.loads(raw) - except json.JSONDecodeError: - raise ValueError( - f"Tool arguments must be valid JSON, got: {raw!r}", - ) from None - return raw or {} - - -def _extract_tool_call( - event: AgentCustomToolUseEvent, -) -> Optional[Dict[str, Any]]: - content = event.content or [] - for block in content: - block_dict = ( - block.to_dict() if hasattr(block, "to_dict") else dict(block) - ) - # function_call events: data is nested inside a "data" key - inner = block_dict.get("data") or {} - if ( - isinstance(inner, dict) - and inner.get("name") - and inner.get("call_id") - ): - return { - "name": inner["name"], - "custom_tool_use_id": inner["call_id"], - "arguments": _coerce_args(inner.get("arguments")), - } - if "name" in block_dict and ( - "custom_tool_use_id" in block_dict or "tool_use_id" in block_dict - ): - return { - "name": block_dict["name"], - "custom_tool_use_id": block_dict.get("custom_tool_use_id") - or block_dict.get("tool_use_id"), - "arguments": _coerce_args( - block_dict.get("arguments") or block_dict.get("input"), - ), - } - meta = event.metadata or {} - if ( - isinstance(meta, dict) - and meta.get("name") - and meta.get("custom_tool_use_id") - ): - return { - "name": meta["name"], - "custom_tool_use_id": meta["custom_tool_use_id"], - "arguments": _coerce_args(meta.get("arguments")), - } - return None - - -def _format_output(value: Any) -> Any: - if isinstance(value, str): - return value - try: - return json.dumps(value, ensure_ascii=False) - except TypeError: - return repr(value) - - -def _extract_session_status(event: Any) -> Optional[str]: - """Extract session_status string from a session_status event. - - The status lives in ``content[0].data.session_status``. Returns ``None`` - when the event does not carry the expected structure. - """ - content = event.content or [] - for block in content: - block_dict = ( - block.to_dict() if hasattr(block, "to_dict") else dict(block) - ) - data = block_dict.get("data") - if isinstance(data, dict): - return data.get("session_status") - return None - - -class SessionToolRunner: - """Sync runner; iterate to receive :class:`DispatchedTool` records.""" - - def __init__( - self, - *, - client: Any, - session_id: str, - tools: Sequence[Any], - events: Optional[Sequence[Any]] = None, - max_idle_seconds: int = 300, - max_concurrent: int = 4, - ) -> None: - self._client = client - self._session_id = session_id - self._specs: Dict[str, ToolSpec] = { - spec.name: spec for spec in collect_specs(tools) - } - self._events = events - self._max_idle = max_idle_seconds - self._executor = ThreadPoolExecutor( - max_workers=max(1, int(max_concurrent)), - thread_name_prefix="agentstudio-tool", - ) - self._stopped = False - self._last_activity = time.monotonic() - self._lock = threading.Lock() - - def __enter__(self) -> "SessionToolRunner": - return self - - def __exit__(self, *exc) -> None: - self.close() - - def close(self) -> None: - if self._stopped: - return - self._stopped = True - self._executor.shutdown(wait=False) - - def __iter__(self) -> Iterator[DispatchedTool]: - return self.run() - - def run(self) -> Iterator[DispatchedTool]: - """Stream events and yield each completed tool dispatch. - - If ``events`` were provided at construction time, they are sent - before opening the stream. The server immediately returns HTTP - headers on the SSE connection, so the stream can be opened after - sending without blocking. - - Tool functions are submitted to a ``ThreadPoolExecutor`` so that - multiple tools can execute concurrently while the event stream - continues to be consumed. - """ - from concurrent.futures import Future - - pending: Dict[Future, Dict[str, Any]] = {} - - # Send events first (server now flushes headers immediately). - if self._events: - self._client.sessions.events.send( - self._session_id, - events=self._events, - ) - self._events = None - - try: - with self._client.sessions.events.stream( - self._session_id, - timeout=self._max_idle, - ) as stream: - for event in stream: - if event.type in ( - SSEEventType.TOOL_CALL, - SSEEventType.FUNCTION_CALL, - ): - self._last_activity = time.monotonic() - call = _extract_tool_call(event) - if call is None: - logger.warning( - "custom_tool_use without" - " identifiable name/id;" - " skipping", - ) - continue - future = self._executor.submit( - self._dispatch_call, - call, - ) - pending[future] = call - elif event.type == SSEEventType.SESSION_STATUS: - session_status = _extract_session_status(event) - if session_status in ( - SessionStatus.IDLE, - SessionStatus.TERMINATED, - ): - yield from self._drain(pending) - if session_status == SessionStatus.TERMINATED: - return - if self._idle_exceeded(): - logger.debug( - "session idle for" - " too long," - " stopping runner", - ) - return - return - yield from self._harvest(pending) - finally: - self.close() - - def _harvest(self, pending: Dict) -> Iterator[DispatchedTool]: - """Yield records for any futures that have completed so far.""" - done = [f for f in list(pending) if f.done()] - for future in done: - pending.pop(future) - try: - record = future.result() - except Exception: # pragma: no cover - logger.exception("tool dispatch future raised") - continue - if record is not None: - yield record - - def _drain(self, pending: Dict) -> Iterator[DispatchedTool]: - """Block until all pending futures complete and yield their records.""" - from concurrent.futures import wait, FIRST_COMPLETED - - while pending: - done_set, _ = wait(list(pending), return_when=FIRST_COMPLETED) - for future in done_set: - pending.pop(future, None) - try: - record = future.result() - except Exception: # pragma: no cover - logger.exception("tool dispatch future raised") - continue - if record is not None: - yield record - - def _idle_exceeded(self) -> bool: - return (time.monotonic() - self._last_activity) >= self._max_idle - - def _dispatch(self, event: Any) -> Optional[DispatchedTool]: - """Legacy single-dispatch entry point (kept for backward compat).""" - call = _extract_tool_call(event) - if call is None: - return None - return self._dispatch_call(call) - - def _dispatch_call(self, call: Dict[str, Any]) -> Optional[DispatchedTool]: - """Execute a single tool call extracted from *call* dict.""" - name = call["name"] - spec = self._specs.get(name) - if spec is None: - self._post_result( - custom_tool_use_id=call["custom_tool_use_id"], - output=f"Tool '{name}' not registered with this runner.", - is_error=True, - ) - return DispatchedTool( - name=name, - custom_tool_use_id=call["custom_tool_use_id"], - arguments=call["arguments"], - output=None, - is_error=True, - duration_ms=0, - ) - - started = time.monotonic() - try: - if spec.func is None: - result = ( - f"Tool '{name}' is a" - " descriptor-only tool" - " (no callable registered)" - ) - is_error = True - else: - result = spec.func(**call["arguments"]) - is_error = False - except Exception as exc: # noqa: BLE001 - logger.exception("tool %r raised", name) - result = f"{type(exc).__name__}: {exc}" - is_error = True - duration_ms = int((time.monotonic() - started) * 1000) - - formatted = _format_output(result) - self._post_result( - custom_tool_use_id=call["custom_tool_use_id"], - output=formatted, - is_error=is_error, - ) - return DispatchedTool( - name=name, - custom_tool_use_id=call["custom_tool_use_id"], - arguments=call["arguments"], - output=formatted, - is_error=is_error, - duration_ms=duration_ms, - ) - - def _post_result( - self, - *, - custom_tool_use_id: str, - output: Any, - is_error: bool, - ) -> None: - evt = user_custom_tool_result( - custom_tool_use_id=custom_tool_use_id, - content=output, - is_error=is_error, - ) - try: - self._client.sessions.events.send( - self._session_id, - [evt], - ) - except Exception: # pragma: no cover - logger.exception("failed to post tool result") - - -class AsyncSessionToolRunner: - """Async counterpart of :class:`SessionToolRunner`.""" - - def __init__( - self, - *, - client: Any, - session_id: str, - tools: Sequence[Any], - events: Optional[Sequence[Any]] = None, - max_idle_seconds: int = 300, - max_concurrent: int = 4, - ) -> None: - self._client = client - self._session_id = session_id - self._specs: Dict[str, ToolSpec] = { - spec.name: spec for spec in collect_specs(tools) - } - self._events = events - self._max_idle = max_idle_seconds - self._max_concurrent = max(1, int(max_concurrent)) - self._pending_tasks: set = set() - - async def __aenter__(self) -> "AsyncSessionToolRunner": - return self - - async def __aexit__(self, *exc) -> None: - await self.aclose() - - async def aclose(self) -> None: - """Cancel any pending tool dispatch tasks.""" - for task in self._pending_tasks: - task.cancel() - self._pending_tasks.clear() - - def __aiter__(self): - return self.run() - - async def run(self) -> AsyncIterator[DispatchedTool]: - """Async generator yielding :class:`DispatchedTool` records. - - If ``events`` were provided at construction time, they are sent - before opening the stream. The server immediately returns HTTP - headers on the SSE connection, so the stream can be opened after - sending without blocking. - - Tool dispatches are spawned as ``asyncio.Task`` objects so that - multiple tools can execute concurrently while the event stream - continues to be consumed. A semaphore bounds the number of - concurrent dispatches to ``max_concurrent``. - """ - import asyncio - - loop = asyncio.get_running_loop() - last_activity = loop.time() - sem = asyncio.Semaphore(self._max_concurrent) - self._pending_tasks.clear() - - async def _guarded_dispatch( - call: Dict[str, Any], - ) -> Optional[DispatchedTool]: - async with sem: - return await self._dispatch_call(call) - - # Send events first (server now flushes headers immediately). - if self._events: - await self._client.sessions.events.send( - self._session_id, - events=self._events, - ) - self._events = None - - async with await self._client.sessions.events.stream( - self._session_id, - timeout=self._max_idle, - ) as stream: - async for event in stream: - if event.type in ( - SSEEventType.TOOL_CALL, - SSEEventType.FUNCTION_CALL, - ): - last_activity = loop.time() - call = _extract_tool_call(event) - if call is None: - continue - task = asyncio.create_task(_guarded_dispatch(call)) - self._pending_tasks.add(task) - elif event.type == SSEEventType.SESSION_STATUS: - session_status = _extract_session_status(event) - if session_status in ( - SessionStatus.IDLE, - SessionStatus.TERMINATED, - ): - # Drain pending tasks before exiting. - async for record in self._drain(self._pending_tasks): - yield record - if session_status == SessionStatus.TERMINATED: - return - if loop.time() - last_activity >= self._max_idle: - return - return - # Yield completed dispatches (non-blocking). - async for record in self._harvest(self._pending_tasks): - yield record - - async def _harvest(self, pending: set) -> AsyncIterator[DispatchedTool]: - """Yield records for any tasks that have completed so far.""" - done = {t for t in pending if t.done()} - for task in done: - pending.discard(task) - try: - record = task.result() - except Exception: # pragma: no cover - logger.exception("tool dispatch task raised") - continue - if record is not None: - yield record - - async def _drain(self, pending: set) -> AsyncIterator[DispatchedTool]: - """Wait for all pending tasks to complete and yield their records.""" - import asyncio - - while pending: - done, _still_pending = await asyncio.wait( - pending, - return_when=asyncio.FIRST_COMPLETED, - ) - for task in done: - pending.discard(task) - try: - record = task.result() - except Exception: # pragma: no cover - logger.exception("tool dispatch task raised") - continue - if record is not None: - yield record - - async def _dispatch(self, event: Any) -> Optional[DispatchedTool]: - """Legacy single-dispatch entry point (kept for backward compat).""" - call = _extract_tool_call(event) - if call is None: - return None - return await self._dispatch_call(call) - - async def _dispatch_call( - self, - call: Dict[str, Any], - ) -> Optional[DispatchedTool]: - import asyncio - - spec = self._specs.get(call["name"]) - if spec is None: - await self._post_result( - call["custom_tool_use_id"], - output=( - f"Tool '{call['name']}'" - " not registered with" - " this runner." - ), - is_error=True, - ) - return DispatchedTool( - name=call["name"], - custom_tool_use_id=call["custom_tool_use_id"], - arguments=call["arguments"], - output=None, - is_error=True, - duration_ms=0, - ) - - loop = asyncio.get_running_loop() - started = loop.time() - try: - if spec.func is None: - result = ( - f"Tool '{call['name']}'" - " is a descriptor-only" - " tool (no callable" - " registered)" - ) - is_error = True - elif spec.is_async: - result = await spec.func(**call["arguments"]) - is_error = False - else: - result = await loop.run_in_executor( - None, - functools.partial( - spec.func, - **call["arguments"], - ), - ) - is_error = False - except Exception as exc: # noqa: BLE001 - logger.exception("tool %r raised", call["name"]) - result = f"{type(exc).__name__}: {exc}" - is_error = True - duration_ms = int((loop.time() - started) * 1000) - formatted = _format_output(result) - await self._post_result( - call["custom_tool_use_id"], - output=formatted, - is_error=is_error, - ) - return DispatchedTool( - name=call["name"], - custom_tool_use_id=call["custom_tool_use_id"], - arguments=call["arguments"], - output=formatted, - is_error=is_error, - duration_ms=duration_ms, - ) - - async def _post_result( - self, - custom_tool_use_id: str, - *, - output: Any, - is_error: bool, - ) -> None: - evt = user_custom_tool_result( - custom_tool_use_id=custom_tool_use_id, - content=output, - is_error=is_error, - ) - try: - await self._client.sessions.events.send( - self._session_id, - [evt], - ) - except Exception: # pragma: no cover - logger.exception("failed to post tool result") diff --git a/dashscope/agentstudio/transport.py b/dashscope/agentstudio/transport.py index e4ce52e..4ca6093 100644 --- a/dashscope/agentstudio/transport.py +++ b/dashscope/agentstudio/transport.py @@ -18,6 +18,7 @@ from __future__ import annotations import asyncio +import logging import random import socket import time @@ -33,6 +34,8 @@ AGENTSTUDIO_MAX_RETRIES, ) +logger = logging.getLogger("dashscope.agentstudio") + # --------------------------------------------------------------------------- # Base data classes (merged from _base.py) # --------------------------------------------------------------------------- @@ -428,6 +431,12 @@ def _parse(self, resp: httpx.Response) -> APIResponse: payload = {"raw": resp.text} if resp.status_code >= 400 or is_error_payload(payload): + logger.warning( + "AgentStudio request failed: status=%s request_id=%s body=%s", + resp.status_code, + header_rid, + str(payload)[:500], + ) raise exceptions.from_response( status_code=resp.status_code, body=payload, @@ -646,6 +655,12 @@ async def _parse(self, resp: httpx.Response) -> APIResponse: payload = {"raw": resp.text} if resp.status_code >= 400 or is_error_payload(payload): + logger.warning( + "AgentStudio request failed: status=%s request_id=%s body=%s", + resp.status_code, + header_rid, + str(payload)[:500], + ) raise exceptions.from_response( status_code=resp.status_code, body=payload, diff --git a/dashscope/agentstudio/types/__init__.py b/dashscope/agentstudio/types/__init__.py index 2eb57f3..fda94eb 100644 --- a/dashscope/agentstudio/types/__init__.py +++ b/dashscope/agentstudio/types/__init__.py @@ -14,6 +14,7 @@ Packages, EnvironmentConfig, StopReason, + Stats, Usage, TextBlock, ImageBlock, @@ -27,11 +28,14 @@ parse_content_blocks, Agent, AgentVersion, + Credential, + CredentialAuth, Environment, File, Skill, SkillVersion, Session, + Vault, SessionThread, DeleteResponse, Message, @@ -84,4 +88,10 @@ SkillListParams, SkillVersionCreateParams, SkillVersionListParams, + VaultCreateParams, + VaultUpdateParams, + VaultListParams, + CredentialCreateParams, + CredentialUpdateParams, + CredentialListParams, ) diff --git a/dashscope/agentstudio/types/models.py b/dashscope/agentstudio/types/models.py index 138fdfb..a9ee906 100644 --- a/dashscope/agentstudio/types/models.py +++ b/dashscope/agentstudio/types/models.py @@ -159,6 +159,12 @@ class StopReason(BaseModel): _fields = ("type", "event_ids") +class Stats(BaseModel): + """Session runtime statistics.""" + + _fields = ("active_seconds", "duration_seconds") + + class Usage(BaseModel): """Token usage carried by ``span.model_request_end`` events.""" @@ -167,6 +173,7 @@ class Usage(BaseModel): "output_tokens", "cache_creation_input_tokens", "cache_read_input_tokens", + "cache_creation", "speed", ) @@ -339,6 +346,7 @@ class Agent(BaseModel): "mcp_servers", "skills", "metadata", + "workspace_id", "archived_at", "created_at", "updated_at", @@ -352,7 +360,12 @@ def system_prompt(self) -> Optional[str]: class AgentVersion(BaseModel): - _fields = ("id", "version", "name", "description", "created_at") + _fields = ( + "agent_id", + "version", + "config", + "created_at", + ) class Environment(BaseModel): @@ -385,17 +398,11 @@ class File(BaseModel): "downloadable", "mime_type", "size_bytes", - "scope", + "status", "created_at", "request_id", ) - def __init__(self, **kwargs: Any) -> None: - scope = kwargs.get("scope") - if isinstance(scope, Mapping): - kwargs["scope"] = Scope(**dict(scope)) - super().__init__(**kwargs) - class Skill(BaseModel): _fields = ( @@ -436,8 +443,6 @@ class Session(BaseModel): "type", "title", "agent", - "agent_id", - "agent_version", "environment_id", "status", "stop_reason", @@ -445,12 +450,38 @@ class Session(BaseModel): "metadata", "stats", "usage", + "vault_ids", "archived_at", "created_at", "updated_at", "request_id", ) + def __init__(self, **kwargs: Any) -> None: + agent = kwargs.get("agent") + if isinstance(agent, Mapping): + kwargs["agent"] = Agent(**dict(agent)) + sr = kwargs.get("stop_reason") + if isinstance(sr, Mapping): + kwargs["stop_reason"] = StopReason(**dict(sr)) + usage = kwargs.get("usage") + if isinstance(usage, Mapping): + kwargs["usage"] = Usage(**dict(usage)) + stats = kwargs.get("stats") + if isinstance(stats, Mapping): + kwargs["stats"] = Stats(**dict(stats)) + super().__init__(**kwargs) + + @property + def agent_id(self) -> Optional[str]: + a = self.agent + return a.id if isinstance(a, Agent) else None + + @property + def agent_version(self) -> Optional[int]: + a = self.agent + return a.version if isinstance(a, Agent) else None + class SessionThread(BaseModel): _fields = ( @@ -520,6 +551,37 @@ def __str__(self) -> str: return f"[{etype}] " + " ".join(str(b) for b in content) return f"[{etype}]" + @property + def stop_reason(self) -> Optional[Dict[str, Any]]: + """``stop_reason`` carried by ``session_status`` idle events. + + The server puts ``stop_reason`` inside the ``session_status`` event's + data block (not in ``session.retrieve()``). Returns a dict like + ``{"type": "end_turn"}`` or ``None`` for non-session_status events. + """ + if getattr(self, "type", None) != SSEEventType.SESSION_STATUS: + return None + for block in self.content or []: + d = getattr(block, "data", None) + if isinstance(d, dict) and "stop_reason" in d: + return d["stop_reason"] + return None + + @property + def session_status(self) -> Optional[str]: + """``session_status`` value from ``session_status`` events. + + Returns ``"idle"``, ``"running"``, ``"rescheduling"``, + ``"terminated"`` or ``None`` for non-session_status events. + """ + if getattr(self, "type", None) != SSEEventType.SESSION_STATUS: + return None + for block in self.content or []: + d = getattr(block, "data", None) + if isinstance(d, dict) and "session_status" in d: + return d["session_status"] + return None + def parse_message(payload: Mapping[str, Any]) -> Message: """Turn a parsed SSE ``data`` dict into a :class:`Message` instance.""" @@ -716,7 +778,66 @@ def user_define_outcome( # =========================================================================== -# Section 6 – Backward compatibility aliases +# Section 6 – Vaults & Credentials +# =========================================================================== + + +class CredentialAuth(BaseModel): + _fields = ( + "type", + "access_token", + "mcp_server_url", + "expires_at", + "refresh", + "token", + "secret_name", + "secret_value", + "networking", + ) + + def __init__(self, **kwargs: Any) -> None: + net = kwargs.get("networking") + if isinstance(net, dict): + kwargs["networking"] = Networking(**net) + super().__init__(**kwargs) + + +class Vault(BaseModel): + _fields = ( + "id", + "archived_at", + "created_at", + "display_name", + "metadata", + "type", + "updated_at", + "request_id", + ) + + +class Credential(BaseModel): + _fields = ( + "id", + "archived_at", + "auth", + "created_at", + "display_name", + "metadata", + "type", + "updated_at", + "vault_id", + "request_id", + ) + + def __init__(self, **kwargs: Any) -> None: + auth = kwargs.get("auth") + if isinstance(auth, dict): + kwargs["auth"] = CredentialAuth(**auth) + super().__init__(**kwargs) + + +# =========================================================================== +# Section 7 – Backward compatibility aliases # =========================================================================== # server_events.py ── ServerEvent was the base class; now it's Message diff --git a/dashscope/agentstudio/types/params.py b/dashscope/agentstudio/types/params.py index 8f41864..9a58a8e 100644 --- a/dashscope/agentstudio/types/params.py +++ b/dashscope/agentstudio/types/params.py @@ -453,3 +453,93 @@ class SkillVersionListParams(BaseModel): """Query params for ``GET /skills/{skill_id}/versions``.""" _fields = ("limit", "page") + + +# =========================================================================== +# Vaults +# =========================================================================== + + +class VaultCreateParams(BaseModel): + """Request body for ``POST /vaults``.""" + + _fields = ("display_name", "metadata") + + +class VaultUpdateParams(BaseModel): + """Request body for ``POST /vaults/{vault_id}``.""" + + _fields = ("display_name", "metadata") + + +class VaultListParams(BaseModel): + """Query params for ``GET /vaults``.""" + + _fields = ("keyword", "include_archived", "limit", "page") + + def __init__( + self, + *, + include_archived: Optional[bool] = None, + **kwargs: Any, + ) -> None: + if include_archived is not None: + kwargs["include_archived"] = str( + include_archived, + ).lower() + super().__init__(**kwargs) + + +# =========================================================================== +# Credentials +# =========================================================================== + + +class CredentialCreateParams(BaseModel): + """Request body for ``POST /vaults/{vault_id}/credentials``.""" + + _fields = ("auth", "display_name", "metadata") + + def __init__(self, *, auth: Any = None, **kwargs: Any) -> None: + if auth is not None: + if hasattr(auth, "to_dict"): + kwargs["auth"] = auth.to_dict() + elif isinstance(auth, Mapping): + kwargs["auth"] = dict(auth) + else: + kwargs["auth"] = auth + super().__init__(**kwargs) + + +class CredentialUpdateParams(BaseModel): + """Request body for ``POST /vaults/{v}/credentials/{c}``.""" + + _fields = ("auth", "display_name", "metadata") + + def __init__(self, *, auth: Any = None, **kwargs: Any) -> None: + if auth is not None: + if hasattr(auth, "to_dict"): + kwargs["auth"] = auth.to_dict() + elif isinstance(auth, Mapping): + kwargs["auth"] = dict(auth) + else: + kwargs["auth"] = auth + super().__init__(**kwargs) + + +class CredentialListParams(BaseModel): + """Query params for ``GET /vaults/{vault_id}/credentials``.""" + + _fields = ("include_archived", "limit", "page") + + def __init__( + self, + *, + include_archived: Optional[bool] = None, + **kwargs: Any, + ) -> None: + if include_archived is not None: + kwargs["include_archived"] = str( + include_archived, + ).lower() + super().__init__(**kwargs) diff --git a/tests/unit/test_agentstudio_models.py b/tests/unit/test_agentstudio_models.py new file mode 100644 index 0000000..e5a20a8 --- /dev/null +++ b/tests/unit/test_agentstudio_models.py @@ -0,0 +1,222 @@ +# -*- coding: utf-8 -*- +"""Unit tests for Vault, Credential, DeleteResponse, Agent.system_prompt.""" + +from dashscope.agentstudio.resources._helpers import ( + _coerce_vault, + _coerce_credential, +) +from dashscope.agentstudio.types import ( + Agent, + Credential, + CredentialAuth, + DeleteResponse, + Environment, + File, + Networking, + Vault, +) + + +# --------------------------------------------------------------------------- +# Vault +# --------------------------------------------------------------------------- + + +def test_vault_coerce_parses_all_fields(): + payload = { + "id": "vlt_1", + "type": "vault", + "display_name": "my-vault", + "metadata": {"team": "backend"}, + "created_at": "2026-06-29T10:00:00Z", + "updated_at": "2026-06-29T10:00:00Z", + "archived_at": None, + "request_id": "req_1", + } + v = _coerce_vault(payload) + assert isinstance(v, Vault) + assert v.id == "vlt_1" + assert v.display_name == "my-vault" + assert v.metadata == {"team": "backend"} + assert v.archived_at is None + + +def test_vault_skips_none_fields_in_repr(): + v = Vault(id="vlt_1", display_name="demo") + r = repr(v) + assert "id='vlt_1'" in r + assert "display_name='demo'" in r + assert "archived_at" not in r # None fields omitted + + +# --------------------------------------------------------------------------- +# CredentialAuth — three auth types +# --------------------------------------------------------------------------- + + +def test_credential_auth_environment_variable(): + auth = CredentialAuth( + type="environment_variable", + secret_name="OPENAI_API_KEY", + secret_value="sk-xxx", + networking={"type": "unrestricted"}, + ) + assert auth.type == "environment_variable" + assert auth.secret_name == "OPENAI_API_KEY" + assert isinstance(auth.networking, Networking) + assert auth.networking.type == "unrestricted" + + +def test_credential_auth_static_bearer(): + auth = CredentialAuth( + type="static_bearer", + token="Bearer eyJhbGci...", + ) + assert auth.type == "static_bearer" + assert auth.token == "Bearer eyJhbGci..." + assert auth.networking is None + + +def test_credential_auth_mcp_oauth(): + auth = CredentialAuth( + type="mcp_oauth", + mcp_server_url="https://mcp.example.com/oauth", + ) + assert auth.type == "mcp_oauth" + assert auth.mcp_server_url == "https://mcp.example.com/oauth" + + +def test_credential_auth_secret_value_not_returned_by_server(): + """Server stores secret_value write-only.""" + auth = CredentialAuth( + type="environment_variable", + secret_name="KEY", + # secret_value intentionally absent (server doesn't return it) + ) + assert auth.secret_value is None + + +# --------------------------------------------------------------------------- +# Credential — nested auth parsing +# --------------------------------------------------------------------------- + + +def test_credential_coerce_wraps_auth_dict(): + payload = { + "id": "vcrd_1", + "type": "vault_credential", + "vault_id": "vlt_1", + "display_name": "OpenAI Key", + "auth": { + "type": "environment_variable", + "secret_name": "OPENAI_API_KEY", + "networking": {"type": "restricted"}, + }, + "created_at": "2026-06-29T10:00:00Z", + "updated_at": "2026-06-29T10:00:00Z", + "metadata": {"service": "openai"}, + "archived_at": None, + "request_id": "req_1", + } + c = _coerce_credential(payload) + assert isinstance(c, Credential) + assert c.id == "vcrd_1" + assert c.vault_id == "vlt_1" + assert isinstance(c.auth, CredentialAuth) + assert c.auth.type == "environment_variable" + assert isinstance(c.auth.networking, Networking) + assert c.auth.networking.type == "restricted" + + +def test_credential_coerce_static_bearer(): + payload = { + "id": "vcrd_2", + "type": "vault_credential", + "vault_id": "vlt_1", + "auth": {"type": "static_bearer", "token": "Bearer xyz"}, + "display_name": "API Token", + } + c = _coerce_credential(payload) + assert c.auth.type == "static_bearer" + assert c.auth.token == "Bearer xyz" + + +# --------------------------------------------------------------------------- +# DeleteResponse +# --------------------------------------------------------------------------- + + +def test_delete_response_fields(): + dr = DeleteResponse(id="vlt_1", type="vault_deleted", request_id="req_1") + assert dr.id == "vlt_1" + assert dr.type == "vault_deleted" + assert dr.request_id == "req_1" + + +def test_delete_response_repr_skips_none(): + dr = DeleteResponse(id="x", type="deleted") + assert "request_id" not in repr(dr) + + +# --------------------------------------------------------------------------- +# Agent.system_prompt property alias (gotcha: create param is system_prompt, +# field is system, property bridges them) +# --------------------------------------------------------------------------- + + +def test_agent_system_prompt_is_alias_for_system(): + agent = Agent( + id="agent_1", + name="demo", + model={"id": "qwen-plus"}, + system="你是一个助手。", + ) + assert agent.system == "你是一个助手。" + assert agent.system_prompt == agent.system + + +def test_agent_system_prompt_none_when_system_unset(): + agent = Agent(id="agent_1", name="demo", model={"id": "qwen-plus"}) + assert agent.system is None + assert agent.system_prompt is None + + +# --------------------------------------------------------------------------- +# Environment config nested parsing +# --------------------------------------------------------------------------- + + +def test_environment_config_nested_objects(): + env = Environment( + id="env_1", + name="python-env", + config={ + "type": "cloud", + "networking": {"type": "unrestricted"}, + "packages": {"pip": ["pandas", "numpy"]}, + }, + ) + assert env.config.type == "cloud" + assert isinstance(env.config.networking, Networking) + assert env.config.networking.type == "unrestricted" + assert env.config.packages.pip == ["pandas", "numpy"] + + +# --------------------------------------------------------------------------- +# File model +# --------------------------------------------------------------------------- + + +def test_file_model_fields(): + f = File( + id="file_1", + filename="doc.pdf", + size_bytes=1024, + mime_type="application/pdf", + status="checking", + ) + assert f.id == "file_1" + assert f.filename == "doc.pdf" + assert f.size_bytes == 1024 + assert f.status == "checking" + assert f.downloadable is None # not set diff --git a/tests/unit/test_agentstudio_protocol.py b/tests/unit/test_agentstudio_protocol.py index bad951b..3c4e5db 100644 --- a/tests/unit/test_agentstudio_protocol.py +++ b/tests/unit/test_agentstudio_protocol.py @@ -257,11 +257,13 @@ def test_session_stats_and_usage_fields(): """Session now parses ``stats`` and ``usage`` from server response.""" from dashscope.agentstudio.types import Session - stats = {"total_events": 42, "total_turns": 5} + stats = {"active_seconds": 42, "duration_seconds": 5} usage = {"input_tokens": 100, "output_tokens": 200} s = Session(id="sesn_1", status="idle", stats=stats, usage=usage) - assert s.stats == stats - assert s.usage == usage + assert s.stats.active_seconds == 42 + assert s.stats.duration_seconds == 5 + assert s.usage.input_tokens == 100 + assert s.usage.output_tokens == 200 def test_from_response_spring_default(): diff --git a/tests/unit/test_agentstudio_runner.py b/tests/unit/test_agentstudio_runner.py deleted file mode 100644 index a2512ed..0000000 --- a/tests/unit/test_agentstudio_runner.py +++ /dev/null @@ -1,388 +0,0 @@ -# -*- coding: utf-8 -*- -"""Tests for SessionToolRunner and AsyncSessionToolRunner.""" - -from __future__ import annotations - -import asyncio -from typing import Any, Dict, List -from unittest.mock import AsyncMock, MagicMock - -import pytest - -from dashscope.agentstudio import Client, AsyncClient -from dashscope.agentstudio.tools import ( - SessionToolRunner, - AsyncSessionToolRunner, - tool, -) -from dashscope.agentstudio.types import ( - AgentCustomToolUseEvent, - SessionStatusIdleEvent, -) - - -# --------------------------------------------------------------------------- -# Sync helpers -# --------------------------------------------------------------------------- - - -class _FakeStream: - def __init__(self, events: List[Any]): - self._events = events - - def __enter__(self): - return self - - def __exit__(self, *exc): - pass - - def __iter__(self): - return iter(self._events) - - def close(self): - pass - - -def _make_client(): - return Client(api_key="test-key") - - -# --------------------------------------------------------------------------- -# Async helpers -# --------------------------------------------------------------------------- - - -class _FakeAsyncStream: - def __init__(self, events: List[Any]): - self._events = events - - async def __aenter__(self): - return self - - async def __aexit__(self, *exc): - pass - - def __aiter__(self): - return self._aiter() - - async def _aiter(self): - for evt in self._events: - yield evt - - async def aclose(self): - pass - - -def _make_async_client(): - return AsyncClient(api_key="test-key") - - -# --------------------------------------------------------------------------- -# Sync runner tests -# --------------------------------------------------------------------------- - - -def test_runner_dispatches_custom_tool_and_posts_result(): - @tool - def add(a: int, b: int) -> int: - """Sum two integers.""" - return a + b - - server_events = [ - AgentCustomToolUseEvent( - id="evt_1", - type="tool_call", - content=[ - { - "type": "data", - "name": "add", - "custom_tool_use_id": "ctu_1", - "arguments": {"a": 2, "b": 3}, - }, - ], - ), - SessionStatusIdleEvent( - id="evt_2", - type="session_status", - content=[ - { - "type": "data", - "data": { - "session_status": "idle", - "stop_reason": "end_turn", - }, - }, - ], - ), - ] - - sent: List[Dict[str, Any]] = [] - client = _make_client() - client.sessions.events.stream = MagicMock( - return_value=_FakeStream(server_events), - ) - client.sessions.events.send = MagicMock( - side_effect=lambda sid, evts, **kw: sent.extend(evts), - ) - - runner = SessionToolRunner( - client=client, - session_id="sesn_1", - tools=[add], - max_idle_seconds=0, - ) - records = list(runner) - - assert len(records) == 1 - assert records[0].name == "add" - assert records[0].is_error is False - assert records[0].output in ("5", 5) - assert len(sent) == 1 - posted = sent[0] - assert posted["type"] == "function_call_output" - assert posted["role"] == "tool" - assert posted["content"][0]["data"]["call_id"] == "ctu_1" - - -def test_runner_reports_unknown_tool_as_error(): - server_events = [ - AgentCustomToolUseEvent( - id="evt_1", - type="tool_call", - content=[ - { - "type": "data", - "name": "missing", - "custom_tool_use_id": "ctu_2", - "arguments": {}, - }, - ], - ), - SessionStatusIdleEvent( - id="evt_2", - type="session_status", - content=[ - { - "type": "data", - "data": { - "session_status": "idle", - "stop_reason": "end_turn", - }, - }, - ], - ), - ] - - sent: List[Dict[str, Any]] = [] - client = _make_client() - client.sessions.events.stream = MagicMock( - return_value=_FakeStream(server_events), - ) - client.sessions.events.send = MagicMock( - side_effect=lambda sid, evts, **kw: sent.extend(evts), - ) - - runner = SessionToolRunner( - client=client, - session_id="sesn_2", - tools=[], - max_idle_seconds=0, - ) - records = list(runner) - - assert len(records) == 1 - assert records[0].is_error is True - assert len(sent) == 1 - assert sent[0]["is_error"] is True - - -# --------------------------------------------------------------------------- -# Async runner tests -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_async_runner_dispatches_custom_tool_and_posts_result(): - @tool - def add(a: int, b: int) -> int: - """Sum two integers.""" - return a + b - - server_events = [ - AgentCustomToolUseEvent( - id="evt_1", - type="tool_call", - content=[ - { - "type": "data", - "name": "add", - "custom_tool_use_id": "ctu_1", - "arguments": {"a": 2, "b": 3}, - }, - ], - ), - SessionStatusIdleEvent( - id="evt_2", - type="session_status", - content=[ - { - "type": "data", - "data": { - "session_status": "idle", - "stop_reason": "end_turn", - }, - }, - ], - ), - ] - - sent: List[Dict[str, Any]] = [] - client = _make_async_client() - client.sessions.events.stream = AsyncMock( - return_value=_FakeAsyncStream(server_events), - ) - client.sessions.events.send = AsyncMock( - side_effect=lambda sid, evts, **kw: sent.extend(evts), - ) - - runner = AsyncSessionToolRunner( - client=client, - session_id="sesn_1", - tools=[add], - max_idle_seconds=0, - ) - records = [record async for record in runner] - - assert len(records) == 1 - assert records[0].name == "add" - assert records[0].is_error is False - assert records[0].output in ("5", 5) - assert len(sent) == 1 - posted = sent[0] - assert posted["type"] == "function_call_output" - assert posted["role"] == "tool" - assert posted["content"][0]["data"]["call_id"] == "ctu_1" - - -@pytest.mark.asyncio -async def test_async_runner_reports_unknown_tool_as_error(): - server_events = [ - AgentCustomToolUseEvent( - id="evt_1", - type="tool_call", - content=[ - { - "type": "data", - "name": "missing", - "custom_tool_use_id": "ctu_2", - "arguments": {}, - }, - ], - ), - SessionStatusIdleEvent( - id="evt_2", - type="session_status", - content=[ - { - "type": "data", - "data": { - "session_status": "idle", - "stop_reason": "end_turn", - }, - }, - ], - ), - ] - - sent: List[Dict[str, Any]] = [] - client = _make_async_client() - client.sessions.events.stream = AsyncMock( - return_value=_FakeAsyncStream(server_events), - ) - client.sessions.events.send = AsyncMock( - side_effect=lambda sid, evts, **kw: sent.extend(evts), - ) - - runner = AsyncSessionToolRunner( - client=client, - session_id="sesn_2", - tools=[], - max_idle_seconds=0, - ) - records = [record async for record in runner] - - assert len(records) == 1 - assert records[0].is_error is True - assert len(sent) == 1 - assert sent[0]["is_error"] is True - - -@pytest.mark.asyncio -async def test_async_runner_parallel_send_and_stream(): - stream_opened = asyncio.Event() - send_started = asyncio.Event() - - async def _fake_stream(*_args, **_kwargs): - stream_opened.set() - await asyncio.sleep(0.1) - return _FakeAsyncStream([]) - - async def _fake_send(*_args, **_kwargs): - send_started.set() - - client = _make_async_client() - client.sessions.events.stream = _fake_stream - client.sessions.events.send = _fake_send - - runner = AsyncSessionToolRunner( - client=client, - session_id="sesn_3", - tools=[], - events=[ - { - "role": "user", - "type": "message", - "content": [{"type": "text", "text": "hi"}], - }, - ], - max_idle_seconds=0, - ) - async for _ in runner: - pass - - assert stream_opened.is_set() - assert send_started.is_set() - - -@pytest.mark.asyncio -async def test_async_runner_with_events_parameter(): - captured_events: List[Any] = [] - - async def _fake_stream(*_args, **_kwargs): - return _FakeAsyncStream([]) - - async def _fake_send(_session_id, events, **_kwargs): - captured_events.extend(list(events)) - - client = _make_async_client() - client.sessions.events.stream = _fake_stream - client.sessions.events.send = _fake_send - - runner = AsyncSessionToolRunner( - client=client, - session_id="sesn_4", - tools=[], - events=[ - { - "role": "user", - "type": "message", - "content": [{"type": "text", "text": "hello"}], - }, - ], - max_idle_seconds=0, - ) - async for _ in runner: - pass - - assert len(captured_events) == 1 - assert captured_events[0]["type"] == "message" diff --git a/tests/unit/test_agentstudio_skills.py b/tests/unit/test_agentstudio_skills.py index 1ccc5d5..0fda724 100644 --- a/tests/unit/test_agentstudio_skills.py +++ b/tests/unit/test_agentstudio_skills.py @@ -38,7 +38,7 @@ def request(self, method, path, **kwargs): @pytest.fixture(name="client") def _client_fixture(): """Create a client with a recording transport.""" - c = Client(api_key="test-key") + c = Client(api_key="test-key", base_url="http://test") c.transport = _Tx() return c @@ -63,18 +63,18 @@ class _F: def test_skill_create_with_file_id_does_not_call_upload(client, fake_upload): upload_calls, _ = fake_upload - client.skills.create(file_id="file_existing", name="demo") + client.skills.create(file_id="file_existing") assert upload_calls == [] body = client.transport.calls[0]["json"] - assert body == {"file_id": "file_existing", "name": "demo"} + assert body == {"file_id": "file_existing"} def test_skill_create_with_file_path_auto_uploads_first(client, fake_upload): upload_calls, holder = fake_upload holder["id"] = "file_uploaded" - client.skills.create(file="/tmp/skill.zip", name="demo") + client.skills.create(file="/tmp/skill.zip") assert len(upload_calls) == 1 assert upload_calls[0]["file"] == "/tmp/skill.zip" @@ -90,7 +90,7 @@ def test_skill_create_rejects_both_file_and_file_id(client): def test_skill_create_rejects_neither_file_nor_file_id(client): with pytest.raises(TypeError): - client.skills.create(name="demo") + client.skills.create() def test_skill_versions_create_auto_upload(client, fake_upload): diff --git a/tests/unit/test_agentstudio_tools.py b/tests/unit/test_agentstudio_tools.py deleted file mode 100644 index 5486f46..0000000 --- a/tests/unit/test_agentstudio_tools.py +++ /dev/null @@ -1,203 +0,0 @@ -# -*- coding: utf-8 -*- -"""@tool decorator schema synthesis and JSON Schema validation.""" - -from typing import List, Optional - -import pytest - -from dashscope.agentstudio.tools import ToolSpec, tool - -jsonschema = pytest.importorskip("jsonschema") - - -def _validate(schema): - # Validator factory raises if the schema itself is invalid. - jsonschema.Draft7Validator.check_schema(schema) - - -# --------------------------------------------------------------------------- -# Tests from test_agentstudio_tools_decorator.py -# --------------------------------------------------------------------------- - - -def test_tool_decorator_basic_schema(): - @tool - def get_weather(city: str, unit: str = "C") -> str: - """Look up the weather for a city.""" - - return f"sunny {city} {unit}" - - spec: ToolSpec = get_weather.__tool_spec__ - assert spec.name == "get_weather" - assert spec.description == "Look up the weather for a city." - schema = spec.parameters - assert schema["type"] == "object" - assert schema["properties"]["city"] == {"type": "string"} - # ``unit`` has a default so the schema records it. - assert schema["properties"]["unit"]["type"] == "string" - assert schema["properties"]["unit"]["default"] == "C" - assert schema["required"] == ["city"] - - -def test_tool_decorator_optional_and_list(): - @tool(name="search", description="Custom desc") - def _search(q: str, limit: Optional[int] = None, tags: List[str] = None): - return [q, limit, tags] - - spec = _search.__tool_spec__ - assert spec.name == "search" - assert spec.description == "Custom desc" - props = spec.parameters["properties"] - assert props["q"] == {"type": "string"} - # Optional[int] -> integer with default=None - assert props["limit"] == {"type": "integer", "default": None} - # List[str] -> array of strings - assert props["tags"] == { - "type": "array", - "items": {"type": "string"}, - "default": None, - } - assert spec.parameters["required"] == ["q"] - - -def test_tool_descriptor_round_trip(): - @tool - def identity(text: str) -> str: - """Echo.""" - return text - - desc = identity.__tool_spec__.to_descriptor() - assert ( - "type" not in desc - ), "BMA backend does not accept 'type' field for custom tools" - assert desc["name"] == "identity" - assert desc["input_schema"]["properties"]["text"] == {"type": "string"} - - -# --------------------------------------------------------------------------- -# Tests from test_agentstudio_tools_schema.py -# --------------------------------------------------------------------------- - - -def test_simple_tool_schema_is_valid_draft7(): - @tool - def get_weather(city: str) -> str: - """Look up the weather for a city.""" - return f"sunny in {city}" - - spec = get_weather.__tool_spec__ - _validate(spec.parameters) - assert spec.parameters["type"] == "object" - assert spec.parameters["properties"]["city"]["type"] == "string" - assert spec.parameters["required"] == ["city"] - assert spec.parameters["additionalProperties"] is False - - -def test_default_value_is_emitted_into_schema(): - @tool - def search(q: str, limit: int = 10) -> str: - """Search the index.""" - return f"{q}{limit}" - - schema = search.__tool_spec__.parameters - _validate(schema) - assert schema["properties"]["limit"]["default"] == 10 - assert "limit" not in schema["required"] - - -def test_optional_strips_none_keeps_inner_type(): - @tool - def lookup(name: str, hint: Optional[int] = None) -> str: - """Look something up.""" - return f"{name}{hint}" - - schema = lookup.__tool_spec__.parameters - _validate(schema) - assert schema["properties"]["hint"]["type"] == "integer" - assert ( - schema["properties"]["hint"]["default"] is None - ) # None default is preserved - assert "hint" not in schema["required"] - - -def test_list_inner_type_is_validated(): - @tool - def tag(names: List[str]) -> str: - """Tag inputs.""" - return str(names) - - schema = tag.__tool_spec__.parameters - _validate(schema) - assert schema["properties"]["names"] == { - "type": "array", - "items": {"type": "string"}, - } - - -def test_args_block_populates_per_param_descriptions(): - @tool - def make_order(item: str, qty: int = 1) -> str: - """Place a supply order. - - Args: - item: SKU identifier of the product to order. - qty: How many units to request. Defaults to 1. - """ - return f"{item}{qty}" - - schema = make_order.__tool_spec__.parameters - _validate(schema) - assert schema["properties"]["item"]["description"] == ( - "SKU identifier of the product to order." - ) - assert ( - "How many units to request" - in schema["properties"]["qty"]["description"] - ) - # Top-level summary trims the Args section - assert make_order.__tool_spec__.description == "Place a supply order." - - -def test_sphinx_style_param_descriptions(): - @tool - def fetch(url: str, timeout: float = 5.0) -> str: - """Fetch a URL. - - :param url: The fully qualified URL to download. - :param timeout: Per-request timeout in seconds. - """ - return f"{url}{timeout}" - - schema = fetch.__tool_spec__.parameters - _validate(schema) - assert ( - schema["properties"]["url"]["description"] - == "The fully qualified URL to download." - ) - assert ( - schema["properties"]["timeout"]["description"] - == "Per-request timeout in seconds." - ) - assert fetch.__tool_spec__.description == "Fetch a URL." - - -def test_descriptor_round_trip_validates_arguments(): - @tool - def ping(host: str, count: int = 1) -> str: - """Ping a host.""" - return f"{host}{count}" - - schema = ping.__tool_spec__.to_descriptor()["input_schema"] - _validate(schema) - # valid args - jsonschema.validate({"host": "example.com"}, schema) - jsonschema.validate({"host": "example.com", "count": 3}, schema) - # invalid args -- additionalProperties=False - with pytest.raises(jsonschema.ValidationError): - jsonschema.validate({"host": "x", "extra": True}, schema) - # missing required - with pytest.raises(jsonschema.ValidationError): - jsonschema.validate({"count": 3}, schema) - # type mismatch - with pytest.raises(jsonschema.ValidationError): - jsonschema.validate({"host": 123}, schema) From d21b09999fb075478935942b6d2720861146d6bf Mon Sep 17 00:00:00 2001 From: foleydang Date: Wed, 1 Jul 2026 14:55:21 +0800 Subject: [PATCH 5/8] fix: remove dead code, fix __del__ safety, make agents.update version explicit - Remove unused _new_request_id() and import uuid from transport.py - Fix __del__ to wrap is_closed check in try/except for interpreter shutdown safety - Make version a required kwarg in agents.update() instead of auto-retrieving - Move stream error handling from session_events into transport retry loop - Add unit tests for agents.update version contract Co-Authored-By: Claude Opus 4.6 --- dashscope/agentstudio/resources/agents.py | 21 +++-- .../agentstudio/resources/session_events.py | 33 -------- dashscope/agentstudio/transport.py | 77 ++++++++----------- tests/unit/test_agentstudio_protocol.py | 76 +++++++++++++++++- 4 files changed, 121 insertions(+), 86 deletions(-) diff --git a/dashscope/agentstudio/resources/agents.py b/dashscope/agentstudio/resources/agents.py index 6576403..2ded152 100644 --- a/dashscope/agentstudio/resources/agents.py +++ b/dashscope/agentstudio/resources/agents.py @@ -23,7 +23,6 @@ AgentVersionListParams, ) - _PATH_AGENTS = "/agents" @@ -82,6 +81,7 @@ def update( self, agent_id: str, *, + version: int, name: Optional[str] = None, description: Optional[str] = None, model: Optional[str] = None, @@ -91,9 +91,14 @@ def update( skills: Optional[Sequence[Mapping[str, Any]]] = None, metadata: Optional[Mapping[str, Any]] = None, ) -> Agent: - current = self.retrieve(agent_id) + """Update the latest version of an agent. + + ``version`` must equal the server's current version; + the server rejects mismatches with HTTP 409. + Retrieve the agent first to obtain the current version. + """ body = AgentUpdateParams( - version=current.version, + version=version, name=name, description=description, model=model, @@ -229,6 +234,7 @@ async def update( self, agent_id: str, *, + version: int, name: Optional[str] = None, description: Optional[str] = None, model: Optional[str] = None, @@ -238,9 +244,14 @@ async def update( skills: Optional[Sequence[Mapping[str, Any]]] = None, metadata: Optional[Mapping[str, Any]] = None, ) -> Agent: - current = await self.retrieve(agent_id) + """Update the latest version of an agent. + + ``version`` must equal the server's current version; + the server rejects mismatches with HTTP 409. + Retrieve the agent first to obtain the current version. + """ body = AgentUpdateParams( - version=current.version, + version=version, name=name, description=description, model=model, diff --git a/dashscope/agentstudio/resources/session_events.py b/dashscope/agentstudio/resources/session_events.py index 6e04b31..7007de9 100644 --- a/dashscope/agentstudio/resources/session_events.py +++ b/dashscope/agentstudio/resources/session_events.py @@ -14,7 +14,6 @@ Sequence, ) -from dashscope.agentstudio import exceptions from dashscope.agentstudio.pagination import ( AsyncCursorPage, CursorPage, @@ -132,22 +131,6 @@ def stream( stream=True, timeout=timeout or AGENTSTUDIO_DEFAULT_TIMEOUT, ) - if resp.status_code >= 400: - try: - resp.read() - body = resp.json() - except ValueError: - body = {"raw": resp.text} - try: - raise exceptions.from_response( - status_code=resp.status_code, - body=body, - headers=resp.headers, - ) - finally: - resp.close() - # Transport is shared -- stream close only closes - # the response, NOT the transport. return _TypedEventStream( EventStream(response=resp), ) @@ -294,22 +277,6 @@ async def stream( stream=True, timeout=timeout or AGENTSTUDIO_DEFAULT_TIMEOUT, ) - if resp.status_code >= 400: - try: - await resp.aread() - body = resp.json() - except ValueError: - body = {"raw": resp.text} - try: - raise exceptions.from_response( - status_code=resp.status_code, - body=body, - headers=resp.headers, - ) - finally: - await resp.aclose() - # Transport is shared -- stream close only closes - # the response, NOT the transport. return _AioTypedEventStream( AsyncEventStream(response=resp), ) diff --git a/dashscope/agentstudio/transport.py b/dashscope/agentstudio/transport.py index 4ca6093..afc9bde 100644 --- a/dashscope/agentstudio/transport.py +++ b/dashscope/agentstudio/transport.py @@ -6,7 +6,7 @@ (async) so the rest of the SDK can speak in plain Python data structures. The transport layer is responsible for: -* injecting auth / workspace / uid / x-request-id headers, +* injecting auth / workspace / uid headers, * JSON encoding / decoding the request and response bodies, * converting non-2xx responses into :class:`AgentStudioError`, * retry-on-network-error with bounded exponential backoff. @@ -22,7 +22,6 @@ import random import socket import time -import uuid from dataclasses import dataclass from typing import Any, Dict, IO, Mapping, Optional, Tuple, Union @@ -132,16 +131,6 @@ def is_error_payload(payload: Any) -> bool: return False -def _new_request_id() -> str: - """Generate ``req_<26-char ULID-ish>``. - - We use a UUID4 substring rather than an actual ULID to avoid an extra - runtime dependency – the backend treats this header opaquely. - """ - - return "req_" + uuid.uuid4().hex[:26] - - def _resolve_timeout( timeout: Union[float, httpx.Timeout, Tuple[float, float], None], ) -> httpx.Timeout: @@ -381,7 +370,12 @@ def request( # pylint: disable=too-many-branches if attempt >= self.max_retries or not should_retry: raise exceptions.APIConnectionError(str(exc)) from exc else: - if not stream and _should_retry( + if stream: + if resp.status_code >= 400: + resp.read() + else: + return resp + if _should_retry( resp.status_code, resp.headers, ): @@ -389,23 +383,18 @@ def request( # pylint: disable=too-many-branches f"HTTP {resp.status_code}", status_code=resp.status_code, ) - if attempt >= self.max_retries: - try: - return self._parse(resp) - finally: - resp.close() - wait = _get_retry_after(resp.headers) or _backoff(attempt) - resp.close() - time.sleep(wait) - attempt += 1 - continue - if stream: - return resp + if attempt < self.max_retries: + wait = _get_retry_after(resp.headers) or _backoff( + attempt, + ) + resp.close() + time.sleep(wait) + attempt += 1 + continue try: return self._parse(resp) finally: - if not stream: - resp.close() + resp.close() time.sleep(_backoff(attempt)) attempt += 1 raise exceptions.APIConnectionError( @@ -470,9 +459,9 @@ def __exit__( self.close() def __del__(self) -> None: - if self.is_closed: - return try: + if self.is_closed: + return self.close() except Exception: pass @@ -605,7 +594,12 @@ async def request( # pylint: disable=too-many-branches if attempt >= self.max_retries or not should_retry: raise exceptions.APIConnectionError(str(exc)) from exc else: - if not stream and _should_retry( + if stream: + if resp.status_code >= 400: + await resp.aread() + else: + return resp + if _should_retry( resp.status_code, resp.headers, ): @@ -613,23 +607,18 @@ async def request( # pylint: disable=too-many-branches f"HTTP {resp.status_code}", status_code=resp.status_code, ) - if attempt >= self.max_retries: - try: - return await self._parse(resp) - finally: - await resp.aclose() - wait = _get_retry_after(resp.headers) or _backoff(attempt) - await resp.aclose() - await asyncio.sleep(wait) - attempt += 1 - continue - if stream: - return resp + if attempt < self.max_retries: + wait = _get_retry_after(resp.headers) or _backoff( + attempt, + ) + await resp.aclose() + await asyncio.sleep(wait) + attempt += 1 + continue try: return await self._parse(resp) finally: - if not stream: - await resp.aclose() + await resp.aclose() await asyncio.sleep(_backoff(attempt)) attempt += 1 raise exceptions.APIConnectionError( diff --git a/tests/unit/test_agentstudio_protocol.py b/tests/unit/test_agentstudio_protocol.py index 3c4e5db..a028afc 100644 --- a/tests/unit/test_agentstudio_protocol.py +++ b/tests/unit/test_agentstudio_protocol.py @@ -21,14 +21,13 @@ from dashscope.agentstudio import exceptions from dashscope.agentstudio.transport import is_error_payload, unwrap from dashscope.agentstudio.types import ( - user_message, - user_interrupt, - user_tool_confirmation, user_custom_tool_result, user_define_outcome, + user_interrupt, + user_message, + user_tool_confirmation, ) - # --------------------------------------------------------------------------- # 1. error.{code, message} # --------------------------------------------------------------------------- @@ -276,3 +275,72 @@ def test_from_response_spring_default(): } err = exceptions.from_response(status_code=404, body=body) assert isinstance(err, exceptions.NotFoundError) + + +# --------------------------------------------------------------------------- +# 4. agents.update version contract (no auto-retrieve) +# --------------------------------------------------------------------------- + + +class _RecordingTransport: + """Minimal transport that records requests and returns a canned agent.""" + + def __init__(self): + self.calls = [] + + def request(self, method, path, **kwargs): + self.calls.append({"method": method, "path": path, **kwargs}) + from dashscope.agentstudio.transport import APIResponse + + return APIResponse( + data={"id": "agent_1", "version": 3, "name": "demo"}, + request_id="req_1", + ) + + +def _client_with_recording_transport(): + from dashscope.agentstudio import Client + + c = Client(api_key="test-key", base_url="http://test") + c.transport = _RecordingTransport() + return c + + +def test_agents_update_requires_version_kwarg(): + """version is required — omitting it is a TypeError.""" + client = _client_with_recording_transport() + with pytest.raises(TypeError): + # pylint: disable=missing-kwoa + client.agents.update( # type: ignore[call-arg] + "agent_1", + name="new-name", + ) + + +def test_agents_update_with_version_sends_body(): + """update() sends POST /agents/{id} with version.""" + client = _client_with_recording_transport() + client.agents.update( + "agent_1", + version=3, + name="new-name", + ) + assert len(client.transport.calls) == 1 + call = client.transport.calls[0] + assert call["method"] == "POST" + assert call["path"] == "/agents/agent_1" + body = call["json"] + assert body["version"] == 3 + assert body["name"] == "new-name" + + +def test_agents_update_does_not_auto_retrieve(): + """SDK must NOT call retrieve() internally.""" + client = _client_with_recording_transport() + client.agents.update( + "agent_1", + version=3, + name="new-name", + ) + assert len(client.transport.calls) == 1 + assert client.transport.calls[0]["method"] == "POST" From 458d053f4ba27f495fdedc08bcc59ff67f1d8e16 Mon Sep 17 00:00:00 2001 From: foleydang Date: Wed, 1 Jul 2026 15:16:15 +0800 Subject: [PATCH 6/8] fix: address code review findings in files, transport, and models - Fix files.py fetch_next to use after_id instead of page for IdCursorPage - Remove dead sync close() body in AsyncTransport (AsyncClient has no sync close) - Handle string agent values in Session.agent_id property Co-Authored-By: Claude Opus 4.6 --- dashscope/agentstudio/resources/files.py | 4 ++-- dashscope/agentstudio/transport.py | 7 +------ dashscope/agentstudio/types/models.py | 4 +++- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/dashscope/agentstudio/resources/files.py b/dashscope/agentstudio/resources/files.py index 22c6c1d..b9068dd 100644 --- a/dashscope/agentstudio/resources/files.py +++ b/dashscope/agentstudio/resources/files.py @@ -190,7 +190,7 @@ def list( def fetch_next(token: str) -> IdCursorPage[File]: return self.list( limit=limit, - page=token, + after_id=token, scope_id=scope_id, ) @@ -291,7 +291,7 @@ async def list( async def fetch_next(token: str) -> AsyncIdCursorPage[File]: return await self.list( limit=limit, - page=token, + after_id=token, scope_id=scope_id, ) diff --git a/dashscope/agentstudio/transport.py b/dashscope/agentstudio/transport.py index afc9bde..fe85edd 100644 --- a/dashscope/agentstudio/transport.py +++ b/dashscope/agentstudio/transport.py @@ -664,12 +664,7 @@ async def _parse(self, resp: httpx.Response) -> APIResponse: ) def close(self) -> None: - """Best-effort synchronous close (for __del__ / GC safety).""" - if self._owns_client: - try: - self._client.close() - except Exception: - pass + """No-op: AsyncClient requires async cleanup via aclose().""" async def aclose(self) -> None: if self._owns_client: diff --git a/dashscope/agentstudio/types/models.py b/dashscope/agentstudio/types/models.py index a9ee906..3dd3dc9 100644 --- a/dashscope/agentstudio/types/models.py +++ b/dashscope/agentstudio/types/models.py @@ -475,7 +475,9 @@ def __init__(self, **kwargs: Any) -> None: @property def agent_id(self) -> Optional[str]: a = self.agent - return a.id if isinstance(a, Agent) else None + if isinstance(a, Agent): + return a.id + return a if isinstance(a, str) else None @property def agent_version(self) -> Optional[int]: From 15b3a2ac8fb0baef412e7963c3026cf3931cbcba Mon Sep 17 00:00:00 2001 From: foleydang Date: Wed, 1 Jul 2026 19:23:39 +0800 Subject: [PATCH 7/8] fix: align SDK params with server API and add SSE wall-clock timeout - Remove after_id/before_id from FileListParams and IdCursorPage class (server Files endpoint only accepts page cursor) - Remove keyword from VaultListParams (server does not accept it) - Add resources field to SessionCreateParams for file mounting at session creation - Add wall-clock idle_timeout (300s) to EventStream/AsyncEventStream so the iterator unblocks when the server only sends keepalive frames (httpx read timeout gets reset by keepalive, blocking forever) Co-Authored-By: Claude Opus 4.6 --- dashscope/agentstudio/__init__.py | 4 -- dashscope/agentstudio/pagination.py | 17 +---- dashscope/agentstudio/resources/files.py | 28 +++----- dashscope/agentstudio/resources/sessions.py | 4 ++ dashscope/agentstudio/streaming.py | 80 ++++++++++++++++++++- dashscope/agentstudio/types/params.py | 12 +++- 6 files changed, 103 insertions(+), 42 deletions(-) diff --git a/dashscope/agentstudio/__init__.py b/dashscope/agentstudio/__init__.py index 17bda3f..47727d5 100644 --- a/dashscope/agentstudio/__init__.py +++ b/dashscope/agentstudio/__init__.py @@ -48,9 +48,7 @@ ) from .pagination import ( AsyncCursorPage, - AsyncIdCursorPage, CursorPage, - IdCursorPage, ) from .types import ( Message, @@ -97,8 +95,6 @@ # pagination "CursorPage", "AsyncCursorPage", - "IdCursorPage", - "AsyncIdCursorPage", # unified message type "Message", "ServerEvent", diff --git a/dashscope/agentstudio/pagination.py b/dashscope/agentstudio/pagination.py index 9dbe481..8277128 100644 --- a/dashscope/agentstudio/pagination.py +++ b/dashscope/agentstudio/pagination.py @@ -4,9 +4,7 @@ AgentStudio uses opaque cursor pagination: callers pass ``limit`` and ``page`` (a cursor token returned by the previous page in -``next_page``). The Files endpoint additionally supports ``after_id`` / -``before_id`` for ID-anchored navigation; that variant is exposed via -:class:`IdCursorPage`. +``next_page``). """ from __future__ import annotations @@ -118,19 +116,6 @@ def __len__(self) -> int: return len(self.data) -class IdCursorPage(CursorPage[T]): - """Files API uses ``after_id`` / ``before_id`` instead of ``page``. - - ``next_page`` is filled with the last item id (or ``None`` when the - list ends), and the SDK injects it as ``after_id`` on the follow-up - request. - """ - - -class AsyncIdCursorPage(AsyncCursorPage[T]): - """Async variant of :class:`IdCursorPage`.""" - - def build_page( *, payload: Dict[str, Any], diff --git a/dashscope/agentstudio/resources/files.py b/dashscope/agentstudio/resources/files.py index b9068dd..348c3b5 100644 --- a/dashscope/agentstudio/resources/files.py +++ b/dashscope/agentstudio/resources/files.py @@ -19,8 +19,8 @@ ) from dashscope.agentstudio.pagination import ( - AsyncIdCursorPage, - IdCursorPage, + AsyncCursorPage, + CursorPage, build_page, ) from dashscope.agentstudio.resources._helpers import ( @@ -171,15 +171,11 @@ def list( limit: Optional[int] = None, page: Optional[str] = None, scope_id: Optional[str] = None, - after_id: Optional[str] = None, - before_id: Optional[str] = None, - ) -> IdCursorPage[File]: + ) -> CursorPage[File]: params = FileListParams( limit=limit, page=page, scope_id=scope_id, - after_id=after_id, - before_id=before_id, ).to_dict() resp = self._client.transport.request( "GET", @@ -187,10 +183,10 @@ def list( params=params, ) - def fetch_next(token: str) -> IdCursorPage[File]: + def fetch_next(token: str) -> CursorPage[File]: return self.list( limit=limit, - after_id=token, + page=token, scope_id=scope_id, ) @@ -198,7 +194,7 @@ def fetch_next(token: str) -> IdCursorPage[File]: payload=resp.data, item_factory=_coerce_file, request_id=resp.request_id, - page_cls=IdCursorPage, + page_cls=CursorPage, fetch_next=fetch_next, ) @@ -272,15 +268,11 @@ async def list( limit: Optional[int] = None, page: Optional[str] = None, scope_id: Optional[str] = None, - after_id: Optional[str] = None, - before_id: Optional[str] = None, - ) -> AsyncIdCursorPage[File]: + ) -> AsyncCursorPage[File]: params = FileListParams( limit=limit, page=page, scope_id=scope_id, - after_id=after_id, - before_id=before_id, ).to_dict() resp = await self._client.transport.request( "GET", @@ -288,10 +280,10 @@ async def list( params=params, ) - async def fetch_next(token: str) -> AsyncIdCursorPage[File]: + async def fetch_next(token: str) -> AsyncCursorPage[File]: return await self.list( limit=limit, - after_id=token, + page=token, scope_id=scope_id, ) @@ -299,7 +291,7 @@ async def fetch_next(token: str) -> AsyncIdCursorPage[File]: payload=resp.data, item_factory=_coerce_file, request_id=resp.request_id, - page_cls=AsyncIdCursorPage, + page_cls=AsyncCursorPage, fetch_next=fetch_next, ) diff --git a/dashscope/agentstudio/resources/sessions.py b/dashscope/agentstudio/resources/sessions.py index a0c9914..3b299c6 100644 --- a/dashscope/agentstudio/resources/sessions.py +++ b/dashscope/agentstudio/resources/sessions.py @@ -42,12 +42,14 @@ def create( agent: str, environment_id: Optional[str] = None, title: Optional[str] = None, + resources: Optional[Sequence[Mapping[str, Any]]] = None, metadata: Optional[Mapping[str, Any]] = None, ) -> Session: body = SessionCreateParams( agent=agent, environment_id=environment_id, title=title, + resources=resources, metadata=metadata, ).to_dict() resp = self._client.transport.request( @@ -163,12 +165,14 @@ async def create( agent: str, environment_id: Optional[str] = None, title: Optional[str] = None, + resources: Optional[Sequence[Mapping[str, Any]]] = None, metadata: Optional[Mapping[str, Any]] = None, ) -> Session: body = SessionCreateParams( agent=agent, environment_id=environment_id, title=title, + resources=resources, metadata=metadata, ).to_dict() resp = await self._client.transport.request( diff --git a/dashscope/agentstudio/streaming.py b/dashscope/agentstudio/streaming.py index 1283a67..d99a745 100644 --- a/dashscope/agentstudio/streaming.py +++ b/dashscope/agentstudio/streaming.py @@ -17,13 +17,22 @@ This module wraps ``httpx_sse.EventSource`` in iterator classes that yield parsed JSON dicts for consumption by the typed event stream layer. + +A wall-clock ``idle_timeout`` (default 300 s) guards against the server +silently dropping business events while still sending ``: keepalive`` +frames — ``httpx``'s read timeout gets reset by every keepalive, so +without an application-level watchdog the iterator would block forever. +When no business event arrives for ``idle_timeout`` seconds, the +underlying response is forcibly closed so ``iter_sse()`` unblocks. """ from __future__ import annotations +import asyncio import json +import threading from dataclasses import dataclass, field -from typing import Any, AsyncIterator, Dict, Iterator +from typing import Any, AsyncIterator, Dict, Iterator, Optional import httpx from httpx_sse import EventSource @@ -31,6 +40,8 @@ from dashscope.agentstudio import exceptions from dashscope.common.logging import logger +_DEFAULT_IDLE_TIMEOUT = 300.0 + # --------------------------------------------------------------------------- # Iterators @@ -47,11 +58,14 @@ class EventStream: """ response: httpx.Response + idle_timeout: Optional[float] = _DEFAULT_IDLE_TIMEOUT _event_source: EventSource = field(init=False) _closed: bool = False + _timer: Optional[threading.Timer] = field(init=False, default=None) def __post_init__(self) -> None: self._event_source = EventSource(self.response) + self._arm_timer() def __enter__(self) -> "EventStream": return self @@ -67,6 +81,7 @@ def _iter(self) -> Iterator[Dict[str, Any]]: raise exceptions.StreamClosedError("stream already closed") try: for sse in self._event_source.iter_sse(): + self._arm_timer() if sse.data: try: payload = json.loads(sse.data) @@ -80,10 +95,38 @@ def _iter(self) -> Iterator[Dict[str, Any]]: finally: self.close() + def _arm_timer(self) -> None: + """Schedule a watchdog that closes the response on idle.""" + self._disarm_timer() + if not self.idle_timeout or self._closed: + return + self._timer = threading.Timer( + self.idle_timeout, + self._on_idle_timeout, + ) + self._timer.daemon = True + self._timer.start() + + def _disarm_timer(self) -> None: + if self._timer is not None: + self._timer.cancel() + self._timer = None + + def _on_idle_timeout(self) -> None: + logger.warning( + "SSE stream idle for %ss, closing response", + self.idle_timeout, + ) + try: + self.response.close() + except Exception: # pragma: no cover + pass + def close(self) -> None: if self._closed: return self._closed = True + self._disarm_timer() try: self.response.close() except Exception: # pragma: no cover @@ -95,8 +138,10 @@ class AsyncEventStream: """Async iterator over an SSE response backed by ``httpx-sse``.""" response: httpx.Response + idle_timeout: Optional[float] = _DEFAULT_IDLE_TIMEOUT _event_source: EventSource = field(init=False) _closed: bool = False + _watcher: Optional[asyncio.Task] = field(init=False, default=None) def __post_init__(self) -> None: self._event_source = EventSource(self.response) @@ -113,8 +158,10 @@ def __aiter__(self) -> AsyncIterator[Dict[str, Any]]: async def _aiter(self) -> AsyncIterator[Dict[str, Any]]: if self._closed: raise exceptions.StreamClosedError("stream already closed") + self._arm_watcher() try: async for sse in self._event_source.aiter_sse(): + self._arm_watcher() if sse.data: try: payload = json.loads(sse.data) @@ -128,10 +175,41 @@ async def _aiter(self) -> AsyncIterator[Dict[str, Any]]: finally: await self.aclose() + def _arm_watcher(self) -> None: + """(Re)schedule an asyncio watchdog that closes on idle.""" + self._disarm_watcher() + if not self.idle_timeout or self._closed: + return + try: + self._watcher = asyncio.create_task(self._watch_idle()) + except RuntimeError: + # No running loop — caller is not in async context. + self._watcher = None + + def _disarm_watcher(self) -> None: + if self._watcher is not None: + self._watcher.cancel() + self._watcher = None + + async def _watch_idle(self) -> None: + assert self.idle_timeout is not None + await asyncio.sleep(self.idle_timeout) + if self._closed: + return + logger.warning( + "SSE stream idle for %ss, closing response", + self.idle_timeout, + ) + try: + await self.response.aclose() + except Exception: # pragma: no cover + pass + async def aclose(self) -> None: if self._closed: return self._closed = True + self._disarm_watcher() try: await self.response.aclose() except Exception: # pragma: no cover diff --git a/dashscope/agentstudio/types/params.py b/dashscope/agentstudio/types/params.py index 9a58a8e..5767921 100644 --- a/dashscope/agentstudio/types/params.py +++ b/dashscope/agentstudio/types/params.py @@ -240,9 +240,11 @@ class SessionCreateParams(BaseModel): """Request body for ``POST /sessions``. ``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. """ - _fields = ("agent", "environment_id", "title", "metadata") + _fields = ("agent", "environment_id", "title", "resources", "metadata") def __init__( # pylint: disable=useless-parent-delegation self, @@ -250,12 +252,16 @@ def __init__( # pylint: disable=useless-parent-delegation agent: str, environment_id: Optional[str] = None, title: Optional[str] = None, + resources: Optional[Sequence[Mapping[str, Any]]] = None, metadata: Optional[Mapping[str, Any]] = None, ) -> None: super().__init__( agent=agent, environment_id=environment_id, title=title, + resources=( + [dict(r) for r in resources] if resources is not None else None + ), metadata=(dict(metadata) if metadata is not None else None), ) @@ -413,7 +419,7 @@ class FileListParams(BaseModel): represented as a param class. """ - _fields = ("limit", "page", "scope_id", "after_id", "before_id") + _fields = ("limit", "page", "scope_id") # =========================================================================== @@ -475,7 +481,7 @@ class VaultUpdateParams(BaseModel): class VaultListParams(BaseModel): """Query params for ``GET /vaults``.""" - _fields = ("keyword", "include_archived", "limit", "page") + _fields = ("include_archived", "limit", "page") def __init__( self, From c14f7f53620662223005fcf670151a7a92533b21 Mon Sep 17 00:00:00 2001 From: foleydang Date: Thu, 2 Jul 2026 00:11:20 +0800 Subject: [PATCH 8/8] fix: SSE idle_timeout uses queue+thread to actually unblock iter_sse() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous Timer-based approach closed the response on idle, but httpx_sse.EventSource.iter_sse() does not unblock when response.close() is called from another thread — the foreground loop stayed stuck. Drain iter_sse() on a background thread and pull events through a queue with timeout in the foreground. On idle, close the response and return; the background thread is a daemon and will be reaped. Also lower the default from 300s to 120s. Co-Authored-By: Claude Opus 4.6 --- dashscope/agentstudio/streaming.py | 190 +++++++++++++++-------------- 1 file changed, 100 insertions(+), 90 deletions(-) diff --git a/dashscope/agentstudio/streaming.py b/dashscope/agentstudio/streaming.py index d99a745..bdbdc68 100644 --- a/dashscope/agentstudio/streaming.py +++ b/dashscope/agentstudio/streaming.py @@ -18,7 +18,7 @@ This module wraps ``httpx_sse.EventSource`` in iterator classes that yield parsed JSON dicts for consumption by the typed event stream layer. -A wall-clock ``idle_timeout`` (default 300 s) guards against the server +A wall-clock ``idle_timeout`` (default 120 s) guards against the server silently dropping business events while still sending ``: keepalive`` frames — ``httpx``'s read timeout gets reset by every keepalive, so without an application-level watchdog the iterator would block forever. @@ -30,6 +30,7 @@ import asyncio import json +import queue import threading from dataclasses import dataclass, field from typing import Any, AsyncIterator, Dict, Iterator, Optional @@ -40,7 +41,7 @@ from dashscope.agentstudio import exceptions from dashscope.common.logging import logger -_DEFAULT_IDLE_TIMEOUT = 300.0 +_DEFAULT_IDLE_TIMEOUT = 120.0 # --------------------------------------------------------------------------- @@ -55,17 +56,23 @@ class EventStream: Emits dict payloads (the parsed ``data`` JSON of each frame). Comment / keepalive frames are silently skipped. The underlying response is closed when iteration completes or :meth:`close` is invoked. + + A wall-clock ``idle_timeout`` guards against the server silently + dropping business events while still sending ``: keepalive`` frames. + ``httpx``'s read timeout gets reset by every keepalive, and + ``response.close()`` does not unblock a blocking ``iter_sse()`` + call, so the iterator is drained on a background thread while the + foreground loop pulls events through a queue with timeout. """ response: httpx.Response idle_timeout: Optional[float] = _DEFAULT_IDLE_TIMEOUT _event_source: EventSource = field(init=False) _closed: bool = False - _timer: Optional[threading.Timer] = field(init=False, default=None) + _producer: Optional[threading.Thread] = field(init=False, default=None) def __post_init__(self) -> None: self._event_source = EventSource(self.response) - self._arm_timer() def __enter__(self) -> "EventStream": return self @@ -79,54 +86,51 @@ def __iter__(self) -> Iterator[Dict[str, Any]]: def _iter(self) -> Iterator[Dict[str, Any]]: if self._closed: raise exceptions.StreamClosedError("stream already closed") + q: "queue.Queue[Any]" = queue.Queue() + + def _produce() -> None: + try: + for sse in self._event_source.iter_sse(): + if sse.data: + try: + payload = json.loads(sse.data) + except json.JSONDecodeError: + logger.warning("SSE frame contains invalid JSON") + q.put({"_raw": sse.data}) + continue + if payload: + q.put(payload) + except Exception as exc: # noqa: W0718 + q.put(exc) + finally: + q.put(None) + + self._producer = threading.Thread(target=_produce, daemon=True) + self._producer.start() + try: - for sse in self._event_source.iter_sse(): - self._arm_timer() - if sse.data: - try: - payload = json.loads(sse.data) - except json.JSONDecodeError: - logger.warning("SSE frame contains invalid JSON") - yield {"_raw": sse.data} - continue - if not payload: - continue - yield payload + while True: + try: + item = q.get(timeout=self.idle_timeout) + except queue.Empty: + logger.warning( + "SSE stream idle for %ss, closing response", + self.idle_timeout, + ) + self.close() + return + if item is None: + return + if isinstance(item, Exception): + raise item + yield item finally: self.close() - def _arm_timer(self) -> None: - """Schedule a watchdog that closes the response on idle.""" - self._disarm_timer() - if not self.idle_timeout or self._closed: - return - self._timer = threading.Timer( - self.idle_timeout, - self._on_idle_timeout, - ) - self._timer.daemon = True - self._timer.start() - - def _disarm_timer(self) -> None: - if self._timer is not None: - self._timer.cancel() - self._timer = None - - def _on_idle_timeout(self) -> None: - logger.warning( - "SSE stream idle for %ss, closing response", - self.idle_timeout, - ) - try: - self.response.close() - except Exception: # pragma: no cover - pass - def close(self) -> None: if self._closed: return self._closed = True - self._disarm_timer() try: self.response.close() except Exception: # pragma: no cover @@ -135,13 +139,18 @@ def close(self) -> None: @dataclass class AsyncEventStream: - """Async iterator over an SSE response backed by ``httpx-sse``.""" + """Async iterator over an SSE response backed by ``httpx-sse``. + + See :class:`EventStream` for the ``idle_timeout`` rationale — the + async variant drains ``aiter_sse()`` on a background task and pulls + events through an :class:`asyncio.Queue` with ``wait_for`` timeout. + """ response: httpx.Response idle_timeout: Optional[float] = _DEFAULT_IDLE_TIMEOUT _event_source: EventSource = field(init=False) _closed: bool = False - _watcher: Optional[asyncio.Task] = field(init=False, default=None) + _producer: Optional[asyncio.Task] = field(init=False, default=None) def __post_init__(self) -> None: self._event_source = EventSource(self.response) @@ -158,58 +167,59 @@ def __aiter__(self) -> AsyncIterator[Dict[str, Any]]: async def _aiter(self) -> AsyncIterator[Dict[str, Any]]: if self._closed: raise exceptions.StreamClosedError("stream already closed") - self._arm_watcher() + q: "asyncio.Queue[Any]" = asyncio.Queue() + + async def _produce() -> None: + try: + async for sse in self._event_source.aiter_sse(): + if sse.data: + try: + payload = json.loads(sse.data) + except json.JSONDecodeError: + logger.warning("SSE frame contains invalid JSON") + await q.put({"_raw": sse.data}) + continue + if payload: + await q.put(payload) + except Exception as exc: # noqa: W0718 + await q.put(exc) + finally: + await q.put(None) + + self._producer = asyncio.create_task(_produce()) + try: - async for sse in self._event_source.aiter_sse(): - self._arm_watcher() - if sse.data: - try: - payload = json.loads(sse.data) - except json.JSONDecodeError: - logger.warning("SSE frame contains invalid JSON") - yield {"_raw": sse.data} - continue - if not payload: - continue - yield payload + while True: + try: + item = await asyncio.wait_for( + q.get(), + timeout=self.idle_timeout, + ) + except asyncio.TimeoutError: + logger.warning( + "SSE stream idle for %ss, closing response", + self.idle_timeout, + ) + await self.aclose() + return + if item is None: + return + if isinstance(item, Exception): + raise item + yield item finally: await self.aclose() - def _arm_watcher(self) -> None: - """(Re)schedule an asyncio watchdog that closes on idle.""" - self._disarm_watcher() - if not self.idle_timeout or self._closed: - return - try: - self._watcher = asyncio.create_task(self._watch_idle()) - except RuntimeError: - # No running loop — caller is not in async context. - self._watcher = None - - def _disarm_watcher(self) -> None: - if self._watcher is not None: - self._watcher.cancel() - self._watcher = None - - async def _watch_idle(self) -> None: - assert self.idle_timeout is not None - await asyncio.sleep(self.idle_timeout) - if self._closed: - return - logger.warning( - "SSE stream idle for %ss, closing response", - self.idle_timeout, - ) - try: - await self.response.aclose() - except Exception: # pragma: no cover - pass - async def aclose(self) -> None: if self._closed: return self._closed = True - self._disarm_watcher() + if self._producer is not None: + self._producer.cancel() + try: + await self._producer + except (asyncio.CancelledError, Exception): + pass try: await self.response.aclose() except Exception: # pragma: no cover