diff --git a/dashscope/agentstudio/__init__.py b/dashscope/agentstudio/__init__.py index 2a8a3b2..9093249 100644 --- a/dashscope/agentstudio/__init__.py +++ b/dashscope/agentstudio/__init__.py @@ -52,11 +52,17 @@ ) from .types import ( Message, + PermissionPolicy, + SecurityAlert, + SecurityAlertList, + SecurityOverview, ServerEvent, + SessionResource, + StopReason, user_define_outcome, user_interrupt, user_message, - user_tool_confirmation, + user_tool_approval_response, user_custom_tool_result, user_tool_result, ) @@ -75,6 +81,10 @@ WebhookEventListParams, ) from .constants import ( + PermissionPolicyType, + SSEEventType, + SessionStatus, + StopReasonType, WebhookDeliveryStatus, WebhookDisabledReason, WebhookEventType, @@ -108,11 +118,17 @@ "AsyncCursorPage", # unified message type "Message", + "PermissionPolicy", + "SecurityAlert", + "SecurityAlertList", + "SecurityOverview", "ServerEvent", + "SessionResource", + "StopReason", # client event helpers (re-exported for convenience) "user_message", "user_interrupt", - "user_tool_confirmation", + "user_tool_approval_response", "user_custom_tool_result", "user_tool_result", "user_define_outcome", @@ -135,4 +151,8 @@ "WebhookDisabledReason", "WebhookDeliveryStatus", "WebhookEventType", + "SSEEventType", + "SessionStatus", + "StopReasonType", + "PermissionPolicyType", ] diff --git a/dashscope/agentstudio/client.py b/dashscope/agentstudio/client.py index 9b01394..206c896 100644 --- a/dashscope/agentstudio/client.py +++ b/dashscope/agentstudio/client.py @@ -21,6 +21,7 @@ AsyncEnvironments, ) from dashscope.agentstudio.resources.files import Files, AsyncFiles +from dashscope.agentstudio.resources.security import Security, AsyncSecurity from dashscope.agentstudio.resources.sessions import Sessions, AsyncSessions from dashscope.agentstudio.resources.skills import Skills, AsyncSkills from dashscope.agentstudio.resources.vaults import Vaults, AsyncVaults @@ -134,6 +135,7 @@ def __init__( self.skills = Skills(self) self.vaults = Vaults(self) self.webhook_endpoints = WebhookEndpoints(self) + self.security = Security(self) def close(self) -> None: self.transport.close() @@ -207,6 +209,7 @@ def __init__( self.skills = AsyncSkills(self) self.vaults = AsyncVaults(self) self.webhook_endpoints = AsyncWebhookEndpoints(self) + self.security = AsyncSecurity(self) async def aclose(self) -> None: await self.transport.aclose() diff --git a/dashscope/agentstudio/constants.py b/dashscope/agentstudio/constants.py index 97c50b1..a97c63d 100644 --- a/dashscope/agentstudio/constants.py +++ b/dashscope/agentstudio/constants.py @@ -34,15 +34,15 @@ class StrEnum(str, enum.Enum): # type: ignore[no-redef] class SSEEventType(StrEnum): """Server-sent event types (the value of ``event.type`` in SSE payloads). - Client-sendable: MESSAGE, INTERRUPT, TOOL_CONFIRMATION, + Client-sendable: MESSAGE, INTERRUPT, TOOL_APPROVAL_RESPONSE, FUNCTION_CALL_OUTPUT, TOOL_CALL_OUTPUT, DEFINE_OUTCOME. - Server-emitted: all types. + Server-emitted: all types (23 total). """ # Client-sendable MESSAGE = "message" INTERRUPT = "interrupt" - TOOL_CONFIRMATION = "tool_confirmation" + TOOL_APPROVAL_RESPONSE = "tool_approval_response" FUNCTION_CALL_OUTPUT = "function_call_output" TOOL_CALL_OUTPUT = "tool_call_output" DEFINE_OUTCOME = "define_outcome" @@ -53,6 +53,7 @@ class SSEEventType(StrEnum): REASONING = "reasoning" MCP_CALL = "mcp_call" MCP_CALL_OUTPUT = "mcp_call_output" + TOOL_APPROVAL_REQUEST = "tool_approval_request" THREAD_MESSAGE_SENT = "thread_message_sent" THREAD_MESSAGE_RECEIVED = "thread_message_received" THREAD_CONTEXT_COMPACTED = "thread_context_compacted" @@ -87,12 +88,44 @@ class BlockType(StrEnum): class SessionStatus(StrEnum): - """Session run-status values (``session_status``).""" + """Session run-status values (``session_status``). + + Top-level ``Session.status`` ∈ {idle, running, terminated, rescheduled}; + the ``session_status`` event ``data.session_status`` value set is the + same plus ``deleted`` (a delete returns a tombstone, not a Session with + ``status=deleted``). ``idle`` also covers waiting for tool approval, + signalled by ``stop_reason.type=requires_action``. + """ IDLE = "idle" RUNNING = "running" - RESCHEDULING = "rescheduling" + RESCHEDULED = "rescheduled" TERMINATED = "terminated" + DELETED = "deleted" + + +class StopReasonType(StrEnum): + """``stop_reason.type`` carried by ``session_status`` idle events. + + Only ``idle`` carries ``stop_reason``; a running Session's + ``stop_reason`` is ``null``. + """ + + END_TURN = "end_turn" + REQUIRES_ACTION = "requires_action" + RETRIES_EXHAUSTED = "retries_exhausted" + + +class PermissionPolicyType(StrEnum): + """Tool approval policy (``permission_policy.type``). + + ``always_allow`` (default) executes the tool without asking; + ``always_ask`` produces a ``tool_approval_request`` per call. + Subagent/subthread ``always_ask`` degrades to ``always_allow``. + """ + + ALWAYS_ALLOW = "always_allow" + ALWAYS_ASK = "always_ask" class WebhookStatus(StrEnum): diff --git a/dashscope/agentstudio/resources/_helpers.py b/dashscope/agentstudio/resources/_helpers.py index 4fd8a7a..3653ce7 100644 --- a/dashscope/agentstudio/resources/_helpers.py +++ b/dashscope/agentstudio/resources/_helpers.py @@ -16,6 +16,8 @@ File, ServerEvent, Session, + SessionResource, + SessionThread, Skill, SkillVersion, Vault, @@ -63,6 +65,14 @@ def _coerce_session(payload: Mapping[str, Any]) -> Session: return Session(**dict(payload)) +def _coerce_session_resource(payload: Mapping[str, Any]) -> SessionResource: + return SessionResource(**dict(payload)) + + +def _coerce_session_thread(payload: Mapping[str, Any]) -> SessionThread: + return SessionThread(**dict(payload)) + + def _coerce_vault(payload: Mapping[str, Any]) -> Vault: return Vault(**dict(payload)) @@ -104,3 +114,27 @@ def _events_path(session_id: str) -> str: def _stream_path(session_id: str) -> str: return f"/sessions/{session_id}/events/stream" + + +def _session_resources_path(session_id: str) -> str: + return f"/sessions/{session_id}/resources" + + +def _session_resource_item_path(session_id: str, resource_id: str) -> str: + return f"/sessions/{session_id}/resources/{resource_id}" + + +def _session_threads_path(session_id: str) -> str: + return f"/sessions/{session_id}/threads" + + +def _session_thread_item_path(session_id: str, thread_id: str) -> str: + return f"/sessions/{session_id}/threads/{thread_id}" + + +def _session_thread_events_path(session_id: str, thread_id: str) -> str: + return f"/sessions/{session_id}/threads/{thread_id}/events" + + +def _session_thread_archive_path(session_id: str, thread_id: str) -> str: + return f"/sessions/{session_id}/threads/{thread_id}/archive" diff --git a/dashscope/agentstudio/resources/security.py b/dashscope/agentstudio/resources/security.py new file mode 100644 index 0000000..a6ebc72 --- /dev/null +++ b/dashscope/agentstudio/resources/security.py @@ -0,0 +1,136 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Security center read interface. + +Exposes the two highest-value read endpoints: ``GET /security/overview`` +(24h dashboard) and ``GET /security/agent_logs`` (alert list). Other +security endpoints (asset summary, policies, alert detail, export, +authorization, activation) are not surfaced here. +""" + +from __future__ import annotations + +from typing import Any, Mapping, Optional, Sequence + +from dashscope.agentstudio.types import SecurityAlertList, SecurityOverview +from dashscope.agentstudio.types.params import SecurityListAgentLogsParams + +_PATH_SECURITY_OVERVIEW = "/security/overview" +_PATH_SECURITY_AGENT_LOGS = "/security/agent_logs" + + +def _overview_from_payload(payload: Mapping[str, Any]) -> SecurityOverview: + return SecurityOverview(**dict(payload)) + + +def _alert_list_from_payload(payload: Mapping[str, Any]) -> SecurityAlertList: + return SecurityAlertList(**dict(payload)) + + +class Security: + """Security center read interface (overview + agent logs).""" + + def __init__(self, client) -> None: + self._client = client + + def overview(self) -> SecurityOverview: + """24h protection dashboard (``GET /security/overview``).""" + resp = self._client.transport.request( + "GET", + _PATH_SECURITY_OVERVIEW, + ) + return _overview_from_payload(resp.data) + + def list_agent_logs( + self, + *, + current_page: Optional[int] = None, + page_size: Optional[int] = None, + risk_level: Optional[str] = None, + status: Optional[str] = None, + risk_name: Optional[str] = None, + app_name: Optional[str] = None, + asset_type: Optional[str] = None, + vendor: Optional[str] = None, + order_by: Optional[str] = None, + order: Optional[str] = None, + lang: Optional[str] = None, + status_list: Optional[Sequence[str]] = None, + ) -> SecurityAlertList: + """Alert list (``GET /security/agent_logs``). + + Page-number pagination: pass ``current_page + 1`` for the next + page, or use the returned ``next_page`` cursor if non-null. + ``check_time`` / ``handle_time`` on rows are millisecond strings. + """ + params = SecurityListAgentLogsParams( + current_page=current_page, + page_size=page_size, + risk_level=risk_level, + status=status, + risk_name=risk_name, + app_name=app_name, + asset_type=asset_type, + vendor=vendor, + order_by=order_by, + order=order, + lang=lang, + status_list=status_list, + ).to_dict() + resp = self._client.transport.request( + "GET", + _PATH_SECURITY_AGENT_LOGS, + params=params, + ) + return _alert_list_from_payload(resp.data) + + +class AsyncSecurity: + """Async security center read interface.""" + + def __init__(self, client) -> None: + self._client = client + + async def overview(self) -> SecurityOverview: + resp = await self._client.transport.request( + "GET", + _PATH_SECURITY_OVERVIEW, + ) + return _overview_from_payload(resp.data) + + async def list_agent_logs( + self, + *, + current_page: Optional[int] = None, + page_size: Optional[int] = None, + risk_level: Optional[str] = None, + status: Optional[str] = None, + risk_name: Optional[str] = None, + app_name: Optional[str] = None, + asset_type: Optional[str] = None, + vendor: Optional[str] = None, + order_by: Optional[str] = None, + order: Optional[str] = None, + lang: Optional[str] = None, + status_list: Optional[Sequence[str]] = None, + ) -> SecurityAlertList: + params = SecurityListAgentLogsParams( + current_page=current_page, + page_size=page_size, + risk_level=risk_level, + status=status, + risk_name=risk_name, + app_name=app_name, + asset_type=asset_type, + vendor=vendor, + order_by=order_by, + order=order, + lang=lang, + status_list=status_list, + ).to_dict() + resp = await self._client.transport.request( + "GET", + _PATH_SECURITY_AGENT_LOGS, + params=params, + ) + return _alert_list_from_payload(resp.data) diff --git a/dashscope/agentstudio/resources/session_events.py b/dashscope/agentstudio/resources/session_events.py index 7007de9..617acaf 100644 --- a/dashscope/agentstudio/resources/session_events.py +++ b/dashscope/agentstudio/resources/session_events.py @@ -37,6 +37,13 @@ SSEEventType, ) +_TERMINAL_STATUSES = ( + SessionStatus.IDLE, + SessionStatus.TERMINATED, + SessionStatus.RESCHEDULED, + SessionStatus.DELETED, +) + class SessionEvents: """Session event send / list / stream.""" @@ -49,14 +56,7 @@ def send( 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")], - ) - """ + """Send events to a session.""" if not events: raise ValueError("events must contain at least 1 entry") body = SessionEventSendParams(input=events).to_dict() @@ -120,14 +120,23 @@ def stream( self, session_id: str, *, + event_deltas: Optional[Sequence[str]] = None, timeout: Optional[float] = None, ) -> "_TypedEventStream": - """Open the SSE stream and return an iterator of typed events.""" + """Open the SSE stream and return an iterator of typed events. + ``event_deltas`` opts into incremental text streaming for the given + event types (``"message"`` and/or ``"reasoning"``; aliases + ``"agent.message"`` / ``"agent.thinking"``). + """ + params: Optional[Dict[str, Any]] = None + if event_deltas: + params = {"event_deltas[]": list(event_deltas)} resp = self._client.transport.request( "GET", _stream_path(session_id), extra_headers={"Accept": "text/event-stream"}, + params=params, stream=True, timeout=timeout or AGENTSTUDIO_DEFAULT_TIMEOUT, ) @@ -142,6 +151,27 @@ class _TypedEventStream: def __init__(self, stream: EventStream) -> None: self._stream = stream + @classmethod + def from_raw_events( + cls, + raw_events: Sequence[Mapping[str, Any]], + ) -> "_TypedEventStream": + """Create from a list of raw event dicts (for testing). + + Events flow through the normal :func:`_coerce_event` pipeline. + """ + obj = object.__new__(cls) + + class _RawStream: + def __iter__(self): + return iter(raw_events) + + def close(self): + pass + + obj._stream = _RawStream() + return obj + def __enter__(self) -> "_TypedEventStream": return self @@ -157,8 +187,7 @@ 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. + ``terminated`` status. """ for event in self: if getattr(event, "type", None) == SSEEventType.MESSAGE: @@ -170,11 +199,26 @@ def text_stream(self): 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, - ): + if d.get("session_status") in _TERMINAL_STATUSES: + return + + @property + def text_deltas(self): + """Iterate over incremental text chunks from ``event_delta`` frames. + + Requires the stream to be opened with ``event_deltas``; otherwise + yields nothing (use :attr:`text_stream` for terminal full text). + """ + for event in self: + etype = getattr(event, "type", None) + if etype == "event_delta": + text = event.delta_text + if text: + yield text + elif etype == SSEEventType.SESSION_STATUS: + block = event.content[0] if event.content else None + d = getattr(block, "data", None) or {} + if d.get("session_status") in _TERMINAL_STATUSES: return def close(self) -> None: @@ -192,14 +236,6 @@ async def send( 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() @@ -266,14 +302,17 @@ async def stream( self, session_id: str, *, + event_deltas: Optional[Sequence[str]] = None, timeout: Optional[float] = None, ) -> "_AioTypedEventStream": - """Open the SSE stream and return an async iterator of typed events.""" - + params: Optional[Dict[str, Any]] = None + if event_deltas: + params = {"event_deltas[]": list(event_deltas)} resp = await self._client.transport.request( "GET", _stream_path(session_id), extra_headers={"Accept": "text/event-stream"}, + params=params, stream=True, timeout=timeout or AGENTSTUDIO_DEFAULT_TIMEOUT, ) @@ -301,16 +340,9 @@ async def _aiter(self) -> AsyncIterator[ServerEvent]: @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 []: @@ -321,11 +353,24 @@ async def _text_stream(self): 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, - ): + if d.get("session_status") in _TERMINAL_STATUSES: + return + + @property + def text_deltas(self): + return self._text_deltas() + + async def _text_deltas(self): + async for event in self: + etype = getattr(event, "type", None) + if etype == "event_delta": + text = event.delta_text + if text: + yield text + elif etype == SSEEventType.SESSION_STATUS: + block = event.content[0] if event.content else None + d = getattr(block, "data", None) or {} + if d.get("session_status") in _TERMINAL_STATUSES: return async def aclose(self) -> None: diff --git a/dashscope/agentstudio/resources/session_resources.py b/dashscope/agentstudio/resources/session_resources.py new file mode 100644 index 0000000..1691a65 --- /dev/null +++ b/dashscope/agentstudio/resources/session_resources.py @@ -0,0 +1,186 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Session Resources runtime CRUD. + +Mount (add), list, get, and delete files mounted into a running session. +Uses ``add`` rather than ``create``; ``update`` is not exposed. +""" + +from __future__ import annotations + +from typing import Optional + +from dashscope.agentstudio.pagination import ( + AsyncCursorPage, + CursorPage, + build_page, +) +from dashscope.agentstudio.resources._helpers import ( + _coerce_session_resource, + _session_resource_item_path, + _session_resources_path, +) +from dashscope.agentstudio.types import DeleteResponse, SessionResource +from dashscope.agentstudio.types.params import ( + SessionResourceAddParams, + SessionResourceListParams, +) + + +class SessionResources: + """Runtime resource mount/unmount on a session.""" + + def __init__(self, client) -> None: + self._client = client + + def add( + self, + session_id: str, + *, + resource_type: str = "file", + file_id: str, + mount_path: Optional[str] = None, + ) -> SessionResource: + """Mount a file into a running session (``POST /sessions/{id}/resources``). + + ``mount_path`` must be an absolute path under ``/uploads/``. The + returned ``file_id`` is the session-scoped copy id and may differ + from the source ``file_id`` passed in. + """ + body = SessionResourceAddParams( + type=resource_type, + file_id=file_id, + mount_path=mount_path, + ).to_dict() + resp = self._client.transport.request( + "POST", + _session_resources_path(session_id), + json=body, + ) + return _coerce_session_resource(resp.data) + + def retrieve( + self, + session_id: str, + resource_id: str, + ) -> SessionResource: + resp = self._client.transport.request( + "GET", + _session_resource_item_path(session_id, resource_id), + ) + return _coerce_session_resource(resp.data) + + # Alias: get() delegates to retrieve(), matching the SDK convention. + get = retrieve # type: ignore[assignment] + + def list( + self, + session_id: str, + *, + limit: Optional[int] = None, + page: Optional[str] = None, + ) -> CursorPage[SessionResource]: + params = SessionResourceListParams(limit=limit, page=page).to_dict() + resp = self._client.transport.request( + "GET", + _session_resources_path(session_id), + params=params, + ) + + def fetch_next(token: str) -> CursorPage[SessionResource]: + return self.list(session_id, limit=limit, page=token) + + return build_page( + payload=resp.data, + item_factory=_coerce_session_resource, + request_id=resp.request_id, + fetch_next=fetch_next, + ) + + def delete( + self, + session_id: str, + resource_id: str, + ) -> DeleteResponse: + resp = self._client.transport.request( + "DELETE", + _session_resource_item_path(session_id, resource_id), + ) + return DeleteResponse(**resp.data) + + +class AsyncSessionResources: + """Async runtime resource mount/unmount on a session.""" + + def __init__(self, client) -> None: + self._client = client + + async def add( + self, + session_id: str, + *, + resource_type: str = "file", + file_id: str, + mount_path: Optional[str] = None, + ) -> SessionResource: + body = SessionResourceAddParams( + type=resource_type, + file_id=file_id, + mount_path=mount_path, + ).to_dict() + resp = await self._client.transport.request( + "POST", + _session_resources_path(session_id), + json=body, + ) + return _coerce_session_resource(resp.data) + + async def retrieve( + self, + session_id: str, + resource_id: str, + ) -> SessionResource: + resp = await self._client.transport.request( + "GET", + _session_resource_item_path(session_id, resource_id), + ) + return _coerce_session_resource(resp.data) + + # Alias: get() delegates to retrieve(). + get = retrieve # type: ignore[assignment] + + async def list( + self, + session_id: str, + *, + limit: Optional[int] = None, + page: Optional[str] = None, + ) -> AsyncCursorPage[SessionResource]: + params = SessionResourceListParams(limit=limit, page=page).to_dict() + resp = await self._client.transport.request( + "GET", + _session_resources_path(session_id), + params=params, + ) + + async def fetch_next(token: str) -> AsyncCursorPage[SessionResource]: + return await self.list(session_id, limit=limit, page=token) + + return build_page( + payload=resp.data, + item_factory=_coerce_session_resource, + request_id=resp.request_id, + page_cls=AsyncCursorPage, + fetch_next=fetch_next, + ) + + async def delete( + self, + session_id: str, + resource_id: str, + ) -> DeleteResponse: + resp = await self._client.transport.request( + "DELETE", + _session_resource_item_path(session_id, resource_id), + ) + return DeleteResponse(**resp.data) diff --git a/dashscope/agentstudio/resources/session_threads.py b/dashscope/agentstudio/resources/session_threads.py new file mode 100644 index 0000000..e2f2031 --- /dev/null +++ b/dashscope/agentstudio/resources/session_threads.py @@ -0,0 +1,250 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Session Threads sub-resource. + +List, get, and archive sub-agent threads within a session, and list the +events scoped to one thread. Thread event streaming is not exposed. +""" + +from __future__ import annotations + +from typing import Optional, Sequence + +from dashscope.agentstudio.pagination import ( + AsyncCursorPage, + CursorPage, + build_page, +) +from dashscope.agentstudio.resources._helpers import ( + _coerce_event, + _coerce_session_thread, + _session_thread_archive_path, + _session_thread_events_path, + _session_thread_item_path, + _session_threads_path, +) +from dashscope.agentstudio.types import ServerEvent, SessionThread +from dashscope.agentstudio.types.params import ( + SessionEventListParams, + SessionThreadListParams, +) + + +class SessionThreadEvents: + """Events scoped to a single sub-agent thread (list only).""" + + def __init__(self, client) -> None: + self._client = client + + def list( + self, + session_id: str, + thread_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", + _session_thread_events_path(session_id, thread_id), + params=params, + ) + + def fetch_next(nxt: str) -> CursorPage[ServerEvent]: + return self.list( + session_id, + thread_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, + ) + + +class AsyncSessionThreadEvents: + """Async events scoped to a single sub-agent thread (list only).""" + + def __init__(self, client) -> None: + self._client = client + + async def list( + self, + session_id: str, + thread_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", + _session_thread_events_path(session_id, thread_id), + params=params, + ) + + async def fetch_next(nxt: str) -> AsyncCursorPage[ServerEvent]: + return await self.list( + session_id, + thread_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, + ) + + +class SessionThreads: + """Sub-agent threads within a session.""" + + def __init__(self, client) -> None: + self._client = client + self.events = SessionThreadEvents(client) + + def list( + self, + session_id: str, + *, + limit: Optional[int] = None, + page: Optional[str] = None, + ) -> CursorPage[SessionThread]: + params = SessionThreadListParams(limit=limit, page=page).to_dict() + resp = self._client.transport.request( + "GET", + _session_threads_path(session_id), + params=params, + ) + + def fetch_next(token: str) -> CursorPage[SessionThread]: + return self.list(session_id, limit=limit, page=token) + + return build_page( + payload=resp.data, + item_factory=_coerce_session_thread, + request_id=resp.request_id, + fetch_next=fetch_next, + ) + + def retrieve(self, session_id: str, thread_id: str) -> SessionThread: + resp = self._client.transport.request( + "GET", + _session_thread_item_path(session_id, thread_id), + ) + return _coerce_session_thread(resp.data) + + # Alias: get() delegates to retrieve(). + get = retrieve # type: ignore[assignment] + + def archive(self, session_id: str, thread_id: str) -> SessionThread: + resp = self._client.transport.request( + "POST", + _session_thread_archive_path(session_id, thread_id), + ) + return _coerce_session_thread(resp.data) + + +class AsyncSessionThreads: + """Async sub-agent threads within a session.""" + + def __init__(self, client) -> None: + self._client = client + self.events = AsyncSessionThreadEvents(client) + + async def list( + self, + session_id: str, + *, + limit: Optional[int] = None, + page: Optional[str] = None, + ) -> AsyncCursorPage[SessionThread]: + params = SessionThreadListParams(limit=limit, page=page).to_dict() + resp = await self._client.transport.request( + "GET", + _session_threads_path(session_id), + params=params, + ) + + async def fetch_next(token: str) -> AsyncCursorPage[SessionThread]: + return await self.list(session_id, limit=limit, page=token) + + return build_page( + payload=resp.data, + item_factory=_coerce_session_thread, + request_id=resp.request_id, + page_cls=AsyncCursorPage, + fetch_next=fetch_next, + ) + + async def retrieve( + self, + session_id: str, + thread_id: str, + ) -> SessionThread: + resp = await self._client.transport.request( + "GET", + _session_thread_item_path(session_id, thread_id), + ) + return _coerce_session_thread(resp.data) + + # Alias: get() delegates to retrieve(). + get = retrieve # type: ignore[assignment] + + async def archive(self, session_id: str, thread_id: str) -> SessionThread: + resp = await self._client.transport.request( + "POST", + _session_thread_archive_path(session_id, thread_id), + ) + return _coerce_session_thread(resp.data) diff --git a/dashscope/agentstudio/resources/sessions.py b/dashscope/agentstudio/resources/sessions.py index 22ba90a..2e56dd7 100644 --- a/dashscope/agentstudio/resources/sessions.py +++ b/dashscope/agentstudio/resources/sessions.py @@ -18,6 +18,14 @@ SessionEvents, AsyncSessionEvents, ) +from dashscope.agentstudio.resources.session_resources import ( + SessionResources, + AsyncSessionResources, +) +from dashscope.agentstudio.resources.session_threads import ( + SessionThreads, + AsyncSessionThreads, +) from dashscope.agentstudio.types import DeleteResponse, Session from dashscope.agentstudio.types.params import ( SessionCreateParams, @@ -35,6 +43,8 @@ class Sessions: def __init__(self, client) -> None: self._client = client self.events = SessionEvents(client) + self.resources = SessionResources(client) + self.threads = SessionThreads(client) def create( self, @@ -160,6 +170,8 @@ class AsyncSessions: def __init__(self, client) -> None: self._client = client self.events = AsyncSessionEvents(client) + self.resources = AsyncSessionResources(client) + self.threads = AsyncSessionThreads(client) async def create( self, diff --git a/dashscope/agentstudio/types/__init__.py b/dashscope/agentstudio/types/__init__.py index 65ec62a..3148a6f 100644 --- a/dashscope/agentstudio/types/__init__.py +++ b/dashscope/agentstudio/types/__init__.py @@ -44,6 +44,11 @@ Skill, SkillVersion, Session, + SessionResource, + SecurityAlert, + SecurityAlertList, + SecurityAlertStats, + SecurityOverview, Vault, SessionThread, DeleteResponse, @@ -52,7 +57,6 @@ parse_server_event, user_message, user_interrupt, - user_tool_confirmation, user_custom_tool_result, user_tool_result, user_define_outcome, diff --git a/dashscope/agentstudio/types/models.py b/dashscope/agentstudio/types/models.py index 29c8aec..c5071c4 100644 --- a/dashscope/agentstudio/types/models.py +++ b/dashscope/agentstudio/types/models.py @@ -129,11 +129,31 @@ class Scope(BaseModel): class Mount(BaseModel): - """Session resource mount descriptor.""" + """Session resource mount descriptor (create-time input on POST /sessions).""" _fields = ("type", "file_id", "skill_id", "mount_path") +class SessionResource(BaseModel): + """A resource mounted into a session at runtime. + + The response ``file_id`` is the session-scoped copy id — **not** the + source file id passed at mount time. ``mount_path`` is the absolute + sandbox path (the server prepends a prefix; the user-supplied path is + preserved losslessly and must be under ``/uploads/``). + """ + + _fields = ( + "id", + "type", + "file_id", + "mount_path", + "created_at", + "updated_at", + "request_id", + ) + + class Networking(BaseModel): _fields = ("type",) # "unrestricted" | "restricted" @@ -154,9 +174,36 @@ def __init__(self, **kwargs: Any) -> None: class StopReason(BaseModel): - """Carried by ``session_status`` idle events.""" + """Carried by ``session_status`` idle events (and ``Session.stop_reason``). - _fields = ("type", "event_ids") + A running Session's ``stop_reason`` is ``null``. ``requires_action`` + carries ``pending_batch_id`` + ``pending_call_ids`` (only the calls not + yet adjudicated); do not derive pending from request/response diffs. + """ + + _fields = ("type", "pending_batch_id", "pending_call_ids") + + +class PermissionPolicy(BaseModel): + """Tool approval policy (``permission_policy`` on tool configs). + + Must be the object form ``{"type": "always_allow" | "always_ask"}``; + the string form is rejected (the server rejects it as well). Defaults + to ``always_allow`` when omitted. + """ + + _fields = ("type",) + + def __init__(self, **kwargs: Any) -> None: + t = kwargs.get("type") + if isinstance(t, str) and t not in ("always_allow", "always_ask"): + raise ValueError( + "permission_policy.type must be 'always_allow' or " + "'always_ask'", + ) + if t is None: + kwargs["type"] = "always_allow" + super().__init__(**kwargs) class Stats(BaseModel): @@ -296,6 +343,7 @@ def __str__(self) -> str: BlockType.ERROR: ErrorBlock, SSEEventType.TOOL_CALL: DataBlock, SSEEventType.TOOL_CALL_OUTPUT: DataBlock, + SSEEventType.TOOL_APPROVAL_REQUEST: DataBlock, SSEEventType.SESSION_STATUS: DataBlock, SSEEventType.REASONING: DataBlock, SSEEventType.MCP_CALL: DataBlock, @@ -338,21 +386,38 @@ class MultiAgentRosterEntry(BaseModel): ``type`` is ``"agent"`` (reference another agent by ``id`` + optional ``version``) or ``"self"`` (a copy of the coordinator; at most one). + ``name`` / ``description`` are populated by the server on retrieval + (enriched from the referenced agent); they are ignored on write. """ - _fields = ("type", "id", "version") + _fields = ("type", "id", "version", "name", "description") + + def __init__(self, **kwargs: Any) -> None: + if not kwargs.get("type"): + kwargs["type"] = "agent" + if kwargs.get("type") == "self": + # A self-reference has no id/version of its own. + kwargs.pop("id", None) + kwargs.pop("version", None) + super().__init__(**kwargs) class MultiAgentConfig(BaseModel): """Multi-agent coordinator config (the ``multiagent`` field). ``type`` is currently always ``"coordinator"``; ``agents`` is the - roster of 1-20 entries. An empty list clears the roster. + roster of entries (the server enforces the 1-20 size limit and the + at-most-one ``"self"`` rule; the SDK normalizes but does not reject + server-returned data, so it never fails to parse a valid agent). + An empty list clears the roster. The agent version is snapshotted when + a session is created; changes only affect new sessions. """ _fields = ("type", "agents") def __init__(self, **kwargs: Any) -> None: + if not kwargs.get("type"): + kwargs["type"] = "coordinator" agents = kwargs.get("agents") if isinstance(agents, list): kwargs["agents"] = [ @@ -363,6 +428,8 @@ def __init__(self, **kwargs: Any) -> None: ) for a in agents ] + elif agents is None: + kwargs["agents"] = [] super().__init__(**kwargs) @@ -526,17 +593,120 @@ def agent_version(self) -> Optional[int]: class SessionThread(BaseModel): + """A sub-agent thread within a session. + + ``agent`` is a ``{id, version}`` reference to the thread's bound agent. + ``status`` is ``idle`` / ``running`` / ``terminated``; ``archived_at`` + is non-null once archived (archived threads are excluded from list by + default). + """ + _fields = ( "id", + "type", "session_id", "parent_thread_id", - "title", + "agent", "status", "created_at", "updated_at", + "archived_at", + "request_id", ) +# =========================================================================== +# Security (overview + agent logs) +# =========================================================================== + + +class SecurityCapability(BaseModel): + """A single capability/protection switch in the overview.""" + + _fields = ("key", "enabled") + + +class SecurityScanStat(BaseModel): + """Scan hit/scanned counters (content_safety / file_scan / skill_scan).""" + + _fields = ("hit", "scanned") + + +class SecurityOverview(BaseModel): + """Response of ``GET /security/overview`` (last-24h dashboard).""" + + _fields = ( + "capabilities", + "protection", + "content_safety", + "file_scan", + "skill_scan", + "request_id", + ) + + def __init__(self, **kwargs: Any) -> None: + for k in ("capabilities", "protection"): + v = kwargs.get(k) + if isinstance(v, list): + kwargs[k] = [ + SecurityCapability(**dict(it)) + if isinstance(it, Mapping) + else it + for it in v + ] + for k in ("content_safety", "file_scan", "skill_scan"): + v = kwargs.get(k) + if isinstance(v, Mapping): + kwargs[k] = SecurityScanStat(**dict(v)) + super().__init__(**kwargs) + + +class SecurityAlertStats(BaseModel): + """Alert counts by severity.""" + + _fields = ("total", "high", "medium", "low") + + +class SecurityAlert(BaseModel): + """A single security alert row. ``check_time`` / ``handle_time`` are + millisecond-string timestamps (not ISO 8601).""" + + _fields = ( + "alert_id", + "risk_level", + "risk_name", + "risk_desc", + "asset_type", + "asset_name", + "app_id", + "app_name", + "agent_name", + "status", + "source", + "check_time", + "handle_time", + "vendor", + ) + + +class SecurityAlertList(BaseModel): + """Response of ``GET /security/agent_logs`` (page-number + cursor).""" + + _fields = ("stats", "data", "next_page", "request_id") + + def __init__(self, **kwargs: Any) -> None: + stats = kwargs.get("stats") + if isinstance(stats, Mapping): + kwargs["stats"] = SecurityAlertStats(**dict(stats)) + data = kwargs.get("data") + if isinstance(data, list): + kwargs["data"] = [ + SecurityAlert(**dict(it)) if isinstance(it, Mapping) else it + for it in data + ] + super().__init__(**kwargs) + + class DeleteResponse(BaseModel): _fields = ("id", "type", "request_id") @@ -822,8 +992,9 @@ def stop_reason(self) -> Optional[Dict[str, Any]]: 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. + Returns ``"idle"``, ``"running"``, ``"rescheduled"``, + ``"terminated"``, ``"deleted"`` or ``None`` for non-session_status + events. """ if getattr(self, "type", None) != SSEEventType.SESSION_STATUS: return None @@ -833,6 +1004,189 @@ def session_status(self) -> Optional[str]: return d["session_status"] return None + @property + def _data(self) -> Optional[Dict[str, Any]]: + """The first content block's ``data`` payload, if any.""" + for block in self.content or []: + d = getattr(block, "data", None) + if isinstance(d, dict): + return d + return None + + @property + def tool_approval_request(self) -> Optional[Dict[str, Any]]: + """``tool_approval_request`` payload: ``batch_id`` / ``call_id`` / + ``name`` / ``arguments`` (JSON string) / ``tool_type`` / + ``server_label`` (MCP only). + + Returns ``None`` for non-``tool_approval_request`` events. The + approval identity is the ``(batch_id, call_id)`` composite key — + ``call_id`` may be reused across turns, so never match on + ``call_id`` alone. + """ + if getattr(self, "type", None) != SSEEventType.TOOL_APPROVAL_REQUEST: + return None + return self._data + + @property + def error(self) -> Optional[Dict[str, Any]]: + """``{"code", "message"}`` from ``type: error`` events, else ``None``. + + Approval failures surface in the event stream as ``type: error`` + events (not as HTTP exceptions). Use :attr:`pending_tool_approvals` + to read the suspend signal alongside this. + """ + if getattr(self, "type", None) != SSEEventType.ERROR: + return None + err = self.extra.get("error") + if err is None: + raw = getattr(self, "_raw", None) or {} + if isinstance(raw, Mapping): + err = raw.get("error") + return err if isinstance(err, dict) else None + + @property + def pending_tool_approvals(self) -> Optional[Dict[str, Any]]: + """Suspend signal ``{"batch_id", "call_ids"}`` carried in the + ``metadata`` of an error / response frame while the approval + barrier is up. ``None`` when the barrier is not up. + + This is the reliable way to tell pending state — do NOT derive it + from request/response event diffs (the server emits it only while + the barrier stands). + """ + md = self.metadata if isinstance(self.metadata, dict) else None + if md is None: + return None + pending = md.get("pending_tool_approvals") + return pending if isinstance(pending, dict) else None + + # -- delta-protocol frames (opt-in via ``event_deltas``) ------------- + # ``event_start`` / ``event_delta`` carry incremental text when the + # stream is opened with ``event_deltas``; a terminal ``object:"message"`` + # event always follows with the full content. They are distinct from the + # business event types carried in ``type``. + + @property + def event_start(self) -> Optional[Dict[str, Any]]: + """``{"id", "type"}`` from an ``event_start`` delta frame (a preview + of an upcoming ``message``/``reasoning`` event; carries no content). + ``None`` for other frames.""" + if getattr(self, "type", None) != "event_start": + return None + ev = self.extra.get("event") + if ev is None: + raw = getattr(self, "_raw", None) or {} + if isinstance(raw, Mapping): + ev = raw.get("event") + return ev if isinstance(ev, dict) else None + + @property + def event_delta(self) -> Optional[Dict[str, Any]]: + """``{"event_id", "delta": {"type", "index", "content"}}`` from an + ``event_delta`` frame. ``None`` for other frames.""" + if getattr(self, "type", None) != "event_delta": + return None + return { + "event_id": self.extra.get("event_id"), + "delta": self.extra.get("delta"), + } + + @property + def delta_text(self) -> Optional[str]: + """Incremental text chunk from an ``event_delta`` frame (the + ``delta.content.text`` of a ``content_delta``), else ``None``. + Use the :attr:`text_deltas` iterator on the stream for the full + sequence.""" + if getattr(self, "type", None) != "event_delta": + return None + delta = self.extra.get("delta") or {} + if not isinstance(delta, dict): + return None + content = delta.get("content") or {} + text = content.get("text") if isinstance(content, dict) else None + return text + + # -- business event data accessors ----------------------------------- + # Convenience accessors over the first content block's ``data`` payload + # (and ``metadata`` where the routing lives). Each returns ``None`` for + # events whose ``type`` does not match. + + @property + def data(self) -> Optional[Dict[str, Any]]: + """The first content block's ``data`` payload, for events that carry + one (``tool_call`` / ``tool_call_output`` / ``mcp_call`` / + ``mcp_call_output`` / ``session_status`` / ``tool_approval_request`` + / ``model_request_end`` / ``outcome_evaluation`` / ``thread_status`` + / ``thread_created`` / ``session_updated``). ``None`` otherwise. + """ + return self._data + + @property + def model_request_end(self) -> Optional[Dict[str, Any]]: + """``model_request_end`` payload: ``model_request_start_id``, + ``is_error``, ``input_tokens`` / ``output_tokens`` / + ``cache_creation_input_tokens`` / ``cache_read_input_tokens``, + ``speed``. ``None`` for other events.""" + if getattr(self, "type", None) != SSEEventType.MODEL_REQUEST_END: + return None + return self._data + + @property + def outcome_evaluation(self) -> Optional[Dict[str, Any]]: + """``outcome_evaluation`` payload: ``outcome_id``, ``iteration``, + ``phase`` (start/ongoing/end), ``result``, ``explanation``, + token usage, ``speed``. ``None`` for other events.""" + if getattr(self, "type", None) != SSEEventType.OUTCOME_EVALUATION: + return None + return self._data + + @property + def thread_status(self) -> Optional[Dict[str, Any]]: + """``thread_status`` payload: ``session_thread_id``, ``agent_name``, + ``thread_status`` (running/idle/terminated/rescheduled), and + ``stop_reason`` when idle. ``None`` for other events.""" + if getattr(self, "type", None) != SSEEventType.THREAD_STATUS: + return None + return self._data + + @property + def thread_created(self) -> Optional[Dict[str, Any]]: + """``thread_created`` payload: ``session_thread_id``, ``agent_name``. + ``None`` for other events.""" + if getattr(self, "type", None) != SSEEventType.THREAD_CREATED: + return None + return self._data + + @property + def session_updated(self) -> Optional[Dict[str, Any]]: + """``session_updated`` payload: ``title``, ``session_metadata``, + ``agent`` (only the changed fields, present when changed). + ``None`` for other events.""" + if getattr(self, "type", None) != SSEEventType.SESSION_UPDATED: + return None + return self._data + + @property + def thread_message_routing(self) -> Optional[Dict[str, str]]: + """Sub-agent routing from ``thread_message_sent`` / ``thread_message_received`` + ``metadata``: ``to_session_thread_id`` / ``to_agent_name`` on sent, + ``from_session_thread_id`` / ``from_agent_name`` on received. + ``None`` for other events.""" + if getattr(self, "type", None) not in ( + SSEEventType.THREAD_MESSAGE_SENT, + SSEEventType.THREAD_MESSAGE_RECEIVED, + ): + return None + md = self.metadata if isinstance(self.metadata, dict) else {} + keys = ( + "to_session_thread_id", + "to_agent_name", + "from_session_thread_id", + "from_agent_name", + ) + return {k: md[k] for k in keys if k in md} or None + def parse_message(payload: Mapping[str, Any]) -> Message: """Turn a parsed SSE ``data`` dict into a :class:`Message` instance.""" @@ -899,35 +1253,37 @@ def user_interrupt( return evt -def user_tool_confirmation( +def user_tool_approval_response( *, - tool_use_id: str, + batch_id: str, + call_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. + """Submit a tool approval ruling for an ``always_ask`` tool call. + + ``result`` must be ``"allow"`` or ``"deny"``; ``deny_message`` is + optional and only meaningful when denying. The approval identity is the + ``(batch_id, call_id)`` composite key — ``call_id`` may be reused across + turns, so never match on ``call_id`` alone. Approval responses target + the primary thread only (no ``session_thread_id``); the legacy + ``tool_confirmation`` type is rejected by the server (HTTP 400 + ``bma_invalid_event``). """ - if result not in ("allow", "deny"): - raise ValueError("tool_confirmation result must be 'allow' or 'deny'") + raise ValueError("result must be 'allow' or 'deny'") data: Dict[str, Any] = { - "call_id": tool_use_id, + "batch_id": batch_id, + "call_id": call_id, "result": result, } if deny_message and result == "deny": data["deny_message"] = deny_message - evt: Dict[str, Any] = { + return { "role": MessageRole.USER, - "type": SSEEventType.TOOL_CONFIRMATION, + "type": SSEEventType.TOOL_APPROVAL_RESPONSE, "content": [{"type": "data", "data": data}], } - if session_thread_id: - evt["session_thread_id"] = session_thread_id - return evt def user_custom_tool_result( diff --git a/dashscope/agentstudio/types/params.py b/dashscope/agentstudio/types/params.py index 3621fff..dae9192 100644 --- a/dashscope/agentstudio/types/params.py +++ b/dashscope/agentstudio/types/params.py @@ -394,13 +394,14 @@ def __init__( # pylint: disable=useless-parent-delegation 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 + ``types`` is a list of event type strings sent as repeated + ``types[]`` query keys (the server parses a list, not a single + comma-joined string). ``created_at_*`` parameters are mapped to the bracket-based wire keys ``created_at[gt]`` etc. """ _fields = ( - "types", + "types[]", "created_at[gt]", "created_at[gte]", "created_at[lt]", @@ -424,7 +425,7 @@ def __init__( ) -> None: kwargs = {} if types is not None: - kwargs["types"] = ",".join(types) + kwargs["types[]"] = list(types) if created_at_gt is not None: kwargs["created_at[gt]"] = created_at_gt if created_at_gte is not None: @@ -439,6 +440,98 @@ def __init__( super().__init__(**kwargs) +# =========================================================================== +# Session Resources (runtime mount) +# =========================================================================== + + +class SessionResourceAddParams(BaseModel): + """Request body for ``POST /sessions/{id}/resources`` (runtime mount). + + ``type`` is currently always ``"file"``; ``mount_path`` must be an + absolute path under ``/uploads/``. The server returns a + session-scoped copy ``file_id`` that differs from this source id. + """ + + _fields = ("type", "file_id", "mount_path") + + +class SessionResourceListParams(BaseModel): + """Query params for ``GET /sessions/{id}/resources``.""" + + _fields = ("limit", "page") + + +class SessionThreadListParams(BaseModel): + """Query params for ``GET /sessions/{id}/threads``.""" + + _fields = ("limit", "page") + + +# =========================================================================== +# Security (overview + agent logs) +# =========================================================================== + + +class SecurityListAgentLogsParams(BaseModel): + """Query params for ``GET /security/agent_logs``. + + Page-number pagination (``current_page`` / ``page_size``); the response + also carries a ``next_page`` cursor (null at the end). Filters: + ``risk_level`` (high/medium/low), ``status``, ``risk_name``, + ``app_name``, ``asset_type`` (agent/tool/skill/knowledge_base/memory/ + channel), ``vendor``, ``order_by`` (default check_time), ``order`` + (asc/desc), ``lang`` (zh/en), ``status_list`` (multi-value). + """ + + _fields = ( + "current_page", + "page_size", + "risk_level", + "status", + "risk_name", + "app_name", + "asset_type", + "vendor", + "order_by", + "order", + "lang", + "status_list", + ) + + def __init__( + self, + *, + current_page: Optional[int] = None, + page_size: Optional[int] = None, + risk_level: Optional[str] = None, + status: Optional[str] = None, + risk_name: Optional[str] = None, + app_name: Optional[str] = None, + asset_type: Optional[str] = None, + vendor: Optional[str] = None, + order_by: Optional[str] = None, + order: Optional[str] = None, + lang: Optional[str] = None, + status_list: Optional[Sequence[str]] = None, + ) -> None: + kwargs = {} + kwargs["current_page"] = current_page + kwargs["page_size"] = page_size + kwargs["risk_level"] = risk_level + kwargs["status"] = status + kwargs["risk_name"] = risk_name + kwargs["app_name"] = app_name + kwargs["asset_type"] = asset_type + kwargs["vendor"] = vendor + kwargs["order_by"] = order_by + kwargs["order"] = order + kwargs["lang"] = lang + if status_list is not None: + kwargs["status_list"] = list(status_list) + super().__init__(**kwargs) + + # =========================================================================== # Deployments # =========================================================================== diff --git a/tests/unit/test_agentstudio_protocol.py b/tests/unit/test_agentstudio_protocol.py index 883e644..70bdafa 100644 --- a/tests/unit/test_agentstudio_protocol.py +++ b/tests/unit/test_agentstudio_protocol.py @@ -25,7 +25,7 @@ user_define_outcome, user_interrupt, user_message, - user_tool_confirmation, + user_tool_approval_response, ) # --------------------------------------------------------------------------- @@ -78,12 +78,14 @@ def test_client_event_keys_are_snake_case(): session_thread_id="th_1", metadata={"k": "v"}, ), - user_tool_confirmation( - tool_use_id="t_1", + user_tool_approval_response( + batch_id="response_xxx:9f2c", + call_id="t_1", result="allow", ), - user_tool_confirmation( - tool_use_id="t_1", + user_tool_approval_response( + batch_id="response_xxx:9f2c", + call_id="t_1", result="deny", deny_message="nope", ), @@ -178,21 +180,34 @@ def test_user_interrupt(): assert evt == {"role": "user", "type": "interrupt"} -def test_user_tool_confirmation_validates_result(): +def test_user_tool_approval_response_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", + user_tool_approval_response( + batch_id="b1", + call_id="t_1", + result="MAYBE", + ) + deny = user_tool_approval_response( + batch_id="b1", + call_id="t_1", result="deny", deny_message="nope", ) assert deny["role"] == "user" - assert deny["type"] == "tool_confirmation" + assert deny["type"] == "tool_approval_response" data_block = deny["content"][0] assert data_block["type"] == "data" + assert data_block["data"]["batch_id"] == "b1" assert data_block["data"]["call_id"] == "t_1" assert data_block["data"]["result"] == "deny" assert data_block["data"]["deny_message"] == "nope" + # allow carries no deny_message + allow = user_tool_approval_response( + batch_id="b1", + call_id="t_1", + result="allow", + ) + assert "deny_message" not in allow["content"][0]["data"] def test_user_custom_tool_result_string_to_text(): @@ -454,3 +469,497 @@ def test_agent_model_hydrates_multiagent(): assert isinstance(agent.multiagent.agents[0], MultiAgentRosterEntry) assert agent.multiagent.agents[0].type == "self" assert agent.multiagent.agents[1].id == "agent_2" + + +# --------------------------------------------------------------------------- +# Tool approval +# --------------------------------------------------------------------------- + + +def _approval_request_msg(): + from dashscope.agentstudio.types import parse_message + + return parse_message( + { + "object": "message", + "status": "completed", + "id": "msg_approval_xxx", + "role": "assistant", + "type": "tool_approval_request", + "content": [ + { + "type": "data", + "data": { + "batch_id": "response_xxx:9f2c", + "call_id": "call_xxx", + "name": "bash", + "arguments": '{"command": "ls -la"}', + "tool_type": "builtin", + }, + }, + ], + }, + ) + + +def test_tool_approval_request_accessor(): + from dashscope.agentstudio.types import parse_message + + m = _approval_request_msg() + ap = m.tool_approval_request + assert ap["batch_id"] == "response_xxx:9f2c" + assert ap["call_id"] == "call_xxx" + assert ( + ap["arguments"] == '{"command": "ls -la"}' + ) # JSON string, not object + assert ap["tool_type"] == "builtin" + # None for non-approval events + assert parse_message({"type": "tool_call"}).tool_approval_request is None + + +def test_stop_reason_status_and_enum(): + from dashscope.agentstudio.types import StopReason + from dashscope.agentstudio.constants import ( + SSEEventType, + SessionStatus, + StopReasonType, + ) + + sr = StopReason( + type="requires_action", + pending_batch_id="b1", + pending_call_ids=["c1", "c2"], + ) + d = sr.to_dict() + assert d["pending_call_ids"] == ["c1", "c2"] + assert SessionStatus.RESCHEDULED == "rescheduled" + assert SessionStatus.DELETED == "deleted" + members = {m.value for m in SSEEventType} + assert len(members) == 23 + assert "tool_confirmation" not in members + assert ( + "tool_approval_request" in members + and "tool_approval_response" in members + ) + assert StopReasonType.REQUIRES_ACTION == "requires_action" + + +def test_permission_policy_validation(): + from dashscope.agentstudio.types import PermissionPolicy + + assert PermissionPolicy(type="always_ask").type == "always_ask" + assert PermissionPolicy().type == "always_allow" # default + with pytest.raises(ValueError): + PermissionPolicy(type="always_x") + # pylint: disable=too-many-function-args + with pytest.raises(TypeError): + PermissionPolicy("always_ask") + # pylint: enable=too-many-function-args + + +def test_approval_errors_map_by_http_status(): + """Approval error codes are not enumerated as separate exception types; + they map by HTTP status, with the reason in .code/.message.""" + from dashscope.agentstudio.exceptions import ( + InternalServerError, + InvalidRequestError, + OverloadedError, + from_response, + ) + + cases = { + ("bma_invalid_event", 400): InvalidRequestError, + ("invalid_tool_approval", 400): InvalidRequestError, + ("pending_tool_approval_unresolved", 400): InvalidRequestError, + ("malformed_model_tool_call", 500): InternalServerError, + ("tool_approval_service_unavailable", 503): OverloadedError, + } + for (code, status), cls in cases.items(): + err = from_response( + status_code=status, + body={"type": "error", "error": {"code": code, "message": "why"}}, + headers={}, + ) + assert isinstance(err, cls), (code, type(err)) + assert err.code == code and err.message == "why" + + +def test_event_list_types_repeated_query(): + """Multi-type filter serializes as repeated types[]= keys, not a single + comma-joined string (which the server parses as one bogus value).""" + import httpx + from dashscope.agentstudio.types.params import SessionEventListParams + + params = SessionEventListParams( + types=["message", "tool_call", "tool_approval_request"], + limit=50, + ).to_dict() + q = httpx.Request("GET", "https://x/", params=params).url.query.decode() + assert "types%5B%5D=message" in q + assert "types%5B%5D=tool_call" in q + assert "types%5B%5D=tool_approval_request" in q + assert "," not in q + + +# --------------------------------------------------------------------------- +# Event deltas +# --------------------------------------------------------------------------- + + +def _delta_payloads(): + return [ + {"type": "event_start", "event": {"id": "sevt_a", "type": "message"}}, + { + "type": "event_delta", + "event_id": "sevt_a", + "delta": { + "type": "content_delta", + "index": 0, + "content": {"type": "text", "text": "事件增量"}, + }, + }, + { + "type": "event_delta", + "event_id": "sevt_a", + "delta": { + "type": "content_delta", + "index": 0, + "content": {"type": "text", "text": "流"}, + }, + }, + { + "object": "message", + "status": "completed", + "id": "sevt_a", + "role": "assistant", + "type": "message", + "content": [{"type": "text", "text": "事件增量流"}], + }, + ] + + +def test_delta_frames_and_text_deltas(): + from dashscope.agentstudio.resources.session_events import ( + _TypedEventStream, + ) + from dashscope.agentstudio.types import parse_message + + msgs = [parse_message(p) for p in _delta_payloads()] + assert msgs[0].event_start == {"id": "sevt_a", "type": "message"} + assert msgs[1].event_delta["event_id"] == "sevt_a" + assert msgs[1].delta_text == "事件增量" + assert msgs[3].delta_text is None # terminal message is not a delta frame + + class _Fake: + def __init__(self, items): + self._items = items + + def __iter__(self): + return iter(self._items) + + def close(self): + pass + + ts = _TypedEventStream.__new__(_TypedEventStream) + ts._stream = _Fake(_delta_payloads()) # pylint: disable=protected-access + assert list(ts.text_deltas) == ["事件增量", "流"] + + +# --------------------------------------------------------------------------- +# Session resources & threads +# --------------------------------------------------------------------------- + + +def test_session_resource_model_and_add_params(): + from dashscope.agentstudio import SessionResource + from dashscope.agentstudio.types.params import SessionResourceAddParams + + r = SessionResource( + id="sesrsc_1", + type="file", + file_id="file_copy", # session-scoped copy, != source id + mount_path="/mnt/session/uploads/data.csv", + created_at="t", + updated_at="t", + request_id="r", + ) + assert r.file_id == "file_copy" + body = SessionResourceAddParams( + type="file", + file_id="file_src", + mount_path="/uploads/data.csv", + ).to_dict() + assert body == { + "type": "file", + "file_id": "file_src", + "mount_path": "/uploads/data.csv", + } + + +def test_session_thread_model_fields(): + from dashscope.agentstudio.types import SessionThread + + th = SessionThread( + id="sthr_01", + type="session_thread", + session_id="sesn_01", + parent_thread_id="sthr_primary", + agent={"id": "agent_01", "version": 1}, + status="idle", + created_at="t", + updated_at="t", + ) + d = th.to_dict() + assert d["type"] == "session_thread" + assert d["agent"] == {"id": "agent_01", "version": 1} + assert "title" not in d # legacy field removed + + +def test_message_error_and_pending_signal(): + from dashscope.agentstudio.types import parse_message + + ev = parse_message( + { + "type": "error", + "error": { + "code": "tool_approval_service_unavailable", + "message": "down", + }, + "metadata": { + "pending_tool_approvals": { + "batch_id": "b1", + "call_ids": ["c1"], + }, + }, + }, + ) + assert ev.error["code"] == "tool_approval_service_unavailable" + assert ev.pending_tool_approvals == {"batch_id": "b1", "call_ids": ["c1"]} + # non-error events: both None + plain = parse_message( + { + "type": "message", + "content": [{"type": "text", "text": "hi"}], + }, + ) + assert plain.error is None and plain.pending_tool_approvals is None + # error without the suspend signal: barrier is not up + err_only = parse_message( + { + "type": "error", + "error": {"code": "malformed_model_tool_call"}, + }, + ) + assert err_only.pending_tool_approvals is None + + +# --------------------------------------------------------------------------- +# P2: Message data accessors & multiagent +# --------------------------------------------------------------------------- + + +def test_message_data_accessors(): + from dashscope.agentstudio.types import parse_message + + # model_request_end + mre = parse_message( + { + "object": "message", + "status": "completed", + "id": "m1", + "type": "model_request_end", + "content": [ + { + "type": "data", + "data": { + "model_request_start_id": "m0", + "is_error": False, + "input_tokens": 100, + "output_tokens": 50, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 80, + "speed": "standard", + }, + }, + ], + }, + ) + assert mre.model_request_end["output_tokens"] == 50 + assert mre.data["model_request_start_id"] == "m0" + assert mre.outcome_evaluation is None # wrong type + + # outcome_evaluation + oe = parse_message( + { + "object": "message", + "status": "completed", + "id": "m2", + "type": "outcome_evaluation", + "content": [ + { + "type": "data", + "data": { + "outcome_id": "out_x", + "iteration": 1, + "phase": "end", + "result": "pass", + "explanation": "ok", + }, + }, + ], + }, + ) + assert oe.outcome_evaluation["phase"] == "end" + assert oe.outcome_evaluation["result"] == "pass" + + # thread_status / thread_created / session_updated share the data shape + ts = parse_message( + { + "type": "thread_status", + "content": [ + { + "type": "data", + "data": { + "session_thread_id": "sthr_1", + "agent_name": "researcher", + "thread_status": "idle", + "stop_reason": {"type": "end_turn"}, + }, + }, + ], + }, + ) + assert ts.thread_status["thread_status"] == "idle" + + tc = parse_message( + { + "type": "thread_created", + "content": [ + { + "type": "data", + "data": { + "session_thread_id": "sthr_1", + "agent_name": "researcher", + }, + }, + ], + }, + ) + assert tc.thread_created["agent_name"] == "researcher" + + su = parse_message( + { + "type": "session_updated", + "content": [{"type": "data", "data": {"title": "新标题"}}], + }, + ) + assert su.session_updated["title"] == "新标题" + # non-matching types return None + assert ts.session_updated is None + + +def test_thread_message_routing_accessor(): + from dashscope.agentstudio.types import parse_message + + sent = parse_message( + { + "type": "thread_message_sent", + "role": "assistant", + "content": [{"type": "text", "text": "查一下"}], + "metadata": { + "to_session_thread_id": "sthr_r", + "to_agent_name": "researcher", + }, + }, + ) + assert sent.thread_message_routing == { + "to_session_thread_id": "sthr_r", + "to_agent_name": "researcher", + } + recv = parse_message( + { + "type": "thread_message_received", + "role": "assistant", + "content": [{"type": "text", "text": "查到了"}], + "metadata": { + "from_session_thread_id": "sthr_r", + "from_agent_name": "researcher", + }, + }, + ) + assert recv.thread_message_routing["from_agent_name"] == "researcher" + # non-thread-message event -> None + assert parse_message({"type": "message"}).thread_message_routing is None + + +def test_multiagent_config_normalization_and_roster_fields(): + """The SDK normalizes multiagent config (type default, self cleanup) but + does not reject server data — the 1-20 / at-most-one-self limits are the + server's to enforce, so parsing a valid agent never fails.""" + from dashscope.agentstudio.types import ( + MultiAgentConfig, + MultiAgentRosterEntry, + ) + + cfg = MultiAgentConfig( + agents=[ + {"type": "self"}, + {"type": "agent", "id": "agent_2", "version": 3}, + ], + ) + assert cfg.type == "coordinator" # defaulted + assert all(isinstance(a, MultiAgentRosterEntry) for a in cfg.agents) + assert cfg.agents[0].type == "self" + # name/description are enriched response fields (present on retrieval) + entry = MultiAgentRosterEntry( + type="agent", + id="a1", + name="worker", + description="reads files", + ) + assert entry.name == "worker" and entry.description == "reads files" + # empty list clears the roster + assert MultiAgentConfig(agents=[]).agents == [] + # None agents normalizes to [] + assert MultiAgentConfig(agents=None).agents == [] + + +def test_text_stream_stops_on_session_status_idle(): + """Regression: text_stream/text_deltas must not crash on session_status events. + RESCHEDULING was deleted from constants but stop-set tuples still referenced it, + causing AttributeError on every stream's normal end path.""" + from dashscope.agentstudio.resources.session_events import ( + _TypedEventStream, + ) + + raw_events = [ + { + "object": "message", + "status": "completed", + "id": "m1", + "type": "message", + "role": "assistant", + "content": [{"type": "text", "text": "hello"}], + }, + { + "object": "message", + "status": "completed", + "id": "m2", + "type": "session_status", + "content": [ + { + "type": "data", + "data": { + "session_status": "idle", + "stop_reason": {"type": "end_turn"}, + }, + }, + ], + }, + ] + + ts = _TypedEventStream.from_raw_events(raw_events) + assert list(ts.text_stream) == ["hello"] + + ts2 = _TypedEventStream.from_raw_events(raw_events) + assert not list(ts2.text_deltas)