> ## Agent Instructions > For AgentScope Python, use https://docs.agentscope.io/stable/en/index for new projects. For existing projects, check the installed agentscope version and use matching versioned documentation. > The /latest/ alias points to development documentation. Use it only with the matching development source. Do not mix AgentScope 1.x and 2.x APIs. > State the AgentScope version when providing installation commands or code examples. ReMe uses its own continuously updated /reme/latest/ documentation. # [Deprecated] Legacy sectioned schema — use /schema/v2 Source: https://docs.agentscope.io/api-reference/agent/[deprecated]-legacy-sectioned-schema-—-use-schemav2 /versions/2.0.4/en/deploy/openapi.json get /agent/schema Return the legacy sectioned JSON Schema fragments. .. deprecated:: Superseded by :func:`get_agent_schema_v2`, which returns the full :class:`AgentData` schema in a single ``schema`` field. Kept for backwards compatibility with existing API consumers. New consumers should call ``GET /agent/schema/v2``. The frontend previously used three sections — identity, context config, and react config — so we return them as separate self-contained schemas rather than a single :class:`AgentData` schema with ``$ref`` s. Returns: `AgentSchemaResponse`: Schemas for the three form sections. # Create a new agent Source: https://docs.agentscope.io/api-reference/agent/create-a-new-agent /versions/2.0.2/en/deploy/openapi.json post /agent/ Create and persist a new agent configuration. Args: body (`CreateAgentRequest`): Agent configuration to store. user_id (`str`): Injected authenticated user ID. storage (`StorageBase`): Injected storage backend. Returns: `CreateAgentResponse`: The server-assigned agent identifier. # Delete an agent Source: https://docs.agentscope.io/api-reference/agent/delete-an-agent /versions/2.0.2/en/deploy/openapi.json delete /agent/{agent_id} Permanently delete an agent configuration. Args: agent_id (`str`): The agent to delete. user_id (`str`): Injected authenticated user ID. storage (`StorageBase`): Injected storage backend. Raises: `HTTPException`: 404 if the agent does not exist or does not belong to the authenticated user. # Full AgentData JSON Schema for the agent form Source: https://docs.agentscope.io/api-reference/agent/full-agentdata-json-schema-for-the-agent-form /versions/2.0.4/en/deploy/openapi.json get /agent/schema/v2 Return the full :class:`AgentData` JSON Schema. Superset of the legacy sectioned endpoint. The response body is a single ``schema`` field carrying the whole Pydantic-generated schema of :class:`AgentData`, with two curated exclusions handled at the model layer (so no post-processing is needed here): - ``id``: server-assigned, marked :class:`SkipJsonSchema` on :attr:`AgentData.id`. - ``context_config.summary_schema``: internal structured-output spec for the compression model, dropped below since it is not user-editable and there is no equivalent hook on the Pydantic side. ``$ref`` inlining is delegated to :func:`~agentscope._utils._common._flatten_json_schema` so the frontend can render every property from the response body alone. The frontend derives its section grouping (identity / context / react / invite) directly from this schema — top-level scalar properties are the "identity" section, and top-level nested-object properties each become their own section. Adding a new user-editable field to :class:`AgentData` is thus enough to have it appear in the create / edit form without a router change. Returns: `AgentSchemaV2Response`: ``schema`` = the full :class:`AgentData` JSON Schema. # List all agents Source: https://docs.agentscope.io/api-reference/agent/list-all-agents /versions/2.0.2/en/deploy/openapi.json get /agent/ Return all agent records belonging to the authenticated user. Args: user_id (`str`): Injected authenticated user ID. storage (`StorageBase`): Injected storage backend. Returns: `ListAgentsResponse`: All agent records and their total count. # Update an agent Source: https://docs.agentscope.io/api-reference/agent/update-an-agent /versions/2.0.2/en/deploy/openapi.json patch /agent/{agent_id} Partially update an existing agent configuration. Only the fields present in the request body are updated; all other fields keep their current values. Args: agent_id (`str`): The agent to update. body (`UpdateAgentRequest`): Fields to update. user_id (`str`): Injected authenticated user ID. storage (`StorageBase`): Injected storage backend. Returns: `AgentRecord`: The full agent record after the update. Raises: `HTTPException`: 404 if the agent does not exist or does not belong to the authenticated user. # Trigger a chat run (fire-and-forget) Source: https://docs.agentscope.io/api-reference/chat/trigger-a-chat-run-fire-and-forget /versions/2.0.2/en/deploy/openapi.json post /chat/ Trigger a chat run for the specified session. The run executes as a background task. Events produced during the run are published to the message bus and delivered to any active ``GET /sessions/{session_id}/stream`` SSE subscriber. The caller does **not** receive events from this endpoint's response body. Accepts the same ``input`` payloads as before: - ``Msg`` / ``list[Msg]``: new user message(s). - ``UserConfirmResultEvent`` / ``ExternalExecutionResultEvent``: resume a paused tool call (human-in-the-loop). - ``None``: continue from current state. Args: request (`ChatRequest`): JSON body with ``agent_id``, ``session_id``, and ``input``. user_id (`str`): Injected user id. chat_service (`ChatService`): Injected application-wide chat service. Returns: `ChatTriggerResponse`: Confirms the run was scheduled. # Create a new credential Source: https://docs.agentscope.io/api-reference/credential/create-a-new-credential /versions/2.0.2/en/deploy/openapi.json post /credential/ Store a new credential. Args: body (`CreateCredentialRequest`): Credential payload to store. user_id (`str`): Injected authenticated user ID. storage (`StorageBase`): Injected storage backend. Returns: `CreateCredentialResponse`: The server-assigned credential identifier. # Delete a credential Source: https://docs.agentscope.io/api-reference/credential/delete-a-credential /versions/2.0.2/en/deploy/openapi.json delete /credential/{credential_id} Permanently delete a credential. Args: credential_id (`str`): The credential to delete. user_id (`str`): Injected authenticated user ID. storage (`StorageBase`): Injected storage backend. Raises: `HTTPException`: 404 if the credential does not exist or does not belong to the authenticated user. # List all credentials Source: https://docs.agentscope.io/api-reference/credential/list-all-credentials /versions/2.0.2/en/deploy/openapi.json get /credential/ Return all credential records belonging to the authenticated user. Args: user_id (`str`): Injected authenticated user ID. storage (`StorageBase`): Injected storage backend. Returns: `ListCredentialsResponse`: All credential records and their total count. # List JSON schemas for all credential types Source: https://docs.agentscope.io/api-reference/credential/list-json-schemas-for-all-credential-types /versions/2.0.2/en/deploy/openapi.json get /credential/schemas Return JSON schemas for all registered credential types. Used by the frontend to render credential creation forms dynamically. # Update a credential Source: https://docs.agentscope.io/api-reference/credential/update-a-credential /versions/2.0.2/en/deploy/openapi.json patch /credential/{credential_id} Replace the payload of an existing credential. Args: credential_id (`str`): The credential to update. body (`UpdateCredentialRequest`): New credential payload. user_id (`str`): Injected authenticated user ID. storage (`StorageBase`): Injected storage backend. Returns: `CredentialRecord`: The updated credential record. Raises: `HTTPException`: 404 if the credential does not exist or does not belong to the authenticated user. # Report service liveness and per-component readiness Source: https://docs.agentscope.io/api-reference/health/report-service-liveness-and-per-component-readiness /versions/2.0.6/en/deploy/openapi.json get /health Report whether the service is ready to serve requests. The check is deliberately I/O-free — it only inspects what is attached to ``app.state``, so probing it never touches Redis, the database or a workspace backend. Under a plain ``uvicorn.run(app)`` the ``not_ready`` status is effectively unreachable: uvicorn completes the lifespan startup before it accepts connections, so anything that reaches this handler already has the lifespan components in place. What it does catch is the mount-as-sub-app deployment documented in :func:`~agentscope.app.create_app` — Starlette does not run a mounted sub-app's lifespan, so every lifespan component stays missing and all business endpoints are broken. Reporting 503 here turns that silent misconfiguration into one clear signal. Args: request (`Request`): The incoming FastAPI request. response (`Response`): The outgoing response, whose status code is downgraded to ``503`` when the service is not ready. Returns: `HealthResponse`: The overall status, API version and the per-component readiness map. # Get Mcp Card Source: https://docs.agentscope.io/api-reference/hub/get-mcp-card /versions/2.0.6/en/deploy/openapi.json get /hub/mcp/{hub_id}/cards/{card_id} Return one MCP card, including the inputs the user must fill. # Get Skill Card Source: https://docs.agentscope.io/api-reference/hub/get-skill-card /versions/2.0.6/en/deploy/openapi.json get /hub/skill/{hub_id}/cards/{card_id} Return one skill card, including its ``SKILL.md`` body. # Install Mcp Source: https://docs.agentscope.io/api-reference/hub/install-mcp /versions/2.0.6/en/deploy/openapi.json post /hub/mcp/{hub_id}/cards/{card_id}/install Fill a card's template and add the result to the user's library. Installing is a user-level act with no workspace involved — putting the MCP into a session's workspace is separate and explicit. The record keeps ``(hub_id, card_id)`` so the library can later answer "where did this come from" and "is there a newer version". Note that the config is not connection-tested here, so a mistyped API key surfaces on first use rather than now. Testing it would mean connecting from the app process, and a stdio MCP would then spawn its command here instead of in the workspace sandbox. # Install Skill Source: https://docs.agentscope.io/api-reference/hub/install-skill /versions/2.0.6/en/deploy/openapi.json post /hub/skill/{hub_id}/cards/{card_id}/install Add a skill card to the user's library. Mirrors the MCP install: user-level, no workspace involved. The archive is not downloaded here — only the card's metadata is recorded, and the files are fetched when the skill is actually put into a workspace. So a hub that has since dropped the card fails then rather than now. # List Mcp Cards Source: https://docs.agentscope.io/api-reference/hub/list-mcp-cards /versions/2.0.6/en/deploy/openapi.json get /hub/mcp/{hub_id}/cards Browse or search one MCP hub's catalog. # List Mcp Hubs Source: https://docs.agentscope.io/api-reference/hub/list-mcp-hubs /versions/2.0.6/en/deploy/openapi.json get /hub/mcp Return every registered MCP hub. # List Skill Cards Source: https://docs.agentscope.io/api-reference/hub/list-skill-cards /versions/2.0.6/en/deploy/openapi.json get /hub/skill/{hub_id}/cards Browse or search one skill hub's catalog. # List Skill Hubs Source: https://docs.agentscope.io/api-reference/hub/list-skill-hubs /versions/2.0.6/en/deploy/openapi.json get /hub/skill Return every registered skill hub. # Batch-query indexing status of one or more documents Source: https://docs.agentscope.io/api-reference/knowledge_bases/batch-query-indexing-status-of-one-or-more-documents /versions/2.0.3/en/deploy/openapi.json get /knowledge_bases/{knowledge_base_id}/documents/status Return the current lifecycle state of a batch of documents. Designed for the front-end's status polling loop: the page sends every in-flight document id at once so per-document round-trips do not multiply with concurrency. Args: knowledge_base_id (`str`): The target knowledge base id. ids (`str`): Comma-separated document ids. user_id (`str`): Injected authenticated user ID. service (`KnowledgeBaseService`): Injected knowledge base service. Returns: `ListKnowledgeDocumentStatusResponse`: Views for the matched documents. # Create a new knowledge base Source: https://docs.agentscope.io/api-reference/knowledge_bases/create-a-new-knowledge-base /versions/2.0.3/en/deploy/openapi.json post /knowledge_bases/ Create a new knowledge base for the authenticated user. Allocates a fresh vector store collection sized to the embedding model's output dimension and persists the knowledge base record. Args: body (`CreateKnowledgeBaseRequest`): Knowledge base name, description, and embedding model configuration. user_id (`str`): Injected authenticated user ID. service (`KnowledgeBaseService`): Injected knowledge base service. Returns: `CreateKnowledgeBaseResponse`: The server-assigned knowledge base identifier. # Delete a document from a knowledge base Source: https://docs.agentscope.io/api-reference/knowledge_bases/delete-a-document-from-a-knowledge-base /versions/2.0.3/en/deploy/openapi.json delete /knowledge_bases/{knowledge_base_id}/documents/{document_id} Remove a document and all its chunks from a knowledge base. Args: knowledge_base_id (`str`): The knowledge base the document belongs to. document_id (`str`): The document to delete. user_id (`str`): Injected authenticated user ID. service (`KnowledgeBaseService`): Injected knowledge base service. # Delete a knowledge base Source: https://docs.agentscope.io/api-reference/knowledge_bases/delete-a-knowledge-base /versions/2.0.3/en/deploy/openapi.json delete /knowledge_bases/{knowledge_base_id} Permanently delete a knowledge base. Drops the underlying vector store collection together with every associated document and the knowledge base record itself. Args: knowledge_base_id (`str`): The knowledge base to delete. user_id (`str`): Injected authenticated user ID. service (`KnowledgeBaseService`): Injected knowledge base service. # JSON Schema for the KB middleware's tunable parameters Source: https://docs.agentscope.io/api-reference/knowledge_bases/json-schema-for-the-kb-middlewares-tunable-parameters /versions/2.0.3/en/deploy/openapi.json get /knowledge_bases/middleware/parameters_schema Return the parameter schema for :class:`agentscope.middleware.RAGMiddleware`. The schema is shaped like every other ``parameter_schema`` served by this service — title / description / default / enum / minimum / maximum — so the front-end can render the session-level KB attachment form with the same schema-driven component used for model parameters. Args: _ (`str`): Injected authenticated user ID; only used to gate the endpoint behind authentication. Returns: `KbMiddlewareParametersSchemaResponse`: The JSON Schema describing the middleware's user-tunable parameters. # List documents registered in a knowledge base Source: https://docs.agentscope.io/api-reference/knowledge_bases/list-documents-registered-in-a-knowledge-base /versions/2.0.3/en/deploy/openapi.json get /knowledge_bases/{knowledge_base_id}/documents List every document registered against a knowledge base. Reads from the storage backend (service-mode source of truth), so documents in any lifecycle state — including ``pending`` / ``parsing`` / ``error`` — are returned alongside ``ready`` ones. Args: knowledge_base_id (`str`): The target knowledge base id. user_id (`str`): Injected authenticated user ID. service (`KnowledgeBaseService`): Injected knowledge base service. Returns: `ListKnowledgeDocumentsResponse`: One view per registered document. # List embedding models compatible with the KB dimension policy Source: https://docs.agentscope.io/api-reference/knowledge_bases/list-embedding-models-compatible-with-the-kb-dimension-policy /versions/2.0.3/en/deploy/openapi.json get /knowledge_bases/embedding_models List embedding models the user can pick at KB-creation time. Walks the caller's credentials, looks up each provider's embedding model class, gathers its model cards, and projects each card through the manager's :class:`DimensionPolicy`. Incompatible cards are dropped; matryoshka cards under a ``FIXED`` / ``LOCKED_BY_EXISTING`` policy are narrowed to the locked dimension. Providers that end up with zero compatible models are omitted from the response entirely. Args: user_id (`str`): Injected authenticated user ID. storage (`StorageBase`): Injected storage backend used to enumerate credentials. manager (`KnowledgeBaseManagerBase`): Injected knowledge base manager. Returns: `ListKbEmbeddingModelsResponse`: One entry per credential with at least one compatible embedding model, plus the policy used for filtering. # List file types the configured parsers can ingest Source: https://docs.agentscope.io/api-reference/knowledge_bases/list-file-types-the-configured-parsers-can-ingest /versions/2.0.3/en/deploy/openapi.json get /knowledge_bases/supported_content_types Advertise the union of media types and filename extensions every registered parser accepts. Used by the front-end to populate the document picker's ``accept`` attribute and to reject drag-dropped files whose extension lies outside the supported set before the upload starts. Routing on upload still goes through the media type — this endpoint is a capability hint, not authoritative dispatch. Args: _ (`str`): Injected authenticated user ID; only used to gate the endpoint behind authentication. parsers (`list[ParserBase] | dict[str, ParserBase]`): Injected parser registry — the same value the index worker uses to dispatch uploads. Returns: `ListSupportedContentTypesResponse`: Deduplicated, sorted unions of ``media_types`` and ``extensions``. # List the caller's knowledge bases Source: https://docs.agentscope.io/api-reference/knowledge_bases/list-the-callers-knowledge-bases /versions/2.0.3/en/deploy/openapi.json get /knowledge_bases/ Return all knowledge bases owned by the authenticated user. Args: user_id (`str`): Injected authenticated user ID. service (`KnowledgeBaseService`): Injected knowledge base service. Returns: `ListKnowledgeBasesResponse`: The user's knowledge bases. # Search a knowledge base by natural-language query Source: https://docs.agentscope.io/api-reference/knowledge_bases/search-a-knowledge-base-by-natural-language-query /versions/2.0.3/en/deploy/openapi.json post /knowledge_bases/{knowledge_base_id}/search Run a similarity search over a knowledge base. Embeds the query with the knowledge base's configured embedding model and returns the top-K most similar chunks. Args: body (`SearchKnowledgeBaseRequest`): The query text and ``top_k``. knowledge_base_id (`str`): The knowledge base to search. user_id (`str`): Injected authenticated user ID. service (`KnowledgeBaseService`): Injected knowledge base service. Returns: `SearchKnowledgeBaseResponse`: Matched chunks ordered by descending similarity. # Update mutable fields on a knowledge base Source: https://docs.agentscope.io/api-reference/knowledge_bases/update-mutable-fields-on-a-knowledge-base /versions/2.0.3/en/deploy/openapi.json patch /knowledge_bases/{knowledge_base_id} Update mutable fields on a knowledge base. Only ``name`` and ``description`` can be updated. The embedding model configuration is pinned at creation time and cannot be changed. Args: body (`UpdateKnowledgeBaseRequest`): The fields to update; omitted fields stay unchanged. knowledge_base_id (`str`): The knowledge base to update. user_id (`str`): Injected authenticated user ID. service (`KnowledgeBaseService`): Injected knowledge base service. Returns: `KnowledgeBaseView`: The knowledge base record after the update. # Upload a document into a knowledge base Source: https://docs.agentscope.io/api-reference/knowledge_bases/upload-a-document-into-a-knowledge-base /versions/2.0.3/en/deploy/openapi.json post /knowledge_bases/{knowledge_base_id}/documents Register an uploaded document and dispatch it for indexing. The HTTP connection covers only the upload phase: the request body is streamed into the blob store, a ``pending`` document record is persisted, the indexing task is dispatched, and the response is returned. Parsing / chunking / embedding happen asynchronously in a worker; the client tracks progress via :func:`list_knowledge_document_status`. Args: knowledge_base_id (`str`): The knowledge base to receive the document. file (`UploadFile`): The uploaded file (multipart/form-data). content_type (`str | None`, optional): Override the IANA media type used to route the upload. user_id (`str`): Injected authenticated user ID. service (`KnowledgeBaseService`): Injected knowledge base service. Returns: `UploadKnowledgeDocumentResponse`: The server-assigned document id, filename, and the initial lifecycle state (always ``"pending"``). # Delete Mcp Source: https://docs.agentscope.io/api-reference/mcp/delete-mcp /versions/2.0.6/en/deploy/openapi.json delete /mcp/{mcp_id} Remove an MCP from the user's library. Workspaces that already hold this MCP keep it — the workspace relation is derived by name, not a foreign key, and tearing down a live stateful MCP session as a side effect of a library edit would surprise the user. # List Mcps Source: https://docs.agentscope.io/api-reference/mcp/list-mcps /versions/2.0.6/en/deploy/openapi.json get /mcp Return every MCP the user has installed, ordered by name. # Update Mcp Source: https://docs.agentscope.io/api-reference/mcp/update-mcp /versions/2.0.6/en/deploy/openapi.json patch /mcp/{mcp_id} Rename, enable/disable, or re-key an installed MCP. Changing a secret is a re-render, not a field edit: an API key can sit anywhere in the config — inside a URL, a header, an env var — so the card's template plus the merged answers is the only thing that knows where to put it. # Create a new schedule Source: https://docs.agentscope.io/api-reference/schedule/create-a-new-schedule /versions/2.0.2/en/deploy/openapi.json post /schedule/ Create a new schedule and register it with the scheduler. Args: body (`CreateScheduleRequest`): Schedule configuration. user_id (`str`): Authenticated user ID. storage (`StorageBase`): Storage instance. scheduler (`SchedulerManager`): Scheduler manager. Returns: `CreateScheduleResponse`: The ID of the newly created schedule. Raises: `HTTPException`: 404 if the specified agent does not exist. # Delete a schedule Source: https://docs.agentscope.io/api-reference/schedule/delete-a-schedule /versions/2.0.2/en/deploy/openapi.json delete /schedule/{schedule_id} Permanently delete a schedule. Removes the record from storage and unregisters the APScheduler job. Args: schedule_id (`str`): ID of the schedule to delete. user_id (`str`): Authenticated user ID. storage (`StorageBase`): Storage instance. scheduler (`SchedulerManager`): Scheduler manager. Raises: `HTTPException`: 404 if the schedule does not exist. # List all schedules Source: https://docs.agentscope.io/api-reference/schedule/list-all-schedules /versions/2.0.2/en/deploy/openapi.json get /schedule/ List all schedules owned by the current user. Args: user_id (`str`): Authenticated user ID. storage (`StorageBase`): Storage instance. Returns: `ListSchedulesResponse`: Paginated list of schedule records. # List execution sessions for a schedule Source: https://docs.agentscope.io/api-reference/schedule/list-execution-sessions-for-a-schedule /versions/2.0.2/en/deploy/openapi.json get /schedule/{schedule_id}/sessions Return all sessions triggered by a given schedule. Args: schedule_id (`str`): ID of the schedule. user_id (`str`): Authenticated user ID. storage (`StorageBase`): Storage instance. Returns: `ScheduleSessionsResponse`: List of execution sessions ordered by creation time (newest first). Raises: `HTTPException`: 404 if the schedule does not exist. # Update a schedule Source: https://docs.agentscope.io/api-reference/schedule/update-a-schedule /versions/2.0.2/en/deploy/openapi.json patch /schedule/{schedule_id} Partially update a schedule. Fields omitted from the request body keep their current values. Changing ``cron_expression`` or ``timezone`` immediately reschedules the APScheduler job. Setting ``enable=False`` removes the job from the scheduler without deleting the record. Args: schedule_id (`str`): ID of the schedule to update. body (`UpdateScheduleRequest`): Fields to update. user_id (`str`): Authenticated user ID. storage (`StorageBase`): Storage instance. scheduler (`SchedulerManager`): Scheduler manager. Returns: `ScheduleRecord`: The updated schedule record. Raises: `HTTPException`: 404 if the schedule does not exist. # Create a new session Source: https://docs.agentscope.io/api-reference/sessions/create-a-new-session /versions/2.0.2/en/deploy/openapi.json post /sessions/ Create (or resume) a session for a given agent and workspace. At most one session exists per ``(user_id, agent_id, workspace_id)`` triple — a second call with the same triple updates the existing session rather than creating a duplicate. Args: body (`CreateSessionRequest`): Agent, workspace, and model config. user_id (`str`): Injected authenticated user ID. storage (`StorageBase`): Injected storage backend. Returns: `CreateSessionResponse`: The session identifier. Raises: `HTTPException`: 404 if the agent or credential does not exist or does not belong to the authenticated user. # Delete a session Source: https://docs.agentscope.io/api-reference/sessions/delete-a-session /versions/2.0.2/en/deploy/openapi.json delete /sessions/{session_id} Permanently delete a session and all its associated state. Args: session_id (`str`): The session to delete. user_id (`str`): Injected authenticated user ID. storage (`StorageBase`): Injected storage backend. Raises: `HTTPException`: 404 if the session does not exist or does not belong to the authenticated user. # Interrupt a running or HITL-parked chat run for a session Source: https://docs.agentscope.io/api-reference/sessions/interrupt-a-running-or-hitl-parked-chat-run-for-a-session /versions/2.0.4/en/deploy/openapi.json post /sessions/{session_id}/interrupt Request interruption of an in-progress reply for a session. Thin HTTP wrapper around :meth:`ChatService.interrupt`; see that method for the running vs not-running dispatch. Idempotent — an idle target session is a silent no-op at the agent layer. Args: session_id: The session whose reply should be interrupted. agent_id: The agent that owns the session. user_id: Injected authenticated user id. chat_service: Injected chat service. Returns: 202 with :class:`InterruptSessionResponse` echoing the session id. Raises: HTTPException: 404 if the session does not exist. # List sessions for an agent Source: https://docs.agentscope.io/api-reference/sessions/list-sessions-for-an-agent /versions/2.0.2/en/deploy/openapi.json get /sessions/ Return all sessions for an agent as enriched :class:`SessionView` entries. Each entry bundles three things the chat UI needs to render without follow-up requests: the session record (incl. ``state``), whether a chat run is currently active, and — when the session participates in a team — the resolved team detail (leader agent + member agents with their session ids). Args: agent_id (`str`): Agent whose sessions to list. user_id (`str`): Injected authenticated user ID. storage (`StorageBase`): Injected storage backend. message_bus (`MessageBus`): Injected message bus (used for ``session_is_running``). Returns: `ListSessionsResponse`: Enriched session views and their count. Raises: `HTTPException`: 404 if the agent does not exist or does not belong to the authenticated user. # Update a session Source: https://docs.agentscope.io/api-reference/sessions/update-a-session /versions/2.0.2/en/deploy/openapi.json patch /sessions/{session_id} Update the model configuration of an existing session. Args: session_id (`str`): The session to update. body (`UpdateSessionRequest`): Fields to update. user_id (`str`): Injected authenticated user ID. storage (`StorageBase`): Injected storage backend. Returns: `SessionRecord`: The full session record after the update. Raises: `HTTPException`: 404 if the session, agent, or credential does not exist or does not belong to the authenticated user. # [Deprecated] Legacy sectioned schema — use /schema/v2 Source: https://docs.agentscope.io/api-reference/agent/[deprecated]-legacy-sectioned-schema-—-use-schemav2 /versions/2.0.4/en/deploy/openapi.json get /agent/schema Return the legacy sectioned JSON Schema fragments. .. deprecated:: Superseded by :func:`get_agent_schema_v2`, which returns the full :class:`AgentData` schema in a single ``schema`` field. Kept for backwards compatibility with existing API consumers. New consumers should call ``GET /agent/schema/v2``. The frontend previously used three sections — identity, context config, and react config — so we return them as separate self-contained schemas rather than a single :class:`AgentData` schema with ``$ref`` s. Returns: `AgentSchemaResponse`: Schemas for the three form sections. # Create a new agent Source: https://docs.agentscope.io/api-reference/agent/create-a-new-agent /versions/2.0.2/en/deploy/openapi.json post /agent/ Create and persist a new agent configuration. Args: body (`CreateAgentRequest`): Agent configuration to store. user_id (`str`): Injected authenticated user ID. storage (`StorageBase`): Injected storage backend. Returns: `CreateAgentResponse`: The server-assigned agent identifier. # Delete an agent Source: https://docs.agentscope.io/api-reference/agent/delete-an-agent /versions/2.0.2/en/deploy/openapi.json delete /agent/{agent_id} Permanently delete an agent configuration. Args: agent_id (`str`): The agent to delete. user_id (`str`): Injected authenticated user ID. storage (`StorageBase`): Injected storage backend. Raises: `HTTPException`: 404 if the agent does not exist or does not belong to the authenticated user. # Full AgentData JSON Schema for the agent form Source: https://docs.agentscope.io/api-reference/agent/full-agentdata-json-schema-for-the-agent-form /versions/2.0.4/en/deploy/openapi.json get /agent/schema/v2 Return the full :class:`AgentData` JSON Schema. Superset of the legacy sectioned endpoint. The response body is a single ``schema`` field carrying the whole Pydantic-generated schema of :class:`AgentData`, with two curated exclusions handled at the model layer (so no post-processing is needed here): - ``id``: server-assigned, marked :class:`SkipJsonSchema` on :attr:`AgentData.id`. - ``context_config.summary_schema``: internal structured-output spec for the compression model, dropped below since it is not user-editable and there is no equivalent hook on the Pydantic side. ``$ref`` inlining is delegated to :func:`~agentscope._utils._common._flatten_json_schema` so the frontend can render every property from the response body alone. The frontend derives its section grouping (identity / context / react / invite) directly from this schema — top-level scalar properties are the "identity" section, and top-level nested-object properties each become their own section. Adding a new user-editable field to :class:`AgentData` is thus enough to have it appear in the create / edit form without a router change. Returns: `AgentSchemaV2Response`: ``schema`` = the full :class:`AgentData` JSON Schema. # List all agents Source: https://docs.agentscope.io/api-reference/agent/list-all-agents /versions/2.0.2/en/deploy/openapi.json get /agent/ Return all agent records belonging to the authenticated user. Args: user_id (`str`): Injected authenticated user ID. storage (`StorageBase`): Injected storage backend. Returns: `ListAgentsResponse`: All agent records and their total count. # Update an agent Source: https://docs.agentscope.io/api-reference/agent/update-an-agent /versions/2.0.2/en/deploy/openapi.json patch /agent/{agent_id} Partially update an existing agent configuration. Only the fields present in the request body are updated; all other fields keep their current values. Args: agent_id (`str`): The agent to update. body (`UpdateAgentRequest`): Fields to update. user_id (`str`): Injected authenticated user ID. storage (`StorageBase`): Injected storage backend. Returns: `AgentRecord`: The full agent record after the update. Raises: `HTTPException`: 404 if the agent does not exist or does not belong to the authenticated user. # Channel Status Source: https://docs.agentscope.io/api-reference/channels/channel-status /versions/2.0.6/en/deploy/openapi.json get /channels/{channel_id}/status The channel's live connection status. # Create Channel Source: https://docs.agentscope.io/api-reference/channels/create-channel /versions/2.0.6/en/deploy/openapi.json post /channels/ Create a channel. # Delete Channel Source: https://docs.agentscope.io/api-reference/channels/delete-channel /versions/2.0.6/en/deploy/openapi.json delete /channels/{channel_id} Delete a channel. # Disable Channel Source: https://docs.agentscope.io/api-reference/channels/disable-channel /versions/2.0.6/en/deploy/openapi.json post /channels/{channel_id}/disable Disable a channel. # Enable Channel Source: https://docs.agentscope.io/api-reference/channels/enable-channel /versions/2.0.6/en/deploy/openapi.json post /channels/{channel_id}/enable Enable a channel. # Get Channel Source: https://docs.agentscope.io/api-reference/channels/get-channel /versions/2.0.6/en/deploy/openapi.json get /channels/{channel_id} Get channel details. # List Channel Sessions Source: https://docs.agentscope.io/api-reference/channels/list-channel-sessions /versions/2.0.6/en/deploy/openapi.json get /channels/{channel_id}/sessions Sessions this channel spawned, newest first. # List Channel Types Source: https://docs.agentscope.io/api-reference/channels/list-channel-types /versions/2.0.6/en/deploy/openapi.json get /channels/types List supported channel types with their JSON schemas. # List Channels Source: https://docs.agentscope.io/api-reference/channels/list-channels /versions/2.0.6/en/deploy/openapi.json get /channels/ List channels owned by the current user. # List Chat Ids Source: https://docs.agentscope.io/api-reference/channels/list-chat-ids /versions/2.0.6/en/deploy/openapi.json get /channels/{channel_id}/chat_ids Known chats for routing config: platform list ∪ passively seen. # Update Channel Source: https://docs.agentscope.io/api-reference/channels/update-channel /versions/2.0.6/en/deploy/openapi.json patch /channels/{channel_id} Update routing / session / config / enabled. # Trigger a chat run (fire-and-forget) Source: https://docs.agentscope.io/api-reference/chat/trigger-a-chat-run-fire-and-forget /versions/2.0.2/en/deploy/openapi.json post /chat/ Trigger a chat run for the specified session. The run executes as a background task. Events produced during the run are published to the message bus and delivered to any active ``GET /sessions/{session_id}/stream`` SSE subscriber. The caller does **not** receive events from this endpoint's response body. Accepts the same ``input`` payloads as before: - ``Msg`` / ``list[Msg]``: new user message(s). - ``UserConfirmResultEvent`` / ``ExternalExecutionResultEvent``: resume a paused tool call (human-in-the-loop). - ``None``: continue from current state. Args: request (`ChatRequest`): JSON body with ``agent_id``, ``session_id``, and ``input``. user_id (`str`): Injected user id. chat_service (`ChatService`): Injected application-wide chat service. Returns: `ChatTriggerResponse`: Confirms the run was scheduled. # Create a new credential Source: https://docs.agentscope.io/api-reference/credential/create-a-new-credential /versions/2.0.2/en/deploy/openapi.json post /credential/ Store a new credential. Args: body (`CreateCredentialRequest`): Credential payload to store. user_id (`str`): Injected authenticated user ID. storage (`StorageBase`): Injected storage backend. Returns: `CreateCredentialResponse`: The server-assigned credential identifier. # Delete a credential Source: https://docs.agentscope.io/api-reference/credential/delete-a-credential /versions/2.0.2/en/deploy/openapi.json delete /credential/{credential_id} Permanently delete a credential. Args: credential_id (`str`): The credential to delete. user_id (`str`): Injected authenticated user ID. storage (`StorageBase`): Injected storage backend. Raises: `HTTPException`: 404 if the credential does not exist or does not belong to the authenticated user. # List all credentials Source: https://docs.agentscope.io/api-reference/credential/list-all-credentials /versions/2.0.2/en/deploy/openapi.json get /credential/ Return all credential records belonging to the authenticated user. Args: user_id (`str`): Injected authenticated user ID. storage (`StorageBase`): Injected storage backend. Returns: `ListCredentialsResponse`: All credential records and their total count. # List JSON schemas for all credential types Source: https://docs.agentscope.io/api-reference/credential/list-json-schemas-for-all-credential-types /versions/2.0.2/en/deploy/openapi.json get /credential/schemas Return JSON schemas for all registered credential types. Used by the frontend to render credential creation forms dynamically. # Update a credential Source: https://docs.agentscope.io/api-reference/credential/update-a-credential /versions/2.0.2/en/deploy/openapi.json patch /credential/{credential_id} Replace the payload of an existing credential. Args: credential_id (`str`): The credential to update. body (`UpdateCredentialRequest`): New credential payload. user_id (`str`): Injected authenticated user ID. storage (`StorageBase`): Injected storage backend. Returns: `CredentialRecord`: The updated credential record. Raises: `HTTPException`: 404 if the credential does not exist or does not belong to the authenticated user. # List all candidate embedding models under the given credential type Source: https://docs.agentscope.io/api-reference/embedding-model/list-all-candidate-embedding-models-under-the-given-credential-type /versions/2.0.6/en/deploy/openapi.json get /embedding-model/ Return all candidate embedding models under the credential type. Unlike ``/knowledge_bases/embedding_models``, which narrows the list to what the knowledge base's dimension policy accepts, this endpoint reports the provider's full catalogue — it answers "what can this credential do", not "what can I build a KB with". Args: body (ListEmbeddingModelsRequest): The request body. Returns: `ListEmbeddingModelsResponse`: The response body. # List all candidate models under the given credential type Source: https://docs.agentscope.io/api-reference/model/list-all-candidate-models-under-the-given-credential-type /versions/2.0.2/en/deploy/openapi.json get /model/ Return all candidate models under the given credential type. Args: body (ListModelsRequest): The request body. Returns: `ListModelsResponse`: The response body. # List messages for a session Source: https://docs.agentscope.io/api-reference/sessions/list-messages-for-a-session /versions/2.0.2/en/deploy/openapi.json get /sessions/{session_id}/messages Return persisted messages for a session. Args: session_id: The session to query. agent_id: Agent the session belongs to. offset: Pagination offset. limit: Maximum number of messages to return. user_id: Injected authenticated user ID. storage: Injected storage backend. message_bus: Injected message bus. Returns: Messages and running status. # Probe the session's high-level status Source: https://docs.agentscope.io/api-reference/sessions/probe-the-sessions-high-level-status /versions/2.0.4/en/deploy/openapi.json get /sessions/{session_id}/status Return the unified :class:`SessionStatus` for a session. Ownership validation, cluster-liveness probing, and parked-state derivation are all delegated to :meth:`SessionService.get_session_status` — see that method for the precedence rules that collapse the two orthogonal signals (message-bus run lock + persisted context tail) into a single four-valued enum. Args: session_id (`str`): The session to probe. agent_id (`str`): The agent that owns the session (ownership validation). user_id (`str`): Injected authenticated user ID. session_service (`SessionService`): Injected session service. Owns both storage and message bus dependencies so the composed answer is derived in a single layer. Returns: `SessionStatusResponse`: The probed session id and its unified status. Raises: `HTTPException`: 404 if the session does not exist or does not belong to the authenticated user. # Subscribe to a session's event stream (SSE) Source: https://docs.agentscope.io/api-reference/sessions/subscribe-to-a-sessions-event-stream-sse /versions/2.0.2/en/deploy/openapi.json get /sessions/{session_id}/stream Subscribe to a session's live event stream. Returns a ``text/event-stream`` that first replays any buffered events from the current run's replay log (if a run is in progress or just finished), then streams live events as they are produced by :meth:`ChatService.run`. The connection stays open until the client disconnects — subsequent runs on the same session are delivered over the same connection. A heartbeat comment frame (``:\n\n``) is sent every 30 seconds to keep the connection alive through reverse proxies. Args: session_id (`str`): The session to subscribe to. agent_id (`str`): The agent that owns the session (used for ownership validation). user_id (`str`): Injected authenticated user id. storage (`StorageBase`): Injected storage backend (ownership check only). message_bus (`MessageBus`): Injected message bus (replay + live subscription). Returns: `StreamingResponse`: SSE stream of AgentEvent frames + periodic heartbeats. # Delete Skill Source: https://docs.agentscope.io/api-reference/skill/delete-skill /versions/2.0.6/en/deploy/openapi.json delete /skill/{skill_id} Remove a skill from the user's library. Workspaces that already hold this skill keep their copy — the files were extracted into the workspace, and this record was only where they came from. # Get Skill Source: https://docs.agentscope.io/api-reference/skill/get-skill /versions/2.0.6/en/deploy/openapi.json get /skill/{skill_id} Return one installed skill, including its ``SKILL.md`` body. # List Skills Source: https://docs.agentscope.io/api-reference/skill/list-skills /versions/2.0.6/en/deploy/openapi.json get /skill Return every skill the user has installed, ordered by name. # List all candidate TTS models under the given credential type Source: https://docs.agentscope.io/api-reference/tts-model/list-all-candidate-tts-models-under-the-given-credential-type /versions/2.0.3/en/deploy/openapi.json get /tts-model/ Return all candidate TTS models under the given credential type. Args: body (ListTTSModelsRequest): The request body. Returns: `ListTTSModelsResponse`: The response body. # Add Mcp Source: https://docs.agentscope.io/api-reference/workspace/add-mcp /versions/2.0.2/en/deploy/openapi.json post /workspace/mcp Add an MCP client to the session's workspace. # Add Mcps From Library Source: https://docs.agentscope.io/api-reference/workspace/add-mcps-from-library /versions/2.0.6/en/deploy/openapi.json post /workspace/mcp/from-library Put MCPs the user has already installed into this workspace. The rendered config never leaves the server, so the client sends ids rather than configs — it has no way to reconstruct one. Adding is per-MCP: one that fails to connect does not cancel the rest, and the response says which ones landed. # Add Skill Source: https://docs.agentscope.io/api-reference/workspace/add-skill /versions/2.0.2/en/deploy/openapi.json post /workspace/skill Add a skill to the session's workspace from the given path. # Add Skills From Library Source: https://docs.agentscope.io/api-reference/workspace/add-skills-from-library /versions/2.0.6/en/deploy/openapi.json post /workspace/skill/from-library Put skills the user has already installed into this workspace. Each one is re-downloaded from its hub and piped into the workspace; the server holds no copy in between. Adding is per-skill, and the response says which ones landed. # Create Download Token Source: https://docs.agentscope.io/api-reference/workspace/create-download-token /versions/2.0.6/en/deploy/openapi.json post /workspace/files/download-token Mint a short-lived token for a browser-native download. The browser writes the response straight to disk only when it issues the request itself, and such a request carries no custom header — hence a credential in the URL. Fetching with ``X-User-ID`` instead works but holds the whole file in the tab. Minting depends on the normal identity, so whatever replaces ``X-User-ID`` guards this too. The session is resolved here only to fail early: the download is a browser navigation, so an error there surfaces as a raw error page rather than something the UI can show. # List Mcps Source: https://docs.agentscope.io/api-reference/workspace/list-mcps /versions/2.0.2/en/deploy/openapi.json get /workspace/mcp Return all MCP clients with live tool list and health status. # List Skills Source: https://docs.agentscope.io/api-reference/workspace/list-skills /versions/2.0.2/en/deploy/openapi.json get /workspace/skill Return all skills available in the session's workspace. # List Workspace Directory Source: https://docs.agentscope.io/api-reference/workspace/list-workspace-directory /versions/2.0.6/en/deploy/openapi.json get /workspace/directories List one directory level, reachable from a session's workspace. Paths are not confined to the workspace root: for a sandboxed backend the reachable filesystem is the sandbox, and for a local one the caller is already trusted with the host. # Read Workspace File Source: https://docs.agentscope.io/api-reference/workspace/read-workspace-file /versions/2.0.6/en/deploy/openapi.json get /workspace/files Stream one file out of a session's workspace. The body is piped chunk by chunk rather than read whole: the API process is shared, so one large download must not be able to exhaust it for everyone else. # Remove Mcp Source: https://docs.agentscope.io/api-reference/workspace/remove-mcp /versions/2.0.2/en/deploy/openapi.json delete /workspace/mcp/{mcp_name} Remove an MCP client from the session's workspace by name. # Remove Skill Source: https://docs.agentscope.io/api-reference/workspace/remove-skill /versions/2.0.2/en/deploy/openapi.json delete /workspace/skill/{skill_name} Remove a skill from the session's workspace by name. # Upload Skill Source: https://docs.agentscope.io/api-reference/workspace/upload-skill /versions/2.0.6/en/deploy/openapi.json post /workspace/skill/upload Install a skill from an uploaded folder. The parts are re-tarred on the fly and piped into the workspace, so the archive is never held whole. The manifest is what the client claims; every limit in it is re-checked here, and the byte counts are verified as the tar is built. # A2A Protocol Source: https://docs.agentscope.io/versions/2.0.8/en/building-blocks/a2a Connect to a remote agent behind the A2A protocol and talk to it. A2A ([Agent2Agent](https://a2a-protocol.org/)) is an agent communication protocol proposed by Google. AgentScope acts as an A2A client through the `A2AAgent` class. It connects to any remote agent implementing A2A 1.0 or above (when the peer only offers 0.3, the official SDK falls back to a compatible transport), turns the messages, statuses, and artifacts returned by the remote agent into AgentScope messages and events, and sends user input (multimodal data included) to the remote agent. `A2AAgent` is only a local proxy for the remote agent's logic; it holds no logic of its own. Its interface matches the `Agent` class, with the following differences: | Capability | `Agent` | `A2AAgent` | | -------------------------------------------------------- | ------- | ---------------------------------------------------------------------------- | | `reply()` / `reply_stream()` | ✅ | Sends the input to the remote side and streams the translated response back | | `observe()` | ✅ | Buffers the messages and sends them with the next `reply()` | | `compress_context()` | ✅ | A no-op; the context is maintained by the remote service | | Model, tools, middleware, permissions, structured output | ✅ | Not provided; all decided by the remote service | | Interruption and resumption, human-in-the-loop | ✅ | Not provided; a remote agent waiting for input surfaces as an ordinary reply | The repository's [`examples/a2a`](https://github.com/agentscope-ai/agentscope/tree/main/examples/a2a) contains a complete two-sided example: an A2A server built from an AgentScope agent, and an `A2AAgent` connecting to it. ## Quickstart A2A support relies on the official SDK, installed with the `a2a` extra. ```bash Install the dependency theme={null} pip install "agentscope[a2a]" ``` An agent card is the remote agent's self-description: a JSON document at a well-known address recording its name, summary, capabilities, and the transports and endpoints it offers. `A2AAgent` uses it to identify the peer and pick a transport, so a connection starts by fetching the card. ```python Resolve an agent card theme={null} import httpx from a2a.client import A2ACardResolver # The httpx client only serves this one resolution and is closed right after async with httpx.AsyncClient() as httpx_client: card = await A2ACardResolver( httpx_client=httpx_client, base_url="http://127.0.0.1:9999", ).get_agent_card() print(card.name) # this name becomes the name of the A2AAgent ``` Hand the card to `A2AAgent`. It owns an A2A client of its own and closes it when leaving the context manager, so one instance serves one conversation and cannot be reopened after closing. The conversation interface matches a local agent's: `reply_stream` yields events in real time, while `reply` consumes them internally and returns the final message. Hand the event stream to the [console](/versions/2.0.8/en/building-blocks/console)'s `ConsoleRenderer` and there is no event dispatching to write yourself. ```python Streaming theme={null} from agentscope.agent import A2AAgent from agentscope.console import ConsoleRenderer from agentscope.message import UserMsg renderer = ConsoleRenderer() async with A2AAgent(card) as agent: # The remote text is printed as it is generated async for event in agent.reply_stream( UserMsg(name="user", content="Plan me a weekend trip to Hangzhou."), ): renderer.render(event) # The second call automatically reuses the same remote conversation async for event in agent.reply_stream( UserMsg(name="user", content="Make it kid-friendly."), ): renderer.render(event) # The renderer accumulates the events back into one reply message print(renderer.last_msg.get_text_content()) ``` ```python One-shot theme={null} from agentscope.agent import A2AAgent from agentscope.message import UserMsg async with A2AAgent(card) as agent: # Wait for the remote side to finish this turn and take the final message reply = await agent.reply( UserMsg(name="user", content="Plan me a weekend trip to Hangzhou."), ) print(reply.get_text_content()) # The second call automatically reuses the same remote conversation reply = await agent.reply( UserMsg(name="user", content="Make it kid-friendly."), ) ``` `A2AAgent.reply_stream` takes no `yield_final_msg` argument as a local agent does; reach for `renderer.last_msg` or `reply` when you need the final message. To skip writing the loop altogether, `launch_console` takes over input, rendering, and interruption, so a remote agent can be dropped straight in for interactive debugging. ```python Talk to a remote agent in the terminal theme={null} from agentscope.console import launch_console async with A2AAgent(card) as agent: await launch_console(agent) ``` The constructor takes the following arguments, where `client` and `state` are keyword-only: | Argument | Description | | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `agent_card` | The remote agent card, used to identify the peer and pick a transport | | `client` | Optional, a self-configured official SDK client, e.g. one over gRPC or with custom authentication. Without it, a streaming client is built from the card, which requires the peer to offer a `JSONRPC` or `HTTP+JSON` endpoint | | `state` | Optional, an existing `A2AAgentState` for resuming an earlier remote conversation | Everything worth persisting about a remote conversation lives in `A2AAgentState`; pass it back to the constructor to resume: ```python Resume the same remote conversation theme={null} from agentscope.state import A2AAgentState agent = A2AAgent(card, state=A2AAgentState(context_id=stored_context_id)) ``` ## Protocol Translation All `A2AAgent` does is translate A2A concepts into AgentScope concepts, at three levels: conversation identifiers, response payloads, and content parts. ### Conversations and Tasks A2A organizes a conversation with two identifiers. They do not map one-to-one onto AgentScope concepts, which is the easiest thing to get wrong in practice: | A2A Identifier | Meaning | On the AgentScope Side | | -------------- | ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `context_id` | One conversation on the remote side | The equivalent of a `session_id`: several `reply()` calls under the same `context_id` share the remote context, carried automatically by `A2AAgent` with nothing to pass by hand | | `task_id` | One unit of execution on the remote side | **Not the same as one `reply()`**: a single `reply()` may span several tasks, or continue a task left over from an earlier `reply()`, depending on the remote implementation | Both are stored in `A2AAgentState`. That state also carries a local `session_id`, used only to group the events this adapter produces and unrelated to the remote side. ### Response Payloads Every kind of remote response payload is broken down into content parts for translation; the payload itself only decides which task the content belongs to and how the reply ends: | A2A Response Payload | How It Is Translated | | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Message` | Its parts become content; this is a direct answer that produces no task, so `task_id` is cleared | | `TaskArtifactUpdateEvent` | The artifact's parts become content; the `append` and `last_chunk` markers decide whether text keeps flowing into the same block, so streamed text is not chopped up | | `TaskStatusUpdateEvent` | The status message's parts become content, and the status decides `finished_reason` and `task_id` | | `Task` | A full snapshot: every artifact is translated first, then the status message | ### Content Parts Each part becomes a content block of the matching type: | A2A Part | AgentScope Content Block | | ---------------------------------------------------- | ------------------------------------------------------------------------ | | Text part | `TextBlock`; consecutive text within one batch flows into the same block | | Bytes part (`raw`) | `DataBlock`, with the bytes carried as base64 | | URL part (`url`) | `DataBlock`, keeping the original URL | | Anything else (a structured data part, for instance) | Unsupported; raises a `ValueError` | The event stream therefore only carries reply start/end events and text / data block events. When you dispatch the events yourself instead of using `ConsoleRenderer`, handling `TextBlockDeltaEvent` and `DataBlockEndEvent` is enough. The `metadata["a2a"]` of a block-end event records which A2A object it came from (`task_id`, `artifact_id`, `message_id`), and the `metadata["a2a"]` of the final message records the `context_id`. Thinking blocks, tool call blocks, hint blocks, and push notifications are out of translation scope: A2A carries the final product, so the remote agent's reasoning and tool calls never show up in the event stream. ### Ending a Reply A suspended remote task is suspended on the server, with nothing suspended locally, so **every response stream that ends means the reply has ended**. The task status the stream stops at decides the `finished_reason` of that reply: | Remote Task Status | `finished_reason` | Meaning | | --------------------------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------- | | `COMPLETED` | `COMPLETED` | The task finished | | `INPUT_REQUIRED`, `AUTH_REQUIRED` | `COMPLETED` | The remote side is waiting for input; its status message comes back as ordinary content, and the next `reply()` answers it | | `CANCELED` | `INTERRUPTED` | The task was cancelled | | `FAILED`, `REJECTED` | `ERROR` | The task failed or was rejected | The `task_id` is kept only while the remote side waits for input (`INPUT_REQUIRED` / `AUTH_REQUIRED`), so the next message continues that task; every other status clears it and the next message starts a new task within the same `context_id`. Two edge cases exist: a task the remote side has already forgotten degrades into a new task, and a task still running remotely raises a `RuntimeError` outright, because sending a message would make it run a second time. A2A credentials travel outside the protocol, so an `AUTH_REQUIRED` task cannot be authorized through this adapter; follow the instructions in the status message to complete it yourself. ## Further Reading Hand a remote agent to the terminal and start chatting. Learn what each event in the stream means. # Configure Agent Source: https://docs.agentscope.io/versions/2.0.8/en/building-blocks/agent/configure-agent Assemble an agent from a model, tools, and configuration An agent is assembled entirely at initialization: pass the model, toolkit, and config objects to `Agent(...)` and it is ready to reply. The examples below cover the most common setups. ```python Minimal Setup theme={null} from agentscope.agent import Agent from agentscope.model import DashScopeChatModel from agentscope.credential import DashScopeCredential agent = Agent( name="my_agent", system_prompt="You are a helpful assistant.", model=DashScopeChatModel( credential=DashScopeCredential(api_key="YOUR_API_KEY"), model="qwen-max", ), ) ``` ```python With Tools/MCPs/Skills theme={null} import os from agentscope.agent import Agent from agentscope.tool import Toolkit, Bash, Edit, Grep, Read, Write from agentscope.mcp import MCPClient, HttpMCPConfig from agentscope.model import DashScopeChatModel from agentscope.credential import DashScopeCredential agent = Agent( name="my_agent", system_prompt="You are a helpful assistant.", model=DashScopeChatModel( credential=DashScopeCredential(api_key="YOUR_API_KEY"), model="qwen-max", ), toolkit=Toolkit( tools=[Bash(), Edit(), Grep(), Read(), Write()], mcps=[ MCPClient( name="amap", is_stateful=False, mcp_config=HttpMCPConfig( url=f"https://mcp.amap.com/mcp?key={os.environ['AMAP_API_KEY']}", ), ), ], skills_or_loaders=["./skills"], ), ) ``` ```python With Custom Context Config theme={null} from agentscope.agent import Agent from agentscope.agent import ContextConfig from agentscope.model import DashScopeChatModel from agentscope.credential import DashScopeCredential agent = Agent( name="my_agent", system_prompt="You are a helpful assistant.", model=DashScopeChatModel( credential=DashScopeCredential(api_key="YOUR_API_KEY"), model="qwen-max", ), context_config=ContextConfig( trigger_ratio=0.7, # compress when 70% of context is used reserve_ratio=0.2, # keep the most recent 20% after compression tool_result_limit=1000, # truncate tool results at 1000 tokens max_image_num=5, # keep only the 5 most recent images ), ) ``` ```python With Custom ReAct Config theme={null} from agentscope.agent import Agent from agentscope.agent import ReActConfig from agentscope.model import DashScopeChatModel from agentscope.credential import DashScopeCredential agent = Agent( name="my_agent", system_prompt="You are a helpful assistant.", model=DashScopeChatModel( credential=DashScopeCredential(api_key="YOUR_API_KEY"), model="qwen-max", ), react_config=ReActConfig( max_iters=30, # at most 30 reasoning-acting iterations structured_output_grace_iters=3, # extra iterations to finish structured output stop_on_reject=True, # stop replying when tool calls are rejected ), ) ``` ## Parameters All configuration enters through the `Agent(...)` constructor. The table below lists every parameter, with the tunable knobs grouped into config objects: | Parameter | Type | Default | Description | | ------------------ | ------------------------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | `name` | `str` | required | Agent identifier, used in messages and logs | | `system_prompt` | `str` | required | The agent's base system prompt | | `model` | `ChatModelBase` | required | The LLM used for reasoning | | `toolkit` | `Toolkit \| None` | `None` | Manages tools, MCP clients, skills, and tool groups | | `state` | `AgentState \| None` | auto-created | Holds context, permission context, and session state | | `offloader` | `Offloader \| None` | `None` | Offloads compressed context and tool results; must implement the `Offloader` protocol | | `middlewares` | `list[MiddlewareBase] \| None` | `None` | Applied at reply, reasoning, acting, model call, and system prompt hooks | | `model_config` | `ModelConfig` | default | Retry count and fallback model | | `context_config` | `ContextConfig` | default | Context compression thresholds, image count and tool result limits | | `injection_config` | `InjectionConfig` | default | Runtime state injection: time, tasks, and context usage (see [Environment Awareness](/versions/2.0.8/en/building-blocks/context/environment-awareness)) | | `react_config` | `ReActConfig` | default | Max iterations, structured output grace iterations, and rejection handling | ## Switch Models Switching the LLM provider only changes the `model` argument: every provider follows the same `Model(credential=..., model=...)` pattern, and the rest of the agent stays untouched. ```python DashScope theme={null} from agentscope.model import DashScopeChatModel from agentscope.credential import DashScopeCredential model = DashScopeChatModel( credential=DashScopeCredential(api_key="YOUR_API_KEY"), model="qwen-max", ) ``` ```python OpenAI theme={null} from agentscope.model import OpenAIChatModel from agentscope.credential import OpenAICredential model = OpenAIChatModel( credential=OpenAICredential(api_key="YOUR_API_KEY"), model="gpt-4o", ) ``` ```python Anthropic theme={null} from agentscope.model import AnthropicChatModel from agentscope.credential import AnthropicCredential model = AnthropicChatModel( credential=AnthropicCredential(api_key="YOUR_API_KEY"), model="claude-sonnet-4-5", ) ``` ```python Gemini theme={null} from agentscope.model import GeminiChatModel from agentscope.credential import GeminiCredential model = GeminiChatModel( credential=GeminiCredential(api_key="YOUR_API_KEY"), model="gemini-2.5-pro", ) ``` ```python Ollama theme={null} from agentscope.model import OllamaChatModel from agentscope.credential import OllamaCredential model = OllamaChatModel( credential=OllamaCredential(host="http://localhost:11434"), model="qwen3:8b", ) ``` ## Support Multi-Entity Conversations An agent is not limited to one-user-one-agent chat. In an agent team, a group chat, or a game with NPCs, messages from **multiple named entities** share the same context, and the agent must know who said what. In AgentScope, each speaker is identified by the `name` field of its `Msg`. A multi-entity conversation is simply a list of messages with different names fed into the agent: ```python theme={null} from agentscope.message import UserMsg msgs = [ UserMsg(name="Alice", content="I vote for the beach."), UserMsg(name="Bob", content="I'd rather go hiking."), UserMsg(name="user", content="Friday, summarize everyone's preference."), ] result = await agent.reply(msgs) ``` Whether these identities survive is decided by the **formatter**, the component that converts `Msg` objects into the provider's API format before each model call. The default chat formatter maps messages onto the API's bare `user`/`assistant` roles, which fits one-user-one-agent chat but drops the names, leaving the speakers indistinguishable. For multi-entity conversations, each provider ships a `MultiAgentFormatter`. It merges the history into a single named transcript (`Alice: ...`, `Bob: ...`) carried in a user message, so the LLM sees each speaker's identity. Switch by passing it to the model: ```python theme={null} from agentscope.model import DashScopeChatModel from agentscope.credential import DashScopeCredential from agentscope.formatter import DashScopeMultiAgentFormatter model = DashScopeChatModel( credential=DashScopeCredential(api_key="YOUR_API_KEY"), model="qwen-max", formatter=DashScopeMultiAgentFormatter(), ) ``` In multi-entity conversations, state the agent's own name in its system prompt, so the LLM knows which speaker it is playing. Each provider has a matching pair: | Provider | Chat formatter (default) | Multi-agent formatter | | --------- | ------------------------ | ------------------------------ | | DashScope | `DashScopeChatFormatter` | `DashScopeMultiAgentFormatter` | | OpenAI | `OpenAIChatFormatter` | `OpenAIMultiAgentFormatter` | | Anthropic | `AnthropicChatFormatter` | `AnthropicMultiAgentFormatter` | | Gemini | `GeminiChatFormatter` | `GeminiMultiAgentFormatter` | | Ollama | `OllamaChatFormatter` | `OllamaMultiAgentFormatter` | | DeepSeek | `DeepSeekChatFormatter` | `DeepSeekMultiAgentFormatter` | | Moonshot | `MoonshotChatFormatter` | `MoonshotMultiAgentFormatter` | | XAI | `XAIChatFormatter` | `XAIMultiAgentFormatter` | ## Next Steps How to build tools, and connect MCP servers and skills. How to hook into reply, reasoning, acting, and model calls. How to work in different sandboxes. How to control which tool calls run, ask, or are denied. # Human-in-the-Loop Source: https://docs.agentscope.io/versions/2.0.8/en/building-blocks/agent/human-in-the-loop Pause for user confirmation or external execution, then resume with result events The agent pauses execution and emits special events when it encounters two situations: a tool call that requires **user confirmation** (permission system returns ASK), or a tool marked as **external execution** (the result must come from outside the agent). In both cases, you resume the agent by passing a result event back via `reply`. While paused, `reply` returns a waiting notice message whose `finished_reason` is `None`: the reply is parked, not finished, and no remaining tool calls are executed until the outside result arrives. ## User Confirmation When the permission system determines a tool call needs user approval, the agent emits a `RequireUserConfirmEvent` and pauses. Use `reply_stream` to detect the pause. The event has the following structure: ID of the current reply, used to resume the agent. Tool calls pending user confirmation. Each `ToolCallBlock` contains: Unique identifier for this tool call. The tool name (e.g. `"Bash"`, `"Write"`). JSON-encoded input parameters. Auto-generated permission rules the user can accept to allow similar future calls. ```python theme={null} from agentscope.event import RequireUserConfirmEvent async for event in agent.reply_stream(msg): if isinstance(event, RequireUserConfirmEvent): for tc in event.tool_calls: print(f"Tool: {tc.name}, Input: {tc.input}") print(f"Suggested rules: {tc.suggested_rules}") ``` For each pending tool call, create a `ConfirmResult` indicating whether to allow or deny it. You can also modify the tool call input or accept suggested permission rules: ```python theme={null} from agentscope.event import ConfirmResult, UserConfirmResultEvent confirm_results = [] for tc in event.tool_calls: confirm_results.append(ConfirmResult( confirmed=True, # or False to deny tool_call=tc, # pass back (optionally modified) rules=tc.suggested_rules, # accept rules for future auto-allow )) ``` Pass the `UserConfirmResultEvent` back to `reply` or `reply_stream`: ```python theme={null} confirm_event = UserConfirmResultEvent( reply_id=event.reply_id, confirm_results=confirm_results, ) result = await agent.reply(confirm_event) ``` * **Confirmed** tool calls execute immediately and the agent continues reasoning * **Denied** tool calls produce an error result visible to the LLM, which may retry with a different approach * **Accepted rules** are persisted to the permission engine, so matching future calls are auto-allowed without prompting again ## External Tool Execution When the agent calls a tool with `is_external_tool = True`, it emits a `RequireExternalExecutionEvent` and pauses. The tool's logic runs outside the agent, typically by a human operator or an external system. The event has the following structure: ID of the current reply, used to resume the agent. Tool calls to be executed externally. Each `ToolCallBlock` contains: Unique identifier for this tool call. The external tool name. JSON-encoded input parameters. ```python theme={null} from agentscope.event import RequireExternalExecutionEvent async for event in agent.reply_stream(msg): if isinstance(event, RequireExternalExecutionEvent): for tc in event.tool_calls: print(f"Execute externally: {tc.name}({tc.input})") ``` Run the operation outside the agent and wrap the results as `ToolResultBlock` objects: ```python theme={null} from agentscope.message import ToolResultBlock, TextBlock, ToolResultState from agentscope.event import ExternalExecutionResultEvent execution_results = [] for tc in event.tool_calls: # Perform the actual operation (API call, human action, etc.) output = await run_external_operation(tc.name, tc.input) execution_results.append(ToolResultBlock( id=tc.id, name=tc.name, output=[TextBlock(text=output)], state=ToolResultState.SUCCESS, )) ``` Pass the `ExternalExecutionResultEvent` back to resume: ```python theme={null} external_event = ExternalExecutionResultEvent( reply_id=event.reply_id, execution_results=execution_results, ) result = await agent.reply(external_event) ``` The results are injected into the agent's context and reasoning continues from where it left off. If multiple tool calls require outside interaction and only partial confirmation or execution results are passed back, the agent won't re-send the requiring events for the unconfirmed or unexecuted tool calls. Confirmations are de-duplicated inside one concurrent batch: when several tool calls run in parallel and a later call is already covered by the rule suggested by an earlier confirmation in the same batch, it is not surfaced a second time. Safety ASKs are never de-duplicated this way. Use `reply_stream` when building interactive UIs: it lets you detect pause events in real time and prompt the user immediately. Use `reply` when you have pre-built automation that handles events programmatically. # Interrupt Agent Source: https://docs.agentscope.io/versions/2.0.8/en/building-blocks/agent/interrupt-agent Stop a running or paused agent cleanly and resume from a consistent state The `Agent` class implements interruption on top of `asyncio.CancelledError`, so you can stop execution at any stage of model inference or tool execution. Once interrupted, the agent's context is left in a consistent state and the conversation can be resumed immediately with a new message. There are two ways to interrupt an agent, depending on whether it is actively running: * **Agent is running**: cancel the coroutine that is awaiting `reply` or `reply_stream` by calling `task.cancel()` on its task. This raises `asyncio.CancelledError` inside the agent, which unwinds the current reasoning-acting step cleanly. * **Agent is paused**: pass a `UserInterruptEvent` to `reply_stream`. If the agent is currently waiting for user confirmation or external tool execution, it discards the pending state and terminates the reply. For each pending tool call it synthesises a `ToolResultBlock` marked as interrupted by the user and yields the corresponding `ToolResultStartEvent`, `ToolResultTextDeltaEvent`, `ToolResultEndEvent`, and finally a `ReplyEndEvent`, leaving the agent ready to accept new input. If the agent is not in a paused state, the event is a no-op and `reply` / `reply_stream` returns immediately. ```python Interrupt a running agent theme={null} import asyncio from agentscope.agent import Agent from agentscope.message import UserMsg async def chat(agent: Agent) -> None: async for event in agent.reply_stream( UserMsg(name="user", content="..."), ): ... async def main() -> None: agent = Agent(...) task = asyncio.create_task(chat(agent)) # Cancel the task at any point to interrupt the agent await asyncio.sleep(1) task.cancel() asyncio.run(main()) ``` ```python Interrupt a paused agent theme={null} from agentscope.agent import Agent from agentscope.event import UserInterruptEvent async def main() -> None: agent = Agent(...) # The agent has previously paused on a RequireUserConfirmEvent # or a RequireExternalExecutionEvent. Send UserInterruptEvent to # clear the pending state and end the current reply. async for event in agent.reply_stream( UserInterruptEvent(reply_id=agent.state.reply_id), ): print(event) ``` Use `agent.state.reply_id` to reference the reply that is currently paused. See [Human-in-the-Loop](/versions/2.0.8/en/building-blocks/agent/human-in-the-loop) for how the agent enters a paused state. # Overview Source: https://docs.agentscope.io/versions/2.0.8/en/building-blocks/agent/overview The stateless reasoning-acting loop engine at the core of AgentScope `Agent` is the core abstraction in AgentScope: a **stateless** reasoning-acting loop engine that integrates models, tools, the permission system, human-in-the-loop, context management, middlewares, state management, and the event system into a single unified interface. Its primary responsibilities are: * Accepting input messages or events, invoking tools to complete tasks * Generating structured output that conforms to a user-provided schema * Managing context, including context compression and offloading * Staying aware of the changing environment (time, tasks, context usage) via runtime state injection * Executing middleware hooks at key lifecycle stages for custom logic * Automatically managing concurrent and sequential tool execution * Handling user interruptions and continuing from paused states ## Core Interfaces The main interfaces of the `Agent` class are as follows: | Method | Description | | ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | | `reply(inputs, structured_schema)` | Run the reasoning-acting loop and return the final `Msg`, optionally enforcing a structured output | | `reply_stream(inputs, structured_schema, yield_final_msg)` | Same as `reply`, but yields `AgentEvent` objects as they are produced | | `observe(msgs)` | Add messages to context without triggering reasoning | | `compress_context(context_config, instructions)` | Compress the context if token count exceeds the threshold, optionally guided by injected instructions | ## Main Loop The agent runs a reasoning-acting loop on every `reply` call. Each round, a single decision point inspects the current state and chooses the next action (reasoning, acting, or exit). The diagram below shows the main control flow. ```mermaid theme={null} flowchart TD A([Input: msg / event]) --> B{Awaiting outside event?} B -- Yes --> C[Handle Event
update tool states] B -- No --> D[Add msgs to context] C --> E D --> E E{Check next action} -- exit: awaiting outside
interaction --> F([Pause: waiting for
confirmation / execution]) E -- exit: final message or
structured output ready --> I([Return final message]) E -- reasoning --> G[Compress context
if needed] E -- acting --> Acting G --> H[LLM Call] H --> E subgraph Acting [Acting] direction TB J[Batch tool calls
sequential / concurrent] --> L[Execute tool calls] L --> M{Permission
Check} M -- ALLOW --> N[Run tool → result] M -- ASK / External --> O([Pause & emit
RequireUserConfirmEvent]) M -- DENY --> P[Error result to LLM] end N --> E P --> E ``` ## Next Steps How to set up models, formatters, tools, and configs. How to reply, stream, require structured output, and persist state. How the agent stays aware of time, tasks, and context usage. How to stop a running or paused agent cleanly. How to pause for user confirmation or external execution. # Run Agent Source: https://docs.agentscope.io/versions/2.0.8/en/building-blocks/agent/run-agent Run agents and work with their replies, context, and state The `Agent` class abstracts what an agent does into a small set of behaviors, each suited to a different goal: | Behavior | Interface | Use It For | | ------------------------------------------- | ------------------------------------ | --------------------------------------------------------------------------- | | [Reply](#reply) | `reply`, `reply_stream` | Drive the reasoning-acting loop, in one shot or as a real-time event stream | | [Structured Output](#structured-output) | `structured_schema` parameter | Require the reply to produce fields conforming to a JSON schema | | [Observation](#message-observation) | `observe` | Inject messages into context without triggering a reply | | [Context Compression](#context-compression) | `compress_context` | Keep long conversations within the model's context window | | [State Persistence](#state-persistence) | `agent.state` plus a storage backend | Pause a session in one process and resume it in another | ## Reply `reply` and `reply_stream` drive the same reasoning-acting loop over the same `inputs`; they differ only in how results are delivered. The `inputs` parameter accepts: | Input | Effect | | -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | | A single `Msg` or a list of `Msg` | Start a new reply | | `UserConfirmResultEvent`, `ExternalExecutionResultEvent` | Resume from a paused state (see [Human-in-the-Loop](/versions/2.0.8/en/building-blocks/agent/human-in-the-loop)) | | `UserInterruptEvent` | Abort a paused reply (see [Interrupt Agent](/versions/2.0.8/en/building-blocks/agent/interrupt-agent)) | | `None` | Continue from the current state without new input | ### Basic Reply One call in, one final `Msg` out: the simplest way to run the agent, suited for automation where intermediate events don't matter. `reply` consumes all events internally and returns the final `Msg` when the agent finishes. If the reply pauses for outside interaction, it returns a waiting notice whose `finished_reason` is `None`, meaning the reply is not finished yet. ```python theme={null} import asyncio from agentscope.message import UserMsg async def main(): msg = UserMsg(name="user", content="What files are in the current directory?") result = await agent.reply(msg) print(result.get_text_content()) asyncio.run(main()) ``` Besides the text content, the returned `Msg` carries the reply's full outcome. The fields worth checking after each call: | Field | Type | Description | | ------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------ | | `content` | `list[ContentBlock]` | All blocks produced across the iterations (text, thinking, tool calls, and tool results) | | `finished_reason` | `ReplyFinishedReason \| None` | How the reply ended: `completed`, `interrupted`, `exceed_max_iters`, or `error`; `None` means paused, not finished | | `structured_output` | `dict \| None` | The validated result when a `structured_schema` was required (see [Structured Output](#structured-output)) | | `usage` | `Usage \| None` | Total input/output tokens accumulated over all model calls in this reply | | `error` | `ErrorInfo \| None` | Structured error info, populated only when `finished_reason` is `error` | | `finished_at` | `str \| None` | ISO 8601 timestamp when the reply finished | A reply with `finished_reason` set to `exceed_max_iters` usually still carries usable text: when the iteration budget runs out before the agent has produced a final answer, the framework makes one more model call with tools disabled and asks it to summarize the work and findings so far. That finalization runs at most once; only if it too produces no text does the reply end empty. See [Message & Event](/versions/2.0.8/en/building-blocks/message-and-event) for the complete `Msg` structure and content block types. ### Streaming Reply The agent emits text deltas, tool call progress, and lifecycle events in real time, which is the basis for interactive UIs. `reply_stream` yields `AgentEvent` objects as they are produced: ```python theme={null} async def main(): msg = UserMsg(name="user", content="Summarize the README.") async for event in agent.reply_stream(msg): if hasattr(event, "delta"): print(event.delta, end="", flush=True) asyncio.run(main()) ``` Pass `yield_final_msg=True` to additionally yield the final `Msg` as the last item of the stream. This is useful when you need the assembled reply message (e.g. its `structured_output` attribute) besides the events: ```python theme={null} from agentscope.message import Msg async for chunk in agent.reply_stream(msg, yield_final_msg=True): if isinstance(chunk, Msg): print("Final message:", chunk.get_text_content()) ``` ## Structured Output A reply can be required to produce fields conforming to a JSON schema, which is typical for generating reports or emitting control fields that drive a workflow. Pass a Pydantic model class via `structured_schema`. The agent equips a builtin `GenerateStructuredOutput` tool whose input schema is your schema: it reasons and calls other tools freely first, then submits the result through this tool. Validation errors are fed back to the model for retry, and the validated result lands on the final message's `structured_output` attribute as a plain dict (the message text is only a placeholder). ```python Basic Reply theme={null} from pydantic import BaseModel, Field from agentscope.message import UserMsg class WeatherReport(BaseModel): city: str = Field(description="The city name") temperature: float = Field(description="Temperature in Celsius") result = await agent.reply( UserMsg(name="user", content="What's the weather in Hangzhou?"), structured_schema=WeatherReport, ) print(result.structured_output) # {"city": "Hangzhou", "temperature": ...} ``` ```python Streaming Reply theme={null} from agentscope.message import Msg, UserMsg async for chunk in agent.reply_stream( UserMsg(name="user", content="What's the weather in Hangzhou?"), structured_schema=WeatherReport, yield_final_msg=True, ): if isinstance(chunk, Msg): print(chunk.structured_output) ``` The requirement is scoped to one reply and survives human-in-the-loop pauses, state persistence, and process restarts, because the schema is stored in the agent state as a plain dict. When resuming a parked reply, do not pass `structured_schema` again; the reply continues with the schema it was parked with. * Validation adapts to how the reply runs. In process, the class itself validates the output: defaults (including `default_factory`) are filled, extra fields are dropped, and custom validators are executed. After reloading a serialized state, only the JSON schema remains: schema-declared defaults are filled, extra fields are kept, and custom validators are skipped. * On reaching `max_iters` without output, the agent forces the `GenerateStructuredOutput` call within `structured_output_grace_iters` extra iterations (default 5). If it still fails, the reply ends with `finished_reason=EXCEED_MAX_ITERS` and `structured_output` is `None`; check for `None` before use. ## Message Observation Messages can be injected into the agent's context without triggering a reply. This is useful in multi-agent setups where one agent needs to see another's output. ```python theme={null} await agent.observe(other_agent_msg) ``` ## Context Compression Long conversations are kept within the model's context window by summarizing older messages, triggered automatically or on demand. The agent compresses its context when the token count exceeds `context_config.trigger_ratio × model.context_length`, and offloads the summarized messages to disk if an `offloader` is configured. `compress_context` takes two optional arguments: a `context_config` that overrides the default thresholds for this call, and an `instructions` `HintBlock` that is injected into the compression context to guide the summarization (for example, what must be preserved): ```python theme={null} from agentscope.agent import ContextConfig from agentscope.message import HintBlock # Use the agent's default config await agent.compress_context() # Or pass a custom config for this call only await agent.compress_context( ContextConfig(trigger_ratio=0.6, reserve_ratio=0.2) ) # Or inject instructions to guide the summarization await agent.compress_context( instructions=HintBlock( hint="Keep every file path and API signature mentioned so far.", ), ) ``` Tool results that exceed `tool_result_limit` tokens are truncated automatically; with an `offloader`, the truncated portion is offloaded and the agent receives a path reference it can read on demand. See [Context](/versions/2.0.8/en/building-blocks/context/overview) for the full compression pipeline and offloading. If the system prompt alone exceeds the compression threshold, `compress_context` raises a `RuntimeError`. Keep system prompts concise or increase the model's context length. ## State Persistence The complete agent state serializes to JSON, so a reply can pause in one process and resume in another. This is the basis for multi-session services. `AgentState` holds everything needed to resume exactly where the agent left off: conversation context, compression summary, permission rules, tool state, and the current reply position. `RedisStorage` is the built-in storage backend, organising state under a `(user_id, agent_id, session_id)` key hierarchy: | Method | Description | | ------------------------------------------------------------ | --------------------------------------------------------------------- | | `get_session(user_id, agent_id, session_id)` | Load a `SessionRecord` whose `.state` field is the saved `AgentState` | | `update_session_state(user_id, agent_id, session_id, state)` | Persist the updated `AgentState` back to Redis after a reply | ```python theme={null} import asyncio from agentscope.agent import Agent from agentscope.state import AgentState from agentscope.model import DashScopeChatModel from agentscope.credential import DashScopeCredential from agentscope.message import UserMsg from agentscope.app.storage import RedisStorage USER_ID = "user_123" AGENT_ID = "agent_456" SESSION_ID = "session_789" async def main(): async with RedisStorage(host="localhost", port=6379) as storage: # Load state from storage, fall back to a fresh state if not found record = await storage.get_session( user_id=USER_ID, agent_id=AGENT_ID, session_id=SESSION_ID, ) state = record.state if record else AgentState() # Create the agent with the restored state agent = Agent( name="my_agent", system_prompt="You are a helpful assistant.", model=DashScopeChatModel( credential=DashScopeCredential(api_key="YOUR_API_KEY"), model="qwen-max", ), state=state, ) # Run a reply turn result = await agent.reply( UserMsg(name="user", content="Continue where we left off."), ) print(result.get_text_content()) # Persist the updated state back to Redis await storage.update_session_state( user_id=USER_ID, agent_id=AGENT_ID, session_id=SESSION_ID, state=agent.state, ) asyncio.run(main()) ``` `update_session_state` raises `KeyError` if the session does not exist yet. Use `upsert_session` to create the session record on the first turn, then switch to `update_session_state` for subsequent turns. # Console Source: https://docs.agentscope.io/versions/2.0.8/en/building-blocks/console Quickly test and verify agent behavior in the terminal When you need to quickly try out or debug an agent, the `agentscope.console` module lets you chat with it and inspect its full event stream directly in the terminal, without launching the web service or hand-dispatching the dozens of event types that `reply_stream` produces. The console module ships two entries, one per usage scenario: | Entry | When to use | | ----------------- | --------------------------------------------------------------------------------------------------------------------------- | | `launch_console` | Interactive chat with a single agent: input loop, tool-call confirmation, and interruption handling built in, zero UI code | | `ConsoleRenderer` | Embedded in your own code: renders the event stream to the terminal, while inputs and orchestration stay under your control | ## Launch an Interactive Chat `launch_console` takes a constructed agent and handles the entire terminal interaction: ```python Chat with an agent in the terminal theme={null} import asyncio import os from agentscope.agent import Agent from agentscope.console import launch_console from agentscope.credential import DashScopeCredential from agentscope.model import DashScopeChatModel from agentscope.tool import Bash, Read, Toolkit, Write async def main() -> None: agent = Agent( name="Friday", system_prompt="You're a helpful assistant named Friday.", model=DashScopeChatModel( credential=DashScopeCredential( api_key=os.environ["DASHSCOPE_API_KEY"], ), model="qwen3.7-max", ), toolkit=Toolkit(tools=[Bash(), Read(), Write()]), ) # Enter the terminal chat; type exit/quit or press Ctrl+D to leave await launch_console(agent) asyncio.run(main()) ``` Each part of the interaction behaves as follows: | Interaction | Behavior | | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Message input | Reads input at the `user>` prompt; type `exit`, `quit`, or press Ctrl+D to leave | | Streamed rendering | Reply text and thinking print live; tool calls and results print as whole blocks | | Tool confirmation | When a tool call requires confirmation, each one is asked in turn: `y` allows once, `a` also accepts the suggested permission rules (matching calls won't ask again), anything else denies | | Interruption | Ctrl+C during streaming interrupts the current reply; Ctrl+C at a confirmation prompt aborts the reply waiting for confirmation | `launch_console` accepts the following parameters: The agent to interact with, or any pipeline satisfying [`PipelineProtocol`](/versions/2.0.8/en/building-blocks/pipeline/overview). The sender name attached to the user's messages, also used as the input prompt. Output verbosity, one of `"quiet"`, `"default"`, or `"debug"`. See [Control the Output Verbosity](#control-the-output-verbosity). Maximum number of printed lines per tool result; the excess collapses into a hint line. `None` disables truncation. `launch_console` involves no session management or persistence: the conversation lives in `agent.state` and ends with the process. For multi-user, multi-session, and persistent deployments, use the [agent service](/versions/2.0.8/en/deploy/agent-service). ## Embed the Event Renderer When you own the run logic yourself (an agent pipeline, a test script), use `ConsoleRenderer` for printing only. The renderer is passive: how events are produced, and how inputs and confirmations are handled, are entirely up to the caller. ```python Render the event stream in your own code theme={null} from agentscope.console import ConsoleRenderer from agentscope.message import UserMsg renderer = ConsoleRenderer() # Hand every event from reply_stream to the renderer async for event in agent.reply_stream(UserMsg("user", "Hi!")): renderer.render(event) # The renderer also accumulates the events back into a complete reply final_msg = renderer.last_msg ``` The renderer attributes events by reply id, so multiple agents speaking in sequence can share one instance: ```python Render a multi-agent pipeline theme={null} renderer = ConsoleRenderer() msg = UserMsg("user", "Draft a product intro") for agent in [writer, reviewer]: async for event in agent.reply_stream(msg): renderer.render(event) # The previous agent's full reply feeds the next one msg = renderer.last_msg ``` The renderer applies the following rules per content type: | Content | Rendering | | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | | Reply text, thinking | Streamed live; thinking is dimmed | | Tool calls, tool results | Printed as whole blocks on their end events, so concurrently-streamed results never interleave; long results truncate by line count | | Hint blocks | Shown in a bordered panel, e.g. the injected runtime state (time, task reminders) | | Binary data | Images, audio, etc. print as placeholders (e.g. `[data: image/png, ~34KB]`) instead of raw base64 | | Token usage | One line of input/output token counts after each model call | For events that need a human in the loop (tool confirmation, external execution), the renderer only displays the notice; collecting the results and resuming the reply is the caller's job. See [Human-in-the-Loop](/versions/2.0.8/en/building-blocks/agent/human-in-the-loop). ## Control the Output Verbosity Both `launch_console` and `ConsoleRenderer` take a `verbosity` parameter with three increasing levels: | Level | What's shown | | --------- | -------------------------------------------------------------------------------------------- | | `quiet` | Only the reply text and errors | | `default` | Plus thinking, tool calls/results, hint blocks, token usage, and confirmation notices | | `debug` | Plus lifecycle events (model call start, reply finish reason, etc.) and tool result metadata | Unknown event types are skipped silently (`debug` prints one line with the type name), so new event types in the protocol never break existing rendering. # Compress Context Source: https://docs.agentscope.io/versions/2.0.8/en/building-blocks/context/compress-context Keep the context length within the preset limit When the context window fills up, AgentScope keeps it in shape with two automatic mechanisms governed by `ContextConfig`: **context compression** (summarize older messages) and **tool result truncation** (cap oversized tool outputs). Both run transparently; the agent continues working without interruption. Beyond that, developers can compress at any time by hand, or leave the timing to the agent itself. ## Configure Compression `ContextConfig` is passed to the agent at construction time: ```python theme={null} from agentscope.agent import Agent from agentscope.agent import ContextConfig agent = Agent( name="my_agent", system_prompt="...", model=model, toolkit=toolkit, context_config=ContextConfig( trigger_ratio=0.8, reserve_ratio=0.1, tool_result_limit=3000, max_image_num=5, ), ) ``` Available fields: | Parameter | Type | Description | | ------------------------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `trigger_ratio` | `float` | Compression activates when token usage exceeds this ratio of the model's context size (capped at `0.9`) | | `reserve_ratio` | `float` | Proportion of context tokens kept as recent messages after compression | | `tool_result_limit` | `int` | Maximum tokens per tool result; outputs exceeding this are truncated | | `max_image_num` | `int` | Maximum number of images kept in the context, `5` by default | | `context_buffer_ratio` | `float` | Buffer ahead of the compression threshold, `0.2` by default; with a trigger ratio of 0.8 and a buffer of 0.2, [context usage is injected](/versions/2.0.8/en/building-blocks/context/environment-awareness) once the input tokens exceed 60% of the model context size, which is also the band where agentic compression takes effect | | `compression_tool_enabled` | `bool` | Whether to expose the `CompressContext` tool so the agent decides when to compress, `False` by default | | `compression_fallback_to_truncation` | `bool` | Whether to fall back to truncating the oldest messages when summarization fails, `True` by default; `False` raises an error instead | | `compression_prompt` | `str` | The prompt that guides the model to generate the summary | | `summary_template` | `str` | String template for formatting the summary into the context | | `summary_schema` | `dict` | JSON Schema constraining the model's structured summary output | `context_buffer_ratio` must be smaller than `trigger_ratio`, so that the context usage is injected and the agent still has room to compress on its own before a hard compression happens. Otherwise the agent constructor raises a `ValueError`. ## Compress Automatically Compression runs automatically before each reasoning step. The flow: The agent totals the tokens of the system prompt, summary, context, and tool schemas. If total tokens exceed `trigger_ratio × context_size`, compression activates. Otherwise the agent proceeds with the model call as usual. Older messages are marked for compression; recent messages within `reserve_ratio × context_size` are kept. Tool call / result pairs are kept intact across the split. The model produces a structured summary from the older messages, with five fields: `task_overview`, `current_state`, `important_discoveries`, `next_steps`, `context_to_preserve`. The summary replaces the compressed messages; the reserved messages become the new context. The agent then continues its reasoning step. The remaining 10% between `trigger_ratio` (max `0.9`) and the full context size is reserved for the compression model call itself: the model needs room to generate the summary. Summarization is retried a few times. When every attempt fails, `compression_fallback_to_truncation` decides what happens: by default the oldest messages are dropped and a truncation note is left where the summary would go, so the agent keeps running with a shortened context; with `False` an error is raised and the context is left untouched, at the risk of exceeding the model's context size. ## Compress Manually Compression can also be triggered manually by calling the agent's `compress_context()` method. Without arguments, it uses the agent's stored `context_config`; pass a one-off `ContextConfig` to override, or an `instructions` `HintBlock` to guide the summarization: ```python theme={null} # Force-check using the agent's default config await agent.compress_context() # Or override the config for this single call (e.g. compress more aggressively) from agentscope.agent import ContextConfig await agent.compress_context( context_config=ContextConfig(trigger_ratio=0.5, reserve_ratio=0.1), ) # Or inject instructions to guide the summarization from agentscope.message import HintBlock await agent.compress_context( instructions=HintBlock( hint="Keep every file path and API signature mentioned so far.", ), ) ``` The method is a no-op when token usage is below `trigger_ratio × context_size`, so it is safe to call between turns or at any custom checkpoint. ## Compress Agentically Automatic compression fires the moment the threshold is crossed, which often lands in the middle of an unfinished piece of work, so the summary tends to lose the details still in flight. Set `compression_tool_enabled` to `True` and the agent gets a `CompressContext` tool, letting it compress ahead of the hard threshold at the boundary between two pieces of work: ```python Enable agentic compression theme={null} from agentscope.agent import Agent, ContextConfig agent = Agent( name="my_agent", system_prompt="...", model=model, toolkit=toolkit, context_config=ContextConfig( trigger_ratio=0.8, # hard threshold: compress automatically once reached context_buffer_ratio=0.2, # hint the agent 20% earlier, i.e. from 60% compression_tool_enabled=True, # expose the CompressContext tool ), ) ``` How it works once enabled: | Aspect | Behavior | | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Hint timing | Once the input tokens pass `(trigger_ratio - context_buffer_ratio) × context_size` and no plan task is in progress, [runtime state injection](/versions/2.0.8/en/building-blocks/context/environment-awareness) tells the agent it may call `CompressContext` | | Compression threshold | The tool itself checks against `trigger_ratio - context_buffer_ratio`, so calling it before the context enters the buffer changes nothing | | Permission | The tool is always allowed, bypassing the confirmation flow of the [permission system](/versions/2.0.8/en/building-blocks/permission-system/overview) | | Failure | When summarization fails, the tool returns an error result and the context stays unchanged, so the agent can carry on | Agentic compression coexists with the automatic one: if the agent misses the buffer, the context is still compressed automatically at `trigger_ratio`, so enabling it needs no extra safety net. ## Limit Images `max_image_num` prevents images from accumulating in the model context over a long conversation. Once the count exceeds the limit, AgentScope removes images starting from the oldest and leaves a hint in their place: * If the agent is configured with an `offloader`, the image is persisted first and the hint carries the path for re-reading it; * Without an `offloader`, the image is dropped and the hint only records that it was removed by the image limit. Set `max_image_num=0` to keep no images in the model context at all. ## Truncate Tool Results After each tool call, the agent compares the result's token count against `tool_result_limit`. If the limit is exceeded, the result is split into a reserved portion (kept in context) and an offloaded portion (handed to the offloader if one is attached, see [Offload Context](/versions/2.0.8/en/building-blocks/context/offload-context)). A truncation marker is appended to the reserved portion so the agent knows the output was clipped: ``` <<>> The remaining content has been omitted for limited context. ``` When an offloader is attached, the marker also points the agent to the persisted full output: ``` <<>> The remaining content has been omitted for limited context. You can refer to the file in '/path/to/tool_result-.txt' for the truncated content if needed. ``` Setting `tool_result_limit` too low may starve the agent of critical tool output. Setting it too high risks one result filling the entire context. # Environment Awareness Source: https://docs.agentscope.io/versions/2.0.8/en/building-blocks/context/environment-awareness Keep the agent aware of time, tasks, context usage, and failing tools as they change An agent stays aware of its changing environment through **runtime state injection**: before each reasoning step, information that changes across turns (current time, plan tasks, context usage, repeated tool failures) is injected into the context as a `HintBlock`, configured via the `injection_config` parameter of `Agent(...)`. The injection covers four dimensions, each with its own timing rule: | Dimension | Injected Content | When It Is Injected | | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Time | The current wall-clock time and its timezone | No time is recorded in the context (the first reply, or right after a context compression), or the elapsed time since the recorded one exceeds `time_interval` hours | | Plan tasks | The counts of in-progress and pending tasks, with a reminder to call `TaskList` | Uncompleted tasks exist while the context contains neither task-related tool calls (e.g. compressed away) nor a previous tasks injection | | Context usage | The current input tokens and the compression threshold; with [agentic compression](/versions/2.0.8/en/building-blocks/context/compress-context#compress-agentically) enabled and no task in progress, it also tells the agent it may compress right away | At the first iteration of a reply, when the input tokens come within `context_buffer_ratio` of the compression threshold, letting the agent perceive that a compression is near | | Tool failures | A reminder to stop retrying and try another approach | The last `tool_retries_limit` tool results in a row all failed, for the same tool with the same arguments (compared after normalization, so key order does not matter); one success in between resets the count | The `context_buffer_ratio` behind the context usage dimension lives in the [context config](/versions/2.0.8/en/building-blocks/context/compress-context#configure-compression); the other three dimensions are governed by `InjectionConfig`. ## How Injection Works Each injected field is wrapped as `value`, and all fields are joined and placed into the `template` (a `` wrapper by default). A typical injected hint looks like: ```text Example Injected Hint theme={null} Treat the following as the ground truth at this point of the conversation. Anything stated earlier is outdated, and a later reminder, if any, supersedes this one: 2026-07-22T10:30:00 Asia/Shanghai You have 1 in-progress tasks and 2 pending tasks. Use `TaskList` to view them if you don't know. The last 3 calls to 'Bash' with the same arguments all failed. Stop retrying the same call as-is, check the error message and try a different approach. ``` Three design decisions are worth knowing: * The injection is **not ephemeral**: it is appended to the persistent context on purpose, so the agent can perceive how time elapses and what it did at each step, building a sense of time. * The hint is attached as a `HintBlock` instead of mutating the system prompt, so prompt caching still works while the agent stays aware of the changing state. * Only information that **changes** within a conversation is injected. Fixed information (the agent's identity, standing instructions) belongs in the system prompt. When an injection happens and `emit_hint_event` is enabled, `reply_stream` also yields a `HintBlockEvent`, so a frontend can render the injected hint. ## Configure Injection Pass an `InjectionConfig` to the agent constructor to tune the injection behavior: ```python theme={null} from agentscope.agent import Agent, InjectionConfig from agentscope.model import DashScopeChatModel from agentscope.credential import DashScopeCredential agent = Agent( name="my_agent", system_prompt="You are a helpful assistant.", model=DashScopeChatModel( credential=DashScopeCredential(api_key="YOUR_API_KEY"), model="qwen-max", ), injection_config=InjectionConfig( timezone="Asia/Shanghai", # inject the time of this timezone time_interval=1.0, # refresh the time at most once per hour ), ) ``` The fields of `InjectionConfig`: | Field | Default | Description | | ---------------------- | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | | `inject_runtime_state` | `True` | Master switch; set `False` to disable runtime state injection entirely | | `timezone` | `"UTC"` | Timezone of the injected time, in the standard timezone database format (e.g. `"Asia/Shanghai"`) | | `time_format` | `"%Y-%m-%dT%H:%M:%S"` | Format of the injected time; must carry the date part so the recorded time round-trips as a full timestamp | | `time_interval` | `0.5` | Minimum elapsed time in hours from the recorded time to trigger a new time injection | | `tool_retries_limit` | `3` | How many consecutive failures of the same tool call trigger the tool-failure hint; minimum `3` | | `tool_retries_hint` | The wording shown in the example above | Template of the tool-failure hint, with two placeholders: `{tool_name}` (the failing tool) and `{count}` (the number of consecutive failures) | | `template` | A `` wrapper | Template around the injected fields; must contain the `{runtime_state}` placeholder | | `injection_source` | `{"label": "System", "sublabel": "Runtime State"}` | The `source` of the injected `HintBlock`, used to recognize the agent's own previous injections when scanning the context | | `task_tool_names` | `TaskCreate`, `TaskGet`, `TaskList`, `TaskUpdate` | Tool names whose calls in the context indicate the agent is already aware of the tasks, suppressing the tasks injection | | `extra_fields` | `{}` | Custom fields attached to every injection (see [Inject Custom Fields](#inject-custom-fields)) | | `emit_hint_event` | `True` | Whether to emit a `HintBlockEvent` when an injection happens | `context_buffer_ratio` on `InjectionConfig` is deprecated; use the field of the same name in the [context config](/versions/2.0.8/en/building-blocks/context/compress-context#configure-compression) instead. Passing it here still works and overrides the value from the context config, along with a `DeprecationWarning`. ## Inject Custom Fields Beyond the built-in dimensions, `extra_fields` injects developer-defined information, such as sensor readings or deployment metadata: ```python theme={null} from agentscope.agent import InjectionConfig injection_config = InjectionConfig( extra_fields={ "battery-level": "78%", # injected as 78% "location": "Hangzhou office", }, ) ``` Extra fields are attached to **every** injection but never trigger one by themselves: they ride along whenever the time, tasks, context usage, or tool failure dimension fires. ## Customize the Template The `template` field controls how the injected fields are presented to the LLM. It must contain the `{runtime_state}` placeholder, which is replaced by the joined `value` fields: ```python theme={null} from agentscope.agent import InjectionConfig injection_config = InjectionConfig( template=( "[Runtime update] The following reflects the current environment:\n" "{runtime_state}" ), ) ``` # Offload Context Source: https://docs.agentscope.io/versions/2.0.8/en/building-blocks/context/offload-context Persist dropped content so the agent can read it back on demand Context offloading writes content the agent has dropped (compressed messages, truncated tool outputs) to external storage, so the agent can read it back later via its file tools (Read, Grep, Glob) when it needs a detail that was compressed away. The component that performs the writes is called an **offloader**. ## Attach an Offloader An offloader is any object satisfying the `Offloader` protocol, a structural contract with two methods: | Method | Description | | ---------------------------------------------- | -------------------------------------------------------------------------------------------- | | `offload_context(session_id, msgs)` | Persist compressed messages; returns a reference (e.g. a file path) to the persisted content | | `offload_tool_result(session_id, tool_result)` | Persist a truncated tool result; returns a reference to the persisted content | Pass any object satisfying this protocol to the agent's `offloader` argument. Every built-in [workspace](/versions/2.0.8/en/building-blocks/workspace/overview) (local, Docker, E2B, ...) implements the protocol and can serve as the offloader directly: ```python theme={null} from agentscope.agent import Agent from agentscope.workspace import LocalWorkspace workspace = LocalWorkspace(workdir="/tmp/agent_workspace") await workspace.initialize() agent = Agent( name="my_agent", system_prompt="...", model=model, toolkit=toolkit, offloader=workspace, ) ``` Without an offloader attached, compressed messages and truncated tool results are simply dropped after they leave the context window. ## Offload to a Workspace A workspace writes offloaded content under `workdir` in its own filesystem, isolating each agent run by `session_id`. The layout below uses the local workspace as an example; sandboxed workspaces use the same structure inside their filesystems: How content is laid out: * **`sessions/{session_id}/`**: one directory per agent session, so concurrent agents don't collide. Compressed messages append to `context.jsonl`; each truncated tool result becomes its own `tool_result-{tool_id}.txt`. * **`data/`**: multimodal files (images, audio) referenced by offloaded messages, deduplicated by SHA-256 content hash. * **`skills/`**: unrelated to offloading; the workspace also serves as the agent's skill directory. ## Create Custom Offloader For backends other than a workspace (databases, cloud blobs, vector stores), implement the `Offloader` protocol. No inheritance is required, since it is a structural protocol: ```python theme={null} from typing import Any from agentscope.message import Msg, ToolResultBlock class S3Offloader: def __init__(self, bucket: str, prefix: str) -> None: self.bucket = bucket self.prefix = prefix async def offload_context( self, session_id: str, msgs: list[Msg], **kwargs: Any, ) -> str: key = f"{self.prefix}/sessions/{session_id}/context.jsonl" content = "\n".join(m.model_dump_json() for m in msgs) await self._upload(self.bucket, key, content) return f"s3://{self.bucket}/{key}" async def offload_tool_result( self, session_id: str, tool_result: ToolResultBlock, **kwargs: Any, ) -> str: key = f"{self.prefix}/sessions/{session_id}/tool_result-{tool_result.id}.txt" # Extract text content from the tool result blocks and upload. ... return f"s3://{self.bucket}/{key}" ``` Pass the instance into `Agent(offloader=...)` like a workspace. # Overview Source: https://docs.agentscope.io/versions/2.0.8/en/building-blocks/context/overview Manage the agent's working memory to keep long tasks on track The context is an agent's working memory: the messages (user inputs, assistant responses, tool calls, tool results) that the LLM sees on every reasoning step. Context management is about more than fitting the model's window; it shapes what the model sees at each step so the agent completes tasks better, through three mechanisms: | Mechanism | What It Does | Page | | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | | **Context injection** | Keeps the agent aware of runtime state that changes across turns (time, tasks, context usage) by injecting hints into the context | [Environment Awareness](/versions/2.0.8/en/building-blocks/context/environment-awareness) | | **Context compression** | Summarizes older messages and truncates oversized tool results, keeping long conversations within the model's window | [Compress Context](/versions/2.0.8/en/building-blocks/context/compress-context) | | **Context offloading** | Persists dropped content (compressed messages, truncated tool results) to external storage so details remain retrievable | [Offload Context](/versions/2.0.8/en/building-blocks/context/offload-context) | The three mechanisms work in concert: injection adds what the model needs to know now, compression removes what it no longer needs verbatim, and offloading keeps the removed content one file-read away. ## Assemble Context Before each model call, the agent assembles a single API input from three layers. The structure below shows what flows into that call: How each layer is built: 1. **System prompt**: starts from the `system_prompt` passed at agent creation, then appends skill instructions (each skill's name and description, sourced from the toolkit), then runs every `on_system_prompt` [middleware](/versions/2.0.8/en/building-blocks/middleware) hook in order. 2. **Summary**: the compressed digest of older messages, present only after a compression has occurred. 3. **Context**: the recent uncompressed messages (user inputs, assistant responses, tool calls, tool results). This is also where injected runtime-state hints live. Fixed information belongs in the system prompt (via the `on_system_prompt` middleware hook for dynamic composition); information that changes within a conversation belongs in the context, injected as hints. See [Environment Awareness](/versions/2.0.8/en/building-blocks/context/environment-awareness) for the distinction. ## Next Steps Inject time, tasks, and context usage so the agent stays oriented. Keep the context length within the preset limit. Persist dropped content so the agent can read it back on demand. Built-in offloader implementations and the agent's working environment. # Long-Term Memory Source: https://docs.agentscope.io/versions/2.0.8/en/building-blocks/long-term-memory Cross-session long-term memory implemented with agent middleware **Long-term memory** is an agent's ability to retain information across sessions, including user preferences, past decisions, and knowledge or rules summarized from conversations. AgentScope implements different long-term memory capabilities as [agent middleware](/versions/2.0.8/en/building-blocks/middleware). Each long-term memory implementation is a `MiddlewareBase` subclass that non-invasively handles memory injection, retrieval, and write-back. AgentScope currently supports the following long-term memory implementations, with more under development: | Name | Code API | Description | | --------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Agentic Memory | `AgenticMemoryMiddleware` | Markdown-file-based long-term memory that agents create, maintain, and use autonomously. | | ReMe | `ReMeMiddleware` | An in-process, file-based long-term memory powered by [ReMe](https://github.com/agentscope-ai/ReMe), which automatically extracts and writes back memories from conversations. | | Mem0 | `Mem0Middleware` | A drop-in long-term memory backend powered by [mem0](https://github.com/mem0ai/mem0). | | More coming ... | | | ## Agentic Memory Agentic Memory is AgentScope's native long-term memory implementation. It provides long-term memory through Markdown file reads, writes, and retrieval. At runtime, the agent autonomously creates Markdown memory files, maintains an index of all memory files in a fixed `MEMORY.md` file, and automatically injects that index into the system prompt. This follows a "progressive disclosure" pattern. Agentic Memory supports different runtime environments through the `backend` parameter, such as local, Docker, E2B, and cloud sandboxes. It uses `LocalBackend` by default. At runtime, the agent uses the built-in `Read`, `Write`, and `Edit` tools to create, access, and modify long-term memory. A typical file structure looks like this: ```text theme={null} /Memory/ ├── MEMORY.md ├── .md ├── .md └── ... ``` Each Markdown file follows the frontmatter convention and includes `name`, `description`, and `type` fields for later retrieval and injection: ```markdown theme={null} --- name: {{memory name}} description: {{one-line description — used to decide relevance in future conversations, so be specific}} type: {{user, feedback, project, reference}} --- {{memory content — for feedback/project types, structure as: rule/fact, then **Why:** and **How to apply:** lines}} ``` `MEMORY.md` stays short. It serves only as an index and is automatically injected into the system prompt. For example: ```text theme={null} - [User profile](user_profile.md) — User location and answer-style preference. - [Feedback on answer style](feedback_answer_style.md) — User prefers concise Chinese answers. ``` Agentic Memory has two retrieval paths. First, the agent can autonomously retrieve relevant files based on the prompt and the `MEMORY.md` index. Second, when `reply` / `reply_stream` is called, the middleware starts an async task that asks an LLM to select relevant Markdown files, then checks before later reasoning steps whether that task has finished and injects the retrieved results as a `HintBlock`. Note that retrieval is asynchronous: injection happens at checkpoints **before reasoning starts** inside the reasoning-acting loop, and the exact timing depends on retrieval latency. If the current reply does not enter a later reasoning round, such as when the model produces no tool calls, the retrieved long-term memory may not be injected into that reply. The workflow is: ```mermaid theme={null} flowchart TD A[User message] --> B[agent.reply / reply_stream] B --> C[on_reply starts async retrieval task] B --> E subgraph MainLoop[Reasoning-acting loop] E{Before reasoning:
is async retrieval done?} E -->|Yes| F[Inject selected memories as HintBlock] E -->|No| G[Skip memory injection this iteration] F --> H[Reasoning] G --> H H --> I{Tool calls?} I -->|Yes| J[Acting: run tools such as Read / Write] J --> K[Agent may update topic .md files and MEMORY.md] K --> E I -->|No| L[Return assistant response] end subgraph AsyncTask[Async memory retrieval task] C --> M[List Markdown topic files] M --> N[Parse filename, frontmatter description, mtime] N --> O[LLM selects relevant filenames] O --> P[Read selected Markdown files] P --> Q[Task finished with formatted memory context] end Q -. checked before each reasoning .-> E ``` Use Agentic Memory in different environments as follows: ```python title="Local environment" theme={null} from agentscope.agent import Agent from agentscope.middleware import AgenticMemoryMiddleware from agentscope.permission import AdditionalWorkingDirectory, PermissionMode from agentscope.tool import Read, Toolkit, Write, Edit workdir = "/tmp/agentscope_ltm_demo" memory = AgenticMemoryMiddleware(workdir=workdir) agent = Agent( name="assistant", system_prompt="You are a helpful assistant.", model=my_chat_model, toolkit=Toolkit(tools=[Read(), Write(), Edit()]), middlewares=[memory], ) # Optional: allow the Write tool in this example to write into workdir. # In production, configure permissions according to your security policy. agent.state.permission_context.mode = PermissionMode.ACCEPT_EDITS agent.state.permission_context.working_directories[workdir] = ( AdditionalWorkingDirectory(path=workdir, source="long-term-memory-demo") ) await agent.reply("Remember that I live in Hangzhou and prefer concise Chinese answers.") # Recreate an agent. Reusing the same workdir reuses the same Markdown memories. new_agent = Agent( name="assistant", system_prompt="You are a helpful assistant.", model=my_chat_model, toolkit=Toolkit(tools=[Read(), Write(), Edit()]), middlewares=[AgenticMemoryMiddleware(workdir=workdir)], ) await new_agent.reply("Do you remember my location and answer style preference?") ``` ```python title="Docker environment" theme={null} from agentscope.agent import Agent from agentscope.middleware import AgenticMemoryMiddleware from agentscope.tool import Read, Toolkit, Write from agentscope.workspace import DockerWorkspace workspace = DockerWorkspace() await workspace.initialize() # Get the Docker sandbox backend. backend = workspace.get_backend() memory = AgenticMemoryMiddleware( workdir=workspace.workdir, # Switch to the sandbox environment. backend=backend, ) agent = Agent( name="assistant", system_prompt="You are a helpful assistant.", model=my_chat_model, toolkit=Toolkit( # Docker workspace tools include Read / Write / Edit by default. tools=await workspace.list_tools(), # You can also pass Read / Write / Edit yourself. # tools=[Read(backend=backend), Write(backend=backend), Edit(backend=backend)], ), middlewares=[memory], ) try: await agent.reply("Remember that I am testing long-term memory in a Docker sandbox.") finally: await workspace.close() ``` ```python title="E2B environment" theme={null} from agentscope.agent import Agent from agentscope.middleware import AgenticMemoryMiddleware from agentscope.tool import Read, Toolkit, Write from agentscope.workspace import E2BWorkspace workspace = E2BWorkspace() await workspace.initialize() # Get the E2B sandbox backend. backend = workspace.get_backend() memory = AgenticMemoryMiddleware( workdir=workspace.workdir, # Switch to the sandbox environment. backend=backend, ) agent = Agent( name="assistant", system_prompt="You are a helpful assistant.", model=my_chat_model, toolkit=Toolkit( # E2B workspace tools include Read / Write / Edit by default. tools=await workspace.list_tools(), # You can also pass Read / Write / Edit yourself. # tools=[Read(backend=backend), Write(backend=backend), Edit(backend=backend)], ), middlewares=[memory], ) try: await agent.reply("Remember that I am testing long-term memory in an E2B sandbox.") finally: await workspace.close() ``` ## ReMe [ReMe](https://github.com/agentscope-ai/ReMe) is a file-based memory toolkit maintained by the AgentScope team. `ReMeMiddleware` embeds ReMe in the current process, so no separate service is required. It listens to the agent's conversations and automatically extracts and writes back memories through ReMe's `auto_memory` job after each reply. The agent does not save memories itself, and ReMe does not provide a manual memory-add tool. The ReMe workspace is specified by `workspace_dir` and stores memory cards and search indexes. Reusing the same workspace across sessions enables cross-session recall. ### Installation `ReMeMiddleware`'s dependencies are available as an optional AgentScope extra: ```bash theme={null} pip install "agentscope[reme]" ``` ### Quick start You can inject AgentScope chat and embedding models into ReMe. The chat model drives `auto_memory` extraction; providing an embedding model automatically enables vector search in AgentScope's minimal embedded configuration. Replace `my_chat_model` and `my_embedding_model` below with your model instances: ```python theme={null} import asyncio from agentscope.agent import Agent from agentscope.middleware import ReMeMiddleware from agentscope.state import AgentState from agentscope.tool import Toolkit async def main(): memory = ReMeMiddleware( workspace_dir=".reme", parameters=ReMeMiddleware.Parameters( chat_model=my_chat_model, embedding_model=my_embedding_model, mode="both", top_k=5, ), ) agent = Agent( name="assistant", system_prompt="You are a helpful assistant.", model=my_chat_model, # In both / agent_control modes, add memory_search to the toolkit. toolkit=Toolkit(tools=await memory.list_tools()), middlewares=[memory], # Set a stable ID for a resumable session; AgentState generates one # automatically when no ID is provided. state=AgentState(session_id="alice-main"), ) try: await agent.reply("Remember that I live in Hangzhou and prefer concise Chinese answers.") finally: # AgentScope does not manage middleware lifecycles automatically. await memory.close() asyncio.run(main()) ``` `ReMeMiddleware` always builds an AgentScope-owned minimal ReMe configuration. It includes conversation write-back, nightly and manual dream consolidation from daily cards into digest memory, and search across both daily and digest memory, without loading ReMe's unrelated standalone jobs. Without an injected chat model, its LLM component reads ReMe's `LLM_*` environment variables. Search stays BM25-only unless an `embedding_model` is provided; when present, the middleware creates a matching vector store from the model's dimensions. ### Control modes `ReMeMiddleware.Parameters.mode` defaults to `"both"` and controls retrieval only; conversation write-back runs automatically in all three modes. | Mode | Behavior | | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `static_control` | The middleware starts a background search when `reply` begins and injects the results as an `AssistantMsg(name="memory")` hint before a later reasoning step; the agent is unaware of the retrieval. Because search runs concurrently with the reply, a single-model-call reply may finish before the hint can be injected. | | `agent_control` | No automatic retrieval. The middleware exposes a `memory_search(query, limit)` tool through `list_tools()` and appends a short usage nudge to the system prompt; the agent decides when to search. The conversation is still written back automatically after the reply. | | `both` | Enables both automatic retrieval and the `memory_search` tool. | In `static_control` mode, `await memory.list_tools()` returns an empty list. In `agent_control` and `both` modes, pass the returned tools into `Toolkit` as shown above. `memory_search` is query-only; there is no `add_memory` tool because writes are always handled automatically by the middleware. ### Session scope and lifecycle * Write-back is scoped by `agent.state.session_id`. Set a stable ID with `AgentState(session_id="...")` for a resumable session; the ID does not belong on the middleware configuration. * Search spans the entire `workspace_dir`, rather than only the current `session_id`. A new agent using the same workspace can therefore recall memories written by an earlier session. * One `ReMeMiddleware` can safely be shared across multiple agents and sessions. The middleware reads each agent's `session_id` at hook time. Call `await memory.close()` explicitly when the application shuts down. After `auto_memory` writes a card, ReMe still needs to index it before it becomes searchable. A search issued immediately after write-back may temporarily miss the new card. The [`examples/long_term_memory/reme`](https://github.com/agentscope-ai/agentscope/tree/main/examples/long_term_memory/reme) example explicitly triggers indexing after write-back so its demonstration is deterministic. ### Key parameters | Location | Parameter | Default | Description | | --------------------- | ----------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | `ReMeMiddleware(...)` | `workspace_dir` | `".reme"` | Directory for ReMe memory cards and indexes. | | `Parameters` | `chat_model` | `None` | LLM injected into ReMe for `auto_memory`; when omitted, the minimal configuration uses ReMe's `LLM_*` environment variables. | | `Parameters` | `embedding_model` | `None` | Embedding model injected into ReMe; providing one creates and enables a dimension-matched vector store, while omitting it keeps search BM25-only. | | `Parameters` | `mode` | `"both"` | `static_control`, `agent_control`, or `both`. | | `Parameters` | `top_k` | `5` | Maximum memories returned by each automatic search and the default `limit` for `memory_search`. | ## Mem0 `Mem0Middleware` is a drop-in long-term memory backend powered by [mem0](https://github.com/mem0ai/mem0). It works with both `mem0.AsyncMemory` (open-source) and `mem0.AsyncMemoryClient` (hosted Platform). With `mem0.AsyncMemory` (open-source), it can route mem0's own memory extraction and embedding through your existing AgentScope models — so mem0 needs no separate provider key. ### Installation `Mem0Middleware`'s dependencies are available as an optional extra in AgentScope: ```bash theme={null} pip install "agentscope[mem0]" ``` ### Quick start The fastest path is to pass your AgentScope chat and embedding models; the middleware builds an open-source mem0 store internally and wires both extraction and embedding through them. ```python theme={null} import asyncio from agentscope.agent import Agent from agentscope.middleware import Mem0Middleware from agentscope.tool import Toolkit async def main(): mw = Mem0Middleware( user_id="alice", chat_model=my_chat_model, embedding_model=my_embedding_model, mode="both", ) agent = Agent( name="assistant", system_prompt="You are a helpful assistant.", model=my_chat_model, toolkit=Toolkit(tools=await mw.list_tools()), middlewares=[mw], ) # Memories written in this session resurface in later sessions # for the same ``user_id``. await agent(...) asyncio.run(main()) ``` `Mem0Middleware` contributes its `search_memory` / `add_memory` tools through `list_tools()`, which the agent does **not** call automatically. To make the tools available to the agent, collect them yourself and pass them into the toolkit — `Toolkit(tools=await mw.list_tools())`. In `static_control` mode `list_tools()` returns an empty list. ### Control modes The `mode` parameter decides how the agent interacts with mem0. It defaults to `"both"`, matching AgentScope 1.x's `ReActAgent.long_term_memory_mode`. | Mode | Behavior | | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `static_control` | The middleware searches mem0 before each reply, injects the retrieved memories into the context as an `AssistantMsg(name="memory")`, and writes the new exchange back after the reply. The agent is unaware of mem0. | | `agent_control` | The middleware exposes `search_memory` / `add_memory` tools and appends a short usage nudge to the system prompt. The agent decides when to read from or write to memory; there is no automatic retrieval or write-back. | | `both` | Both patterns are active at once — automatic retrieval **and** on-demand tools. | ### Construction paths `Mem0Middleware` supports three ways to wire up the mem0 backend: Pass AgentScope models and let the middleware build an open-source `AsyncMemory` internally (mem0's default Qdrant store). The embedding model's `dimensions` must match the vector store (the default Qdrant expects `1536`). ```python theme={null} Mem0Middleware( user_id="alice", chat_model=my_chat_model, embedding_model=my_embedding_model, ) ``` Start from your own `mem0.configs.base.MemoryConfig` to customize the vector store, history DB, or reranker, while still routing the LLM and embedder through AgentScope. Only the `.llm` / `.embedder` slots are overridden; every other field is preserved. ```python theme={null} Mem0Middleware( user_id="alice", chat_model=my_chat_model, embedding_model=my_embedding_model, mem0_config=my_mem0_config, ) ``` Pass a pre-built mem0 client when you want full control (e.g. the hosted Platform, or sharing one store across agents). When `client` is given it takes absolute precedence, and `chat_model` / `embedding_model` / `mem0_config` are ignored. ```python theme={null} from mem0 import AsyncMemoryClient Mem0Middleware( user_id="alice", client=AsyncMemoryClient(api_key="m0-..."), ) ``` `Mem0Middleware` requires an **async** mem0 client (`mem0.AsyncMemory` or `mem0.AsyncMemoryClient`). The synchronous `Memory` / `MemoryClient` are not supported. ### Key parameters | Parameter | Type | Default | Description | | ----------------------- | ----------------------------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------- | | `user_id` | `str` | *(required)* | mem0 namespace for the user's memories. | | `mode` | `"static_control" \| "agent_control" \| "both"` | `"both"` | How the agent interacts with mem0 (see above). | | `agent_id` | `str \| None` | `None` | Optional finer-grained namespace. | | `top_k` | `int` | `5` | Max memories retrieved per static-control search; also the default for the `search_memory` tool. | | `threshold` | `float \| None` | `None` | Minimum similarity score; `None` lets mem0 decide. | | `scope_search_by_agent` | `bool` | `True` | When `True`, searches filter by both `user_id` and `agent_id`; when `False`, a user's memories are shared across agents. | | `await_write` | `bool` | `True` | When `True`, the post-turn write is awaited inline; when `False`, it's fire-and-forget (faster, but exceptions only surface in logs). | ### Agent-callable tools In `agent_control` and `both` modes, the middleware contributes two tools the model can invoke on demand: * **`search_memory(keywords, limit=5)`** — retrieves memories using a list of short, targeted keywords. Each keyword is issued as an independent query; results are merged and deduplicated. * **`add_memory(thinking, content)`** — records durable facts. Only `content` (a list of standalone sentences) is persisted to mem0; `thinking` stays in the transcript for auditability. Both tools auto-allow themselves and read `user_id` / `agent_id` directly from the middleware instance, so they require no extra wiring beyond adding them to the toolkit. # Message & Event Source: https://docs.agentscope.io/versions/2.0.8/en/building-blocks/message-and-event The core data abstractions for agent communication and streaming Message and Event are the two fundamental data structures in AgentScope. * **Message** — the unit of inter-agent communication and persistence. Each `Msg` represents a complete conversation turn that is stored in context and exchanged between agents. * **Event** — the unit of frontend interaction and streaming. Events carry incremental progress updates (text tokens, tool call fragments, permission requests) and drive real-time UIs and human-in-the-loop workflows. A sequence of events produced during a single `reply` call accumulates into exactly one assistant `Msg`. This guarantees that the complete message state is always recoverable from its event stream. ## Message An instance of the `Msg` class in AgentScope contains a complete conversation turn — a user input, or a complete assistant response, organized through different types of content blocks (Blocks). 1. Running an agent's `reply_stream` once produces a complete `Msg` instance, containing all information such as multiple rounds of reasoning, tool calls, and execution results. 2. During frontend rendering, a `Msg` instance corresponds to a single complete message bubble. ### Structure The `Msg` class has the following core fields: | Field | Type | Description | | ------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `id` | `str` | Unique message identifier | | `name` | `str` | Name of the sender | | `role` | `"user" \| "assistant" \| "system"` | The sender's role | | `content` | `list[ContentBlock]` | Ordered list of content blocks | | `metadata` | `dict` | Arbitrary key-value metadata | | `created_at` | `str` | ISO 8601 timestamp of creation | | `finished_at` | `str \| None` | ISO 8601 timestamp when the message was finalized | | `usage` | `Usage \| None` | Token usage statistics (for assistant messages) | | `finished_reason` | `ReplyFinishedReason \| None` | How the reply ended: `completed` / `interrupted` / `exceed_max_iters` / `error`; `None` means paused, not finished | | `structured_output` | `dict \| None` | The validated structured result when the reply required a [structured schema](/versions/2.0.8/en/building-blocks/agent/run-agent#structured-output); set by the agent on the final message | | `error` | `ErrorInfo \| None` | Structured error info, populated only when `finished_reason` is `error` | ### Content Blocks Message content is composed of typed blocks. Each block represents a distinct piece of information: | Block Type | Description | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `TextBlock` | Plain text content | | `DataBlock` | Binary data (images, audio, video, etc.), either via base64 or URL | | `ThinkingBlock` | Model reasoning (chain-of-thought) | | `ToolCallBlock` | Tool call, containing name, input, and state | | `ToolResultBlock` | Tool execution result | | `HintBlock` | Hint information (e.g., scheduled-task triggers, team messages, background tool results); supports multimodal data and uses `source` to identify the hint's origin. | Role constraints are enforced at construction: * `msg.role=="user"` messages can only contain `TextBlock` and `DataBlock`; * `msg.role=="system"` messages can only contain `TextBlock`; * `msg.role=="assistant"` messages can contain all block types. These content blocks carry different data information, and their detailed fields are described below: | Field | Type | Description | | ------ | ----- | ---------------------------------------------------------------------------- | | `type` | `str` | Fixed as `"text"`. | | `text` | `str` | The actual text content. | | `id` | `str` | The unique identifier of the content block (auto-generated UUID by default). | This content block allows passing through custom metadata defined by the model provider (such as the `signature` in Anthropic models, etc.). | Field | Type | Description | | ---------- | ----- | ---------------------------------------------------------------------------- | | `type` | `str` | Fixed as `"thinking"`. | | `thinking` | `str` | The reasoning or chain-of-thought text of the model. | | `id` | `str` | The unique identifier of the content block (auto-generated UUID by default). | | Field | Type | Description | | -------- | --------------------------- | ---------------------------------------------------------------------------- | | `type` | `str` | Fixed as `"data"`. | | `id` | `str` | The unique identifier of the content block (auto-generated UUID by default). | | `source` | `Base64Source \| URLSource` | Identifies the data source. Supports Base64 input or URL input. | | `name` | `str \| None` | Optional field, indicating the name of this content asset. | **Data Source Configuration:** * **`Base64Source`**: * `type`: Fixed as `"base64"`. * `data`: Base64-encoded binary data. * `media_type`: Media type (e.g., `"image/png"`, `"audio/mpeg"`, `"video/mp4"`, etc.). * **`URLSource`**: * `type`: Fixed as `"url"`. * `url`: A valid URI/URL string satisfying the RFC 3986 standard. * `media_type`: Media type (e.g., `"image/png"`, `"audio/wav"`, etc.). When finally passed to the LLM API, `HintBlock` is also converted to a standard user message (User message). To avoid confusion with user inputs, it is recommended to use XML tags (e.g., `...`) to wrap the hint content. | Field | Type | Description | | -------- | ------------------------------------- | ------------------------------------------------------------------------------------------ | | `type` | `str` | Fixed as `"hint"`. | | `hint` | `str \| list[TextBlock \| DataBlock]` | The hint content — supports either plain text or a list of compound multimodal blocks. | | `id` | `str` | The unique identifier of the content block (auto-generated UUID by default). | | `source` | `str \| None` | Sender/origin label of the hint (can be a JSON string for frontend parsing and rendering). | | Field | Type | Description | | ----------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `type` | `str` | Fixed as `"tool_call"`. | | `id` | `str` | Unique identifier for this tool call. | | `name` | `str` | Name of the tool to be called. | | `input` | `str` | Tool call parameters in JSON string format. | | `state` | `ToolCallState` | Tool call state:
• `"pending"`: Pending, has not passed verification and permission checks.
• `"asking"`: Suspended, waiting for user authorization.
• `"allowed"`: Approved by the user or permission rules, waiting to execute.
• `"submitted"`: Submitted externally and waiting for execution results.
• `"finished"`: Execution completed (whether successful or failed). | | `suggested_rules` | `list[PermissionRule]` | Suggested authorization rules attached when the user confirms. |
The `id` of a `ToolResultBlock` must match the `id` of the launching `ToolCallBlock`. Supports multimodal data. | Field | Type | Description | | -------- | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `type` | `str` | Fixed as `"tool_result"`. | | `id` | `str` | Unique ID matching the corresponding tool call. | | `name` | `str` | Name of the tool called. | | `output` | `str \| list[TextBlock \| DataBlock]` | Execution output — supports either plain text or multimodal data. | | `state` | `ToolResultState` | Tool execution state:
• `"running"`: Tool executing.
• `"success"`: Finished successfully.
• `"error"`: Finished with error.
• `"interrupted"`: Interrupted by the user.
• `"denied"`: Denied execution by the user or security rules. |
### Create Messages AgentScope provides three shortcut methods to construct `Msg` objects to avoid repeatedly setting the `role` parameter, and supports building `TextBlock`s from strings: | Factory Function | Role | | ----------------------------- | ----------- | | `UserMsg(name, content)` | `user` | | `AssistantMsg(name, content)` | `assistant` | | `SystemMsg(name, content)` | `system` | When the `content` parameter is a string, it is automatically wrapped into a `TextBlock`. ```python Create Text Message theme={null} from agentscope.message import UserMsg, SystemMsg, AssistantMsg # User message user_msg = UserMsg( name="user", content="What's in this image?" ) # System message, used only for system prompts system_msg = SystemMsg( name="system", content="You are an AI assistant named Friday." ) # Assistant message assistant_msg = AssistantMsg( name="Friday", content="Hello, how can I help you today?" ) ``` ```python Create Multimodal Message theme={null} from agentscope.message import UserMsg, TextBlock, DataBlock, Base64Source # User message user_msg = UserMsg( name="user", content=[ TextBlock(text="Describe this image:"), DataBlock( source=Base64Source( data="...", media_type="image/png" ) ), ], ) ``` ```python Create Tool Call Message theme={null} from agentscope.message import AssistantMsg, ThinkingBlock, TextBlock, ToolCallBlock, ToolCallState, ToolResultBlock, ToolResultState assistant_msg = AssistantMsg( name="Friday", content=[ ThinkingBlock(thinking="I should invoke a tool to search for the weather."), TextBlock(text="Let me search the weather in Beijing."), ToolCallBlock( id="tool_call_1", name="weather_search", input='{"city": "Beijing"}', state=ToolCallState.FINISHED, ), ToolResultBlock( id="tool_call_1", name="weather_search", output="The weather in Beijing is sunny, with a temperature of 25°C.", state=ToolResultState.SUCCESS, ), ] ) ``` ### Access Content `Msg` provides helper methods to extract specific block types: | Method | Returns | | ---------------------------------- | -------------------------------------------------- | | `get_text_content(separator="\n")` | Concatenated text from all `TextBlock`s, or `None` | | `get_content_blocks(block_type)` | Filtered list of blocks by type | | `has_content_blocks(block_type)` | `True` if blocks of the given type exist | ```python theme={null} # Get all text content text = msg.get_text_content() # Get all tool calls tool_calls = msg.get_content_blocks("tool_call") # Check if message has tool results if msg.has_content_blocks("tool_result"): ... ``` ## Event Events are the streaming counterpart of messages. While the agent executes, it yields a sequence of `AgentEvent` objects that represent incremental progress — text tokens arriving, tool calls being constructed, results streaming back. Each event is lightweight and self-contained. ### Event Lifecycle Every event carries a `reply_id` that links it to the message being constructed. Within a reply, `block_id` or `tool_call_id` identifies which content block an event belongs to. Events follow a **start → delta → end** pattern for each content block: ```mermaid theme={null} sequenceDiagram participant Client participant Agent Agent->>Client: ReplyStartEvent rect rgba(100, 150, 255, 0.1) Note over Client,Agent: Reasoning Phase Agent->>Client: ModelCallStartEvent rect rgba(200, 200, 100, 0.1) Note over Client,Agent: TextBlock (block_id) Agent->>Client: TextBlockStartEvent Agent->>Client: TextBlockDeltaEvent (×N) Agent->>Client: TextBlockEndEvent end rect rgba(200, 200, 100, 0.1) Note over Client,Agent: DataBlock (block_id) Agent->>Client: DataBlockStartEvent Agent->>Client: DataBlockDeltaEvent (×N) Agent->>Client: DataBlockEndEvent end rect rgba(200, 200, 100, 0.1) Note over Client,Agent: ToolCallBlock (tool_call_id) Agent->>Client: ToolCallStartEvent Agent->>Client: ToolCallDeltaEvent (×N) Agent->>Client: ToolCallEndEvent end Agent->>Client: ModelCallEndEvent end rect rgba(100, 255, 150, 0.1) Note over Client,Agent: Acting Phase rect rgba(200, 200, 100, 0.1) Note over Client,Agent: ToolResultBlock (tool_call_id) Agent->>Client: ToolResultStartEvent Agent->>Client: ToolResultTextDeltaEvent (×N) Agent->>Client: ToolResultDataDeltaEvent (×N) Agent->>Client: ToolResultEndEvent end end Agent->>Client: ReplyEndEvent ``` All events within the same reply share the same `reply_id`. Within a reply, use `block_id` to correlate text/thinking/data block events, and `tool_call_id` to correlate tool call and tool result events. ### Event Types All events inherit from `EventBase` which provides common fields: | Field | Type | Description | | ------------ | ----- | ----------------------- | | `id` | `str` | Unique event identifier | | `created_at` | `str` | ISO 8601 timestamp | Events are grouped by category below. Every event also carries a `reply_id` field (except where noted) that links it to the message being constructed. **ReplyStartEvent** — Agent begins a new reply. | Field | Type | Description | | ------------ | ----- | ---------------------------------- | | `reply_id` | `str` | ID of the reply message | | `session_id` | `str` | ID of the session | | `name` | `str` | Agent name | | `role` | `str` | Agent role (default `"assistant"`) | **ReplyEndEvent** — Agent finishes the reply. | Field | Type | Description | | ----------------- | --------------------- | ------------------------------------------------------------------------------- | | `reply_id` | `str` | ID of the reply message | | `session_id` | `str` | ID of the session | | `finished_reason` | `ReplyFinishedReason` | How the reply ended: `completed` / `interrupted` / `exceed_max_iters` / `error` | | `error` | `ErrorInfo \| None` | Structured error info, populated only when `finished_reason` is `error` | **ExceedMaxItersEvent** — Agent reached the maximum reasoning-acting iterations. | Field | Type | Description | | ---------- | ----- | ----------------------- | | `reply_id` | `str` | ID of the reply message | | `name` | `str` | Agent name | **TextBlockStartEvent** — A new text block begins. | Field | Type | Description | | ---------- | ----- | ----------------------------------- | | `reply_id` | `str` | ID of the reply message | | `block_id` | `str` | Unique identifier of the text block | **TextBlockDeltaEvent** — Incremental text content arrives. | Field | Type | Description | | ---------- | ----- | ----------------------------------- | | `reply_id` | `str` | ID of the reply message | | `block_id` | `str` | Unique identifier of the text block | | `delta` | `str` | Incremental text content | **TextBlockEndEvent** — The text block is complete. | Field | Type | Description | | ---------- | ----- | ----------------------------------- | | `reply_id` | `str` | ID of the reply message | | `block_id` | `str` | Unique identifier of the text block | **ThinkingBlockStartEvent** — A new thinking block begins. | Field | Type | Description | | ---------- | ----- | --------------------------------------- | | `reply_id` | `str` | ID of the reply message | | `block_id` | `str` | Unique identifier of the thinking block | **ThinkingBlockDeltaEvent** — Incremental thinking content arrives. | Field | Type | Description | | ---------- | ----- | --------------------------------------- | | `reply_id` | `str` | ID of the reply message | | `block_id` | `str` | Unique identifier of the thinking block | | `delta` | `str` | Incremental thinking text | **ThinkingBlockEndEvent** — The thinking block is complete. | Field | Type | Description | | ---------- | ----- | --------------------------------------- | | `reply_id` | `str` | ID of the reply message | | `block_id` | `str` | Unique identifier of the thinking block | **DataBlockStartEvent** — A new data block begins (image, audio, etc.). | Field | Type | Description | | ------------ | ----- | ----------------------------------- | | `reply_id` | `str` | ID of the reply message | | `block_id` | `str` | Unique identifier of the data block | | `media_type` | `str` | MIME type (e.g. `"image/png"`) | **DataBlockDeltaEvent** — Incremental binary data arrives. | Field | Type | Description | | ------------ | ----- | ----------------------------------- | | `reply_id` | `str` | ID of the reply message | | `block_id` | `str` | Unique identifier of the data block | | `data` | `str` | Incremental base64-encoded data | | `media_type` | `str` | MIME type | **DataBlockEndEvent** — The data block is complete. | Field | Type | Description | | ---------- | ----- | ----------------------------------- | | `reply_id` | `str` | ID of the reply message | | `block_id` | `str` | Unique identifier of the data block | **ToolCallStartEvent** — The agent begins a tool call. | Field | Type | Description | | ---------------- | ----- | ---------------------------------- | | `reply_id` | `str` | ID of the reply message | | `tool_call_id` | `str` | Unique identifier of the tool call | | `tool_call_name` | `str` | Name of the tool being called | **ToolCallDeltaEvent** — Incremental tool call input arrives. | Field | Type | Description | | -------------- | ----- | --------------------------------------- | | `reply_id` | `str` | ID of the reply message | | `tool_call_id` | `str` | Unique identifier of the tool call | | `delta` | `str` | Incremental JSON fragment of tool input | **ToolCallEndEvent** — The tool call input is complete. | Field | Type | Description | | -------------- | ----- | ---------------------------------- | | `reply_id` | `str` | ID of the reply message | | `tool_call_id` | `str` | Unique identifier of the tool call | **ToolResultStartEvent** — Tool execution begins. | Field | Type | Description | | ---------------- | ----- | --------------------------------- | | `reply_id` | `str` | ID of the reply message | | `tool_call_id` | `str` | ID of the corresponding tool call | | `tool_call_name` | `str` | Name of the tool | **ToolResultTextDeltaEvent** — Incremental text output from the tool. | Field | Type | Description | | -------------- | ----- | --------------------------------- | | `reply_id` | `str` | ID of the reply message | | `tool_call_id` | `str` | ID of the corresponding tool call | | `delta` | `str` | Incremental text content | **ToolResultDataDeltaEvent** — Binary data output from the tool. | Field | Type | Description | | -------------- | ------------- | ------------------------------------------------------------ | | `reply_id` | `str` | ID of the reply message | | `tool_call_id` | `str` | ID of the corresponding tool call | | `block_id` | `str` | Unique identifier of the data block | | `media_type` | `str` | MIME type of the content | | `data` | `str \| None` | Base64-encoded data (mutually exclusive with `url`) | | `url` | `str \| None` | URL pointing to the content (mutually exclusive with `data`) | **ToolResultEndEvent** — Tool execution is complete. | Field | Type | Description | | -------------- | ----------------- | ---------------------------------------------------------------------- | | `reply_id` | `str` | ID of the reply message | | `tool_call_id` | `str` | ID of the corresponding tool call | | `state` | `ToolResultState` | Final state: `SUCCESS`, `ERROR`, `INTERRUPTED`, `DENIED`, or `RUNNING` | **ModelCallStartEvent** — A model API call begins. | Field | Type | Description | | ------------ | ----- | ------------------------------ | | `reply_id` | `str` | ID of the reply message | | `model_name` | `str` | Name of the model being called | **ModelCallEndEvent** — A model API call completes. | Field | Type | Description | | --------------- | ----- | --------------------------------- | | `reply_id` | `str` | ID of the reply message | | `input_tokens` | `int` | Number of input tokens consumed | | `output_tokens` | `int` | Number of output tokens generated | **RequireUserConfirmEvent** — Agent pauses for user confirmation. | Field | Type | Description | | ------------ | --------------------- | ------------------------------------ | | `reply_id` | `str` | ID of the reply message | | `tool_calls` | `list[ToolCallBlock]` | Tool calls pending user confirmation | **RequireExternalExecutionEvent** — Agent pauses for external execution. | Field | Type | Description | | ------------ | --------------------- | ------------------------------------ | | `reply_id` | `str` | ID of the reply message | | `tool_calls` | `list[ToolCallBlock]` | Tool calls to be executed externally | **UserConfirmResultEvent** — User provides confirmation results (input event). | Field | Type | Description | | ----------------- | --------------------- | ----------------------------------------------- | | `reply_id` | `str` | ID of the reply message | | `confirm_results` | `list[ConfirmResult]` | Confirmation results for each pending tool call | **ExternalExecutionResultEvent** — External system provides execution results (input event). | Field | Type | Description | | ------------------- | ----------------------- | ----------------------------------------- | | `reply_id` | `str` | ID of the reply message | | `execution_results` | `list[ToolResultBlock]` | Results returned by the external executor | Unlike text / thinking / data / tool blocks, these events do not follow the start → delta → end pattern. The full payload arrives in a single event because it is known up-front rather than streamed. **HintBlockEvent** — A `HintBlock` is injected into the agent's context (e.g. a scheduled-task trigger, a team message, a result returned by an offloaded background tool). | Field | Type | Description | | ---------- | ------------------------------------- | --------------------------------------------------------------------------------------------------------------- | | `reply_id` | `str` | ID of the reply message | | `block_id` | `str` | Unique identifier of the hint block | | `hint` | `str \| list[TextBlock \| DataBlock]` | The hint payload — plain text or a list of multimodal blocks | | `source` | `str \| None` | Optional sender / origin tag (typically a small JSON object describing how the frontend should label this hint) | **CustomEvent** — Generic extensible event used by service-layer middleware to notify subscribers about state changes (task progress, team membership, permission updates, …) without polluting the core agent event enum. | Field | Type | Description | | ---------- | ------ | ---------------------------------------------------------- | | `reply_id` | `str` | ID of the reply message | | `name` | `str` | The signal name (e.g. `"tasks_context"`, `"team_updated"`) | | `value` | `dict` | Arbitrary JSON-serialisable payload for this signal | ## Reconstruct Messages from Events Events and messages are not independent — they are two views of the same data. Every event produced by `reply_stream` can be applied to a `Msg` via `append_event()`, reconstructing the complete message incrementally. This guarantees that the final message state is fully recoverable from the event stream alone. ```python theme={null} from agentscope.message import Msg, AssistantMsg msg = None # Accumulate events into the message async for event in agent.reply_stream(user_msg): if isinstance(event, ReplyStartEvent): # Create a new message when the reply starts msg = AssistantMsg(name=event.name, content=[], id=event.reply_id) else: # For all other events, append to the message to reconstruct its state msg.append_event(event) ``` The `append_event` method handles all event types: | Event Type | Effect on Msg | | ------------------------------ | ------------------------------------------------------------------------------------------------------------------- | | `ReplyEndEvent` | Sets `finished_at`, `finished_reason`, and `error` | | `ModelCallEndEvent` | Accumulates `input_tokens` / `output_tokens` into `usage` | | `TextBlockStartEvent` | Appends a new empty `TextBlock` | | `TextBlockDeltaEvent` | Concatenates `delta` to the block's text | | `DataBlockStartEvent` | Appends a new empty `DataBlock` | | `DataBlockDeltaEvent` | Concatenates `data` to the block's base64 content | | `ThinkingBlockStartEvent` | Appends a new empty `ThinkingBlock` | | `ThinkingBlockDeltaEvent` | Concatenates `delta` to the block's thinking text | | `ToolCallStartEvent` | Appends a new `ToolCallBlock` with empty input | | `ToolCallDeltaEvent` | Concatenates `delta` to the tool call's input | | `ToolResultStartEvent` | Appends a new `ToolResultBlock` with empty output | | `ToolResultTextDeltaEvent` | Appends text to the tool result's output | | `ToolResultDataDeltaEvent` | Appends a binary data block to the tool result's output | | `ToolResultEndEvent` | Sets the tool result's final `state` | | `HintBlockEvent` | Appends a `HintBlock` to content (carrying the event's `hint` and `source`) so the hint is persisted and replayable | | `RequireUserConfirmEvent` | Updates tool call states to `ASKING` | | `ExternalExecutionResultEvent` | Appends `ToolResultBlock`s to content | This design makes deployment more flexible: the backend can stream events via SSE to the frontend, which reconstructs and renders the message in real time. Even if the connection is interrupted, replaying the event sequence from any checkpoint can restore the exact message state. ### TypeScript Support AgentScope provides TypeScript versions of messages and event primitives, so frontends can use the exact same `appendEvent` API to reconstruct messages from the event stream. Install the TypeScript version of AgentScope: ```bash theme={null} pnpm install @agentscope-ai/agentscope ``` Example of receiving and reconstructing messages on the frontend: ```typescript theme={null} import { Msg, AssistantMsg, EventType } from "@agentscope-ai/agentscope/message"; let msg: Msg | null = null; for await (const event of stream) { if (event.type === EventType.REPLY_START) { msg = new AssistantMsg({ name: event.name, content: [], id: event.reply_id }); } else { msg?.appendEvent(event); } } ``` ### Example: Streaming UI A typical pattern for building a streaming interface: ```python theme={null} from agentscope.message import AssistantMsg, UserMsg from agentscope.event import ( ReplyStartEvent, TextBlockDeltaEvent, ToolCallStartEvent, ToolResultEndEvent, ReplyEndEvent, ) msg = None async for event in agent.reply_stream(UserMsg("user", "Fix the bug")): if isinstance(event, ReplyStartEvent): msg = AssistantMsg(name=event.name, content=[], id=event.reply_id) elif isinstance(event, TextBlockDeltaEvent): print(event.delta, end="", flush=True) elif isinstance(event, ToolCallStartEvent): print(f"\n[Calling {event.tool_call_name}...]") elif isinstance(event, ToolResultEndEvent): print(f"[Tool finished: {event.state}]") elif isinstance(event, ReplyEndEvent): print("\n[Done]") # Always accumulate into the message if msg is not None: msg.append_event(event) # msg now contains the complete reply ``` ## Further Reading How the agent produces events and messages in the ReAct loop How messages are stored, compressed, and offloaded # Middleware Source: https://docs.agentscope.io/versions/2.0.8/en/building-blocks/middleware Intercept and extend agent behavior at key lifecycle points ## Overview Agent middleware is the mechanism for injecting custom logic — logging, tracing, input rewriting, access control — into key points of the agent execution pipeline, without modifying the agent or model code. AgentScope exposes 6 hook positions plus a tool-provider hook, covering the full path from the outer reply process down to the raw model API call: | Position | Type | Description | | --------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `on_reply` | Onion | Wraps a complete reply, covering all ReAct rounds, tool executions, and the final output | | `on_reasoning` | Onion | Wraps a single ReAct round's reasoning step (input assembly → model call → stream decoding) | | `on_acting` | Onion | Wraps a single tool call execution | | `on_model_call` | Onion | Wraps the underlying `ChatModel` API call — the closest to the model | | `on_compress_context` | Onion | Wraps `Agent.compress_context()` — fires before each reasoning step when the agent decides whether to compress its context | | `on_system_prompt` | Transformer | Fires every time the system prompt is assembled; multiple middlewares chain in sequence, each transforming the previous one's output | | `list_tools` | Tool source | Optional. Returns a `list[ToolBase]` that the middleware contributes. **Not invoked automatically** — the caller assembling the agent's toolkit decides whether to call it and how to merge the result. | These hooks operate at the **agent** level. For per-tool onion hooks that fire on every invocation of a specific tool — regardless of whether it's called inside or outside an agent — see [Tool Middleware](/versions/2.0.8/en/building-blocks/tool/python-tool#tool-middleware). The three types differ as follows: * **Onion** — middleware wraps the next handler, allowing logic before/after `next_handler()` and observation of the intermediate event stream. * **Transformer** — middlewares form a pipeline; the previous one's output feeds into the next one. There is no "inner layer" concept. * **Tool source** — not a hook on the runtime path. `Agent.__init__` does not call `list_tools()`; you opt in explicitly by collecting the tools from your middlewares and passing them into the toolkit yourself. The diagram below shows how these hooks nest within the agent lifecycle. `on_system_prompt` is embedded inside `on_reasoning` because it fires when the reasoning step assembles the system prompt; `on_compress_context` sits at the top of each ReAct round, before reasoning: `on_acting` currently wraps only tool execution inside the agent runtime; tools dispatched outside the agent via external execution are not tracked by `on_acting`. ## Equip Middleware AgentScope packages a set of hooks into a class — a single middleware class can implement any subset of the 6 hook positions (plus the optional `list_tools` tool-provider hook) at the same time. Pass instances to `Agent(middlewares=[...])` to equip them: ```python theme={null} from agentscope.agent import Agent from agentscope.middleware import TracingMiddleware agent = Agent( name="assistant", system_prompt="You are a helpful assistant.", model=model, toolkit=toolkit, middlewares=[TracingMiddleware()], ) ``` At construction time the agent scans each middleware instance, checks which hooks it actually implements, and routes it into the matching position-specific execution lists. Unimplemented positions are skipped automatically with no call overhead. ## Built-in Middleware AgentScope currently supports the following middleware implementations: | Scenario | Implementation | Description | | ----------------- | ------------------------------------------------ | -------------------------------------------------------------- | | Tracing | `TracingMiddleware` | Provides OpenTelemetry tracing across the full agent lifecycle | | Budget control | `ReplyBudgetControlMiddleware` | Controls the token budget for a single reply | | Speech generation | `TTSMiddleware` | Intercepts text output and synthesizes speech | | Long-term memory | `AgenticMemoryMiddleware`,
`Mem0Middleware` | Provides cross-session long-term memory implementations | | RAG | `RAGMiddleware` | Provides access to external knowledge bases | ### Tracing `TracingMiddleware` wires the full agent lifecycle to [OpenTelemetry](https://opentelemetry.io/docs/specs/semconv/gen-ai/) tracing. It instruments `on_reply`, `on_model_call`, and `on_acting`, producing hierarchical spans. Before using it, register a `TracerProvider` and an OTLP exporter in the process: ```python theme={null} from opentelemetry import trace from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import BatchSpanProcessor from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter provider = TracerProvider() provider.add_span_processor( BatchSpanProcessor(OTLPSpanExporter(endpoint="http://localhost:4318/v1/traces")), ) trace.set_tracer_provider(provider) ``` Then attach `TracingMiddleware` to the agent: ```python theme={null} from agentscope.agent import Agent from agentscope.middleware import TracingMiddleware agent = Agent( name="assistant", system_prompt="You are a helpful assistant.", model=model, toolkit=toolkit, middlewares=[TracingMiddleware()], ) ``` Each reply produces a nested span tree. The key attributes captured at each level are: From `on_reply`: * Agent name, session ID, reply ID * Input messages and the final output message * HITL pending tool calls * External execution pending tool calls From `on_model_call`: * Model name, provider, input/output token counts * Request and response message content * Wraps streaming responses, writing attributes onto the final chunk From `on_acting`: * Tool name, call ID, input arguments * Tool execution result When no `TracerProvider` is configured, every hook short-circuits directly to `next_handler()` — no spans are created, no attributes are computed — making the overhead negligible. When the agent receives an `ExternalExecutionResultEvent` (a tool executed outside the agent), `TracingMiddleware` synthesizes a compensating span for each external execution result, preserving full observability for tools run by external systems. #### Add Additional Spans To trace custom operations within the agent lifecycle, use the [standard OpenTelemetry Python SDK](https://opentelemetry.io/docs/languages/python/) directly. Obtain a tracer scoped to AgentScope and wrap any target code in a span: ```python theme={null} from opentelemetry import trace from agentscope import __version__ tracer = trace.get_tracer("agentscope", __version__) with tracer.start_as_current_span( name="your_span_name", attributes={ # Optional key-value pairs attached to the span, # e.g. function name, input arguments, or any custom metadata. }, end_on_exit=True, ) as span: # your code here ``` These custom spans are emitted alongside AgentScope's built-in spans and delivered to the same OTLP collector configured in the `TracerProvider`. ### Budget Control `ReplyBudgetControlMiddleware` enforces a **weighted token budget per reply**. It tracks cumulative token usage across all reasoning steps within a single reply and, once the budget is exhausted, instructs the agent to wrap up immediately without invoking any further tools. This is useful for capping the cost or latency of long, tool-heavy ReAct loops. The weighted cost is computed on every model call as: ``` cost = input_token_weight * input_tokens + output_token_weight * output_tokens ``` Once the accumulated cost reaches `token_budget`, the middleware: 1. Appends a `HintBlock` to the last assistant message in the agent's context (or creates a new `AssistantMsg` if needed), reminding the model to produce a final concluding response. 2. Overrides `tool_choice` to `ToolChoice(mode="none")` for the next reasoning step, preventing any further tool calls. Attach it like any other middleware: ```python theme={null} from agentscope.agent import Agent from agentscope.middleware import ReplyBudgetControlMiddleware agent = Agent( name="assistant", system_prompt="You are a helpful assistant.", model=model, toolkit=toolkit, middlewares=[ ReplyBudgetControlMiddleware( token_budget=10000, input_token_weight=1.0, output_token_weight=2.0, ), ], ) ``` The constructor accepts the following parameters: Maximum weighted token cost allowed per reply. Once the accumulated cost reaches this threshold, the agent is instructed to wrap up without calling any more tools. Multiplier applied to input tokens when computing the weighted cost. Multiplier applied to output tokens when computing the weighted cost. Set this higher than `input_token_weight` to reflect that output tokens are typically more expensive. The message injected into the agent's context when the budget is exceeded. Defaults to a built-in wrap-up prompt that asks the model to provide a final concluding response without invoking any tools. The middleware is **stateless on the instance itself** — all runtime state lives in `agent.state.middle_context`, keyed by the middleware key and the current `reply_id`. This means the same middleware instance can safely be shared across multiple agents, and budget state persists across human-in-the-loop (HITL) interruptions and resumptions. State is automatically cleaned up when the reply ends. The budget is scoped **per reply**, not per agent lifetime. Each new reply starts with a fresh counter, so the limit applies independently to every call to `agent(...)`. ### Speech Generation `TTSMiddleware` intercepts the agent's text output and synthesizes speech audio, injecting `DataBlockStartEvent` / `DataBlockDeltaEvent` / `DataBlockEndEvent` into the event stream alongside the text. It hooks into `on_reply` to observe every `TextBlockDeltaEvent` and `TextBlockEndEvent`. ```python theme={null} from agentscope.agent import Agent from agentscope.middleware import TTSMiddleware from agentscope.tts import DashScopeTTSModel from agentscope.credential import DashScopeCredential agent = Agent( name="assistant", system_prompt="You are a helpful assistant.", model=model, toolkit=toolkit, middlewares=[ TTSMiddleware( DashScopeTTSModel( credential=DashScopeCredential(api_key="..."), model="qwen3-tts-flash", parameters=DashScopeTTSModel.Parameters(voice="Cherry"), stream=True, ), ), ], ) ``` The middleware automatically adapts to the TTS model's mode: | TTS Mode | Behavior | | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Non-realtime (`realtime=False`) | Accumulates text until `TextBlockEndEvent`, then calls `synthesize(text)` and emits the full audio as one data block | | Realtime (`realtime=True`) | Pushes each `TextBlockDeltaEvent.delta` via `push()`, emitting audio chunks as they arrive; calls `synthesize()` on `TextBlockEndEvent` to flush remaining audio | The output event stream differs by mode: **Non-realtime** — audio follows after the text block completes: ``` TextBlockStartEvent TextBlockDeltaEvent (text) TextBlockDeltaEvent (text) TextBlockEndEvent DataBlockStartEvent ← audio synthesized after text ends DataBlockDeltaEvent (audio) DataBlockDeltaEvent (audio) DataBlockEndEvent ``` **Realtime** — audio chunks arrive interleaved with text as synthesis runs concurrently: ``` TextBlockStartEvent TextBlockDeltaEvent (text) DataBlockStartEvent ← audio begins during text stream DataBlockDeltaEvent (audio) TextBlockDeltaEvent (text) DataBlockDeltaEvent (audio) TextBlockEndEvent DataBlockDeltaEvent (audio) ← remaining audio from synthesize() DataBlockEndEvent ``` Each `DataBlockDeltaEvent.data` carries an incremental base64-encoded audio chunk; the full audio is the concatenation of every delta's decoded bytes, keyed by `block_id`. ### Long-Term Memory AgentScope supports long-term memory as middleware, so agents can persist and recall information across sessions. See [Long-Term Memory](/versions/2.0.8/en/building-blocks/long-term-memory) for details. ### RAG AgentScope also provides knowledge-base access through middleware, allowing agents to access external knowledge bases during reasoning. See [RAG](/versions/2.0.8/en/building-blocks/rag) for details. ## Custom Middleware Subclass `MiddlewareBase` and implement only the hook positions you need. The example below covers every position in a single middleware. Each onion hook receives an `input_kwargs` dict carrying the fields that flow into the wrapped layer; forward it with `next_handler(**input_kwargs)`, or pass keyword arguments to override specific fields: ```python theme={null} from typing import AsyncGenerator, Awaitable, Callable from agentscope.agent import Agent from agentscope.event import AgentEvent from agentscope.message import Msg from agentscope.middleware import MiddlewareBase from agentscope.model import ChatResponse from agentscope.tool import ToolBase class FullObservabilityMiddleware(MiddlewareBase): """Observe every middleware position at once, plus contribute a tool.""" async def on_reply( self, agent: Agent, # {"inputs": Msg | list[Msg] | UserConfirmResultEvent | ExternalExecutionResultEvent | None} input_kwargs: dict, next_handler: Callable[..., AsyncGenerator[AgentEvent | Msg, None]], ) -> AsyncGenerator[AgentEvent | Msg, None]: print(f"[reply] start for {agent.name}") async for item in next_handler(**input_kwargs): yield item print(f"[reply] end for {agent.name}") async def on_reasoning( self, agent: Agent, # {"tool_choice": ToolChoice | None} input_kwargs: dict, next_handler: Callable[..., AsyncGenerator[AgentEvent, None]], ) -> AsyncGenerator[AgentEvent, None]: print("[reasoning] start") async for event in next_handler(**input_kwargs): yield event print("[reasoning] end") async def on_model_call( self, agent: Agent, # {"messages": list[Msg], "tools": list[dict], "tool_choice": ToolChoice | None, "current_model": ChatModelBase} input_kwargs: dict, next_handler: Callable[ ..., Awaitable[ChatResponse | AsyncGenerator[ChatResponse, None]] ], ) -> ChatResponse | AsyncGenerator[ChatResponse, None]: print(f"[model_call] {input_kwargs['current_model'].model}") result = await next_handler(**input_kwargs) print("[model_call] done") return result async def on_compress_context( self, agent: Agent, # {"context_config": ContextConfig | None} input_kwargs: dict, next_handler: Callable[..., Awaitable[None]], ) -> None: print(f"[compress_context] checking context for {agent.name}") await next_handler(**input_kwargs) print("[compress_context] done") async def on_system_prompt( self, agent: Agent, current_prompt: str, ) -> str: print(f"[system_prompt] length={len(current_prompt)}") return current_prompt async def list_tools(self) -> list[ToolBase]: # Optional hook. Not invoked automatically by ``Agent.__init__``; # if you want these tools available to the agent, collect them # from your middlewares yourself and pass them into the toolkit. return [] ``` ### Control The Agent Loop `on_reply` does more than observe events: forwarding, replacing or swallowing them changes the whole reply flow. In particular, if a middleware receives a `ReplyEndEvent` and does not yield it, the agent runs another reasoning-acting iteration. The reply only ends once the end event travels through the entire middleware chain. The middleware below appends a review instruction the first time the agent is about to finish, and forces one more iteration: ```python theme={null} from collections.abc import AsyncGenerator, Callable from agentscope.agent import Agent from agentscope.event import AgentEvent, ReplyEndEvent from agentscope.message import HintBlock, Msg from agentscope.middleware import MiddlewareBase from agentscope.types import ReplyFinishedReason class ContinueOnceMiddleware(MiddlewareBase): async def on_reply( self, agent: Agent, input_kwargs: dict, next_handler: Callable[..., AsyncGenerator[AgentEvent | Msg, None]], ) -> AsyncGenerator[AgentEvent | Msg, None]: continued = False async for item in next_handler(**input_kwargs): can_continue = ( isinstance(item, ReplyEndEvent) and item.finished_reason in { ReplyFinishedReason.COMPLETED, ReplyFinishedReason.EXCEED_MAX_ITERS, } and not continued ) if can_continue: continued = True agent.state.cur_iter = min( agent.state.cur_iter, agent.react_config.max_iters - 1, ) agent.state.append_context( agent.name, [HintBlock(hint="Double-check the result before finishing.")], ) continue # Swallow the ReplyEndEvent and run one more iteration yield item ``` Before swallowing `EXCEED_MAX_ITERS`, free up at least one iteration (adjust `cur_iter` as above), otherwise the agent cannot make new progress. An `INTERRUPTED` end event must not be continued this way; pass it through unchanged. In practice, also set an explicit retry limit so the middleware cannot spin the reply loop forever. ### Execution Order Onion hooks (`on_reply`, `on_reasoning`, `on_acting`, `on_model_call`) — **the first middleware in the list is the outermost layer**: ```python theme={null} middlewares = [mw1, mw2] # Call order: # mw1 pre → mw2 pre → inner logic → mw2 post → mw1 post ``` For streaming / event-yielding hooks, the inner middleware sees each yielded event first: ``` mw1_pre → mw2_pre → mw2_event → mw1_event → ... → mw2_post → mw1_post ``` Transformer hooks (`on_system_prompt`) — middlewares **chain left to right**: ```python theme={null} middlewares = [mw1, mw2] # original_prompt → mw1.on_system_prompt() → mw2.on_system_prompt() → final ``` The overall execution order of all hooks within a single reply follows the agent lifecycle: ``` on_reply └── per ReAct round: ├── on_compress_context → compress_context() │ └── on_system_prompt (token counting before compression) ├── on_reasoning │ ├── _prepare_model_input() → on_system_prompt │ └── on_model_call └── on_acting (once per tool call in this round) ``` `list_tools` is not part of the per-reply execution path and is not invoked automatically by the agent — it is a convenience interface so a middleware can advertise its own tools. The caller assembling the toolkit decides whether to collect them. ## Practical Examples ### Timing middleware The middleware below records the elapsed time of every model call: ```python theme={null} import time from agentscope.middleware import MiddlewareBase class TimingMiddleware(MiddlewareBase): async def on_model_call(self, agent, input_kwargs, next_handler): model_name = input_kwargs["current_model"].model start = time.time() result = await next_handler() elapsed = time.time() - start print(f"[timing] {agent.name} → {model_name}: {elapsed:.2f}s") return result ``` ### Rate-limiting middleware The middleware below enforces a minimum interval between two model calls: ```python theme={null} import asyncio import time from agentscope.middleware import MiddlewareBase class RateLimitMiddleware(MiddlewareBase): def __init__(self, min_interval: float = 1.0): self._last_call = 0.0 self._min_interval = min_interval async def on_model_call(self, agent, input_kwargs, next_handler): now = time.time() wait = self._min_interval - (now - self._last_call) if wait > 0: await asyncio.sleep(wait) self._last_call = time.time() return await next_handler() ``` ### Dynamic system prompt middleware The middleware below injects real-time context into the system prompt: ```python theme={null} from datetime import datetime from agentscope.middleware import MiddlewareBase class DynamicContextMiddleware(MiddlewareBase): def __init__(self, context_fn): self._context_fn = context_fn async def on_system_prompt(self, agent, current_prompt): context = self._context_fn() return f"{current_prompt}\n\n## Current Context\n{context}" agent = Agent( ... middlewares=[ DynamicContextMiddleware( lambda: f"Time: {datetime.now().isoformat()}" ), ], ) ``` ### Model fallback middleware The middleware below switches to a fallback model when the primary one fails: ```python theme={null} from agentscope.middleware import MiddlewareBase class ModelFallbackMiddleware(MiddlewareBase): def __init__(self, fallback_model): self._fallback = fallback_model async def on_model_call(self, agent, input_kwargs, next_handler): try: return await next_handler() except Exception as e: print(f"Primary model failed: {e}, switching to fallback") return await next_handler( current_model=self._fallback, ) ``` # Embedding Source: https://docs.agentscope.io/versions/2.0.8/en/building-blocks/model/embedding Turn text and media into vectors for search, RAG, and memory An **Embedding Model** converts text (and, for multimodal models, images, videos, and other media) into dense vectors that power semantic search, RAG, and memory retrieval. AgentScope currently ships the following embedding model classes: | Provider | Model Class | Highlights | | --------- | ------------------------- | ------------------------------------------------------------------------------------------------------ | | DashScope | `DashScopeEmbeddingModel` | Unified text + multimodal API (`text-embedding-v4`, `qwen3-vl-embedding`, ...), content-aware batching | | OpenAI | `OpenAIEmbeddingModel` | `text-embedding-3-small/large`, compatible with OpenAI-compatible endpoints | | Gemini | `GeminiEmbeddingModel` | Text (`gemini-embedding-001`) and multimodal (`gemini-embedding-2`, image / video / audio / PDF) | | Ollama | `OllamaEmbeddingModel` | Local embedding models (`nomic-embed-text`, ...), credential carries the host URL | ## Create Embedding Model Every embedding model takes a credential, a model name, and an optional `Parameters` object, the same pattern as chat models. `Parameters` carries `dimensions`, the output vector size: ```python DashScope theme={null} import os from agentscope.embedding import DashScopeEmbeddingModel from agentscope.credential import DashScopeCredential model = DashScopeEmbeddingModel( credential=DashScopeCredential(api_key=os.environ["DASHSCOPE_API_KEY"]), model="text-embedding-v4", parameters=DashScopeEmbeddingModel.Parameters(dimensions=1024), ) ``` ```python OpenAI theme={null} import os from agentscope.embedding import OpenAIEmbeddingModel from agentscope.credential import OpenAICredential model = OpenAIEmbeddingModel( credential=OpenAICredential(api_key=os.environ["OPENAI_API_KEY"]), model="text-embedding-3-small", parameters=OpenAIEmbeddingModel.Parameters(dimensions=1536), ) ``` ```python Gemini theme={null} import os from agentscope.embedding import GeminiEmbeddingModel from agentscope.credential import GeminiCredential model = GeminiEmbeddingModel( credential=GeminiCredential(api_key=os.environ["GEMINI_API_KEY"]), model="gemini-embedding-001", parameters=GeminiEmbeddingModel.Parameters(dimensions=768), ) ``` ```python Ollama theme={null} from agentscope.embedding import OllamaEmbeddingModel from agentscope.credential import OllamaCredential model = OllamaEmbeddingModel( credential=OllamaCredential(host="http://localhost:11434"), model="nomic-embed-text", ) ``` Common constructor arguments shared by every embedding model: | Argument | Type | Description | | ----------------- | ---------------------------- | -------------------------------------------------------------------------------------- | | `credential` | `CredentialBase` | Provider-specific credential | | `model` | `str` | Model identifier (e.g. `"text-embedding-v4"`) | | `parameters` | `Parameters \| None` | `dimensions`, the output vector size (default `512`) | | `embedding_cache` | `EmbeddingCacheBase \| None` | Optional cache that skips repeated API calls (see [Embedding Cache](#embedding-cache)) | | `context_size` | `int` | Maximum input tokens per item | | `max_retries` | `int` | Maximum retries per batch on retryable failures | | `retry_delay` | `float` | Seconds between retry attempts | Valid `dimensions` values differ per model: each model card pins the default via its top-level `dimensions` field and the allowed values via `supported_dimensions` (e.g. `text-embedding-v4` accepts 2048 / 1536 / 1024 / ... / 64). See [EmbeddingModelCard](#embeddingmodelcard). ## Call Embedding Model Invoke the model by calling it with a list of inputs. Text-only models accept `list[str]`; multimodal models also accept `DataBlock` elements: ```python theme={null} async def __call__( self, inputs: list[str | DataBlock], **kwargs: Any, ) -> EmbeddingResponse: ``` Batching and retry are handled for you: 1. Inputs are split into chunks of the model's batch size (10 for DashScope text, 2048 for OpenAI, 100 for Gemini, 512 for Ollama). 2. All chunks are dispatched **concurrently** via `asyncio.gather`. 3. Each chunk is retried independently up to `max_retries` times on provider-specific retryable errors. 4. Results are merged into a single `EmbeddingResponse`, preserving input order. ```python theme={null} import asyncio import os from agentscope.embedding import DashScopeEmbeddingModel from agentscope.credential import DashScopeCredential async def main(): model = DashScopeEmbeddingModel( credential=DashScopeCredential(api_key=os.environ["DASHSCOPE_API_KEY"]), model="text-embedding-v4", parameters=DashScopeEmbeddingModel.Parameters(dimensions=1024), ) response = await model( ["What is AgentScope?", "A multi-agent framework."], ) print(len(response.embeddings)) # 2, one vector per input print(len(response.embeddings[0])) # 1024 print(response.usage.tokens) # total tokens consumed print(response.source) # "api" or "cache" asyncio.run(main()) ``` Each `EmbeddingResponse` carries: | Field | Type | Description | | ---------------------------- | ------------------------ | --------------------------------------------------------------- | | `embeddings` | `list[Embedding]` | One vector per input, in input order | | `usage` | `EmbeddingUsage \| None` | `tokens` consumed and `time` elapsed in seconds | | `source` | `"api" \| "cache"` | Whether the result came from the API or the cache | | `id` / `created_at` / `type` | `str` | Response identity and timestamp; `type` is always `"embedding"` | ### Multimodal Embedding Multimodal models (`DashScopeEmbeddingModel` with `qwen3-vl-embedding` etc., `GeminiEmbeddingModel` with `gemini-embedding-2`) accept `DataBlock` inputs alongside strings (images as URL or base64, videos as URL): ```python theme={null} import asyncio import os from agentscope.embedding import DashScopeEmbeddingModel from agentscope.credential import DashScopeCredential from agentscope.message import DataBlock, URLSource async def main(): model = DashScopeEmbeddingModel( credential=DashScopeCredential(api_key=os.environ["DASHSCOPE_API_KEY"]), model="qwen3-vl-embedding", ) response = await model([ "A cat sitting on a windowsill", DataBlock( source=URLSource( url="https://example.com/cat.png", media_type="image/png", ), ), ]) print(len(response.embeddings)) # 2, one vector per input asyncio.run(main()) ``` Multimodal models replace the plain batch-size split with **content-aware batching**: inputs are greedily packed into batches that respect the model's per-request limits on total elements, images, and videos (e.g. `qwen3-vl-embedding` allows 20 elements / 5 images / 1 video per request, `tongyi-embedding-vision-plus` allows 20 / 64 / 8). You never need to split inputs yourself. ## Embedding Cache Pass an `EmbeddingCacheBase` implementation through the `embedding_cache` argument to reuse previously computed vectors. The built-in `FileEmbeddingCache` stores each result as a `.npy` file keyed by the SHA-256 hash of the request: ```python theme={null} import asyncio import os from agentscope.embedding import DashScopeEmbeddingModel, FileEmbeddingCache from agentscope.credential import DashScopeCredential async def main(): model = DashScopeEmbeddingModel( credential=DashScopeCredential(api_key=os.environ["DASHSCOPE_API_KEY"]), model="text-embedding-v4", embedding_cache=FileEmbeddingCache( cache_dir="./.cache/embeddings", max_file_number=1000, max_cache_size=100, # MB ), ) r1 = await model(["What is AgentScope?"]) print(r1.source) # "api": first call hits the API r2 = await model(["What is AgentScope?"]) print(r2.source) # "cache": identical request served locally asyncio.run(main()) ``` When `max_file_number` or `max_cache_size` is exceeded, the oldest files are evicted first. To use a different backend (Redis, SQLite, ...), subclass `EmbeddingCacheBase` and implement its four methods: `store`, `retrieve`, `remove`, and `clear`. ## Custom Embedding Provider Adding an embedding provider follows the same steps as a [custom chat provider](/versions/2.0.8/en/building-blocks/model/llm#custom-provider). ### Step 1: Link the Credential Override `get_embedding_model_class()` on your credential (the base implementation returns `None`, meaning "no embedding support"): ```python theme={null} from typing import Type, TYPE_CHECKING from agentscope.credential import CredentialBase if TYPE_CHECKING: from agentscope.embedding import EmbeddingModelBase class MyProviderCredential(CredentialBase): # ... fields and get_chat_model_class() as before ... @classmethod def get_embedding_model_class(cls) -> Type["EmbeddingModelBase"]: from .my_embedding import MyProviderEmbeddingModel return MyProviderEmbeddingModel ``` ### Step 2: Implement the Embedding Model Subclass `EmbeddingModelBase` and implement `_call_api` for a **single batch**; batching, concurrency, and retry are inherited from the base class. Declare provider-specific transient errors via `_get_retryable_exceptions`: ```python theme={null} from typing import Any, Type from agentscope.embedding import EmbeddingModelBase, EmbeddingResponse, EmbeddingUsage class MyProviderEmbeddingModel(EmbeddingModelBase[str]): def __init__( self, credential: "MyProviderCredential", model: str, parameters: "MyProviderEmbeddingModel.Parameters | None" = None, context_size: int = 8192, max_retries: int = 3, retry_delay: float = 1.0, ) -> None: super().__init__( credential=credential, model=model, parameters=parameters, context_size=context_size, batch_size=100, # max items per API call max_retries=max_retries, retry_delay=retry_delay, ) @classmethod def _get_retryable_exceptions(cls) -> tuple[Type[Exception], ...]: return (TimeoutError,) # retried up to max_retries times async def _call_api( self, inputs: list[str], **kwargs: Any, ) -> EmbeddingResponse: # len(inputs) <= self.batch_size is guaranteed. # Call your provider's API and return the vectors. ... ``` Bind the generic parameter to the input type your provider supports: `EmbeddingModelBase[str]` for text-only, `EmbeddingModelBase[str | DataBlock]` for multimodal, so IDEs surface the correct `inputs` type to callers. ### Step 3: Add Model Cards (optional) Drop YAML files into a `_models/` directory next to your implementation; `MyProviderEmbeddingModel.list_models()` then picks them up, exactly like chat model cards. ## EmbeddingModelCard `EmbeddingModelCard` mirrors the general [ModelCard](/versions/2.0.8/en/building-blocks/model/overview#integrate-with-frontend) for the frontend, with embedding-specific defaults: the output type `application/x-embedding` marks a model as producing dense vectors. | Field | Difference from `ModelCard` | | ---------------------- | -------------------------------------------------------------------------------------------------------------------------- | | `type` | Always `"embedding_model"` | | `input_types` | Defaults to `["text/plain"]`; multimodal cards add `image/*`, `video/*`, ... | | `output_types` | Defaults to `["application/x-embedding"]` | | `dimensions` | **Required** top-level field: the default output vector size, exposed as a strongly typed `int` | | `supported_dimensions` | Allowed output sizes for Matryoshka-style models (e.g. OpenAI's `text-embedding-3-*`); `None` means the dimension is fixed | | `context_size` | Optional; maximum input tokens per request, if known | | `output_size` | Not present; embedding models have no output token limit | A typical YAML card (the real card for `text-embedding-v4`): ```yaml theme={null} name: text-embedding-v4 label: Text Embedding v4 status: active input_types: - text/plain output_types: - application/x-embedding context_size: 8192 # Default output vector size, plus the sizes this # Matryoshka-style model can be truncated to dimensions: 1024 supported_dimensions: [2048, 1536, 1024, 768, 512, 256, 128, 64] ``` Retrieve cards from the model class directly, or discover the class from a credential via `get_embedding_model_class()`: ```python theme={null} from agentscope.credential import DashScopeCredential from agentscope.embedding import OpenAIEmbeddingModel # Directly on the model class cards = OpenAIEmbeddingModel.list_models() # Or discover the class from a credential embed_cls = DashScopeCredential.get_embedding_model_class() cards = embed_cls.list_models() for card in cards: print(f"{card.name}: context={card.context_size}, inputs={card.input_types}") ``` # LLM Source: https://docs.agentscope.io/versions/2.0.8/en/building-blocks/model/llm Create chat models, call them, and plug in your own provider An LLM (called a **chat model** in AgentScope's class names) drives an agent's conversation and tool calls, accepting and producing multimodal content beyond plain text. AgentScope currently ships the following chat model classes: | Provider | Model Class | | ---------------------- | --------------------- | | OpenAI | `OpenAIChatModel` | | OpenAI (Responses API) | `OpenAIResponseModel` | | Anthropic | `AnthropicChatModel` | | DashScope | `DashScopeChatModel` | | DeepSeek | `DeepSeekChatModel` | | Gemini | `GeminiChatModel` | | Moonshot | `MoonshotChatModel` | | Volcengine | `VolcengineChatModel` | | xAI | `XAIChatModel` | | Ollama | `OllamaChatModel` | ## Create Chat Model Every chat model takes a credential, a model name, and an optional provider-specific `Parameters` object. The three tabs below show typical setups for streaming, tool calling, and reasoning: ```python Streaming theme={null} import os from agentscope.model import DashScopeChatModel from agentscope.credential import DashScopeCredential model = DashScopeChatModel( credential=DashScopeCredential(api_key=os.environ["DASHSCOPE_API_KEY"]), model="qwen-plus", stream=True, ) ``` ```python Tools theme={null} import os from agentscope.model import DashScopeChatModel from agentscope.credential import DashScopeCredential model = DashScopeChatModel( credential=DashScopeCredential(api_key=os.environ["DASHSCOPE_API_KEY"]), model="qwen-plus", stream=False, parameters=DashScopeChatModel.Parameters( parallel_tool_calls=False, ), ) ``` ```python Reasoning theme={null} import os from agentscope.model import DashScopeChatModel from agentscope.credential import DashScopeCredential model = DashScopeChatModel( credential=DashScopeCredential(api_key=os.environ["DASHSCOPE_API_KEY"]), model="qwen3-235b-a22b-thinking-2507", parameters=DashScopeChatModel.Parameters( thinking_enable=True, thinking_budget=2048, ), ) ``` Common constructor arguments shared by every chat model: | Argument | Type | Description | | -------------- | ----------------------- | -------------------------------------------------------------------------------------------- | | `credential` | `CredentialBase` | Provider-specific credential | | `model` | `str` | Model identifier (e.g. `"qwen-plus"`) | | `parameters` | `Parameters \| None` | Provider-specific parameters such as `temperature`, `thinking_enable`, `parallel_tool_calls` | | `stream` | `bool` | Whether to stream output | | `max_retries` | `int` | Maximum API retries on failure | | `context_size` | `int` | Context window used for context compression | | `formatter` | `FormatterBase \| None` | Override message formatter (see [Formatter](#formatter)) | ## Call Chat Model Invoke the model by calling it with a list of `Msg` objects, plus optional `tools` and `tool_choice`: ```python theme={null} async def __call__( self, messages: list[Msg], tools: list[dict] | None = None, tool_choice: ToolChoice | None = None, **kwargs: Any, ) -> ChatResponse | AsyncGenerator[ChatResponse, None]: ``` The return type follows the model's `stream` setting: * **`stream=False`**: awaits a single `ChatResponse` carrying the full output. * **`stream=True`**: awaits an `AsyncGenerator[ChatResponse, None]`. Intermediate chunks (`is_last=False`) carry only the **delta** generated in that step. The final chunk (`is_last=True`) carries the **full accumulated content**. The two tabs below show both modes side by side: ```python Streaming theme={null} import asyncio import os from agentscope.model import DashScopeChatModel from agentscope.credential import DashScopeCredential from agentscope.message import UserMsg async def main(): model = DashScopeChatModel( credential=DashScopeCredential(api_key=os.environ["DASHSCOPE_API_KEY"]), model="qwen-plus", stream=True, ) msgs = [UserMsg(name="user", content="Count from 1 to 5.")] # stream=True: iterate over an async generator of ChatResponse chunks async for chunk in await model(msgs): if chunk.is_last: print("Final:", chunk.content) # full accumulated content else: print("Delta:", chunk.content) # delta only asyncio.run(main()) ``` ```python Non-Streaming theme={null} import asyncio import os from agentscope.model import DashScopeChatModel from agentscope.credential import DashScopeCredential from agentscope.message import UserMsg async def main(): model = DashScopeChatModel( credential=DashScopeCredential(api_key=os.environ["DASHSCOPE_API_KEY"]), model="qwen-plus", stream=False, ) msgs = [UserMsg(name="user", content="Count from 1 to 5.")] # stream=False: await a single ChatResponse with the full output response = await model(msgs) print(response.content) # [TextBlock(text='1, 2, 3, 4, 5')] asyncio.run(main()) ``` A representative streaming trace, illustrating the delta-then-accumulated pattern: ``` Delta: [TextBlock(text='1')] Delta: [TextBlock(text=', 2,')] Delta: [TextBlock(text=' 3, ')] Delta: [TextBlock(text='4, 5')] Final: [TextBlock(text='1, 2, 3, 4, 5')] ``` Each `ChatResponse` carries content blocks (`TextBlock`, `ThinkingBlock`, `ToolCallBlock`, `DataBlock`), an `is_last` flag, a `finished_reason` (`FinishedReason.COMPLETED` or `FinishedReason.INTERRUPTED`), and a `ChatUsage` recording token counts and elapsed time. ## Interrupt Model Call Interruption at the model layer means **asynchronous cancellation**: a model call runs inside an asyncio task, and cancelling that task (raising `asyncio.CancelledError`) stops the in-flight API request, typically because the user interrupted the agent while it was reasoning. Instead of discarding the partial output, `ChatModelBase.__call__` catches the cancellation and returns a final `ChatResponse` carrying the content accumulated so far, marked with `finished_reason=FinishedReason.INTERRUPTED`. Normal completions end with `FinishedReason.COMPLETED`, so downstream code can always distinguish a complete answer from a truncated one: ```python theme={null} import asyncio from agentscope.model import FinishedReason async def call_model(): async for chunk in await model(msgs): if chunk.is_last and chunk.finished_reason == FinishedReason.INTERRUPTED: # Partial content accumulated before the cancellation print("Interrupted:", chunk.content) task = asyncio.create_task(call_model()) # Cancelling the task interrupts the in-flight model call task.cancel() ``` This is the model-layer half of agent interruption: the `Agent` class builds on the same mechanism to stop a running reasoning-acting loop and keep its context consistent. See [Interrupt Agent](/versions/2.0.8/en/building-blocks/agent/interrupt-agent) for the agent-level behavior. ## Generate Structured Output When you need a JSON object that conforms to a Pydantic model or JSON schema, call `generate_structured_output` instead of `__call__`. It returns a `StructuredResponse` whose `content` is a validated dict matching the schema: ```python theme={null} import asyncio import os from pydantic import BaseModel from agentscope.model import DashScopeChatModel from agentscope.credential import DashScopeCredential from agentscope.message import UserMsg class WeatherInfo(BaseModel): city: str temperature: float unit: str async def main(): model = DashScopeChatModel( credential=DashScopeCredential(api_key=os.environ["DASHSCOPE_API_KEY"]), model="qwen-plus", stream=False, ) response = await model.generate_structured_output( messages=[UserMsg(name="user", content="What's the weather in Shanghai?")], structured_model=WeatherInfo, ) print(response.content) # validated dict matching WeatherInfo asyncio.run(main()) ``` `generate_structured_output` synthesizes a forced tool call from the schema, then validates and repairs the model's response. ## Formatter A formatter translates AgentScope's `Msg` objects into the `list[dict]` payload that each provider's API expects. It is configured via the optional `formatter` argument on the chat model constructor. Every provider ships two built-in variants: | Variant | Use Case | | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **ChatFormatter** (default) | Standard single-agent dialog. Each `Msg` maps 1:1 to an API message, preserving native roles (`user`, `assistant`, `system`). | | **MultiAgentFormatter** | Multi-agent scenarios such as debate or moderation. Consecutive agent messages are grouped and wrapped in `` tags with the sender's name, while tool call / result sequences keep their native API format. | Switch to multi-agent mode by passing the MultiAgent variant; no agent code changes are required: ```python theme={null} import os from agentscope.model import OpenAIChatModel from agentscope.credential import OpenAICredential from agentscope.formatter import OpenAIMultiAgentFormatter model = OpenAIChatModel( credential=OpenAICredential(api_key=os.environ["OPENAI_API_KEY"]), model="gpt-4.1", formatter=OpenAIMultiAgentFormatter(), ) ``` For non-standard payload shapes (e.g. a provider whose API doesn't follow the OpenAI or Anthropic conventions), subclass `FormatterBase` and pass an instance through the same `formatter` argument. ### Multimodal Tool Results A tool can return images, audio and other `DataBlock`s, and APIs differ in how much of that they can carry, so the formatter decides how those blocks enter the request: | Formatter | How multimodal tool results are carried | | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `OpenAIResponseFormatter` | Written straight into the content array of `function_call_output`, with text, images and files in the order the tool returned them, so the media stays associated with its `call_id`. Text-only results remain string outputs. | | Every other formatter | The tool result keeps only text referring to the block, and the block itself is promoted into the user message that follows. The reference uses the `DataBlock`'s own stable `id`, so reformatting the same history produces the same output and provider prefix caching keeps working. | Both paths fall back to text for a media type the target model does not support: base64 data is written to a file and the tool result carries its path, while URL data is given as the URL. ## Custom Provider You can extend AgentScope with your own model provider by implementing a credential and a chat model, then registering the credential. ### Step 1: Define the Credential Subclass `CredentialBase` with a unique `type` discriminator and implement `get_chat_model_class()`: ```python theme={null} from typing import Literal, Type, TYPE_CHECKING from pydantic import ConfigDict, Field, SecretStr from agentscope.credential import CredentialBase if TYPE_CHECKING: from agentscope.model import ChatModelBase class MyProviderCredential(CredentialBase): model_config = ConfigDict(title="My Provider API") type: Literal["my_provider_credential"] = "my_provider_credential" api_key: SecretStr = Field(description="API key for My Provider.") base_url: str = Field(default="https://api.myprovider.com/v1") @classmethod def get_chat_model_class(cls) -> Type["ChatModelBase"]: from .my_model import MyProviderChatModel return MyProviderChatModel ``` ### Step 2: Implement the Chat Model Subclass `ChatModelBase`, define a `Parameters` inner class, and implement `_call_api`. The base class owns retry, streaming accumulation, and interruption handling, so `_call_api` only needs to translate provider responses into AgentScope `ChatResponse` chunks: ```python theme={null} from typing import Literal, Any, AsyncGenerator from pydantic import BaseModel, Field from agentscope.model import ChatModelBase, ChatResponse from agentscope.message import Msg from agentscope.tool import ToolChoice from agentscope.formatter import FormatterBase, OpenAIChatFormatter class MyProviderChatModel(ChatModelBase): class Parameters(BaseModel): max_tokens: int | None = Field(default=None, gt=0) temperature: float | None = Field(default=None, ge=0, le=2) type: Literal["my_provider_chat"] = "my_provider_chat" def __init__( self, credential: "MyProviderCredential", model: str, parameters: Parameters | None = None, stream: bool = True, max_retries: int = 3, context_size: int = 128000, formatter: FormatterBase | None = None, ) -> None: super().__init__( credential=credential, model=model, parameters=parameters or self.Parameters(), stream=stream, max_retries=max_retries, context_size=context_size, ) # If your API follows the OpenAI format, reuse OpenAIChatFormatter; # otherwise implement your own FormatterBase subclass. self.formatter = formatter or OpenAIChatFormatter() async def _call_api( self, model_name: str, messages: list[Msg], tools: list[dict] | None = None, tool_choice: ToolChoice | None = None, **kwargs: Any, ) -> ChatResponse | AsyncGenerator[ChatResponse, None]: formatted_messages = await self.formatter.format(messages) # Call your provider's API using self.credential.api_key, etc. # Return one ChatResponse for stream=False, or an async generator # that yields delta ChatResponse chunks for stream=True. # Do not accumulate streaming chunks here; ChatModelBase.__call__ # will append the final accumulated response and mark interruptions. ... ``` For streaming custom providers, yield provider deltas as `ChatResponse(content=..., is_last=False)`. If `_call_api` ends without yielding a final `is_last=True` chunk, `ChatModelBase.__call__` will accumulate the deltas with `ChatResponse.append_chat_response()` and emit the final response automatically. If the stream is cancelled, it emits the partial accumulated response with `finished_reason=FinishedReason.INTERRUPTED`. ### Step 3: Add Model Cards (optional) Drop YAML files into a `_models/` directory next to your model implementation. Each file describes one model: its capabilities (`input_types`, `output_types`), limits (`context_size`, `output_size`), and any per-model `parameter_overrides` (see [Integrate with Frontend](/versions/2.0.8/en/building-blocks/model/overview#integrate-with-frontend) for the full card format): ```yaml theme={null} name: my-model-v1 label: My Model V1 status: active input_types: - text/plain output_types: - text/plain context_size: 128000 output_size: 4096 parameter_overrides: max_tokens: {"maximum": 4096} ``` `MyProviderChatModel.list_models()` then loads every YAML in that directory. To pull cards from a different location (for example, a registry your application maintains separately), pass `custom_yaml_dir`: ```python theme={null} cards = MyProviderChatModel.list_models(custom_yaml_dir="/path/to/cards") ``` # Overview Source: https://docs.agentscope.io/versions/2.0.8/en/building-blocks/model/overview Connect model providers with a credential and discover their models through model cards The model layer connects AgentScope to LLM providers through a two-tier hierarchy. A **Credential** sits at the top, with the model families the provider exposes beneath it: **LLM (Chat Model)**, **TTS**, **Embedding**, and **Realtime Model**. A **Credential** carries the API authentication fields a provider requires (`api_key`, `base_url`, ...). From a single credential, you can discover every model the provider offers in each family it supports. The table below shows each built-in credential and the model classes under it: | Credential | LLM (Chat Model) | TTS | Embedding | Realtime | | ---------------------- | -------------------------------------------- | -------------------------------------------------------------------------------------- | ------------------------- | ----------------------------------------------------------- | | `DashScopeCredential` | `DashScopeChatModel` | `DashScopeTTSModel`
`DashScopeRealtimeTTSModel`
`DashScopeCosyVoiceTTSModel` | `DashScopeEmbeddingModel` | `DashScopeRealtimeModel`
`DashScopeAudioRealtimeModel` | | `OpenAICredential` | `OpenAIChatModel`
`OpenAIResponseModel` | `OpenAITTSModel` | `OpenAIEmbeddingModel` | `OpenAIRealtimeModel` | | `GeminiCredential` | `GeminiChatModel` | `GeminiTTSModel` | `GeminiEmbeddingModel` | `GeminiRealtimeModel` | | `AnthropicCredential` | `AnthropicChatModel` | — | — | — | | `DeepSeekCredential` | `DeepSeekChatModel` | — | — | — | | `MoonshotCredential` | `MoonshotChatModel` | — | — | — | | `VolcengineCredential` | `VolcengineChatModel` | — | — | — | | `XAICredential` | `XAIChatModel` | — | — | `XAIRealtimeModel` | | `OllamaCredential` | `OllamaChatModel` | — | `OllamaEmbeddingModel` | — | This layering mirrors the natural frontend flow (register a credential first, then pick a model from under it), letting the UI authenticate once and surface every model family the provider supports. All model families share the same construction pattern: a model takes a **credential**, a **model name**, and an optional provider-specific **`Parameters`** object. Each family page covers its own creation and invocation details: * [LLM](/versions/2.0.8/en/building-blocks/model/llm): drives an agent's conversation and tool calls * [TTS](/versions/2.0.8/en/building-blocks/model/tts): converts text into synthesized speech audio * [Embedding](/versions/2.0.8/en/building-blocks/model/embedding): converts text and media into dense vectors **Realtime Model** powers speech-to-speech voice conversations. See [Speech-to-Speech](/versions/2.0.8/en/building-blocks/realtime/speech-to-speech) for how to create and use one. ## Integrate with Frontend ### What is ModelCard A model card is a declarative description of a model's capabilities and constraints, designed to drive the frontend: model selectors, parameter forms, and feature toggles can be rendered dynamically without hardcoding any provider-specific knowledge. Each model family has its own card class: | Card Class | Family | `type` Discriminator | | -------------------- | ---------------- | -------------------- | | `ModelCard` | LLM (chat model) | `"chat_model"` | | `TTSModelCard` | TTS | `"tts_model"` | | `EmbeddingModelCard` | Embedding | `"embedding_model"` | All three card classes share a common core of fields: | Field | Type | Description | | --------------------- | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | `name` | `str` | Model identifier (e.g. `"claude-sonnet-4-6"`) | | `label` | `str` | Human-readable display name (e.g. `"Claude Sonnet 4.6"`) | | `status` | `"active" \| "deprecated" \| "sunset"` | Model lifecycle status | | `input_types` | `list[str]` | Accepted input MIME types, used by the frontend to filter attachment uploads (e.g. only show an image button when `image/*` is supported) | | `output_types` | `list[str]` | Output MIME types the model can produce, advertising capabilities such as a thinking toggle when `application/x-thinking` is present | | `parameter_schema` | `dict` | Final JSON Schema for the parameter form: base schema merged with per-model overrides (see below) | | `parameter_overrides` | `dict[str, dict]` | The raw per-model overrides from the YAML, before merging | On top of the common core, each card type adds family-specific fields: | Card Class | Family-Specific Fields | | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ModelCard` | `context_size` (max context window in tokens), `output_size` (max output tokens), `deprecated_at` (deprecation date) | | `TTSModelCard` | `realtime` (whether the model supports streaming input), `deprecated_at`; the YAML's `voices` list is injected into `parameter_schema` as an enum on the `voice` field | | `EmbeddingModelCard` | `dimensions` (default output vector size, required), `supported_dimensions` (allowed sizes for Matryoshka-style models, `None` = fixed), `context_size` (optional) | `input_types` and `output_types` use MIME types to describe modality. Common values: | MIME Type | Meaning | | ------------------------------------------ | ---------------------------- | | `text/plain` | Text | | `application/x-thinking` | Reasoning / chain-of-thought | | `application/x-embedding` | Dense vector embedding | | `image/*` (e.g. `image/png`, `image/jpeg`) | Image | | `audio/*` (e.g. `audio/wav`, `audio/mp3`) | Audio | | `video/*` (e.g. `video/mp4`) | Video | Each card is defined by a YAML file that ships alongside the model implementation. The tabs below show a real card from each family: ```yaml Chat (claude-sonnet-4-6) theme={null} name: claude-sonnet-4-6 label: Claude Sonnet 4.6 status: active input_types: - text/plain - application/x-thinking - image/jpeg - image/png - image/gif - image/webp output_types: - text/plain - application/x-thinking context_size: 1000000 output_size: 65536 parameter_overrides: max_tokens: {"maximum": 65536} ``` ```yaml TTS (qwen3-tts-flash) theme={null} name: qwen3-tts-flash label: Qwen3-TTS-Flash status: active input_types: - text/plain output_types: - audio/wav # Injected into parameter_schema as an enum on the voice field, # so the frontend renders a dropdown selector. voices: - Cherry - Serena - Ethan - Chelsie parameter_overrides: {} ``` ```yaml Embedding (text-embedding-v4) theme={null} name: text-embedding-v4 label: Text Embedding v4 status: active input_types: - text/plain output_types: - application/x-embedding context_size: 8192 # Default output vector size, plus the sizes this # Matryoshka-style model can be truncated to. dimensions: 1024 supported_dimensions: [2048, 1536, 1024, 768, 512, 256, 128, 64] ``` ### Parameter Schema and Overrides The `parameter_schema` exposed to the frontend is built in two layers: 1. **Base schema**: auto-derived from the model's `Parameters` class via `model_json_schema()`. This lists every adjustable parameter (`temperature`, `max_tokens`, `thinking_enable`, ...) along with its type and the API-wide range. 2. **Per-model overrides**: the YAML's `parameter_overrides` block is merged on top, field by field. Overrides matter because adjustable ranges are not uniform across an API: every Qwen model accepts `max_tokens`, but each one has a different ceiling. Overrides let a card tighten a range, pin a default, or hide a parameter that doesn't apply. | Override syntax | Effect | | ------------------------- | ----------------------------------------------------------------------- | | `param: { ... }` | Shallow-merge into the base field (e.g. `max_tokens: {maximum: 16384}`) | | `param: { hidden: true }` | Hide the parameter from the frontend | | `param: null` | Remove the parameter entirely | Some adjustments are applied automatically, without an explicit override: chat cards drop `thinking_enable` / `thinking_budget` when `application/x-thinking` is absent from `output_types` and cap `max_tokens` at `output_size`; TTS cards turn the `voices` list into an enum on the `voice` field. ### Retrieve ModelCards Model card discovery follows the hierarchy: **credential class ⇒ model class ⇒ model cards**. Each credential knows its linked model class for every family (`get_chat_model_class()`, `get_tts_model_classes()`, `get_embedding_model_class()`), and each model class loads the YAML card definitions from the `_models/` directory next to its implementation: ```python theme={null} from agentscope.credential import DashScopeCredential # Credential class -> model class model_cls = DashScopeCredential.get_chat_model_class() # -> DashScopeChatModel # Model class -> model cards cards = model_cls.list_models() # -> list[ModelCard] ``` In practice, call `list_models()` on either end of the chain. The tabs below show the retrieval for each family: ```python Chat theme={null} from agentscope.credential import DashScopeCredential from agentscope.model import AnthropicChatModel # Via credential class: delegates to its linked chat model class cards = DashScopeCredential.list_models() # Or directly on the model class cards = AnthropicChatModel.list_models() for card in cards: print(f"{card.name}: context={card.context_size}, inputs={card.input_types}") ``` ```python TTS theme={null} from agentscope.credential import DashScopeCredential from agentscope.tts import DashScopeTTSModel # Via credential class: aggregates cards from every linked TTS model class cards = DashScopeCredential.list_tts_models() # Or directly on one TTS model class cards = DashScopeTTSModel.list_models() for card in cards: print(f"{card.name} (realtime={card.realtime}): {card.label}") ``` ```python Embedding theme={null} from agentscope.credential import DashScopeCredential from agentscope.embedding import OpenAIEmbeddingModel # Discover the embedding model class from the credential embed_cls = DashScopeCredential.get_embedding_model_class() cards = embed_cls.list_models() # Or directly on the model class cards = OpenAIEmbeddingModel.list_models() for card in cards: print(f"{card.name}: dimensions={card.dimensions}") ``` This design allows the frontend to discover available models, their capabilities, and valid parameter ranges, all from a single credential, without any hardcoded provider logic. ## Next Steps Create and call chat models, generate structured output, and add custom providers. Synthesize speech in standard and realtime modes, and integrate TTS with agents. Embed text and multimodal content, with built-in batching, retry, and caching. # TTS Source: https://docs.agentscope.io/versions/2.0.8/en/building-blocks/model/tts Turn text into speech, in standard or realtime streaming mode A **TTS Model** converts text into synthesized speech audio, supporting both standard and realtime (streaming-input) synthesis modes. AgentScope currently ships the following TTS model classes: | Provider | Model Class | Highlights | | --------------------- | ---------------------------- | -------------------------------------------------------------------------------------------------- | | OpenAI | `OpenAITTSModel` | tts-1, tts-1-hd, gpt-4o-mini-tts; multiple voices; configurable output format (mp3, wav, opus, …) | | Gemini | `GeminiTTSModel` | gemini-2.5-flash-preview-tts, gemini-2.5-pro-preview-tts; 30 prebuilt voices; streaming output | | DashScope | `DashScopeTTSModel` | Qwen3-TTS, multiple voices, streaming output | | DashScope (Realtime) | `DashScopeRealtimeTTSModel` | Qwen3-TTS WebSocket streaming input, ideal for LLM output piping | | DashScope (CosyVoice) | `DashScopeCosyVoiceTTSModel` | CosyVoice-v3, supports both standard and realtime (streaming-input) modes; cosyvoice-v3-flash/plus | ## Create TTS Model Every TTS model takes a credential, a model name, and an optional provider-specific `Parameters` object. The tabs below show the standard and realtime setups: ```python OpenAI theme={null} import os from agentscope.tts import OpenAITTSModel from agentscope.credential import OpenAICredential tts = OpenAITTSModel( credential=OpenAICredential(api_key=os.environ["OPENAI_API_KEY"]), model="tts-1", parameters=OpenAITTSModel.Parameters(voice="alloy", response_format="mp3"), stream=True, ) ``` ```python Gemini theme={null} import os from agentscope.tts import GeminiTTSModel from agentscope.credential import GeminiCredential tts = GeminiTTSModel( credential=GeminiCredential(api_key=os.environ["GEMINI_API_KEY"]), model="gemini-2.5-flash-preview-tts", parameters=GeminiTTSModel.Parameters(voice="Kore"), stream=True, ) ``` ```python Non-Realtime (Standard) theme={null} import os from agentscope.tts import DashScopeTTSModel from agentscope.credential import DashScopeCredential tts = DashScopeTTSModel( credential=DashScopeCredential(api_key=os.environ["DASHSCOPE_API_KEY"]), model="qwen3-tts-flash", parameters=DashScopeTTSModel.Parameters(voice="Cherry"), stream=True, ) ``` ```python Realtime (Qwen3 Streaming Input) theme={null} import os from agentscope.tts import DashScopeRealtimeTTSModel from agentscope.credential import DashScopeCredential tts = DashScopeRealtimeTTSModel( credential=DashScopeCredential(api_key=os.environ["DASHSCOPE_API_KEY"]), model="qwen3-tts-flash-realtime", parameters=DashScopeRealtimeTTSModel.Parameters(voice="Serena"), stream=True, ) ``` ```python CosyVoice (Standard) theme={null} import os from agentscope.tts import DashScopeCosyVoiceTTSModel from agentscope.credential import DashScopeCredential tts = DashScopeCosyVoiceTTSModel( credential=DashScopeCredential(api_key=os.environ["DASHSCOPE_API_KEY"]), model="cosyvoice-v3-flash", parameters=DashScopeCosyVoiceTTSModel.Parameters(voice="longanhuan"), stream=True, ) ``` ```python CosyVoice (Realtime) theme={null} import os from agentscope.tts import DashScopeCosyVoiceTTSModel from agentscope.credential import DashScopeCredential tts = DashScopeCosyVoiceTTSModel( credential=DashScopeCredential(api_key=os.environ["DASHSCOPE_API_KEY"]), model="cosyvoice-v3-flash", parameters=DashScopeCosyVoiceTTSModel.Parameters(voice="longanhuan", realtime=True), stream=True, ) ``` Common constructor arguments shared by every TTS model: | Argument | Type | Description | | ------------ | -------------------- | -------------------------------------------- | | `credential` | `CredentialBase` | Provider-specific credential | | `model` | `str` | Model identifier (e.g. `"qwen3-tts-flash"`) | | `parameters` | `Parameters \| None` | Provider-specific parameters such as `voice` | | `stream` | `bool` | Whether to stream audio output | Additional arguments for `DashScopeRealtimeTTSModel` and `DashScopeCosyVoiceTTSModel` (realtime mode): | Argument | Type | Default | Description | | ------------------- | ------------- | ------- | ------------------------------------------------------------------ | | `cold_start_length` | `int \| None` | `None` | Minimum character count before first text chunk is sent to the API | | `cold_start_words` | `int \| None` | `None` | Minimum word count before first text chunk is sent | | `max_retries` | `int` | `3` | Maximum retry attempts on WebSocket failure | | `retry_delay` | `float` | `5.0` | Initial retry delay in seconds (exponential backoff) | ## Call TTS Model Invoke the model by calling `synthesize()` with the text to speak: ```python theme={null} async def synthesize( self, text: str | None = None, **kwargs: Any, ) -> TTSResponse | AsyncGenerator[TTSResponse, None]: ``` The return type follows the model's `stream` setting: * **`stream=False`**: returns a single `TTSResponse` with the complete audio. * **`stream=True`**: returns an `AsyncGenerator[TTSResponse, None]`. Each chunk carries an incremental audio delta; the final chunk has `is_last=True`. Each `TTSResponse` carries: | Field | Type | Description | | ---------- | ------------------- | ---------------------------------------------------------------------------------------------------------- | | `content` | `DataBlock \| None` | Audio data. Format indicated by `content.source.media_type` (e.g. `"audio/wav"`, `"audio/pcm;rate=24000"`) | | `is_last` | `bool` | `True` on the final streaming chunk | | `usage` | `TTSUsage \| None` | Token counts (`input_tokens`, `output_tokens`) and elapsed `time` in seconds | | `id` | `str` | Auto-generated unique identifier | | `metadata` | `dict \| None` | Optional provider-specific metadata | ```python OpenAI theme={null} import asyncio import os from agentscope.tts import OpenAITTSModel from agentscope.credential import OpenAICredential async def main(): tts = OpenAITTSModel( credential=OpenAICredential(api_key=os.environ["OPENAI_API_KEY"]), model="tts-1", parameters=OpenAITTSModel.Parameters(voice="alloy"), stream=True, ) async for chunk in await tts.synthesize("Hello, world!"): if chunk.content: # chunk.content is a DataBlock with base64-encoded audio/mpeg print(f"Audio chunk: {len(chunk.content.source.data)} bytes") asyncio.run(main()) ``` ```python Gemini theme={null} import asyncio import os from agentscope.tts import GeminiTTSModel from agentscope.credential import GeminiCredential async def main(): tts = GeminiTTSModel( credential=GeminiCredential(api_key=os.environ["GEMINI_API_KEY"]), model="gemini-2.5-flash-preview-tts", parameters=GeminiTTSModel.Parameters(voice="Kore"), stream=True, ) async for chunk in await tts.synthesize("Hello, world!"): if chunk.content: # chunk.content is a DataBlock with base64-encoded audio/wav print(f"Audio chunk: {len(chunk.content.source.data)} bytes") asyncio.run(main()) ``` ```python DashScope theme={null} import asyncio import os from agentscope.tts import DashScopeTTSModel from agentscope.credential import DashScopeCredential async def main(): tts = DashScopeTTSModel( credential=DashScopeCredential(api_key=os.environ["DASHSCOPE_API_KEY"]), model="qwen3-tts-flash", parameters=DashScopeTTSModel.Parameters(voice="Cherry"), stream=True, ) # Streaming synthesis async for chunk in await tts.synthesize("Hello, world!"): if chunk.content: # chunk.content is a DataBlock with base64-encoded audio/wav print(f"Audio chunk: {len(chunk.content.source.data)} bytes") asyncio.run(main()) ``` ## Realtime TTS (Streaming Input) For realtime models (`DashScopeRealtimeTTSModel` and `DashScopeCosyVoiceTTSModel` with `realtime=True`), text can be pushed incrementally as it arrives from a streaming LLM. Both share the same `push()` / `synthesize()` interface. The lifecycle is managed via `async with` or manual `connect()` / `close()`: `DashScopeRealtimeTTSModel` (Qwen3) produces audio at token-level granularity, so each `push()` call typically returns audio data. In contrast, `DashScopeCosyVoiceTTSModel` with `realtime=True` relies on the CosyVoice server which automatically segments text into sentences before synthesizing. Audio is only returned after a complete sentence boundary is detected, so `push()` may return empty responses for partial sentences. Calling `synthesize()` forces synthesis of all remaining text including incomplete sentences. ```python theme={null} import asyncio import os from agentscope.tts import DashScopeRealtimeTTSModel from agentscope.credential import DashScopeCredential async def main(): tts = DashScopeRealtimeTTSModel( credential=DashScopeCredential(api_key=os.environ["DASHSCOPE_API_KEY"]), model="qwen3-tts-flash-realtime", parameters=DashScopeRealtimeTTSModel.Parameters(voice="Cherry"), stream=True, ) async with tts: # Push text incrementally as it arrives from a streaming LLM. # Each push() returns a TTSResponse with audio accumulated so far # (or content=None if not yet available). resp1 = await tts.push("Hello, ") if resp1.content: print("Audio available after first push") resp2 = await tts.push("how are you today?") if resp2.content: print("Audio available after second push") # Finalize: flush remaining buffered text and collect final audio. # text= is optional: pass extra text to append before finalizing, # or omit to finalize previously pushed text only. response = await tts.synthesize() asyncio.run(main()) ``` The realtime lifecycle methods: | Method | Description | | -------------- | -------------------------------------------------------------------------- | | `connect()` | Open WebSocket connection | | `push(text)` | Append text incrementally (non-blocking), returns audio accumulated so far | | `synthesize()` | Finalize and return remaining audio | | `close()` | Tear down connection | ## Integrate with Agent In the agent layer, TTS is integrated via [`TTSMiddleware`](/versions/2.0.8/en/building-blocks/middleware#ttsmiddleware), which intercepts the agent's text output and synthesizes speech automatically: ```python theme={null} from agentscope.agent import Agent from agentscope.middleware import TTSMiddleware from agentscope.tts import DashScopeTTSModel from agentscope.credential import DashScopeCredential agent = Agent( name="assistant", model=chat_model, middlewares=[ TTSMiddleware( DashScopeTTSModel( credential=DashScopeCredential(api_key="..."), model="qwen3-tts-flash", parameters=DashScopeTTSModel.Parameters(voice="Cherry"), stream=True, ), ), ], ) # The agent's reply stream now includes audio events async for event in agent.reply_stream(user_msg): # TextBlockDeltaEvent: text content # DataBlockDeltaEvent: audio content (WAV) ... ``` The middleware automatically selects the optimal synthesis strategy: | TTS Mode | Middleware Behavior | | ------------ | ------------------------------------------------------------------ | | Non-realtime | Waits for full text, then synthesizes all at once | | Realtime | Pushes text deltas as they arrive, streams audio back concurrently | ## TTS Model Card `TTSModelCard` describes a TTS model's capabilities (available voices, streaming support, and parameter ranges) and follows the same schema and override mechanics as the general [ModelCard](/versions/2.0.8/en/building-blocks/model/overview#integrate-with-frontend). Each card is defined by a YAML file alongside the model implementation. The tabs below show one card per provider: ```yaml OpenAI TTS-1 theme={null} name: tts-1 label: TTS-1 status: active input_types: - text/plain output_types: - audio/mpeg - audio/opus - audio/aac - audio/flac - audio/wav - audio/pcm voices: - alloy - ash - ballad - coral - echo - fable - onyx - nova - sage - shimmer - verse parameter_overrides: instructions: hidden: true ``` ```yaml Gemini TTS theme={null} name: gemini-2.5-flash-preview-tts label: Gemini 2.5 Flash Preview TTS status: active input_types: - text/plain output_types: - audio/wav voices: - Zephyr - Puck - Charon - Kore - Fenrir - Leda - Orus - Aoede parameter_overrides: {} ``` ```yaml Qwen3 TTS theme={null} name: qwen3-tts-flash label: Qwen3-TTS-Flash status: active input_types: - text/plain output_types: - audio/wav voices: - Cherry - Serena - Ethan - Chelsie parameter_overrides: {} ``` ```yaml CosyVoice theme={null} name: cosyvoice-v3-flash label: CosyVoice-v3-Flash status: active input_types: - text/plain output_types: - audio/wav voices: - longanhuan - longanyang - longhuhu_v3 - longyingmu_v3 - longxiaochun_v3 - longxiaoxia_v3 - longlaotie_v3 - longshuo_v3 - longshu_v3 parameter_overrides: {} ``` The `voices` list is automatically injected into the `parameter_schema` as an enum constraint on the `voice` field, so the frontend renders a dropdown selector. | Field | Type | Description | | ---------------------- | ----------- | --------------------------------------------------------------------------------------------------------------------- | | `name` | `str` | Model identifier (e.g. `"qwen3-tts-flash"`) | | `label` | `str` | Display name (e.g. `"Qwen3-TTS-Flash"`) | | `status` | `str` | `"active"`, `"deprecated"`, or `"sunset"` | | `realtime` | `bool` | Whether model supports streaming input | | `input_types` | `list[str]` | Accepted input MIME types (always `["text/plain"]`) | | `output_types` | `list[str]` | Output MIME types (typically `["audio/wav"]`) | | `parameter_schema` | `dict` | Merged JSON Schema for the parameter form: base schema from `Parameters` class, enriched with `voices` enum from YAML | | `parameters_overrides` | `dict` | Per-model overrides (same syntax as chat model cards) | Retrieve TTS model cards via the credential: ```python theme={null} from agentscope.credential import DashScopeCredential, GeminiCredential cards = DashScopeCredential.list_tts_models() for card in cards: print(f"{card.name} (realtime={card.realtime}): {card.label}") # Gemini TTS model cards gemini_cards = GeminiCredential.list_tts_models() ``` Or directly on the model class: ```python theme={null} from agentscope.tts import OpenAITTSModel, GeminiTTSModel, DashScopeTTSModel, DashScopeCosyVoiceTTSModel # OpenAI TTS models openai_cards = OpenAITTSModel.list_models() # Gemini TTS models gemini_cards = GeminiTTSModel.list_models() # Qwen3 TTS models cards = DashScopeTTSModel.list_models() # CosyVoice models cosyvoice_cards = DashScopeCosyVoiceTTSModel.list_models() ``` ## Custom TTS Provider To add a new TTS provider, implement a `TTSModelBase` subclass and register it on the credential: ```python theme={null} from typing import Literal, Type, TYPE_CHECKING, AsyncGenerator, Any from pydantic import BaseModel, Field from agentscope.tts import TTSModelBase, TTSResponse from agentscope.credential import CredentialBase if TYPE_CHECKING: from agentscope.tts import TTSModelBase as TTSBase class MyTTSModel(TTSModelBase): class Parameters(BaseModel): voice: str = Field(default="default", title="Voice") type: Literal["my_tts"] = "my_tts" async def synthesize( self, text: str | None = None, **kwargs: Any ) -> TTSResponse | AsyncGenerator[TTSResponse, None]: # Call your provider's API here ... # Register on your credential class MyCredential(CredentialBase): @classmethod def get_tts_model_classes(cls) -> list[Type["TTSBase"]]: return [MyTTSModel] ``` # Overview Source: https://docs.agentscope.io/versions/2.0.8/en/building-blocks/permission-system/overview How rules, modes, and tool-level checks decide every tool call The permission system intercepts every tool call an agent makes and produces one of three decisions: **allow** the tool to execute, **deny** it, or **ask the user** for confirmation. The decision is driven by three components working together, each covered in its own page: | Component | What It Does | Where It Comes From | | --------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | | [Permission Rule](/versions/2.0.8/en/building-blocks/permission-system/permission-rule) | Explicit allow/deny/ask patterns per tool and call, evaluated with highest priority | Pre-configured in `PermissionContext`, or added at runtime when the user accepts a suggested rule during an ASK | | [Permission Mode](/versions/2.0.8/en/building-blocks/permission-system/permission-mode) | A global policy that decides which decision points are active and the default handling for calls nothing else resolves | Set at configuration time, switchable at runtime | | [Tool-Level Checks](/versions/2.0.8/en/building-blocks/permission-system/tool-check) | Dynamic analysis the tool itself performs on the actual inputs: read-only detection, dangerous-path protection, working-directory auto-allow | Implemented in each tool's `check_read_only()` / `check_permissions()` | The sequence diagram below shows how a tool call flows through the system. An ASK decision surfaces to the user together with auto-generated **suggested rules**; accepting one persists it, so future identical calls are handled without prompting: ```mermaid theme={null} sequenceDiagram participant LLM participant PS as Permission System participant Tool participant User LLM->>PS: Tool Call Note over PS: Rules · Mode · Tool-Level Checks alt ALLOW PS->>Tool: execute Tool->>LLM: result else DENY PS->>LLM: denied else ASK + Suggestions PS->>User: ASK + Suggestions alt User approves User->>Tool: allow Tool->>LLM: result User-->>PS: accept suggested rule else User denies User->>PS: deny PS->>LLM: denied end end ``` ## Decision Matrix Every call walks the same decision points from top to bottom, and the first decisive answer wins. The matrix below shows, for each mode, what each decision point produces. A "skipped" cell means the mode does not consult that point at all; when a point stays silent (no rule matches, or the tool returns `PASSTHROUGH`), evaluation continues downward until the fallback: | Decision Point | `DEFAULT` | `ACCEPT_EDITS` | `EXPLORE` | `BYPASS` | `DONT_ASK` | | -------------------------- | ------------------------- | ------------------------------------------------------- | --------- | ---------------------------------- | ------------------------------------------ | | ① Deny rule match | DENY | DENY | DENY | DENY | DENY | | ② Ask rule match | ASK | ASK | ASK | ASK | DENY | | ③ Read-only fast path | ALLOW | ALLOW | ALLOW | ALLOW | ALLOW | | ④ Tool `check_permissions` | ALLOW / DENY / safety ASK | Same as `DEFAULT`, plus working-directory edits → ALLOW | skipped | ALLOW / DENY (safety ASKs skipped) | Same as `ACCEPT_EDITS`, but any ASK → DENY | | ⑤ Allow rule match | ALLOW | ALLOW | skipped | ALLOW | ALLOW | | ⑥ Fallback | ASK | ASK | DENY | ALLOW | DENY | How to read the rows: * **① / ② / ⑤ Rules**: user-configured patterns, evaluated deny → ask first, allow late. Deny and ask rules are honored in **every** mode (in `DONT_ASK`, an ask rule converts to DENY because no user is available to answer). * **③ Read-only fast path**: if the specific invocation is factually read-only (`check_read_only(tool_input)`), it is auto-allowed in every mode. The check is per-invocation, not a static tool flag: `git status` passes, a Bash command that writes files does not, and a command flagged with injection risk is never treated as read-only. * **④ Tool `check_permissions`**: the tool inspects the invocation and returns ALLOW, DENY, ASK, or PASSTHROUGH. A **safety ASK** (`bypass_immune=True`) cannot be silenced by allow rules in ⑤; a regular ASK can. Working-directory auto-allow for edits happens here. * **⑥ Fallback**: the mode's default when nothing above decided, which gives each mode its personality: prompt (`DEFAULT` / `ACCEPT_EDITS`), refuse (`EXPLORE` / `DONT_ASK`), or trust (`BYPASS`). See [Permission Mode](/versions/2.0.8/en/building-blocks/permission-system/permission-mode) for each mode's full decision flowchart, and the [safety check contract](/versions/2.0.8/en/building-blocks/permission-system/tool-check#safety-check-contract) for the exact per-mode handling of safety ASKs. ## Common Scenarios The following examples show how to configure `AgentState.permission_context` for common deployment scenarios. Each recipe combines a mode with rules to match a specific use case. ```python Read-only exploration theme={null} # EXPLORE mode: agent can freely use read-only tools (Read, Grep, # Glob) and read-only bash commands (`ls`, `git status`, `cat`, ...). # Any modification (Write, Edit, or non-read-only bash command) is # denied automatically. agent = Agent( name="explorer", system_prompt="...", model=model, state=AgentState( permission_context=PermissionContext(mode=PermissionMode.EXPLORE) ), ) ``` ```python Unattended automation theme={null} from agentscope.permission import PermissionRule, PermissionBehavior agent = Agent( name="ci_agent", system_prompt="...", model=model, state=AgentState( permission_context=PermissionContext( mode=PermissionMode.DONT_ASK, allow_rules={ "Bash": [ PermissionRule(tool_name="Bash", rule_content="npm run:*", behavior=PermissionBehavior.ALLOW, source="project"), PermissionRule(tool_name="Bash", rule_content="git commit:*", behavior=PermissionBehavior.ALLOW, source="project"), ], }, ) ), ) # Only explicitly allowed commands run; everything else (including # safety ASKs like `rm -rf /` or writes to ~/.bashrc) is converted # to DENY. Prefer DONT_ASK over BYPASS for unattended runs: it keeps # the tools' safety net while still never prompting the user. ``` ```python BYPASS with explicit guardrails theme={null} # BYPASS skips tools' safety ASKs by design, so deny rules become the # only guardrail. Always pair BYPASS with deny rules for the paths # and commands you want to protect. agent = Agent( name="my_agent", system_prompt="...", model=model, state=AgentState( permission_context=PermissionContext( mode=PermissionMode.BYPASS, deny_rules={ "Bash": [ PermissionRule(tool_name="Bash", rule_content="rm:*", behavior=PermissionBehavior.DENY, source="userSettings"), PermissionRule(tool_name="Bash", rule_content="git push:*", behavior=PermissionBehavior.DENY, source="userSettings"), ], "Write": [ PermissionRule(tool_name="Write", rule_content="**/.bashrc", behavior=PermissionBehavior.DENY, source="userSettings"), PermissionRule(tool_name="Write", rule_content="**/.ssh/**", behavior=PermissionBehavior.DENY, source="userSettings"), ], }, ) ), ) # Everything allowed except the deny-listed commands and paths. # Without these deny rules, BYPASS would let the agent rm anything, # git push anywhere, or overwrite ~/.bashrc: that is by design. ``` ## Next Steps Pick a global policy and see each mode's full decision flow. Write allow/deny/ask patterns and accept suggested rules at runtime. Read-only detection, dangerous paths, and custom safety checks. The toolkit that registers the tools these permissions govern. # Permission Mode Source: https://docs.agentscope.io/versions/2.0.8/en/building-blocks/permission-system/permission-mode Pick the global policy that fits how your agent is deployed A permission mode is the global policy behind every tool-call decision: it selects which decision points are active and what happens to calls nothing else resolves. AgentScope supports five modes, each suited to a different deployment scenario: | Mode | Behavior | Use Case | | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- | | `DEFAULT` | Read-only invocations are auto-allowed (`Read` / `Glob` / `Grep`, and read-only Bash commands like `ls`, `git status`, `cat`, ...); everything else prompts unless an allow rule matches. Safety ASKs cannot be overridden by allow rules | Most secure, recommended default | | `ACCEPT_EDITS` | Everything `DEFAULT` allows, **plus** edits within a working directory are auto-allowed without prompting: `Write` / `Edit` on files under a configured working directory, and Bash filesystem commands (`mkdir`/`touch`/`rm`/`cp`/`mv`/`sed`) **when every target path resolves inside a working directory** | Active development with user present | | `EXPLORE` | Read-only operations allowed; all modifications denied. Allow rules and the tool's safety checks are not consulted: the read-only guarantee cannot be granted away by a rule. User-configured DENY/ASK rules still take precedence over the read-only auto-allow | Code exploration, planning | | `BYPASS` | Fully trusted: deny/ask rules and tool DENYs still apply, but **tool safety ASKs are skipped** (`rm -rf /`, writes to `~/.bashrc`, command injection, etc. all pass through) and everything else is allowed. Use deny rules to protect specific paths | Sandboxed environments or fully trusted runs | | `DONT_ASK` | The unattended counterpart of `ACCEPT_EDITS`: read-only auto-allowed, working-directory edits auto-allowed, but anything that would otherwise prompt the (absent) user is **DENIED** instead of asked. Never returns ASK | Unattended / scheduled execution | ## Set the Mode Set the mode via `AgentState.permission_context` when creating the agent, or update it at runtime: ```python At initialization theme={null} from agentscope.agent import Agent from agentscope.state import AgentState from agentscope.permission import PermissionContext, PermissionMode agent = Agent( name="my_agent", system_prompt="...", model=model, state=AgentState( permission_context=PermissionContext( mode=PermissionMode.DEFAULT, ) ), ) ``` ```python At runtime theme={null} # Switch to read-only mode on the fly agent.state.permission_context.mode = PermissionMode.EXPLORE # Switch to unattended mode for batch execution agent.state.permission_context.mode = PermissionMode.DONT_ASK ``` ```python ACCEPT_EDITS with working directory theme={null} from agentscope.permission import AdditionalWorkingDirectory agent = Agent( name="my_agent", system_prompt="...", model=model, state=AgentState( permission_context=PermissionContext( mode=PermissionMode.ACCEPT_EDITS, working_directories={ "/my/project": AdditionalWorkingDirectory( path="/my/project", source="userSettings", ) }, ) ), ) ``` ## Decision Flow per Mode Every mode walks the decision points summarized in the [decision matrix](/versions/2.0.8/en/building-blocks/permission-system/overview#decision-matrix); the flowcharts below expand each mode into its full decision flow. ASK outcomes trigger user confirmation; if the user accepts the auto-generated suggested rule, it is persisted for future calls. ```mermaid theme={null} flowchart TD A([Tool Call]) --> D1{Deny Rules?} D1 -->|Match| DENY([DENY]) D1 -->|No| D2{Ask Rules?} D2 -->|Match| ASK([ASK]) D2 -->|No| D3{check_read_only?} D3 -->|True| ALLOW([ALLOW]) D3 -->|False| D4[Tool check_permissions] D4 -->|ALLOW| ALLOW D4 -->|DENY| DENY D4 -->|"Safety ASK (bypass_immune)"| ASK D4 -->|PASSTHROUGH / other ASK| D5{Allow Rules?} D5 -->|Match| ALLOW D5 -->|No| ASK style DENY fill:#ff6b6b,color:#fff style ALLOW fill:#51cf66,color:#fff style ASK fill:#ffd43b,color:#333 ``` ```mermaid theme={null} flowchart TD A([Tool Call]) --> E1{Deny Rules?} E1 -->|Match| DENY([DENY]) E1 -->|No| E2{Ask Rules?} E2 -->|Match| ASK([ASK]) E2 -->|No| E3{check_read_only?} E3 -->|True| ALLOW([ALLOW]) E3 -->|False| DENY style DENY fill:#ff6b6b,color:#fff style ALLOW fill:#51cf66,color:#fff style ASK fill:#ffd43b,color:#333 ``` ```mermaid theme={null} flowchart TD A([Tool Call]) --> AE1{Deny Rules?} AE1 -->|Match| DENY([DENY]) AE1 -->|No| AE2{Ask Rules?} AE2 -->|Match| ASK([ASK]) AE2 -->|No| AE3{check_read_only?} AE3 -->|True| ALLOW([ALLOW]) AE3 -->|False| AE4[Tool check_permissions] AE4 -->|ALLOW| ALLOW AE4 -->|DENY| DENY AE4 -->|"Safety ASK (bypass_immune)"| ASK AE4 -->|PASSTHROUGH / other ASK| AE5{Allow Rules?} AE5 -->|Match| ALLOW AE5 -->|No| ASK style DENY fill:#ff6b6b,color:#fff style ALLOW fill:#51cf66,color:#fff style ASK fill:#ffd43b,color:#333 ``` ```mermaid theme={null} flowchart TD A([Tool Call]) --> B1{Deny Rules?} B1 -->|Match| DENY([DENY]) B1 -->|No| B2{Ask Rules?} B2 -->|Match| ASK([ASK]) B2 -->|No| B3{check_read_only?} B3 -->|True| ALLOW([ALLOW]) B3 -->|False| B4[Tool check_permissions] B4 -->|ALLOW| ALLOW B4 -->|DENY| DENY B4 -->|"Any ASK (safety ignored) / PASSTHROUGH"| B5{Allow Rules?} B5 -->|Match or No| ALLOW style DENY fill:#ff6b6b,color:#fff style ALLOW fill:#51cf66,color:#fff style ASK fill:#ffd43b,color:#333 ``` ```mermaid theme={null} flowchart TD A([Tool Call]) --> DA1{Deny Rules?} DA1 -->|Match| DENY([DENY]) DA1 -->|No| DA2{Ask Rules?} DA2 -->|Match| DENY DA2 -->|No| DA3{check_read_only?} DA3 -->|True| ALLOW([ALLOW]) DA3 -->|False| DA4[Tool check_permissions] DA4 -->|ALLOW| ALLOW DA4 -->|DENY| DENY DA4 -->|"Any ASK (incl. safety)"| DENY DA4 -->|PASSTHROUGH| DA5{Allow Rules?} DA5 -->|Match| ALLOW DA5 -->|No| DENY style DENY fill:#ff6b6b,color:#fff style ALLOW fill:#51cf66,color:#fff ``` **Deny rules** and **explicit ask rules** are always honored, in every mode (including `BYPASS`). **Tool-emitted safety ASKs** (`bypass_immune=True`) are honored in `DEFAULT`, `ACCEPT_EDITS`, and `DONT_ASK`; they cannot be silenced by allow rules. In `BYPASS` mode they are skipped on purpose: BYPASS's contract is "the user has opted out of safety prompts; only deny/ask rules remain as guardrails." See the [safety check contract](/versions/2.0.8/en/building-blocks/permission-system/tool-check#safety-check-contract). # Permission Rule Source: https://docs.agentscope.io/versions/2.0.8/en/building-blocks/permission-system/permission-rule Write allow, deny, and ask patterns for specific tools and calls A `PermissionRule` maps a specific tool and call pattern to one of three behaviors: `ALLOW`, `DENY`, or `ASK`. Rules are evaluated with the highest priority in every [mode](/versions/2.0.8/en/building-blocks/permission-system/permission-mode): deny and ask rules first, allow rules after the tool's own checks. Each rule consists of the following fields. When the permission engine evaluates a rule, it calls the tool's `match_rule()` method with `rule_content` and the actual call input to determine whether the rule applies. Tool this rule applies to: `"Bash"`, `"Read"`, `"Write"`, `"Edit"`, or any custom tool name. Match pattern, whose semantics depend on `tool_name`: * **Bash**: wildcard prefix pattern (`npm run:*` matches `npm run build`, `npm run test`) * **Read / Write / Edit**: glob pattern (`src/**/*.py` matches any `.py` under `src/`) * **Other tools**: exact JSON-serialized parameter match `ALLOW`, `DENY`, or `ASK` Origin of the rule: `"userSettings"`, `"projectSettings"`, `"session"`, etc. ## Pattern Examples `rule_content` is consumed by each tool's `match_rule()` method and auto-generated by `ToolBase.generate_suggestions()`. Because both methods are part of the tool interface, each tool can define its own pattern syntax and matching logic independently. For AgentScope's built-in tools, the patterns are as follows: Matches against the **`command`** parameter. Pattern format is `COMMAND_PREFIX:*`: the prefix is the leading token of the command, and `*` matches any arguments that follow. | Pattern | Matches | Does Not Match | | -------------- | ------------------------------- | -------------- | | `npm run:*` | `npm run build`, `npm run test` | `npm install` | | `git commit:*` | `git commit -m "fix"` | `git push` | | `rm:*` | `rm file.txt`, `rm -rf /tmp/x` | `ls` | ```python theme={null} PermissionRule( tool_name="Bash", rule_content="npm run:*", behavior=PermissionBehavior.ALLOW, source="userSettings", ) ``` Matches against the **`file_path`** parameter using a glob pattern via `fnmatch`. | Pattern | Matches | | ------------- | ------------------------- | | `src/**` | Any file under `src/` | | `src/**/*.py` | Python files under `src/` | | `config.json` | Exact file match | ```python theme={null} PermissionRule( tool_name="Write", rule_content="src/**", behavior=PermissionBehavior.ALLOW, source="userSettings", ) ``` ## Configure Rules Rules enter the engine in two ways: statically at initialization, or dynamically when the user accepts a suggested rule at runtime. **At initialization**: pass rules into `PermissionContext` when creating the agent: ```python theme={null} from agentscope.agent import Agent from agentscope.state import AgentState from agentscope.permission import ( PermissionContext, PermissionMode, PermissionRule, PermissionBehavior ) agent = Agent( name="my_agent", system_prompt="...", model=model, state=AgentState( permission_context=PermissionContext( mode=PermissionMode.DEFAULT, allow_rules={ "Bash": [PermissionRule(tool_name="Bash", rule_content="npm run:*", behavior=PermissionBehavior.ALLOW, source="userSettings")], "Write": [PermissionRule(tool_name="Write", rule_content="src/**", behavior=PermissionBehavior.ALLOW, source="userSettings")], }, deny_rules={ "Bash": [PermissionRule(tool_name="Bash", rule_content="rm:*", behavior=PermissionBehavior.DENY, source="userSettings")], }, ) ), ) ``` **At runtime via suggestions**: when the permission system returns ASK, it auto-generates suggested rules from the current call. Pass accepted rules back in `ConfirmResult.rules`; the agent adds them to the engine automatically: ```python theme={null} from agentscope.event import ConfirmResult # The ASK decision includes suggested_rules generated from the current call. # To accept a suggestion, include it in the result event: result = ConfirmResult( confirmed=True, tool_call=tool_call_block, # The tool call being confirmed rules=[suggested_rule], # accepted rules are persisted to the engine ) ``` An allow rule cannot override a tool-emitted **safety ASK** (`bypass_immune=True`), such as a write into `~/.ssh/`. See the [safety check contract](/versions/2.0.8/en/building-blocks/permission-system/tool-check#safety-check-contract). # Tool-Level Checks Source: https://docs.agentscope.io/versions/2.0.8/en/building-blocks/permission-system/tool-check Runtime safety analysis each tool performs on its own inputs Beyond rules and modes, each tool analyzes its actual call inputs at runtime through two interface methods: `check_read_only()` powers the read-only fast path, and `check_permissions()` performs the tool's own safety analysis. AgentScope's built-in tools cover three areas: | Check | What It Does | Active In | | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | | [Read-only detection](#read-only-commands) | Parses each invocation and auto-allows factually read-only calls | Every mode | | [Dangerous path protection](#dangerous-path-protection) | Flags operations touching sensitive files with a bypass-immune safety ASK | `DEFAULT` / `ACCEPT_EDITS` (converted to DENY in `DONT_ASK`; skipped in `BYPASS`) | | Working-directory auto-allow | Auto-allows `Write` / `Edit` inside configured working directories; `Bash` filesystem commands require **every** target path inside a working directory | `ACCEPT_EDITS` / `DONT_ASK` | The working-directory auto-allow is always subordinate to the safety checks: a dangerous operation inside a working directory still ASKs or DENYs. ## Custom Tools A custom tool implements `check_permissions()` to add tool-specific permission logic. Tools whose read-only status depends on the input (like `Bash`: `ls` is read-only, `rm` is not) should also override `check_read_only()`. ```python theme={null} from agentscope.tool import ToolBase from agentscope.permission import PermissionContext, PermissionDecision, PermissionBehavior class MyTool(ToolBase): name = "MyTool" # Static default. For tools whose answer depends on input, leave # this as the conservative default and override check_read_only(). is_read_only = False async def check_read_only(self, tool_input: dict) -> bool: """Optional: dynamic read-only check. Defaults to returning self.is_read_only. Override when whether an invocation modifies state depends on the input. The engine calls this for the read-only fast path in every mode (the auto-allow that runs before check_permissions). """ return tool_input.get("operation") in {"list", "describe", "get"} async def check_permissions( self, tool_input: dict, context: PermissionContext, ) -> PermissionDecision: target = tool_input.get("target") # Custom safety check: block operations on production resources. # Setting bypass_immune=True makes this ASK survive allow rules # in DEFAULT/ACCEPT_EDITS/DONT_ASK; BYPASS still skips it. if target and target.startswith("prod-"): return PermissionDecision( behavior=PermissionBehavior.ASK, message=f"Operation targets production resource: {target}", decision_reason="Safety check: production resource", bypass_immune=True, ) # Return PASSTHROUGH to let the engine continue with rules/mode return PermissionDecision(behavior=PermissionBehavior.PASSTHROUGH) ``` ## Safety Check Contract A **safety check** is a tool-emitted ASK that the tool considers too dangerous to be silently overridden, e.g. `Write` to `~/.bashrc` or `Bash` with `rm -rf /`. Setting `bypass_immune=True` on the decision asks the engine to surface the ASK to the user even when an allow rule matches or the mode would otherwise auto-allow. Use it whenever a wrong call would cause damage the user almost certainly didn't intend. Example: a custom `DeployTool` returns `bypass_immune=True` when the target is `prod-*`, so a blanket `allow_rules["DeployTool"] = ["*"]` configured for staging cannot accidentally authorize a production deploy. The exact handling per mode: | Mode | `bypass_immune=True` ASK is... | | -------------- | -------------------------------------------------------------------------------------------------------- | | `DEFAULT` | honored; allow rules cannot override it | | `ACCEPT_EDITS` | honored; same as `DEFAULT` | | `EXPLORE` | not applicable (the engine does not call `check_permissions` in EXPLORE; the read-only verdict is final) | | `BYPASS` | **ignored**; BYPASS skips all safety ASKs by design | | `DONT_ASK` | converted to DENY (no user available to answer) | A regular ASK (`bypass_immune=False`, the default) can be overridden by a matching allow rule in `DEFAULT`/`ACCEPT_EDITS`, and is silently allowed by `BYPASS`'s fallback. ## Read-Only Commands Common read-only bash commands are auto-allowed without any rules, in **every mode** (including `DEFAULT`). A compound command (`&&`, `||`, `;`, `|`) is read-only only if **all** subcommands are read-only. Output redirections (`>`, `>>`) always make a command non-read-only. A command flagged with command-injection risk (e.g. `ls $(rm -rf /)`) is **not** treated as read-only, so it is not auto-allowed here; it falls through to the tool's safety check. | Category | Commands | | ---------------- | ------------------------------------------------------------------------------------------------------------------------- | | Git | `git status`, `git log`, `git diff`, `git show`, `git branch`, `git blame`, `git grep`, `git reflog`, `git config --list` | | Files | `ls`, `cat`, `head`, `tail`, `grep`, `rg`, `find`, `tree`, `stat`, `wc`, `pwd`, `which` | | Docker | `docker ps`, `docker images`, `docker logs`, `docker inspect`, `docker info` | | GitHub CLI | `gh repo view`, `gh issue list`, `gh pr list`, `gh status` | | Package managers | `npm list`, `pip list`, `pip show`, `node --version`, `python --version` | ## Dangerous Path Protection Operations targeting the following paths trigger a bypass-immune ASK in `DEFAULT`, `ACCEPT_EDITS`, and `DONT_ASK` (converted to DENY in `DONT_ASK`). `BYPASS` mode explicitly skips this check; if you need dangerous-path protection while running in BYPASS, add deny rules for the specific paths. | Category | Paths | | ------------- | ------------------------------------------------------------- | | Shell configs | `.bashrc`, `.zshrc`, `.bash_profile`, `.profile` | | Git configs | `.gitconfig`, `.gitmodules` | | SSH | `.ssh/config`, `.ssh/authorized_keys`, `id_rsa`, `id_ed25519` | | Credentials | `.env`, `.env.local`, `.npmrc`, `.pypirc`, `.aws/credentials` | | Directories | `.git/`, `.ssh/`, `.claude/`, `.vscode/`, `.aws/`, `.kube/` | # Goal Pipeline Source: https://docs.agentscope.io/versions/2.0.8/en/building-blocks/pipeline/goal Keep an executor working until a verifier accepts the result `GoalPipeline` is a pipeline of two agents: an executor produces a result, a verifier judges it against the goal, and a refusal goes back to the executor with its reason, until the work passes or the attempts run out. The loop runs like this: ``` ┌───────── refused: sent back with the reason ─────────┐ │ │ ▼ │ input ─▶ executor ────── result ──────▶ verifier ─────────────┘ │ └── passed ──▶ done ``` The verifier is an ordinary `Agent`, not a special kind of object. Its verdict comes back as [structured output](/versions/2.0.8/en/building-blocks/agent/run-agent), so a check that has to read files, run commands or ask a person goes through exactly the machinery the executor does. ## Running a Pipeline The example below has two agents collaborate on a programming task: the executor writes the code, the verifier checks it. ```python goal_pipeline.py theme={null} import asyncio import os from agentscope.agent import Agent from agentscope.console import launch_console from agentscope.credential import DashScopeCredential from agentscope.model import DashScopeChatModel from agentscope.pipeline import GoalPipeline from agentscope.tool import Toolkit from agentscope.workspace import LocalWorkspace async def main() -> None: # Both agents share one workspace, so the verifier sees what the # executor actually wrote rather than what it claims to have written async with LocalWorkspace(workdir="./workspace") as workspace: model = DashScopeChatModel( credential=DashScopeCredential( api_key=os.getenv("DASHSCOPE_API_KEY"), ), model="qwen3.8-max", ) # The executor does the work, writing code with the workspace tools executor = Agent( name="Executor", system_prompt="You're a programmer named 'Executor'.", model=model, toolkit=Toolkit(tools=await workspace.list_tools()), offloader=workspace, ) # The verifier judges it. Given the same tools, it can read the # code and run the tests instead of trusting the executor's account verifier = Agent( name="Verifier", system_prompt="You're a reviewer named 'Verifier'.", model=model, toolkit=Toolkit(tools=await workspace.list_tools()), offloader=workspace, ) pipe = GoalPipeline( executor=executor, verifier=verifier, # Stop after five refusals, passed or not max_iters=5, ) # The pipeline satisfies PipelineProtocol, so the console takes it await launch_console(agent=pipe) asyncio.run(main()) ``` ### Constructor Arguments `GoalPipeline` takes the following arguments: | Argument | Type | Description | | ------------------------ | -------------------------- | ---------------------------------------------------------------------------- | | `executor` | `Agent` | The agent doing the work | | `verifier` | `Agent` | The agent judging it | | `verifier_reset_context` | `bool`, defaults to `True` | Whether the verifier's conversation is cleared after each refusal | | `max_iters` | `int`, defaults to `10` | How many refusals are allowed | | `max_retries` | `int`, defaults to `3` | How many times either side is asked again after an invalid structured output | The goal is not given at construction. It arrives with the task: the first message the pipeline receives is both what the executor is asked to do and what the verifier judges against. ### Verification and Retries Both agents hand their results to the pipeline as structured output: | Agent | Field | Meaning | | -------- | --------- | ---------------------------------------------------------------------------------------------- | | Executor | `report` | What was achieved (file paths, entry points, how to run it), for the verifier to check against | | Verifier | `result` | `pass`, `fail`, or `impossible` when the goal cannot be reached at all | | Verifier | `message` | On a refusal, what is missing and where to fix it | `message` reaches the executor verbatim, so it has to say what is missing rather than that something is. A full round goes: The executor takes the task, leaves its output in the shared workspace, and hands back a `report`. The verifier checks the output against the goal and returns `result` and `message`. On `pass` or `impossible` the pipeline finishes and the event stream closes. `message` is wrapped in a reminder and handed to the executor, which starts the round again. Two kinds of retry are counted differently, and only the first spends an attempt: | Case | Meaning | Spends `max_iters` | | ----------------------------------------------- | ----------------------------------------------- | ---------------------------------- | | The verifier returns `fail` | The work genuinely is not good enough | Yes | | Either agent returns no valid structured output | The model did not call the tool it was asked to | No, it is reminded and asked again | The second is a malfunction rather than a judgement; charging it would quietly cost the executor attempts. `max_retries` caps how many times it is asked again. Once `max_iters` is reached the pipeline stops, the stream ends, and the last `message` stays in the verifier's conversation. `verifier_reset_context` decides what context the verifier brings into the next round. It defaults to `True`, clearing the verifier's conversation after each completed `fail` verdict so the next verification sees only this round's output and `report` rather than being steered by earlier verdicts; `False` keeps the accumulated conversation and summary. The option touches the verifier's conversation state alone — executor state, tool and task configuration, middleware state, structured-output retries and HITL resumes are unaffected. ## Interruption and Resuming When either agent stops for tool authorization, `reply_stream` simply ends: no coroutine pinned, no lock held, no polling. Feed the result back in to carry on: ```python Resuming after an interruption theme={null} # A RequireUserConfirmEvent appears mid-stream and the stream ends async for event in pipe.reply_stream(user_msg): ... # Hand the answer back, and the run picks up where it stopped async for event in pipe.reply_stream(user_confirm_result_event): ... ``` Resuming does not require saying who to resume. The event carries the `reply_id` of whichever agent parked, and the pipeline routes the result there. `reply_stream` accepts these inputs: | Input | Meaning | | ------------------------------ | ---------------------------------------------------- | | `Msg` / `list[Msg]` | Start a fresh run; the attempt budget starts over | | `UserConfirmResultEvent` | The user's answer to a tool authorization prompt | | `ExternalExecutionResultEvent` | The result of an external execution | | `UserInterruptEvent` | Abandon the parked call, ending the pipeline with it | The attempt budget lives on the pipeline instance rather than inside `reply_stream`, so resuming does not hand the run a fresh set of attempts. ## Debugging in the Terminal The quickest way to watch a pipeline is to hand the whole thing to the [console](/versions/2.0.8/en/building-blocks/console), with no adapter code: ```python Running a pipeline in the terminal theme={null} await launch_console(agent=pipe) ``` Once it is running, the parts divide up like this: | What you see | Who provides it | | ------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- | | Both agents taking turns, their thinking, tool calls and results in one stream | The pipeline, emitting its agents' events with `reply_id` telling them apart | | Authorization prompts (`y` allows once, `a` also accepts the suggested rules) | The console asking, with `reply_id` returning the answer to the right agent | | `Ctrl+C` interrupts the current reply, `exit` / `quit` / `Ctrl+D` leaves | The console | Answering an authorization prompt with `Ctrl+D` sends a `UserInterruptEvent`: the parked agent closes its pending tool calls and the pipeline ends with it. An interruption abandons the run rather than continuing it. # Overview Source: https://docs.agentscope.io/versions/2.0.8/en/building-blocks/pipeline/overview Run several agents by a fixed logic behind a single interface A pipeline composes several agents: the developer settles their order and the conditions that move work between them before the run starts, and nothing changes it afterwards. To a caller it looks exactly like one agent, taking inputs and streaming events back. A pipeline takes on three things: | Responsibility | What it means | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | Fixed logic | Who goes first, what sends work back, how many rounds are allowed: all settled when the pipeline is built, never left to the model | | One event stream | Every event from every agent inside leaves through the same `reply_stream` | | Human interaction | When an agent stops for tool authorization the pipeline ends the stream instead of suspending, and hands the answer back to whichever agent was waiting | Every agent's events leave through the same `reply_stream`. A developer rendering them needs `reply_id` to tell which agent an event belongs to; it matches that agent's `agent.state.reply_id`. The pipeline module is experimental. Its interfaces may change in later releases. ## Interface `PipelineProtocol` names one capability: take inputs, stream events. ```python PipelineProtocol theme={null} class PipelineProtocol(Protocol): def reply_stream( self, inputs: Msg | list[Msg] | UserConfirmResultEvent | UserInterruptEvent | ExternalExecutionResultEvent, ) -> AsyncGenerator[AgentEvent | Msg, None]: """Reply to the inputs and stream what happens.""" ``` `Agent` already satisfies it, an agent being a pipeline of one. So a pipeline goes wherever an agent goes: ```python Accepting either theme={null} async def watch(target: Agent | PipelineProtocol, inputs) -> None: # Both are driven the same way, with nothing to branch on async for event in target.reply_stream(inputs): print(event) ``` `launch_console` in the [console](/versions/2.0.8/en/building-blocks/console) was widened this way, so a developer can hand a pipeline straight to the terminal. ## Implementations AgentScope ships one pipeline so far: | Class | Arrangement | | ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------- | | [`GoalPipeline`](/versions/2.0.8/en/building-blocks/pipeline/goal) | An executor produces, a verifier judges, a refusal goes back with its reason, until the work passes or the attempts run out | ## Further Reading Keep an executor working until a verifier accepts the result. Run a pipeline in the terminal and answer its authorization prompts. Let the model keep its own task list, which pipelines complement. Authorize tools and resume after an interruption on a single agent. # Plan Source: https://docs.agentscope.io/versions/2.0.8/en/building-blocks/plan Give agents a structured task list to plan, track, and coordinate complex work ## Overview Planning is how an agent breaks a complex request into discrete, ordered, and trackable steps. Instead of letting the model juggle a multi-step goal entirely in free-form reasoning, AgentScope exposes a small set of built-in tools that let the agent maintain an **explicit, structured task list** — created, queried, and updated through normal tool calls. AgentScope ships four planning tools out of the box: | Tool | Operation | Read-only | | ------------ | --------------------------------------------------------------------------------- | --------- | | `TaskCreate` | Append a new task to the task list | No | | `TaskGet` | Retrieve full details (description, status, dependencies) for a single task by ID | Yes | | `TaskList` | List every task with its status, owner, and blocking relationships | Yes | | `TaskUpdate` | Update a task's status, fields, or dependency edges; or delete it | No | All four are state-injected tools (`is_state_injected = True`): the agent runtime hands each call the live `AgentState`, and the tools read from / write to `agent.state.tasks_context`. That means the task list is **scoped per agent** and persists with the agent state. ## Use Plan Tools ### Equip the Tools Instantiate the tools and register them on a `Toolkit` like any other built-in tool: ```python theme={null} from agentscope.agent import Agent from agentscope.tool import ( Toolkit, TaskCreate, TaskGet, TaskList, TaskUpdate, ) toolkit = Toolkit( tools=[ TaskCreate(), TaskGet(), TaskList(), TaskUpdate(), ], ) agent = Agent( name="planner", system_prompt="You are a planning assistant.", model=model, toolkit=toolkit, ) ``` Each tool's `description` already contains a detailed prompt describing when to call it, when to skip it, and how to interpret its output, so no additional system-prompt engineering is required. `check_permissions()` is hard-wired to `ALLOW` — the planning tools are pure in-memory state mutations and never trigger user prompts. ### Task Lifecycle A typical planning loop looks like this: On a new instruction, the agent calls `TaskCreate` once per discrete step, providing a short imperative `subject` and a richer `description`. New tasks are appended in creation order; their `id` is a stable, monotonically increasing numeric string (`"1"`, `"2"`, …). `TaskList` returns a compact one-line-per-task summary (id, status, subject, owner, blocked-by), which the agent uses to pick the next available task — typically the lowest-ID `pending` task with no unresolved `blocked_by`. Before starting work, the agent calls `TaskUpdate` to set the task's `status` to `in_progress` (and optionally an `owner` for multi-agent scenarios). `TaskGet` returns the full description, dependency edges, and metadata for a specific task — useful right before execution if the description is long. On completion, `TaskUpdate` flips the status to `completed`. If the agent uncovers new work, it loops back to `TaskCreate`; if a task becomes obsolete, it sets status `deleted` (a hard removal that also rewires the dependency edges of any tasks that referenced it). The status workflow is intentionally linear: ``` pending → in_progress → completed (or) ↘ deleted (any state, hard remove) ``` ### Express Dependencies Tasks expose two symmetric dependency edges: * `blocks` — the IDs of tasks that cannot start until this one is completed. * `blocked_by` — the IDs of tasks that must complete before this one can start. `TaskUpdate` takes `add_blocks` and `add_blocked_by` arguments. Each one mutates **both sides** of the edge automatically, so the data stays consistent: ```python theme={null} # After creating task "1" and task "2", make "2" depend on "1": await TaskUpdate()( task_id="2", add_blocked_by=["1"], _agent_state=agent.state, ) # Now: task "2".blocked_by == ["1"] AND task "1".blocks == ["2"] ``` When a task is deleted, its ID is removed from every other task's `blocks` and `blocked_by` lists, so the dependency graph remains valid. Completing a task through `TaskUpdate` does the same to its dependents' `blocked_by`, so what remains there is always a prerequisite still outstanding and a task whose blockers are resolved never looks unavailable. `TaskList` annotates every task that still has unresolved `blocked_by` entries, and `TaskGet` returns the full edge list. The agent uses these hints to prefer unblocked work, but **enforcement is advisory** — nothing in the runtime prevents the model from working on a blocked task. ## Storage All task state lives on the agent itself, under `agent.state.tasks_context`. The relevant types are: ```python theme={null} class Task(BaseModel): id: str # Monotonic numeric string, assigned by TaskCreate subject: str # Imperative one-liner description: str # Detailed requirements / context state: Literal["pending", "in_progress", "completed"] = "pending" owner: str | None = None blocks: list[str] = [] # Task IDs blocked by this task blocked_by: list[str] = [] # Task IDs blocking this task metadata: dict[str, Any] = {} created_at: str # ISO-8601 timestamp, set on creation class TaskContext(BaseModel): tasks: list[Task] = [] ``` `AgentState.tasks_context` is a regular field on the `agent.state` model, which means: * **It survives serialization.** Saving `agent.state` captures the task list verbatim, and restoring the state restores the plan. * **It is per-agent.** Two agents do not share a task list by default; multi-agent coordination is the developer's job. * **It is mutable from outside the agent loop.** Anything that can reach `agent.state` — middleware, application code, evaluators — can read and write tasks directly. The planning tools have no privileged access; they are simply a convenient LLM-facing surface over the same data structure. ## Customize Tasks Because tasks live on `agent.state.tasks_context`, developers can manage them programmatically without going through the LLM. This is useful for: * **Seeding** the agent with a pre-baked plan generated elsewhere (e.g. by another agent, a workflow engine, or static analysis). * **Importing** existing work items from an external tracker (Jira, GitHub issues, an internal task DB). * **Migrating** state across agent instances or restoring partially completed plans. * **Evaluation** of planning behavior, where the harness needs to inject ground-truth tasks before the agent reasons over them. The example below seeds two dependent tasks before the agent's first reply: ```python theme={null} from agentscope.agent import Agent from agentscope.state import Task from agentscope.tool import Toolkit, TaskCreate, TaskGet, TaskList, TaskUpdate agent = Agent( name="planner", system_prompt="You are a planning assistant.", model=model, toolkit=Toolkit( tools=[TaskCreate(), TaskGet(), TaskList(), TaskUpdate()], ), ) agent.state.tasks_context.tasks.extend( [ Task( id="1", subject="Fetch project requirements", description="Read README.md and CONTRIBUTING.md in the repo root.", metadata={"source": "seed"}, ), Task( id="2", subject="Draft an implementation plan", description="Produce a step-by-step plan based on the requirements.", blocked_by=["1"], metadata={"source": "seed"}, ), ], ) # Keep the reverse edge consistent: agent.state.tasks_context.tasks[0].blocks.append("2") ``` When mutating `tasks_context` directly, you are responsible for: * **Unique, parseable IDs.** `TaskCreate` derives the next ID by taking `max(int(task.id) for task in tasks) + 1`. Non-numeric IDs are ignored when computing the next ID, but they will not be revisited — assign numeric string IDs (`"1"`, `"2"`, …) to keep auto-generation working. * **Bidirectional dependency edges.** `blocks` and `blocked_by` must stay in sync. `TaskUpdate` does this automatically; manual edits do not. * **Valid status values.** Only `pending`, `in_progress`, and `completed` are valid for `Task.state`. `deleted` is an *operation* exposed by `TaskUpdate`, not a stored state — to drop a task by hand, simply remove it from the list (and clean up its edges). You can also clear or replace the plan at any time: ```python theme={null} agent.state.tasks_context.tasks.clear() ``` The next agent turn will see an empty plan and start over. ## Further Reading * [Tool](/versions/2.0.8/en/building-blocks/tool/python-tool) — the toolkit, the `ToolBase` interface, and how state-injected tools receive `AgentState`. * [Agent](/versions/2.0.8/en/building-blocks/agent/run-agent#state-persistence) — the agent lifecycle, including how `AgentState` is created, restored, and persisted. # RAG Source: https://docs.agentscope.io/versions/2.0.8/en/building-blocks/rag Build retrieval-augmented generation (RAG) capabilities for agents. In AgentScope, RAG is composed of the following **independently replaceable** modules: | Module | Description | | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Parser | Splits a raw file into a list of `Section` objects, where each `Section` corresponds to a natural boundary in the source (PDF page, PPTX slide, Markdown heading block, whole image, etc.) | | Chunker | Cuts `Section`s into the final `Chunk`s to be indexed; never merges across `Section`s | | Embedding Model | Embeds a `Chunk`'s text or multimodal content into a vector | | Vector Store | Connects to a vector database, stores `Chunk` vectors with metadata, and supports retrieval | | KnowledgeBase handle | Binds together an embedding model, a vector store, and a collection, exposing `insert_document` / `search` / `list_documents` / `delete_document` as the one-stop entry point | This chapter focuses on **using RAG in non-service scenarios** — indexing files, retrieving knowledge, and integrating with an agent. For embedding models and how to configure them, see the [Embedding Model chapter](/versions/2.0.8/en/building-blocks/model/embedding); for the service version of RAG (with an HTTP service, file hosting, and distributed indexing), see [RAG Service](/versions/2.0.8/en/deploy/rag). ## Existing Implementations AgentScope ships out-of-the-box default implementations for every module, all inheriting from base classes so users can easily swap them out: ### Parser | Class | Description | Supported File Types | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `TextParser` | Text parser: the entire file is returned as a single `Section` and split downstream by a chunker | `text/plain`
`text/markdown`
`text/csv`
`text/html`
`text/x-rst`
`application/json`
`application/xml`
`application/x-yaml` | | `PDFParser` | PDF parser, **one `Section` per page**; the metadata carries a `page` field that starts at 1. | `application/pdf` | | `PPTParser` | PowerPoint (`.pptx`) parser, walks slides in order:
- text/tables are merged into the same `Section`,
- images are read as standalone `DataBlock`s.
The metadata carries a `slide` field that starts at 1. | `application/vnd.openxmlformats-officedocument.presentationml.presentation` | | `WordParser` | Word (`.docx`) parser, walks the document element by element:
- adjacent paragraphs (and, by default, tables) are merged into the same text `Section`,
- embedded images are read as standalone `DataBlock`s. | `application/vnd.openxmlformats-officedocument.wordprocessingml.document` | | `ExcelParser` | Excel (`.xlsx` / `.xls`) parser, reads the workbook sheet by sheet:
- each sheet's table is rendered as Markdown or JSON text,
- embedded images (with `include_image=True`) are read as standalone `DataBlock`s.
With `separate_sheet=True` the metadata carries a `sheet` field. | `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`
`application/vnd.ms-excel` | | `ImageParser` | Image parser, reads the entire image as a single `Section` | `image/png`
`image/jpeg`
`image/gif`
`image/bmp`
`image/webp` | The PDF, PPT, Word, and Excel parsers depend on additional third-party libraries; install them in one shot with `pip install agentscope[rag]`. ### Chunker | Class | Description | | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `ApproxTokenChunker` | Splits text by approximate token count, without depending on any tokenizer.
Approximation strategy: `len(text.encode("utf-8")) // 4`; multimodal `DataBlock`s pass through unchanged. | | In development ... | | Chunkers declare their tunable options through a Pydantic parameter model. For example, use `ApproxTokenChunker.Parameters` to configure chunk size and overlap: ```python theme={null} from agentscope.rag import ApproxTokenChunker chunker = ApproxTokenChunker( parameters=ApproxTokenChunker.Parameters( chunk_size=256, overlap=32, ), ) ``` ### Embedding Model See the [Embedding Model chapter](/versions/2.0.8/en/building-blocks/model/embedding). ### Vector Database | Class | Description | | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `QdrantStore` | Qdrant-based vector database implementation, supporting in-memory (`location=":memory:"`), local disk (`path=...`), and remote service (`url=...`) deployments | | `MilvusLiteStore` | Milvus Lite-based vector database implementation, supporting local persistent `.db` files (`uri="./rag_demo.db"`) and Milvus-compatible endpoint URIs | | `MongoDBStore` | MongoDB Vector Search-based implementation (Atlas or self-hosted); each knowledge base maps to one collection in the configured database. Fields used in `metadata_filter` must be declared via `filter_fields` at construction so they enter the vector index | | `ElasticsearchStore` | Elasticsearch-based implementation using dense-vector indexes with approximate kNN search; each knowledge base maps to one index | `agentscope[rag]` only carries the dependencies needed for document parsing. Each vector database has its own optional extra: `agentscope[vdb-qdrant]`, `agentscope[vdb-milvus]`, `agentscope[vdb-mongodb]`, and `agentscope[vdb-elasticsearch]`. ## Using RAG AgentScope recommends going through the **`KnowledgeBase` handle** as the entry point for RAG. It binds an embedding model, a vector store, a collection (and an optional `metadata_filter` for multi-tenant isolation) together and exposes only four operations: | Method | Description | | ------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | `insert_document(chunks, document_id=None, document_metadata=None)` | Embeds and writes a batch of `Chunk`s as a single document; returns the `document_id` | | `search(queries, top_k=5, score_threshold=None)` | Runs vector retrieval over a list of queries (`str` / `TextBlock` / `DataBlock`), with automatic deduplication and sorting | | `delete_document(document_id)` | Removes every chunk of one document by its `document_id` | | `list_documents()` | Returns a list of `DocumentSummary` entries for every document in this knowledge base | ### Indexing a File Indexing a file goes through three steps — **file parsing → chunking → embedding + insertion** — one per module. The end-to-end flow: Call the parser's `parse` method to read the raw file into a list of `Section`s, where each `Section` corresponds to a natural boundary in the source (PDF page / PPT slide / image …). The `file` parameter of `parse(file, filename)` accepts both **`bytes`** and **`str`**: * `bytes` is treated as the raw file content; * `str` in a binary parser (`PDFParser` / `PPTParser` / `WordParser` / `ExcelParser` / `ImageParser`) is a **filesystem path** that the parser reads from disk for you; * `str` in `TextParser` is disambiguated at runtime — if the string names an existing file it is read and decoded with the configured `encoding`; otherwise it is used verbatim as pre-decoded text. ```python Text file theme={null} from agentscope.rag import TextParser parser = TextParser() # 1) Pass bytes directly sections = await parser.parse( file=b"# Cats\nCats sleep 12-16 hours per day.\n", filename="cats.md", ) # 2) Pass a file path (an existing file is read from disk) sections = await parser.parse(file="./cats.md", filename="cats.md") ``` ```python PDF theme={null} from agentscope.rag import PDFParser parser = PDFParser() # 1) Pass a file path sections = await parser.parse(file="./report.pdf", filename="report.pdf") # 2) Or pass bytes directly (e.g. from an HTTP upload / blob store) with open("report.pdf", "rb") as f: sections = await parser.parse(file=f.read(), filename="report.pdf") # Each section.metadata contains {"page": N} ``` ```python PowerPoint theme={null} from agentscope.rag import PPTParser parser = PPTParser( include_image=True, # Whether to extract embedded images separate_table=False, # Whether to emit tables as their own Section table_format="markdown", # Table rendering: "markdown" or "json" slide_prefix="", slide_suffix="", ) # File path sections = await parser.parse(file="./deck.pptx", filename="deck.pptx") ``` ```python Word theme={null} from agentscope.rag import WordParser parser = WordParser( include_image=True, # Whether to extract embedded images separate_table=False, # Whether to emit tables as their own Section table_format="markdown", # Table rendering: "markdown" or "json" ) # File path sections = await parser.parse(file="./doc.docx", filename="doc.docx") ``` ```python Excel theme={null} from agentscope.rag import ExcelParser parser = ExcelParser( include_sheet_names=True, # Prepend each sheet's name as a header include_cell_coordinates=False, # Include cell coordinates like [A1] include_image=False, # Whether to extract embedded images separate_sheet=False, # Keep sheets in separate Sections table_format="markdown", # Table rendering: "markdown" or "json" ) # File path sections = await parser.parse(file="./table.xlsx", filename="table.xlsx") # With separate_sheet=True, each section.metadata contains {"sheet": ""} ``` ```python Image theme={null} from agentscope.rag import ImageParser parser = ImageParser() # File path sections = await parser.parse(file="./cat.png", filename="cat.png") # The entire image is wrapped as a single DataBlock; metadata records the media_type ``` Call the chunker's `chunk` method to turn the `Section` list into the final `Chunk` list to be indexed. Conventions: never merge across `Section`s; multimodal `DataBlock`s pass through as whole chunks; `chunk_index` runs consecutively from 0; every chunk carries the same `total_chunks`. ```python theme={null} from agentscope.rag import ApproxTokenChunker chunker = ApproxTokenChunker( parameters=ApproxTokenChunker.Parameters( chunk_size=256, overlap=32, ), ) chunks = await chunker.chunk(sections) ``` Construct a `KnowledgeBase` handle and write the chunk list — embedding and storage are taken care of by the handle. All chunks of the same document share one `document_id`, which makes whole-document deletion easy. ```python theme={null} from agentscope.credential import DashScopeCredential from agentscope.embedding import DashScopeEmbeddingModel from agentscope.rag import KnowledgeBase, QdrantStore embedding_model = DashScopeEmbeddingModel( credential=DashScopeCredential(api_key="YOUR_API_KEY"), model="text-embedding-v4", dimensions=1024, ) # QdrantStore is an async context manager; entering it opens the client connection store = QdrantStore(location=":memory:") # or url="http://..." for a real cluster async with store: knowledge = KnowledgeBase( name="demo-kb", description="A toy corpus.", embedding_model=embedding_model, vector_store=store, collection="demo-kb", ) # The backing collection is created on first use, sized to embedding_model.dimensions document_id = await knowledge.insert_document( chunks, document_metadata={"filename": "cats.md"}, ) ``` `KnowledgeBase` does not open or close the vector store connection itself; enter the `VectorStoreBase` instance in an `async with` block before using it. To use another backend, install its extra and replace only the vector store construction: ```python Milvus Lite theme={null} from agentscope.rag import MilvusLiteStore # Local persistent .db file; requires agentscope[vdb-milvus] store = MilvusLiteStore(uri="./rag_demo.db") ``` ```python MongoDB theme={null} from agentscope.rag import MongoDBStore # MongoDB Vector Search (Atlas or self-hosted); requires agentscope[vdb-mongodb] store = MongoDBStore( uri="mongodb+srv://user:pass@cluster.mongodb.net", database="agentscope_rag", # Fields used in metadata_filter must be declared here, # so they are included in the vector search index filter_fields=["chunk.metadata.tenant_id"], ) ``` ```python Elasticsearch theme={null} from agentscope.rag import ElasticsearchStore # Dense-vector indexes with approximate kNN; requires agentscope[vdb-elasticsearch] store = ElasticsearchStore(hosts="http://localhost:9200") ``` ### Vector Retrieval Call `KnowledgeBase.search` directly with a list of query strings / `TextBlock`s / `DataBlock`s — no manual embedding required: ```python theme={null} async with store: results = await knowledge.search( queries=["When do cats sleep?"], top_k=3, score_threshold=None, # minimum score to keep a hit; None keeps everything ) for r in results: print(r.score, r.document_id, r.chunk.content) ``` `search` does the following internally: 1. **Drops unusable queries**: when the bound embedding model's `supports_multimodal == False`, `DataBlock` queries are silently dropped. 2. **Batched embedding**: every query is embedded in a single batch, then the collection is searched concurrently. 3. **Deduplication**: hits are deduplicated by `(document_id, chunk_index)` keeping the highest score. 4. **Truncation**: results are sorted by descending score and truncated to `top_k`. The return value is a list of `VectorSearchResult`s; each entry carries `score`, `document_id`, and the matched `chunk`. A higher `score` always means a better match: stores that use a distance metric (L2, for instance) negate it before returning, so scores from those knowledge bases are negative and `score_threshold` has to be negative too. Scores are comparable only within one knowledge base, never across knowledge bases built on different embedding models or metrics. ### Document Management `KnowledgeBase` exposes two document-level helpers: ```python theme={null} # List every document (one DocumentSummary per document) summaries = await knowledge.list_documents() for s in summaries: print(s.document_id, s.source, s.chunk_count, s.metadata) # Delete every chunk belonging to one document await knowledge.delete_document(document_id) ``` `DocumentSummary` carries the `document_id`, the original filename `source`, `chunk_count`, and the `metadata` recorded on the first chunk by the parser / uploader. ### Multi-tenant Isolation: `metadata_filter` When multiple logical knowledge bases need to share one physical collection, pass a `metadata_filter` when constructing the `KnowledgeBase` (a typical pattern is stamping every record with `{"tenant_id": "..."}`): ```python theme={null} knowledge = KnowledgeBase( name="tenant-a-kb", description="...", embedding_model=embedding_model, vector_store=store, collection="shared", metadata_filter={"tenant_id": "tenant-a"}, ) ``` `metadata_filter` is a **defense-in-depth** mechanism: * `search` and `list_documents` restrict records to those matching every `key == value` pair — nothing ever escapes the scope. * `insert_document` **forces** the same metadata fields onto every chunk, so even a buggy or malicious parser cannot rebind a record into another scope. `None` (the default) disables filtering — appropriate for deployments where every knowledge base owns its own collection outright. ### Multimodal Support AgentScope's RAG natively supports the ingestion and retrieval of multimodal data — the key is matching the parser's and the embedding model's capabilities: the former must be able to parse multimodal files into `DataBlock`s, the latter must be able to embed `DataBlock`s directly. * **Check which file types a Parser supports**: every `ParserBase` subclass declares its capability via the class attribute `supported_media_types` (a list of IANA media types), which you can read directly or auto-complete in your IDE. ```python theme={null} >>> from agentscope.rag import TextParser, ImageParser >>> TextParser.supported_media_types ['text/plain', 'text/markdown', 'text/csv', 'text/html', 'text/x-rst', 'application/json', 'application/xml', 'application/x-yaml'] >>> ImageParser.supported_media_types ['image/png', 'image/jpeg', 'image/gif', 'image/bmp', 'image/webp'] ``` * **Check which modalities an embedding model supports**: the instance attribute `embedding_model.supports_multimodal` tells whether the model can directly handle `DataBlock`s (images / video / audio). ```python theme={null} >>> embedding_model.supports_multimodal True ``` When the parser yields `Chunk`s containing multimodal content and `embedding_model.supports_multimodal == True`, the ingestion and retrieval pipelines work without any extra configuration. Text-only models silently drop `DataBlock` queries inside `KnowledgeBase.search` instead of raising. ### Integrating with an Agent `RAGMiddleware` plugs retrieval into the `Agent` class's reasoning-acting loop. The middleware does not own the embedding model or the vector store — it consumes **a list of pre-built `KnowledgeBase` handles**, which may mix knowledge bases that use different embedding models. `RAGMiddleware` supports two working modes (`RAGMiddleware.Parameters.mode`), which can be used individually or **combined** (by attaching two instances with different `mode`s): | Mode | Trigger | Retrieval Query | Injection | | --------------------- | ------------------------------------------------------------------------------- | -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | | `"static"` | Before the **first reasoning step** of each reply (`agent.state.cur_iter == 0`) | The input message of the reply method is used as the retrieval query | Retrieval results are wrapped into a `HintBlock` and injected into the context | | `"agentic"` (default) | The model invokes the retrieval tool on its own | Decided by the model itself | Exposes a `search_knowledge` tool — the agent decides when to retrieve and what query to use | All parameters are wrapped in the nested `RAGMiddleware.Parameters` model: | Field | Default | Description | | -------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `mode` | `"agentic"` | Integration mode, see above | | `top_k` | `5` | Maximum number of hits returned in one search, deduplicated across knowledge bases and query inputs before truncation | | `score_threshold` | `None` | Minimum score to keep a hit; `None` keeps everything. A higher score means a better match, and it is negative under a distance metric | | `rerank_candidate_k` | `None` | How many candidates the rerank model judges before `top_k` of them are kept; must be no smaller than `top_k`, defaults to twice it, capped at 50. Ignored when no rerank model is configured | | `emit_hint_event` | `True` | In `static` mode, whether to additionally emit a `HintBlockEvent` so the frontend can display the matched snippets | | `persist_hint` | `False` | In `static` mode, whether the injected block stays persistently in the context (it is removed after reasoning by default, to avoid polluting the next turn) | In addition, in `agentic` mode `RAGMiddleware.list_tools()` returns a single `search_knowledge` tool — you must manually register it in the agent's `Toolkit` so the model can call it. The tool's description automatically lists the `name` / `description` of every attached knowledge base; the model can also restrict a search to a subset via the `knowledge_bases=[...]` argument. Configure RAG on an agent instance with the following code: ```python static mode theme={null} from agentscope.middleware import RAGMiddleware from agentscope.tool import Toolkit static_mw = RAGMiddleware( knowledge_bases=[knowledge], # One or more KnowledgeBase handles parameters=RAGMiddleware.Parameters( mode="static", top_k=3, emit_hint_event=False, ), ) agent = Agent( name="static-agent", system_prompt="Answer the user's question using the retrieved material.", model=chat_model, toolkit=Toolkit(), middlewares=[static_mw], ) ``` ```python agentic mode theme={null} from agentscope.middleware import RAGMiddleware from agentscope.tool import Toolkit agentic_mw = RAGMiddleware( knowledge_bases=[knowledge], parameters=RAGMiddleware.Parameters(mode="agentic", top_k=3), ) # Note: in agentic mode you must manually inject the search_knowledge tool into the Toolkit toolkit = Toolkit(tools=await agentic_mw.list_tools()) agent = Agent( name="agentic-agent", system_prompt="When necessary, call the search_knowledge tool to look up material.", model=chat_model, toolkit=toolkit, middlewares=[agentic_mw], ) ``` ```python combined modes theme={null} from agentscope.middleware import RAGMiddleware from agentscope.tool import Toolkit # static gives the first turn some background automatically; # agentic lets the model fetch more on demand static_mw = RAGMiddleware( knowledge_bases=[knowledge], parameters=RAGMiddleware.Parameters(mode="static", top_k=3), ) agentic_mw = RAGMiddleware( knowledge_bases=[knowledge], parameters=RAGMiddleware.Parameters(mode="agentic", top_k=3), ) # Note: in agentic mode you must manually inject the search_knowledge tool into the Toolkit toolkit = Toolkit(tools=await agentic_mw.list_tools()) agent = Agent( name="hybrid-agent", system_prompt="Answer using the retrieved material; call search_knowledge for more when needed.", model=chat_model, toolkit=toolkit, middlewares=[static_mw, agentic_mw], ) ``` ### Rerank the Results Vector retrieval ranks by embedding similarity, so the chunks that answer the question best are not always on top. Pass a `rerank_model` to `RAGMiddleware` and each retrieval first pulls back `rerank_candidate_k` candidates, then lets that model read them and pick the final `top_k`: ```python Configure a rerank model theme={null} from agentscope.middleware import RAGMiddleware from agentscope.model import DashScopeChatModel from agentscope.credential import DashScopeCredential rag_mw = RAGMiddleware( knowledge_bases=[knowledge], rerank_model=DashScopeChatModel( # the chat model that does the reranking credential=DashScopeCredential(api_key="YOUR_API_KEY"), model="qwen-max", ), parameters=RAGMiddleware.Parameters( mode="agentic", top_k=3, # keep 3 hits in the end rerank_candidate_k=10, # pull 10 candidates for the rerank model first ), ) ``` Reranking applies to both modes. What to keep in mind: | Aspect | Description | | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Candidate count | `rerank_candidate_k` must be no smaller than `top_k`, defaults to `2 × top_k`, and is capped at 50; with too few candidates the rerank model can only reorder what vector retrieval already returned | | Failure | Reranking is best-effort: a failed model call is logged as a warning and the vector retrieval order is used instead, without interrupting the retrieval | | Multimodal candidates | Candidates the rerank model cannot read (a modality it does not support, for example) are pushed to the end or dropped | | Prompt | `rerank_prompt` overrides the rerank instruction; it must contain the `{query}` placeholder and may contain `{top_k}`. The framework appends the candidate contents as content blocks after the instruction | Reranking costs one extra LLM call per retrieval. The more and the longer the candidates, the higher the latency and cost, so start from the default `2 × top_k` and tune from there. ## Custom Extensions All RAG modules use base-class inheritance, so users can customize Parser, Chunker, Embedding Model, and Vector Store — inherit from the corresponding base class, implement its core methods, and the custom class slots seamlessly into the pipeline above. Contributions of new Parsers, Chunkers, and Vector Stores to the official AgentScope repository are welcome! ### Custom Parser Inherit from `ParserBase`, declare the IANA media types you can handle in the class attribute `supported_media_types`, and implement `async def parse(file, filename)` to split a byte stream into a list of `Section`s: ```python theme={null} from agentscope.message import TextBlock from agentscope.rag import ParserBase, Section class MyMarkdownParser(ParserBase): supported_media_types = ["text/markdown"] async def parse( self, file: bytes | str, filename: str, ) -> list[Section]: text = file.decode("utf-8") if isinstance(file, bytes) else file # Split by H2 headings into multiple Sections, preserving the source return [ Section( content=TextBlock(text=block), source=filename, metadata={"index": index}, ) for index, block in enumerate(text.split("\n## ")) ] ``` You may also override `supported_extensions()` if needed (the default reverse-lookup from `supported_media_types` produces noisy developer extensions; override explicitly when you want the front-end file picker to show only a curated set). ### Custom Chunker Inherit from `ChunkerBase` and implement `async def chunk(sections)` to turn a list of `Section`s into the `Chunk`s to be indexed. Conventions: never merge across `Section`s; multimodal `DataBlock`s pass through as whole chunks; `chunk_index` runs consecutively from 0 across the result list; `total_chunks` stays consistent on every chunk: ```python theme={null} from pydantic import Field from agentscope.message import TextBlock from agentscope.rag import Chunk, ChunkerBase, Section class FixedCharChunker(ChunkerBase): chunker_type = "fixed_char" class Parameters(ChunkerBase.Parameters): chunk_size: int = Field(default=1000, ge=1) async def chunk(self, sections: list[Section]) -> list[Chunk]: chunks: list[Chunk] = [] for section in sections: # Multimodal content is not split — pass through as a whole chunk if not isinstance(section.content, TextBlock): chunks.append( Chunk( content=section.content, source=section.source, chunk_index=0, total_chunks=0, metadata=dict(section.metadata), ), ) continue text = section.content.text for start in range(0, len(text), self.parameters.chunk_size): chunks.append( Chunk( content=TextBlock( text=text[ start : start + self.parameters.chunk_size ], ), source=section.source, chunk_index=0, total_chunks=0, metadata=dict(section.metadata), ), ) # Renumber consistently for index, chunk in enumerate(chunks): chunk.chunk_index = index chunk.total_chunks = len(chunks) return chunks ``` `chunker_type` identifies the chunker for persistence and reconstruction, and must be unique within an application. The JSON Schema of `Parameters` is used directly for server-side configuration forms and parameter validation. ### Custom Vector Database Inherit from `VectorStoreBase`, implement `create_collection` / `delete_collection` / `has_collection` / `insert` / `delete` / `search` / `list_documents`, and manage the underlying connection lifecycle through `__aenter__` / `__aexit__`: ```python theme={null} from typing import Any from agentscope.rag import ( DocumentSummary, VectorRecord, VectorSearchResult, VectorStoreBase, ) class MyVectorStore(VectorStoreBase): async def __aenter__(self) -> "MyVectorStore": self._client = await connect_my_backend(...) return self async def __aexit__(self, exc_type, exc, tb) -> None: await self._client.close() async def create_collection(self, name: str, dimensions: int) -> None: ... async def delete_collection(self, name: str) -> None: ... async def has_collection(self, name: str) -> bool: ... async def insert( self, collection: str, records: list[VectorRecord], ) -> None: ... async def delete(self, collection: str, document_id: str) -> None: ... async def search( self, collection: str, query_vector: list[float], top_k: int = 5, metadata_filter: dict[str, Any] | None = None, ) -> list[VectorSearchResult]: ... async def list_documents( self, collection: str, metadata_filter: dict[str, Any] | None = None, ) -> list[DocumentSummary]: ... ``` Implementation notes: * `delete` removes **every** record belonging to a `document_id`; callers add and remove documents as a unit. * `search` and `list_documents` must translate `metadata_filter` into a backend-native payload filter so multi-tenant isolation works. * `insert` must persist both `VectorRecord.document_id` and the `chunk` — otherwise `delete` and `list_documents` cannot work. ## Further Reading A multi-tenant, distributed RAG service with HTTP API, file hosting, and managed vector databases. See how `RAGMiddleware` plugs into the reply / reasoning hooks. Available embedding models and their parameters. # Overview Source: https://docs.agentscope.io/versions/2.0.8/en/building-blocks/realtime/overview Talk to an agent by voice, and let it call tools while the conversation runs Realtime agents are experimental. The interfaces may change in future releases. A realtime agent (`RealtimeAgent`) takes speech in and sends speech back: it listens continuously, answers out loud, and calls tools mid-conversation, instead of waiting for a complete text message before replying. Two implementations exist, differing in how audio reaches the model: | Implementation | How it works | | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | Speech-to-speech | Audio flows straight in and out of one end-to-end speech model, which handles recognition, understanding, and synthesis internally | | Cascaded (ASR + LLM + TTS) | Speech recognition turns the user's voice into text for an `Agent`, and speech synthesis speaks the reply | AgentScope supports the speech-to-speech implementation today; the cascaded one is coming soon. Both share the following capabilities: | Capability | Description | | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Turn detection | Voice activity detection (VAD) decides when the user starts and stops speaking, which marks the turn boundaries. The provider API can do this, or you can plug in a local VAD | | Barge-in | The moment the user speaks, the current reply stops, and the context keeps only the part the user actually heard. Your code can interrupt as well | | Tool calling | Works with `Toolkit` and the permission system, so tools run during the voice conversation | | Human-in-the-loop | The agent can ask the user to confirm a tool call without pausing the audio stream | | Automatic reconnection | After the provider API closes a session, the user's next utterance reconnects it, with the conversation history kept in the agent's state | Each implementation has its own page: Hold a realtime voice conversation through an end-to-end speech model, with barge-in, tool calling, and automatic reconnection. # Speech-to-Speech Source: https://docs.agentscope.io/versions/2.0.8/en/building-blocks/realtime/speech-to-speech Hold a realtime voice conversation through an end-to-end speech model In the speech-to-speech implementation, audio flows straight in and out of one end-to-end speech model, which handles recognition, understanding, and synthesis internally. Compared with turn-based pipelines, it does not wait for the user to finish before running each stage, so latency stays low, tone and emotion survive the round trip, and the user can interrupt at any time. AgentScope implements this through `RealtimeAgent`, which supports: * **Turn detection**: let the provider API decide when the user has finished, or plug in a local VAD and decide yourself * **Barge-in**: the user speaking cuts off the current reply, and the context keeps only what the user actually heard; your code can interrupt as well * **Tool calling with user confirmation**: works with `Toolkit` and the permission system, and the audio stream keeps running while the user decides * **Text input**: send text during a voice conversation, on models that accept text * **Automatic reconnection**: after the provider API closes a session on idle or timeout, the next input reconnects and restores the conversation * **Turn aggregation**: merge sentences split by a pause, and drop acknowledgements that carry no content The table below lists the supported provider APIs and models. Each model class takes the credential of the API it belongs to, exactly like every other model in AgentScope: | Provider API | Model Class | Models | | -------------------------- | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | | DashScope (Qwen-Omni) | `DashScopeRealtimeModel` | `qwen3.5-omni-plus-realtime`
`qwen3.5-omni-flash-realtime`
`qwen3-omni-flash-realtime`
`qwen-omni-turbo-realtime` | | DashScope (Qwen-Audio-3.0) | `DashScopeAudioRealtimeModel` | `qwen-audio-3.0-realtime-plus`
`qwen-audio-3.0-realtime-flash` | | OpenAI Realtime | `OpenAIRealtimeModel` | `gpt-realtime-2.1`
`gpt-realtime-2.1-mini`
`gpt-realtime-2`
`gpt-realtime-1.5` | | Gemini Live | `GeminiRealtimeModel` | `gemini-3.1-flash-live-preview`
`gemini-2.5-flash-native-audio-preview-12-2025` | | xAI Grok Voice | `XAIRealtimeModel` | `grok-voice-latest`
`grok-voice-think-fast-2.0` | Calling `list_models()` on a model class returns the [model cards](/versions/2.0.8/en/building-blocks/model/overview#what-is-modelcard) of every model under that API, carrying its sample rates, context limits, and available voices, ready to render a model selector in the frontend. ## Core Concepts A speech-to-speech agent is built from three components: * Audio transport (`TransportBase`): where sound comes from and goes to, such as a local sound card or a browser * Realtime speech model (`RealtimeModelBase`): the session with the provider API, translating protocol messages into uniform model events * `RealtimeAgent`: sits between the two, tracking conversation turns and handling barge-in, tool calls, and event output The diagram below shows how audio and events move between them: ```mermaid theme={null} flowchart LR U([User]) <-- Audio --> T[Audio Transport
TransportBase] T <-- Audio / Control Frames --> A[RealtimeAgent] A <-- Audio / Model Events --> M[Realtime Speech Model
RealtimeModelBase] M <-- WebSocket --> P[(Provider API)] A <-- Tool Calls / Permission Checks --> K[Toolkit / Permission System] A -- Agent Events --> D([Developer Code]) ``` Each component owns a distinct set of responsibilities: | Component | Responsible for | | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | `RealtimeAgent` | Splitting turns, barge-in and context truncation, tool calls and user confirmation, emitting agent events, reconnecting after the model session drops | | Realtime speech model (`RealtimeModelBase`) | Maintaining the WebSocket session, translating protocol messages into uniform model events, declaring sample rates and capabilities | | Audio transport (`TransportBase`) | Capture and playback, tracking playout progress, fading out on barge-in, turning the client's control frames into calls on the agent | Both the model and the transport extend from a base class, so you can adapt a new provider API or connect a different client such as a browser. ## Quick Start Start by installing the realtime extra, which brings in the WebSocket client and the local sound card library: ```bash Install the realtime dependencies theme={null} pip install "agentscope[realtime]" ``` The sound card library `sounddevice` depends on PortAudio. macOS and Windows ship it with the package; on Debian/Ubuntu, run `apt install libportaudio2` first. The four steps below build a voice agent on the local microphone that talks back and can be interrupted: A model class takes a model name and the credential of the API it belongs to. The model card is matched by name, which fixes the sample rates and context limits, while the voice, turn detection method, and other tuneables go through `Parameters`. The tabs below create the model on each provider API; the three steps that follow are identical whichever you pick: ```python DashScope Qwen-Audio-3.0 theme={null} import os from agentscope.credential import DashScopeCredential from agentscope.realtime import DashScopeAudioRealtimeModel model = DashScopeAudioRealtimeModel( model="qwen-audio-3.0-realtime-plus", credential=DashScopeCredential(api_key=os.environ["DASHSCOPE_API_KEY"]), parameters=DashScopeAudioRealtimeModel.Parameters(voice="longanqian"), ) ``` ```python DashScope Qwen-Omni theme={null} import os from agentscope.credential import DashScopeCredential from agentscope.realtime import DashScopeRealtimeModel model = DashScopeRealtimeModel( model="qwen3.5-omni-plus-realtime", credential=DashScopeCredential(api_key=os.environ["DASHSCOPE_API_KEY"]), parameters=DashScopeRealtimeModel.Parameters(voice="Tina"), ) ``` ```python OpenAI theme={null} import os from agentscope.credential import OpenAICredential from agentscope.realtime import OpenAIRealtimeModel model = OpenAIRealtimeModel( model="gpt-realtime-2.1", credential=OpenAICredential(api_key=os.environ["OPENAI_API_KEY"]), parameters=OpenAIRealtimeModel.Parameters(voice="marin"), ) ``` ```python Gemini theme={null} import os from agentscope.credential import GeminiCredential from agentscope.realtime import GeminiRealtimeModel model = GeminiRealtimeModel( model="gemini-3.1-flash-live-preview", credential=GeminiCredential(api_key=os.environ["GEMINI_API_KEY"]), parameters=GeminiRealtimeModel.Parameters(voice="Puck"), ) ``` ```python xAI theme={null} import os from agentscope.credential import XAICredential from agentscope.realtime import XAIRealtimeModel model = XAIRealtimeModel( model="grok-voice-latest", credential=XAICredential(api_key=os.environ["XAI_API_KEY"]), # Setting reasoning_effort to "none" buys faster but shallower answers parameters=XAIRealtimeModel.Parameters(voice="eve", reasoning_effort="high"), ) ``` The agent owns the model session, and the system prompt is sent once when it connects: ```python Create the agent theme={null} from agentscope.agent import RealtimeAgent agent = RealtimeAgent( name="Friday", system_prompt="You are a voice assistant. Keep your answers short.", model=model, ) ``` The transport decides where sound comes from and goes to, and `LocalAudioTransport` uses this machine's microphone and speaker. Its sample rates must match the model's, so build it from the model's properties instead of hardcoding the numbers: ```python Create the audio transport theme={null} from agentscope.realtime import LocalAudioTransport transport = LocalAudioTransport( input_sample_rate=model.input_sample_rate, output_sample_rate=model.output_sample_rate, ) ``` `reply_stream()` borrows the transport to pump audio continuously and emits events as an async iterator. The user speaking is reported as a reply too, with `role` set to `"user"`, so the loop below tells the two sides apart by `reply_id` and prints both to the terminal: ```python Run the conversation and print both sides theme={null} from agentscope.event import ReplyEndEvent, ReplyStartEvent, TextBlockDeltaEvent user_turns: set[str] = set() async with agent, transport: async for event in agent.reply_stream(transport): match event: case ReplyStartEvent(role="user"): user_turns.add(event.reply_id) case ReplyStartEvent(): print(f"[{agent.name}] ", end="", flush=True) case TextBlockDeltaEvent() if event.reply_id in user_turns: print(f"[user] {event.delta}") case TextBlockDeltaEvent(): print(event.delta, end="", flush=True) case ReplyEndEvent() if event.reply_id not in user_turns: print(f" ({event.finished_reason})") ``` Speak into the microphone to hear a reply, speak again mid-reply to interrupt it, and press Ctrl+C to exit. Three lifecycles run in this example, each owned by a different object: | Object | Owner | Lifecycle | | --------------- | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Model session | `RealtimeAgent` | For the duration of `async with agent`, or between manual `connect()` and `close()` calls; reconnects on the next utterance after the provider API closes the session | | Audio transport | You | For the duration of `async with transport`; the agent only borrows it and never closes it for you | | One run | `agent.reply_stream(transport)` | From the first audio the transport produces until its input ends, covering any number of conversation turns | Keeping the three apart means a client reconnecting does not lose the model session, and a model session timing out does not affect the transport. The same agent can call `reply_stream()` again with a different transport, with the conversation history still in `agent.state`. ## Use the Agent `RealtimeAgent` takes the following constructor arguments: The agent's name, written into agent messages and events. The system prompt, sent once when connecting to the model, with the toolkit's skill descriptions appended. The realtime speech model, see the model table above. The toolkit the model can call. Tools run on the agent side and go through permission checks. Conversation history, permission rules, and tool context. A new state is created when omitted. Local voice activity detection. When provided, it decides the turn boundaries and the provider API's own turn detection is disabled, see [Turn Detection](#turn-detection). The turn aggregator that merges split sentences and drops empty acknowledgements. Uses the default configuration when omitted. Its core methods are: | Method | Purpose | | ------------------------- | ------------------------------------------------------------------------------------------- | | `connect()` / `close()` | Open and close the model session; `async with agent` is equivalent to both | | `reply_stream(transport)` | Borrow a transport, pump audio continuously, and emit agent events until the transport ends | | `send(inputs)` | Send input other than audio: text, tool confirmation results, interruptions | | `interrupt()` | Interrupt the current reply | ### Run and Handle Events `reply_stream()` takes a transport that is already started, keeps feeding its audio to the model, and emits events as an async iterator until the transport's input ends. You own the transport, so `reply_stream()` does not close it when it returns, and the same agent can run again with a different one. `reply_stream()` emits the same [agent events](/versions/2.0.8/en/building-blocks/message-and-event) as `Agent.reply_stream`, so event handling written for a text agent works unchanged. The user speaking is reported as a reply as well: a `ReplyStartEvent` with `role` set to `"user"` when they start, a `ReplyEndEvent` when they stop, and text block events once the transcript is final, which lets the outer loop assemble user and agent messages with one set of logic. The model's audio arrives as data block events: | Event | Meaning | | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ReplyStartEvent` / `ReplyEndEvent` | The start and end of one reply. With `role` set to `"user"`, they mark the user starting and finishing; an agent reply carries a `finished_reason` of `completed`, `interrupted`, or `error` | | `TextBlockDeltaEvent` | A text delta: the transcript of the agent's reply, or of what the user said this turn | | `DataBlockDeltaEvent` | Audio deltas of the reply, already played by the transport and usually not worth handling | | `ToolCallStartEvent` / `ToolCallEndEvent` | A tool call issued by the model | | `ToolResultStartEvent` / `ToolResultEndEvent` | The result of running the tool | | `RequireUserConfirmEvent` | A tool call that needs user confirmation | ### Send Input On models that accept text input, `send()` delivers text during a voice conversation. The text first interrupts the current reply, then reaches the model as one user turn, and the reply still comes back as speech: ```python Send text input theme={null} await agent.send("Check today's weather for me") ``` The input types `send()` accepts line up with `Agent.reply`: | Input | Purpose | | ------------------------ | ----------------------------------------------------------------------------------------------------- | | `str` or `Msg` | One text turn, available on models that accept text input and raising `NotImplementedError` otherwise | | `UserConfirmResultEvent` | The result of a tool confirmation | | `UserInterruptEvent` | Interrupt the current reply, equivalent to `interrupt()` | The Qwen-Omni API behind `DashScopeRealtimeModel` accepts no text turns. Every other model class supports text input, which the `supports_text_input` class attribute reports. ### Barge-In When the user speaks while a reply is playing, the agent stops playback immediately and cancels the reply on the model side. The transport reports how far playback actually got, and the agent truncates the agent message in the context to the part the user really heard, so the model does not assume the whole sentence landed. You can also interrupt from code, for example in response to a stop button: ```python Interrupt the current reply theme={null} await agent.interrupt() ``` An interrupted reply ends with a `ReplyEndEvent` whose `finished_reason` is `interrupted`. Because text deltas arrive ahead of audio, the frontend has already received more text than the user heard, so that text block's `TextBlockEndEvent` carries a `text` field with the final text. `Msg.append_event` applies it automatically; a frontend assembling messages itself needs to replace the block's content with it. The agent-side context is always truncated. What happens on the model side depends on the provider's protocol, which a model class reports through its `truncation` attribute: | `truncation` | Provider API | On the model side | | ------------ | ------------------------- | ----------------------------------------------------------------------------- | | `EXPLICIT` | OpenAI Realtime | Accepts a truncate frame, so the model's context matches what the user heard | | `SERVER` | Gemini Live | The provider handles the interruption itself, and no truncate frame is needed | | `NONE` | DashScope, xAI Grok Voice | Accepts no truncate frame, so the model side still holds the full reply | ### Call Tools With a `toolkit` provided, the model can call tools during the conversation. Tools run on the agent side, and permission checks and user confirmation work as they do for a [regular agent](/versions/2.0.8/en/building-blocks/agent/human-in-the-loop). The difference is that a realtime agent never pauses: after emitting `RequireUserConfirmEvent`, `reply_stream()` keeps emitting other events, and you send the result back through `send()` whenever it is ready, decoupled from the event stream itself: ```python Attach tools and receive confirmation requests theme={null} from agentscope.event import RequireUserConfirmEvent from agentscope.tool import Bash, Read, Toolkit agent = RealtimeAgent( name="Friday", system_prompt="...", model=model, toolkit=Toolkit(tools=[Bash(), Read()]), ) async with agent, transport: async for event in agent.reply_stream(transport): if isinstance(event, RequireUserConfirmEvent): # Hand the request to the UI, and do not block the event stream here show_confirm_dialog(event) ``` Once the user decides, send a `UserConfirmResultEvent` back to the agent. The call can live in a UI callback, a WebSocket message handler, or terminal input. The two tabs below show both: ```python UI callback theme={null} from agentscope.event import ConfirmResult, UserConfirmResultEvent async def on_confirm_clicked(event: RequireUserConfirmEvent, allowed: bool) -> None: # Triggered by a button click or similar, unrelated to the reply_stream() loop await agent.send( UserConfirmResultEvent( reply_id=event.reply_id, confirm_results=[ ConfirmResult(tool_call=call, confirmed=allowed) for call in event.tool_calls ], ), ) ``` ```python Terminal input theme={null} import asyncio from agentscope.event import ConfirmResult, UserConfirmResultEvent async def confirm_in_terminal(event: RequireUserConfirmEvent) -> None: loop = asyncio.get_running_loop() results = [] for call in event.tool_calls: # Terminal input blocks the thread, so run it in a thread pool and keep the audio going answer = await loop.run_in_executor( None, input, f"Allow {call.name}({call.input})? [y/N] ", ) results.append( ConfirmResult(tool_call=call, confirmed=answer.lower() == "y"), ) await agent.send( UserConfirmResultEvent(reply_id=event.reply_id, confirm_results=results), ) ``` Two things to keep in mind when using tools: * Only models whose model card sets `supports_tools` receive the tool list. Among DashScope's Qwen-Omni models, that is the qwen3.5 series only; every model on the other provider APIs supports tools. * A confirmation request that goes unanswered for five minutes is treated as a rejection. Realtime agents do not support meta tools (tool groups) yet: the tool list and system prompt are sent once when connecting to the model, so activating a tool group or adding a tool mid-session has no effect. Put every tool you need into the toolkit at construction time. ### Turn Detection Turn detection decides when the user has finished speaking, and only one side can own it. By default the provider API does, and the agent just reacts to the speech start and stop it reports. Passing a `vad` argument moves the decision to the agent and disables turn detection on the provider API side. The two modes compare as follows: | Mode | How to configure | When to use | | ---------------------- | ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | | Provider API detection | Leave `vad` unset and pick the detection method through `Parameters.turn_detection` | The default, with no extra model needed | | Local detection | Pass a `VADBase` implementation | You need a custom endpointing policy, or the provider API offers no turn detection | Each provider API accepts its own `turn_detection` values, with sensitivity and silence duration configured through `Parameters` as well: | Provider API | `turn_detection` values | | -------------------------- | ------------------------------------ | | DashScope (Qwen-Omni) | `server_vad`, `semantic_vad`, `none` | | DashScope (Qwen-Audio-3.0) | `server_vad`, `smart_turn`, `none` | | OpenAI Realtime | `server_vad`, `semantic_vad`, `none` | | Gemini Live | `automatic`, `none` | | xAI Grok Voice | `server_vad`, `none` | Local detection means implementing `VADBase`: `push()` receives every PCM16 chunk the transport delivers and returns a `SpeechTransition` only on the chunk where speech starts or ends, and `None` otherwise; `reset()` clears the internal state when the audio stream breaks, such as on a reconnection. Passing `vad` sets `turn_detection` to `none` for you: ```python Plug in a local VAD theme={null} from agentscope.realtime import SpeechTransition, VADBase class MyVAD(VADBase): sample_rate = 16000 # Must match the transport's input sample rate def push(self, pcm: bytes) -> SpeechTransition | None: # Return STARTED on the chunk where speech begins, ENDED on the one where it stops ... def reset(self) -> None: ... agent = RealtimeAgent(name="Friday", system_prompt="...", model=model, vad=MyVAD()) ``` In either mode, the user transcript reported by the provider API or detected locally passes through a `TurnAggregator` before it is written to the context: ```python Configure turn aggregation theme={null} from agentscope.agent import RealtimeAgent, TurnAggregator agent = RealtimeAgent( name="Friday", system_prompt="...", model=model, aggregator=TurnAggregator( merge_window_ms=800, # Transcripts within 800ms of the last turn merge into it backchannels=frozenset({"uh-huh", "ok"}), # These acknowledgements do not form a turn min_chars=1, # Transcripts shorter than this are dropped ), ) ``` ### Automatic Reconnection Every provider API closes sessions on its own, only the trigger differs: DashScope times out after around three minutes of silence, a Gemini Live audio session is capped at around 15 minutes, and OpenAI Realtime at around an hour. The agent treats this as normal: it logs an INFO line, keeps the transport open, and reconnects on the user's next utterance, replaying the audio recorded in the meantime. The conversation history lives in `agent.state`, and on reconnection the agent appends the earlier transcript to the system prompt, so the model picks the topic back up. Long silences and long conversations are both safe, and neither needs handling for the session timeout. To continue the same conversation after a client disconnects, keep the agent open and call `reply_stream()` again with a new transport. ## Audio Transport The audio transport decides where sound comes from and goes to, and the agent does not care whether it is a local sound card or a browser. AgentScope currently provides: | Transport | Description | | --------------------- | ------------------------------------------------------------------------------------------------ | | `LocalAudioTransport` | The local microphone and speaker, built on `sounddevice`, good for debugging on your own machine | | Browser transport | Coming soon | ### Local Sound Card `LocalAudioTransport` takes the following arguments: The capture sample rate, which must equal the model's `input_sample_rate`. The playback sample rate, which must equal the model's `output_sample_rate`. The input device's index or name. Uses the system default when omitted. The output device's index or name. Uses the system default when omitted. The duration of each uplink audio chunk. The fade-out applied to audio still playing when an interruption happens, which avoids a pop. Sample rates differ between provider APIs (DashScope captures at 16 kHz, OpenAI and xAI at 24 kHz), so build the transport from the model's properties instead of hardcoding the numbers. When the default devices are not the right ones, list the available devices with `sounddevice` and pick one by index: ```bash List audio devices theme={null} python -m sounddevice ``` Two suggestions for working with a local sound card: * **Wear headphones.** On speakers, the microphone picks up the agent's own voice, turn detection reads it as the user speaking, and the agent interrupts itself. `LocalAudioTransport` does no echo cancellation. * **Do not let one Bluetooth headset handle both input and output.** macOS switches devices such as AirPods into hands-free mode, which often ends up silent. Pair the headset microphone with the built-in speaker instead, for example `LocalAudioTransport(input_device=3, output_device=2)`. ### Custom Transport To connect a browser or another audio source, subclass `TransportBase` and implement the following: | Method | Purpose | | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | | `start()` / `close()` | Open and close the audio device or connection | | `incoming()` | An async iterator emitting uplink `AudioFrame`s (PCM16 audio) and `ControlFrame`s (text, confirmation, and other control frames) | | `send_audio(pcm, item_id)` | Play one chunk of model audio, with `item_id` marking which reply it belongs to | | `clear_audio()` | Drop unplayed audio on an interruption and return a `PlayoutPosition`, whose `played_ms` is how many milliseconds of this reply actually played | | `playout` | A property holding the current playout position | The position returned by `clear_audio()` is what context truncation relies on, so track playout progress as close to the speaker as possible: in a browser, inside the AudioWorklet. # Meta Tool Source: https://docs.agentscope.io/versions/2.0.8/en/building-blocks/tool/manage-tools Let the agent activate and deactivate tool groups at runtime The built-in **meta tool** (`reset_tools`) lets the agent self-manage which tool groups are active at runtime. This keeps its context focused: only tools relevant to the current task are exposed. ## Define Tool Group A `ToolGroup` is a named bundle of tools, MCP clients, and skills. Pass groups to `Toolkit(tool_groups=[...])`. The reserved `"basic"` group is created automatically from the constructor's top-level `tools`, `mcps`, and `skills_or_loaders` arguments and is always active. ```python theme={null} from agentscope.tool import Toolkit, ToolGroup, Bash, Read, Write, Edit toolkit = Toolkit( tools=[Bash(), Read(), Write(), Edit()], tool_groups=[ ToolGroup( name="database", description="Tools for database operations.", instructions="Always wrap mutations in a transaction.", tools=[db_query_tool, db_migrate_tool], ), ToolGroup( name="deployment", description="Tools for deploying services.", instructions="Confirm the target environment before deploying.", tools=[deploy_tool, rollback_tool], ), ], ) ``` `ToolGroup` accepts the same `tools`, `mcps`, and `skills_or_loaders` arguments as the toolkit, plus a `description` shown to the agent in the meta tool schema and an optional `instructions` string returned when the group is activated. ## Use Meta Tool When at least one non-basic tool group exists, `Toolkit` auto-registers `reset_tools` and exposes its schema to the agent. Each non-basic group becomes a boolean field on that schema, and the agent calls the meta tool with the desired final state. The behavior at runtime: * Tools in the `"basic"` group are always exposed; they are never affected by the meta tool. * Each call to `reset_tools` overwrites the activated set: any non-basic group not explicitly set to `True` becomes inactive, regardless of its previous state. * For each group transitioning to active, its `instructions` (when provided) are concatenated and returned in the meta tool's response, telling the agent how to use that group properly. * Tools from inactive groups are hidden from the agent's tool schema, freeing context space for the active set. The meta tool input represents the **final state** of all groups, not incremental changes. Any group not explicitly set to `True` will be deactivated, regardless of its previous state. # MCP Source: https://docs.agentscope.io/versions/2.0.8/en/building-blocks/tool/mcp Connect agents to MCP servers and use their tools AgentScope integrates with [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) servers, letting an agent reach any MCP-compatible tool provider. The framework handles protocol negotiation, tool discovery, and result conversion automatically. Two connection modes are supported: | Mode | Transport | Lifecycle | | ------------- | ------------- | ----------------------------------------------------------------------- | | **Stateful** | STDIO or HTTP | Persistent session with explicit `connect()` / `close()` | | **Stateless** | HTTP only | Ephemeral session created per tool call, no lifecycle management needed | MCP tools are namespaced as `mcp__{server_name}__{tool_name}` to avoid name collisions, and tools annotated with `readOnlyHint` are recognized as read-only by the permission system (auto-allowed in EXPLORE and ACCEPT\_EDITS modes; in DEFAULT they still ASK unless an allow rule matches). ## Register MCP Client Build one or more `MCPClient` instances and pass them to `Toolkit(mcps=[...])`. Stateful clients must be connected before the toolkit is constructed. ```python Stateful (STDIO) theme={null} from agentscope.mcp import MCPClient, StdioMCPConfig from agentscope.tool import Toolkit client = MCPClient( name="filesystem", is_stateful=True, mcp_config=StdioMCPConfig( command="mcp-server-filesystem", args=["--root", "/my/project"], ), ) await client.connect() toolkit = Toolkit(mcps=[client]) ``` ```python Stateful (HTTP) theme={null} from agentscope.mcp import MCPClient, HttpMCPConfig from agentscope.tool import Toolkit client = MCPClient( name="weather", is_stateful=True, mcp_config=HttpMCPConfig( url="https://api.weather.com/mcp", headers={"Authorization": "Bearer xxx"}, ), ) await client.connect() toolkit = Toolkit(mcps=[client]) ``` ```python Stateless (HTTP) theme={null} from agentscope.mcp import MCPClient, HttpMCPConfig from agentscope.tool import Toolkit client = MCPClient( name="search", is_stateful=False, mcp_config=HttpMCPConfig(url="https://api.search.com/mcp"), ) toolkit = Toolkit(mcps=[client]) ``` ## Filter Exposed Tools To expose only a subset of an MCP server's tools, set `enable_tools` or `disable_tools` on the client itself: ```python theme={null} client = MCPClient( name="search", is_stateful=False, mcp_config=HttpMCPConfig(url="https://api.search.com/mcp"), enable_tools=["web_search", "image_search"], ) ``` ## Update HTTP Headers at Runtime The `headers` in `mcp_config` are fixed at construction. When an auth token rotates mid-session, call `set_runtime_headers()` on a Streamable HTTP client to replace the headers sent with subsequent requests — no rebuild and no reconnect: ```python theme={null} # The map is replaced, not merged; an empty dict clears it and the static headers apply again await client.set_runtime_headers({"Authorization": "Bearer new-token"}) ``` The behaviour has a few boundaries worth knowing: | Aspect | Behaviour | | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Scope | Streamable HTTP clients only; STDIO and SSE clients raise `ValueError` | | When it takes effect | The next outbound request. A call already under way keeps the snapshot it started with, and the headers of a Streamable HTTP session's long-lived GET stream are fixed when the stream is established | | Precedence | Headers MCP sets per request (`mcp-session-id`, `content-type`, ...) always win; `connection`, `content-length`, `host` and `transfer-encoding` are owned by the HTTP layer and are rejected | | Persistence | Runtime headers are live instance state, excluded from `model_dump` and from workspace persistence | In a Docker workspace the live MCP client runs inside the gateway process and the host holds a proxy. Calling `set_runtime_headers()` on the proxy relays the update to the gateway — see [MCP Gateway](/versions/2.0.8/en/building-blocks/workspace/mcp-gateway). ## Use MCP Tools Outside a Toolkit If you need to invoke MCP tools outside a `Toolkit`, call `await client.list_tools()` to retrieve a list of `MCPTool` adapters and use them like any other `ToolBase` instance. # Overview Source: https://docs.agentscope.io/versions/2.0.8/en/building-blocks/tool/overview Equip agents with tools, MCP servers, and skills through the toolkit Tools are how an agent acts on the world: running shell commands, reading files, calling APIs. Each tool exposes itself to the LLM as a JSON Schema, and the agent invokes it through a unified streaming interface. AgentScope organizes tool-related building blocks under three concepts: | Concept | Role | | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Tool** | Any class that satisfies the `ToolBase` interface, including the built-ins shipped with AgentScope and the `FunctionTool` / `MCPTool` adapters that wrap plain functions or MCP-server tools | | **Toolkit** | The container that registers tools, MCP clients, and skills, exposes their JSON schemas to the model, and dispatches each tool call to the right tool object | | **Tool Group** | A named bundle of tools, MCP clients, and skills that can be activated or deactivated as a unit; the agent toggles groups at runtime via the built-in meta tool | A minimal `Toolkit` takes a list of tool instances: ```python theme={null} from agentscope.tool import Toolkit, Bash, Read, Write, Edit toolkit = Toolkit( tools=[Bash(), Read(), Write(), Edit()], ) ``` A `Toolkit` created with `tools` alone exposes those tools in the special `"basic"` group, which is always active. Adding `mcps`, `skills_or_loaders`, or extra `tool_groups` extends what the agent can reach. ## Next Steps Each capability source has its own page: Built-in tools, custom tools, function wrapping, and tool middleware. Connect MCP servers and use their tools. Extend agent capabilities with markdown instruction sets. Let the agent activate and deactivate tool groups at runtime. ## Further Reading How agents orchestrate tool calls in the ReAct loop. Fine-grained control over which tools can execute and when. # Python Tool Source: https://docs.agentscope.io/versions/2.0.8/en/building-blocks/tool/python-tool Build tools from classes or plain functions, and control how they run A Python tool is any object satisfying the `ToolBase` interface. AgentScope ships built-in tools for common operations and exposes the same interface for developers to build their own: | Topic | Covers | | ---------------------------------------------------------- | ------------------------------------------------------------- | | [ToolBase Interface](#toolbase-interface) | The attributes and methods every tool implements | | [Built-in Tools](#use-built-in-tools) | Ready-to-use tools: `Bash`, file tools, plan tools | | [Switch Tool Backend](#switch-tool-backend) | Run built-in tools against Docker, E2B, or other environments | | [Create Custom Tool](#create-custom-tool) | Subclass `ToolBase` with your own schema and permission logic | | [Wrap Function as Tool](#wrap-function-as-tool) | Turn a plain Python function into a tool with `FunctionTool` | | [External Execution Tool](#define-external-execution-tool) | Delegate execution to a human or an external system | | [Tool Middleware](#tool-middleware) | Attach onion-style hooks to a tool instance | ## ToolBase Interface `ToolBase` is the abstract base class every tool satisfies. The tables below list its attributes and methods. Attributes that describe the tool to the agent and the runtime: | Attribute | Type | Description | | --------------------- | ------------- | -------------------------------------------------------------------------------------------------------------------- | | `name` | `str` | The tool name presented to the agent | | `description` | `str` | Agent-oriented description of what the tool does | | `input_schema` | `dict` | JSON Schema defining the tool's parameters | | `is_concurrency_safe` | `bool` | Whether the tool is safe to call in parallel | | `is_read_only` | `bool` | Whether the tool only reads data without side effects | | `is_external_tool` | `bool` | If `True`, execution is delegated externally (see [Define External Execution Tool](#define-external-execution-tool)) | | `is_state_injected` | `bool` | If `True`, the agent state is injected via the `_agent_state` argument | | `is_mcp` | `bool` | Whether the tool comes from an MCP server | | `mcp_name` | `str \| None` | The MCP server name when `is_mcp` is `True` | Methods that hook into execution and the permission system: | Method | Required | Description | | ---------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `check_permissions(tool_input, context)` | Yes | Runtime permission check before execution; returns `PermissionDecision` | | `check_read_only(tool_input)` | Optional | Per-invocation read-only check; returns `bool`. Defaults to `self.is_read_only`. Override when read-only-ness depends on the input (e.g. `Bash`: `ls` is read-only, `rm` is not). Used by the permission engine to decide auto-allow in EXPLORE / ACCEPT\_EDITS. | | `match_rule(rule_content, tool_input)` | Optional | Custom rule-matching logic for the permission system; returns `bool` | | `generate_suggestions(tool_input)` | Optional | Generate suggested permission rules from a tool call; returns `list[PermissionRule]` | | `call(**kwargs)` | Yes\* | The tool's execution logic, the subclass override point. Returns `ToolChunk` or `AsyncGenerator[ToolChunk, None]`. Not required for external execution tools (`is_external_tool = True`). | | `__call__(**kwargs)` | — | Framework dispatch entry point. Runs the middleware chain (if any), then delegates to `call()`. Do not override. | ## Use Built-in Tools AgentScope ships a set of ready-to-use tools covering common agent operations. Instantiate them and pass into `Toolkit(tools=[...])`: | Tool | Description | Read-only | | ------------ | ---------------------------------------------- | --------- | | `Bash` | Execute shell commands | No | | `PowerShell` | Execute PowerShell commands on Windows | No | | `Read` | Read file contents with line numbers | Yes | | `Write` | Create or overwrite files | No | | `Edit` | Perform exact string replacements in files | No | | `Glob` | Find files by glob pattern | Yes | | `Grep` | Search file contents using ripgrep | Yes | | `TaskCreate` | Create a structured task for progress tracking | No | | `TaskGet` | Retrieve task details by ID | Yes | | `TaskList` | List all tasks and their status | Yes | | `TaskUpdate` | Update task status or metadata | No | Two more tools, the `reset_tools` meta tool and the `Skill` viewer, are auto-registered by `Toolkit` whenever extra tool groups or skills exist. Developers do not instantiate them directly. See [Meta Tool](/versions/2.0.8/en/building-blocks/tool/manage-tools) and [Skill](/versions/2.0.8/en/building-blocks/tool/skill). ### Bash The `Bash` tool executes shell commands and returns stdout/stderr. It implements every optional interface method to provide fine-grained permission control. `check_permissions()` runs a layered safety analysis on the command string: 1. **Injection risk detection**: flags dynamic shell structures (`$(...)`, backticks, process substitution) that cannot be statically analyzed → ASK 2. **Read-only command detection**: auto-allows safe commands (`git status`, `ls`, `cat`, `grep`, `docker ps`, etc.), including compound commands where every subcommand is read-only → ALLOW 3. **Dangerous command patterns**: detects destructive operations (e.g. `chmod 777`, `mkfs`) → ASK 4. **Sed constraint check**: blocks in-place `sed -i` against dangerous files → ASK 5. **Dangerous path protection**: checks if the command operates on sensitive config files (`.bashrc`, `.ssh/`, `.env`) → ASK 6. **Dangerous removal detection**: catches `rm` / `rmdir` targeting critical system paths (`/`, `~`, `/usr`) → ASK 7. **ACCEPT\_EDITS mode**: auto-allows filesystem commands (`mkdir`, `touch`, `rm`, `rmdir`, `mv`, `cp`, `sed`) **only when every target path resolves inside a configured working directory**. A command that touches any path outside the working set (e.g. `cp /etc/hosts /tmp/x`) falls through to PASSTHROUGH instead of auto-allowing. `check_read_only()` returns `True` for any command identified by the read-only detector above (step 2), and `False` otherwise. The permission engine uses it to decide auto-allow in EXPLORE / ACCEPT\_EDITS without re-running the full safety analysis. `match_rule()` uses prefix-based wildcard matching against the command string: | Pattern | Matches | Does not match | | -------------- | ------------------------------- | -------------- | | `npm run:*` | `npm run build`, `npm run test` | `npm install` | | `git commit:*` | `git commit -m "fix"` | `git push` | | `rm:*` | `rm file.txt`, `rm -rf /tmp/x` | `ls` | `generate_suggestions()` extracts the command prefix (first two tokens) and proposes a prefix rule. For example, `git commit -m "fix bug"` produces the suggestion `git commit:*`. The constructor accepts optional extra entries for the dangerous-path lists: ```python theme={null} from agentscope.tool import Bash bash = Bash( additional_dangerous_files=[".secrets"], additional_dangerous_directories=[".credentials"], ) ``` ### PowerShell The `PowerShell` tool is the Windows counterpart of `Bash`. `LocalWorkspace.list_tools()` returns it in place of `Bash` when the host is Windows, so the same agent code works on both platforms: ```python theme={null} from agentscope.tool import PowerShell pwsh = PowerShell(cwd="C:\\Users\\me\\project") ``` Its execution model differs from `Bash` in a few ways: | Aspect | Behavior | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Executable | Prefers `pwsh` (PowerShell 6+) and falls back to `powershell.exe`; the probe runs once and the result is cached | | Session state | Every command runs with `-NoProfile -NonInteractive`, so neither the user profile nor session state (variables, current location) carries over between calls | | Encoding | The command is sent as a UTF-16LE base64 `-EncodedCommand`, so quoting and non-ASCII characters survive intact | | Timeout | The `timeout` argument is in milliseconds: 120000 by default, capped at 600000 | | Output | stdout and stderr are returned together and truncated at 30000 characters | `check_permissions()` returns ASK for **every** PowerShell command. The static command analysis that lets `Bash` auto-allow read-only commands is not yet implemented for PowerShell, so no command is classified as safe. This is a regular ASK that allow rules and `BYPASS` mode can still override, and `generate_suggestions()` deliberately proposes no rules to keep the permission boundary conservative. ### File Tools (Read, Write, Edit) The file tools enforce a read-before-write rule: `Write` and `Edit` require the target file to have been read via `Read` first. This prevents blind overwrites and ensures the agent always operates on current content. | Tool | Operation | Key behavior | | ------- | ------------------------------- | ---------------------------------------------------------------------------------------------------- | | `Read` | Read file contents | Returns text with line numbers; returns images and PDFs as `DataBlock`s when the model supports them | | `Write` | Create or overwrite a file | Fails if the file exists but has not been read first | | `Edit` | Replace exact strings in a file | Fails if `old_string` is not found or is not unique (unless `replace_all=True`); requires prior read | `check_permissions()`: `Write` and `Edit` share the same permission logic: 1. **Dangerous path protection**: operations on sensitive files (`.bashrc`, `.env`, `.ssh/`) return a bypass-immune ASK (`bypass_immune=True`), so allow rules cannot silently authorize them. The ASK is still skipped in `BYPASS` mode (which opts out of safety prompts by design) and converted to DENY in `DONT_ASK` mode. See the [permission system docs](/versions/2.0.8/en/building-blocks/permission-system/tool-check#safety-check-contract) for the full contract. 2. **ACCEPT\_EDITS mode**: auto-allows operations on files within configured working directories 3. **PASSTHROUGH**: falls through to the permission engine for rule matching `Read` is read-only and always returns PASSTHROUGH (the engine handles EXPLORE-mode and ACCEPT\_EDITS-mode auto-allow via `check_read_only`). `Read` picks its return format based on the file type: * Text files are paginated with `offset` / `limit` and returned as `TextBlock`s with line numbers; * Image types the model supports are returned as base64 `DataBlock`s; * PDFs are returned as `DataBlock`s when the model supports `application/pdf`, otherwise their text is extracted locally. Beyond 10 pages, a range such as `pages="1-5"` is required, and one call reads at most 20 pages. Pass the input types from the model card straight to `Read` so the tool only returns multimodal content the downstream model can handle: ```python theme={null} read = Read(model_input_types=model_card.input_types) result = await read(file_path="/workspace/report.pdf", pages="1-5") ``` `match_rule()`: all three tools use `fnmatch` glob matching against the `file_path` argument: | Pattern | Matches | | ------------- | ------------------------- | | `src/**` | Any file under `src/` | | `src/**/*.py` | Python files under `src/` | | `config.json` | Exact file match | `generate_suggestions()` proposes a glob covering the parent directory. For example, editing `/project/src/main.py` produces the suggestion `src/**`. ### Plan Tools (TaskCreate, TaskGet, TaskList, TaskUpdate) The plan tools give the agent a structured task list it can append to, query, and update through normal tool calls. They share a single store on `agent.state.tasks_context`, are state-injected, and always pass permission checks: the agent treats them as free-cost coordination primitives for breaking complex work into trackable steps. See [Plan](/versions/2.0.8/en/building-blocks/plan) for the full task lifecycle, the storage model, and how to seed or customize tasks programmatically. ## Switch Tool Backend The `Bash`, `PowerShell`, `Grep`, `Glob`, `Read`, `Write`, and `Edit` tools in AgentScope support backend switching: delegating their execution to different runtime environments such as the local filesystem, a Docker container, an E2B sandbox, and so on. A backend is selected via the `backend` argument. Backend instances are obtained from a `Workspace`, which defaults to the local environment. See [Workspace](/versions/2.0.8/en/building-blocks/workspace/overview) for more details. ```python title="Switch to Docker backend" theme={null} from agentscope.workspace import DockerWorkspace # Obtain the backend from a Docker sandbox workspace = DockerWorkspace(...) await workspace.initialize() backend = workspace.get_backend() # Configure the tool bash = Bash(backend=backend) # Run logic ... await workspace.close() ``` ```python title="Switch to E2B backend" theme={null} from agentscope.workspace import E2BWorkspace # Obtain the backend from an E2B sandbox workspace = E2BWorkspace(...) await workspace.initialize() backend = workspace.get_backend() # Configure the tool bash = Bash(backend=backend) # Run logic ... await workspace.close() ``` ## Create Custom Tool To create a custom tool, subclass `ToolBase`, declare its schema, and implement `check_permissions` and `call`: ```python theme={null} from agentscope.tool import ToolBase, ToolChunk from agentscope.permission import ( PermissionContext, PermissionDecision, PermissionBehavior, ) from agentscope.message import TextBlock class WebSearch(ToolBase): name = "WebSearch" description = "Search the web for information on a given query." input_schema = { "type": "object", "properties": { "query": { "type": "string", "description": "The search query.", }, }, "required": ["query"], } is_concurrency_safe = True is_read_only = True async def check_permissions( self, tool_input: dict, context: PermissionContext, ) -> PermissionDecision: return PermissionDecision( behavior=PermissionBehavior.ALLOW, message="Web search is read-only.", ) async def call(self, query: str) -> ToolChunk: results = await do_search(query) return ToolChunk(content=[TextBlock(text=results)]) ``` Two extension hooks worth knowing about when writing custom tools with safety logic: * **`check_read_only(tool_input)`**: override when whether an invocation modifies state depends on the input (like `Bash`: `ls` is read-only, `rm` is not). Defaults to returning the static `is_read_only` attribute. The permission engine calls it before deciding EXPLORE / ACCEPT\_EDITS auto-allow. * **`PermissionDecision(..., bypass_immune=True)`**: set on a returned ASK to mark it as a safety check that allow rules cannot silence (e.g. a `DeployTool` flagging `prod-*` targets). See the [safety check contract](/versions/2.0.8/en/building-blocks/permission-system/tool-check#safety-check-contract) for per-mode handling. ## Wrap Function as Tool For lightweight cases that don't justify a full subclass, wrap a plain Python function with the `FunctionTool` adapter. It auto-extracts the tool name from `func.__name__`, the description from the function docstring, and the input schema from type hints. ```python theme={null} from agentscope.tool import FunctionTool, Toolkit def get_weather(city: str, unit: str = "celsius") -> str: """Get the current weather for a city. Args: city: The city name to look up. unit: Temperature unit, either "celsius" or "fahrenheit". """ return f"The weather in {city} is 22°{unit[0].upper()}" toolkit = Toolkit(tools=[FunctionTool(get_weather)]) ``` `FunctionTool` accepts overrides when the auto-extracted defaults are not what you want: | Argument | Type | Description | | --------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | | `func` | `Callable` | The Python function to wrap | | `name` | `str \| None` | Override the tool name (defaults to `func.__name__`) | | `description` | `str \| None` | Override the description (defaults to the docstring) | | `input_schema` | `dict \| type[BaseModel] \| None` | Provide a JSON Schema or Pydantic model for the arguments explicitly; inferred from the function annotations when omitted | | `is_concurrency_safe` | `bool` | Whether parallel calls are safe (default `True`) | | `is_read_only` | `bool` | Whether the function has side effects (default `False`) | | `is_state_injected` | `bool` | Whether the agent state is injected as `_agent_state` (default `False`) | Pass a Pydantic model directly when the arguments need enums, numeric ranges or nested structures: ```python theme={null} from pydantic import BaseModel, Field class WeatherInput(BaseModel): city: str days: int = Field(default=1, ge=1, le=7) def get_forecast(city: str, days: int = 1) -> str: return f"Sunny in {city} for the next {days} day(s)" weather_tool = FunctionTool(get_forecast, input_schema=WeatherInput) ``` `FunctionTool` also resolves the deferred annotations produced by `from __future__ import annotations`. When automatic inference cannot express the constraints you need, prefer `input_schema`. Wrapped functions default to `ASK` permission behavior: the user must explicitly allow each call. Subclass `ToolBase` directly when you need custom permission logic. ## Define External Execution Tool An external execution tool delegates its actual execution outside the agent runtime, typically to a human operator or an external system. When the agent calls one, it emits a `RequireExternalExecutionEvent` and pauses until the result is delivered via `ExternalExecutionResultEvent`. This pattern underlies the [human-in-the-loop](/versions/2.0.8/en/building-blocks/agent/human-in-the-loop) workflow, where certain actions require human approval or manual execution. To create an external execution tool, set `is_external_tool = True`. There is no need to implement `call`: ```python theme={null} from agentscope.tool import ToolBase from agentscope.permission import ( PermissionContext, PermissionDecision, PermissionBehavior, ) class HumanApproval(ToolBase): name = "HumanApproval" description = "Request human approval for a sensitive operation." input_schema = { "type": "object", "properties": { "action": {"type": "string", "description": "The action requiring approval."}, "reason": {"type": "string", "description": "Why this action needs approval."}, }, "required": ["action", "reason"], } is_concurrency_safe = True is_read_only = False is_external_tool = True async def check_permissions( self, tool_input: dict, context: PermissionContext, ) -> PermissionDecision: return PermissionDecision( behavior=PermissionBehavior.ALLOW, message="External tool dispatch is always allowed.", ) ``` ## Tool Middleware Tool middleware attaches onion-style hooks directly to a tool instance. Every time that tool is invoked, whether by an agent or called directly, the registered middlewares fire in order, wrap the execution, and can observe or transform both inputs and outputs. This is separate from agent-level middleware (`MiddlewareBase`): `on_acting` in agent middleware wraps the entire tool-call slot inside the ReAct loop (including permission checks and event emission), while `ToolMiddlewareBase` hooks only inside the tool's own `call()` execution chain and fires even when the tool is called outside any agent. ### ToolMiddlewareBase Interface Subclass `ToolMiddlewareBase` and implement the single abstract async-generator method `on_tool_call`: | Parameter | Type | Description | | -------------- | ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `tool` | `ToolBase` | The tool instance being invoked | | `input_kwargs` | `dict[str, Any]` | Input arguments for this invocation. Pass (possibly modified) arguments to `next_handler` to control what the inner layers receive | | `next_handler` | `Callable[..., AsyncGenerator[ToolChunk, None]]` | Call as `next_handler(**input_kwargs)` to continue to the next layer. Always returns an async generator regardless of whether the underlying tool is streaming or not | ### Execution Model * The **first** registered middleware is the **outermost** layer: its pre-logic runs first, its post-logic runs last. * `next_handler(**input_kwargs)` always returns `AsyncGenerator[ToolChunk, None]`. Streaming and non-streaming tools are unified, so a middleware never needs to handle the two shapes separately. * The innermost layer calls the tool's own `call()`. ``` middlewares[0].on_tool_call └─ middlewares[1].on_tool_call └─ ... → tool.call() ``` ### Attach Middleware Pass a list of middleware instances to the tool constructor via the `middlewares` argument: ```python theme={null} from agentscope.tool import Bash bash = Bash(middlewares=[LoggingMiddleware(), MetricsMiddleware()]) # Execution order: LoggingMiddleware → MetricsMiddleware → Bash.call() ``` ### Example A logging middleware that prints before and after each invocation, and a retry middleware that re-attempts on failure: ```python theme={null} from typing import AsyncGenerator, Any, Callable from agentscope.tool import ToolMiddlewareBase, ToolBase, ToolChunk, Bash class LoggingMiddleware(ToolMiddlewareBase): async def on_tool_call( self, tool: ToolBase, input_kwargs: dict[str, Any], next_handler: Callable[..., AsyncGenerator[ToolChunk, None]], ) -> AsyncGenerator[ToolChunk, None]: print(f"→ Calling {tool.name} with {input_kwargs}") async for chunk in next_handler(**input_kwargs): yield chunk print(f"✓ {tool.name} finished") class RetryMiddleware(ToolMiddlewareBase): def __init__(self, max_attempts: int = 3): self.max_attempts = max_attempts async def on_tool_call( self, tool: ToolBase, input_kwargs: dict[str, Any], next_handler: Callable[..., AsyncGenerator[ToolChunk, None]], ) -> AsyncGenerator[ToolChunk, None]: for attempt in range(1, self.max_attempts + 1): try: async for chunk in next_handler(**input_kwargs): yield chunk return except Exception as e: if attempt == self.max_attempts: raise print(f"Attempt {attempt} failed: {e}, retrying…") bash = Bash(middlewares=[LoggingMiddleware(), RetryMiddleware(max_attempts=3)]) ``` **Tool middleware vs. agent middleware**: use `ToolMiddlewareBase` for cross-cutting concerns that belong to the tool itself (logging, metrics, retry). Use `MiddlewareBase.on_acting` when you need access to the broader agent context, such as permission decisions, the tool-call event, or the surrounding ReAct round. See [Middleware](/versions/2.0.8/en/building-blocks/middleware) for the full agent-level hook reference. # Skill Source: https://docs.agentscope.io/versions/2.0.8/en/building-blocks/tool/skill Extend agent capabilities with markdown instruction sets Skills are markdown-based instruction sets that extend agent capabilities without writing new tool code. Each skill is a directory containing a `SKILL.md` file with frontmatter metadata and detailed instructions. Unlike tools, skills are not callable directly. The agent uses the auto-registered `Skill` viewer tool to read a skill's instructions, then follows those instructions using its existing tools. ## Register Skill Pass skill sources to the `Toolkit` constructor through `skills_or_loaders`. Each entry can be a directory path string, a `Skill` object, or a `SkillLoaderBase` subclass: ```python Directory path (simple) theme={null} from agentscope.tool import Toolkit toolkit = Toolkit( skills_or_loaders=["/path/to/skills"], ) ``` ```python LocalSkillLoader (with subdirectory scanning) theme={null} from agentscope.tool import Toolkit from agentscope.skill import LocalSkillLoader loader = LocalSkillLoader( directory="/path/to/skills", scan_subdir=True, ) toolkit = Toolkit(skills_or_loaders=[loader]) ``` ## How Skill Works When a `Toolkit` is constructed with skills, the registration and lookup flow runs in two phases. At initialization: * The toolkit scans every registered skill source and collects each skill's name, description, and directory. * It auto-registers the built-in `Skill` viewer tool. * It composes a system-prompt fragment listing the available skills (name and description only) and instructing the agent to invoke the `Skill` viewer to read the full content. At runtime: * The agent picks a skill by name and calls the `Skill` viewer. * The viewer reads the corresponding `SKILL.md` and returns its full markdown. * The agent follows those instructions using its already-equipped tools. Skills are not tools: the agent cannot call a skill directly. It must first use the `Skill` viewer to read the instructions, then execute the steps described within using its other tools. # Manage Resources Source: https://docs.agentscope.io/versions/2.0.8/en/building-blocks/workspace/manage-resources Add and remove MCP servers and skills at runtime A workspace's resources are not fixed at creation time. MCP servers and skills can be added or removed while the workspace is running, and every change is persisted so it survives restarts. The lifecycle methods round out the picture, taking a workspace from provisioned to released. ## Manage MCP Servers MCP declarations are isolated by `agent_id + session_id`. Even when several sessions share one workspace, the connections, cookies and login state of a stateful MCP never leak into another session. The client is only connected the first time that session calls `list_mcps`: ```python theme={null} from agentscope.mcp import MCPClient, HttpMCPConfig # Register a new MCP server; raises ValueError if the name already exists await workspace.add_mcp( MCPClient( name="amap", is_stateful=False, mcp_config=HttpMCPConfig(url="https://mcp.amap.com/mcp?key=..."), ), agent_id="coder", session_id="session-1", ) # Deregister by name; unknown names log a warning and return silently await workspace.remove_mcp( "amap", agent_id="coder", session_id="session-1", ) # Enumerate the currently registered clients mcps = await workspace.list_mcps( agent_id="coder", session_id="session-1", ) ``` Persistence follows the workspace's own model: an ephemeral `DockerWorkspace` without a host `workdir` keeps the MCP list in memory only, and it is lost when the container goes away. ## Manage Skills Skills are isolated by `agent_id`. `skill_paths` first populates the `skills/.seed` template; the workspace creates a private partition for an agent the first time it accesses skills. `add_skill`, `remove_skill` and `list_skills` only operate on the given agent's partition: ```python theme={null} # Copy a local skill directory into the workspace; # raises ValueError if SKILL.md is missing or the directory already exists await workspace.add_skill("./skills/web-search", agent_id="coder") # Delete by the skill's front-matter name; raises KeyError if not found await workspace.remove_skill("web-search", agent_id="coder") # Enumerate the available skills (parsed from each SKILL.md) skills = await workspace.list_skills(agent_id="coder") ``` ## Manage the Lifecycle Three methods take a workspace through its life, and the `async with` protocol wraps `initialize` / `close` for scoped use: | Method | Effect | | -------------- | ----------------------------------------------------------------------------------------------------------------- | | `initialize()` | Provision the backend (start the container / sandbox / Pod), restore MCP declarations, and prepare the skill seed | | `reset()` | Return the workspace to an empty state: close and remove all MCPs, delete all skills, and wipe per-session state | | `close()` | Release all resources and connections | ```python theme={null} async with LocalWorkspace(workdir="./ws") as workspace: ... # initialize() on entry, close() on exit ``` `reset()` deletes the MCP declarations, skill partitions and session state of every session. Afterwards, a session inherits `default_mcps` again the next time it accesses MCPs, but `skill_paths` is **not** re-seeded. ## Allocate Workspaces in a Service In a multi-tenant service, deciding which request gets which workspace (per user, per agent, or per session), caching live instances, and evicting idle ones is the job of the **workspace manager**, a separate service-side component. See its dedicated chapter: Allocation, isolation policies, TTL eviction, and integration with the Agent Service. # MCP Gateway Source: https://docs.agentscope.io/versions/2.0.8/en/building-blocks/workspace/mcp-gateway How sandboxed workspaces expose their MCP servers to the host Sandboxed workspaces (Bubblewrap, Docker, E2B, Daytona, K8s, OpenSandbox) cannot register host-side MCP clients directly: the MCP servers live inside the container or sandbox, and stdio sessions cannot cross that boundary. AgentScope solves this with an **MCP gateway**, a lightweight FastAPI process that runs *inside* the workspace, owns the upstream MCP sessions, and exposes them over a single authenticated HTTP endpoint that the host talks to. ```mermaid theme={null} flowchart LR subgraph Host Agent --> Toolkit Toolkit --> GC["GatewayMCPClient
(MCPClient subclass)"] end subgraph Sandbox["Container / Cloud Sandbox"] GC -- "HTTPS
Bearer token" --> GW["MCP Gateway
(FastAPI)"] GW --> MCP1["MCP Server 1 (stdio)"] GW --> MCP2["MCP Server 2 (http)"] GW --> MCPN["MCP Server N"] end ``` The gateway exposes a small REST surface (`GET /health`, `GET/POST/DELETE /mcps`, `GET /mcps/{name}/tools`, `POST /mcps/{name}/tools/{tool}`, `PUT /mcps/{name}/runtime-headers`) protected by a per-workspace bearer token minted at each `initialize()`. On the host, two adapters preserve the standard interfaces: | Adapter | Base Class | Role | | ------------------ | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GatewayMCPClient` | `MCPClient` | `connect` / `close` / `list_tools` become HTTP requests against the gateway, so the rest of the toolkit cannot tell it apart from a local MCP client; `set_runtime_headers` updates the live gateway-side client through `PUT /mcps/{name}/runtime-headers` | | `GatewayMCPTool` | `ToolBase` | `__call__` posts to `/mcps/{name}/tools/{tool}` and reconstructs the returned `ToolChunk` | [Updating HTTP headers at runtime](/versions/2.0.8/en/building-blocks/tool/mcp#update-http-headers-at-runtime) works through the gateway proxy too, but the proxy must already be connected, since what changes is the live client on the gateway side. `connect` registers the current runtime headers along with the client, so they survive a reconnect. A `RuntimeError` is raised when the workspace image predates the endpoint or the gateway restarted and holds no live client. This abstraction keeps the agent-side code identical across every workspace backend: a workspace returns `MCPClient` instances from `list_mcps()` regardless of whether the upstream session lives on the host (`LocalWorkspace`) or inside an isolated environment (all sandboxed workspaces). The gateway is **not** published on a host-reachable network port. Each host-to-gateway call is executed *inside* the sandbox: `GatewayMCPClient` issues the request as a `curl` command run through the backend's `exec_shell`, so the gateway only ever listens on the sandbox's own loopback. Because the sandbox exposes no externally-listening service, this design avoids the attack surface an outward-facing gateway port would introduce. `BubblewrapWorkspace` is the exception: it shares the host network namespace, so its gateway loopback is the host's loopback and other local processes could reach the port. Two extra safeguards apply there: | Safeguard | What It Does | | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Bearer token | The gateway requires a per-workspace token, so another process that finds the port cannot drive the MCP servers | | Instance nonce | `/health` is probed without the token and must return the nonce minted for the freshly launched gateway, so a port race never leaks the token to an unrelated process | # Overview Source: https://docs.agentscope.io/versions/2.0.8/en/building-blocks/workspace/overview Give the agent an execution environment it can act in and persist to A workspace is the agent's execution environment. It supplies the resources the agent acts with, and owns the lifecycle of everything living inside it (MCP server processes, dynamically added skills, offloaded files): | Resource | What the Workspace Provides | | ------------------ | ------------------------------------------------------------------------------------------------------------------------ | | Tools | Built-in tools (Bash, Read, Write, ...) executed through the workspace's backend, plus tools from registered MCP servers | | Skills | Markdown instruction sets stored under `skills/`, loadable by the agent's `Skill` viewer | | Context offloading | Persistent storage for compressed messages and truncated tool results, via the `Offloader` protocol | AgentScope ships seven workspace implementations, one per execution environment. All of them expose the same interface, so the same agent code runs against any backend: | Class | Environment | Persistence | | ---------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | | `LocalWorkspace` | Host filesystem | `workdir` on the host | | `BubblewrapWorkspace` | Linux [bubblewrap](https://github.com/containers/bubblewrap) sandbox | Host `host_workdir` mounted at `/workspace`; omit it for an ephemeral temp directory | | `DockerWorkspace` | Docker container | Host `workdir` bind-mounted to `/workspace`; omit it for an ephemeral container | | `E2BWorkspace` | [E2B](https://e2b.dev) cloud sandbox | Sandbox filesystem; reattached by sandbox metadata | | `DaytonaWorkspace` | [Daytona](https://www.daytona.io) sandbox | Sandbox filesystem; reattached by sandbox labels | | `K8sWorkspace` | Kubernetes Pod | PVC mounted into the Pod; reattached by workspace-id-derived name | | `OpenSandboxWorkspace` | [OpenSandbox](https://github.com/agentscope-ai/opensandbox) sandbox | Sandbox filesystem; reattached by sandbox metadata | ## Interface Every implementation derives from `WorkspaceBase`, whose methods fall into four roles: | Role | Methods | Called By | | ------------------ | ----------------------------------------------------------------------- | ------------------------------------------------------ | | Lifecycle | `initialize()` / `close()` / `reset()`, plus the `async with` protocol | The developer, or a workspace manager | | Discovery | `list_tools()` / `list_mcps()` / `list_skills()` / `get_instructions()` | Agent assembly, to build the toolkit and system prompt | | Offloading | `offload_context()` / `offload_tool_result()` | The agent, when compression or truncation fires | | Dynamic management | `add_mcp()` / `remove_mcp()` / `add_skill()` / `remove_skill()` | The developer or service, at runtime | For the sandboxed backends (Bubblewrap, Docker, E2B, Daytona, K8s, OpenSandbox), MCP servers run *inside* the isolated environment; the host reaches them through an in-workspace gateway, covered in [MCP Gateway](/versions/2.0.8/en/building-blocks/workspace/mcp-gateway). ## Next Steps Create a workspace on any backend and wire it into an agent. Add and remove MCP servers and skills at runtime. How sandboxed workspaces expose their MCP servers to the host. Allocate and isolate workspaces per user, agent, or session in a service. # Run Workspace Source: https://docs.agentscope.io/versions/2.0.8/en/building-blocks/workspace/run-workspace Create a workspace on any backend and wire it into an agent Running a workspace takes two steps: create one on the backend that matches your target environment, then hand its resources to the agent. ## Create Workspace Each backend has its own persistence model and directory layout. Pick the tab matching your target environment: `LocalWorkspace` persists state directly under `workdir` on the host filesystem; restarts simply re-open the same directory. The directory has the following layout: ``` {workdir}/ ├── .mcp # MCP declarations stored per agent / session ├── data/ # offloaded multimodal payloads (deduped by SHA-256) ├── skills/ # the .seed template and per-agent skill directories └── sessions/ # per-session context.jsonl and tool-result files ``` ```python theme={null} from agentscope.workspace import LocalWorkspace workspace = LocalWorkspace( workdir="/data/my-workspace", default_mcps=[], skill_paths=["./skills/web-search"], ) await workspace.initialize() ``` `BubblewrapWorkspace` runs every command through [bubblewrap](https://github.com/containers/bubblewrap) (`bwrap`), so it needs no daemon or cloud account, only the `bwrap` binary on a Linux host. `host_workdir` is mounted read-write at `/workspace` and holds the persisted state; omit it for an ephemeral temp directory removed on close. ``` {host_workdir}/ # host directory, mounted at /workspace in the sandbox ├── .agentscope # gateway venv and runtime files ├── .mcp # MCP declarations stored per agent / session ├── data/ # offloaded multimodal payloads ├── skills/ # the .seed template and per-agent skill directories └── sessions/ # per-session context.jsonl and tool-result files ``` ```python theme={null} from agentscope.workspace import BubblewrapWorkspace workspace = BubblewrapWorkspace( host_workdir="/data/bwrap-workspaces/agent-1", # mounted at /workspace host_cache_dir=None, # None keeps a workspace-private package cache gateway_port=None, # None picks an available loopback port extra_pip=["numpy", "pandas"], default_mcps=[], skill_paths=["./skills/web-search"], ) await workspace.initialize() ``` This is **not** a network sandbox. The MCP gateway is reached over host loopback across separate `bwrap` executions, so `share_net` must stay `True` and sandboxed code shares the host network namespace: it can reach any service the host can, including other loopback services and cloud metadata endpoints. Bubblewrap still isolates the other namespaces and the mounted filesystem. Use Docker, K8s, or a cloud sandbox when the workload also needs network isolation. An explicit `host_cache_dir` must not overlap `host_workdir`; sharing one cache directory across workspaces trades isolation for faster bootstrap and should be limited to mutually trusted workspaces. `DockerWorkspace` bind-mounts the host `workdir` to `/workspace` inside the container, so the layout below lives on the host and survives container restarts. Omit `workdir` for a purely ephemeral container whose writable layer disappears with it. ``` {workdir}/ # host directory, bind-mounted to /workspace in container ├── .mcp # MCP declarations stored per agent / session ├── data/ # offloaded multimodal payloads ├── skills/ # the .seed template and per-agent skill directories └── sessions/ # per-session context.jsonl and tool-result files ``` ```python theme={null} from agentscope.workspace import DockerWorkspace workspace = DockerWorkspace( base_image="python:3.11-slim", workdir="/data/docker-workspaces/agent-1", # bind-mounted to /workspace node_version="20", extra_pip=["numpy", "pandas"], default_mcps=[], skill_paths=["./skills/web-search"], ) await workspace.initialize() ``` `E2BWorkspace` uses the sandbox filesystem itself as the persistence layer; there is no host `workdir`. Each sandbox is tagged with `workspace_id` in its E2B metadata; on restart the workspace looks it up via `AsyncSandbox.list(...)` and reconnects with `connect(sandbox_id=...)`. Pausing keeps disk state, resuming restores it intact. ``` $workdir/ # inside the sandbox ├── .mcp # MCP declarations stored per agent / session ├── data/ # offloaded multimodal payloads ├── skills/ # the .seed template and per-agent skill directories └── sessions/ # per-session context.jsonl and tool-result files ``` ```python theme={null} from agentscope.workspace import E2BWorkspace workspace = E2BWorkspace( template="base", api_key="your-e2b-api-key", # or set E2B_API_KEY timeout_seconds=300, default_mcps=[], skill_paths=["./skills/web-search"], ) await workspace.initialize() ``` `DaytonaWorkspace` targets a [Daytona](https://www.daytona.io) deployment; the sandbox filesystem is the persistence layer, so there is no host `workdir`. Each sandbox carries `workspace_id` in its Daytona labels; on restart the workspace reattaches by that label and reuses the sandbox's disk state. ``` $workdir/ # inside the sandbox ├── .mcp # MCP declarations stored per agent / session ├── data/ # offloaded multimodal payloads ├── skills/ # the .seed template and per-agent skill directories └── sessions/ # per-session context.jsonl and tool-result files ``` ```python theme={null} from agentscope.workspace import DaytonaWorkspace workspace = DaytonaWorkspace( api_key="your-daytona-api-key", # "" reads the SDK's env config api_url="", # optional, for self-hosted deployments timeout_seconds=300, extra_pip=["numpy", "pandas"], default_mcps=[], skill_paths=["./skills/web-search"], ) await workspace.initialize() ``` `K8sWorkspace` runs one Pod per workspace on a Kubernetes cluster, with a PVC mounted as the persistence layer, so the filesystem below survives Pod restarts. On restart the workspace reattaches to the Pod and PVC by their workspace-id-derived names. Set `delete_pvc_on_close=True` to remove the PVC when the workspace closes. ``` /workspace/ # inside the Pod, backed by the PVC ├── .mcp # MCP declarations stored per agent / session ├── data/ # offloaded multimodal payloads ├── skills/ # the .seed template and per-agent skill directories └── sessions/ # per-session context.jsonl and tool-result files ``` ```python theme={null} from agentscope.workspace import K8sWorkspace workspace = K8sWorkspace( namespace="agentscope", # namespace for the Pod and PVC kubeconfig=None, # None uses the in-cluster config image="python:3.11-slim", # container image storage_size="1Gi", # PVC size backing the filesystem default_mcps=[], skill_paths=["./skills/web-search"], ) await workspace.initialize() ``` `OpenSandboxWorkspace` targets an [OpenSandbox](https://github.com/agentscope-ai/opensandbox) deployment; the sandbox filesystem is the persistence layer, so there is no host `workdir`. Each sandbox is tagged with `workspace_id` under the `agentscope.workspace.id` metadata key; on restart the workspace filters `list_sandbox_infos(...)` by that key and resumes the matching sandbox, restoring its disk state intact. ``` /workspace/ # inside the sandbox (SANDBOX_WORKDIR) ├── .mcp # MCP declarations stored per agent / session ├── data/ # offloaded multimodal payloads ├── skills/ # the .seed template and per-agent skill directories └── sessions/ # per-session context.jsonl and tool-result files ``` ```python theme={null} from agentscope.workspace import OpenSandboxWorkspace workspace = OpenSandboxWorkspace( image="python:3.11-slim", api_key="your-opensandbox-api-key", # or use the SDK's env fallback domain="your-opensandbox-domain", # optional, for self-hosted deployments protocol="http", timeout_seconds=300, extra_pip=["numpy", "pandas"], default_mcps=[], skill_paths=["./skills/web-search"], ) await workspace.initialize() ``` The gateway venv is bootstrapped inside the sandbox on first `initialize()`; `extra_pip` packages are installed into it alongside the base gateway requirements. Because the slim base image streams `apt-get` + `uv` + `pip` for several minutes on a cold start, bootstrap commands run under a longer per-command timeout; leave `request_timeout_seconds` at its default (which matches the bootstrap budget) unless you know your image is pre-provisioned. `default_mcps` and `skill_paths` are seed-time inputs, but they never make resources shared inside a shared workspace. Each agent / session gets its own client instances the first time it accesses MCPs, and each agent gets its own skill partition copied from `skills/.seed` the first time it accesses skills. Later additions and removals only affect that agent / session. ## Integrate with Agent A workspace plugs into `Agent` along two axes: as a source of tools, MCPs, and skills, and as the offloader for context compression: ```python theme={null} from agentscope.agent import Agent from agentscope.tool import Toolkit from agentscope.workspace import LocalWorkspace workspace = LocalWorkspace(workdir="./my-workspace") await workspace.initialize() agent_id = "coder" session_id = "session-1" agent = Agent( name="coder", system_prompt="You are a coding assistant.", model=model, toolkit=Toolkit( tools=await workspace.list_tools(), mcps=await workspace.list_mcps( agent_id=agent_id, session_id=session_id, ), skills_or_loaders=await workspace.list_skills(agent_id=agent_id), ), offloader=workspace, ) ``` | Axis | Wiring | What the Agent Gets | | ---------- | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Resources | `Toolkit(tools=..., mcps=..., skills_or_loaders=...)` | Built-in tools, MCP-provided tools, and skills available in the workspace | | Offloading | `Agent(offloader=workspace)` | When context compression triggers or a tool result exceeds the size limit, the agent calls `workspace.offload_context()` / `offload_tool_result()` and stores the returned reference path in place of the original payload | The tools returned by `list_tools()` are already bound to the workspace's execution backend, so on a sandboxed workspace they run inside the container or sandbox. To bind your own tool instances to the same environment, obtain the backend with `workspace.get_backend()` and pass it to the tool constructor; see [Switch Tool Backend](/versions/2.0.8/en/building-blocks/tool/python-tool#switch-tool-backend). Because the workspace exposes its resources as flat lists, you can partition them into `ToolGroup`s when the agent has too many tools to keep all active at once. Pass the groups to `Toolkit(tool_groups=[...])` and the agent activates them on demand through the built-in [meta tool](/versions/2.0.8/en/building-blocks/tool/manage-tools); only the reserved `basic` group stays always-on. ```python theme={null} from agentscope.tool import Toolkit, ToolGroup mcps = await workspace.list_mcps( agent_id=agent_id, session_id=session_id, ) skills = await workspace.list_skills(agent_id=agent_id) toolkit = Toolkit( tools=await workspace.list_tools(), # always active (basic group) tool_groups=[ ToolGroup(name="search", description="Web search and retrieval.", mcps=[m for m in mcps if m.name.startswith("search")]), ToolGroup(name="coding", description="Code editing skills.", skills_or_loaders=skills), ], ) ``` # Architecture Source: https://docs.agentscope.io/versions/2.0.8/en/deploy/agent-service Host your agent as a multi-tenant, multi-session HTTP service Agent Service is the FastAPI-based hosting layer that turns AgentScope agents into a **multi-tenant, multi-session HTTP service**. It owns everything *around* the agent — request routing, per-user resource lifecycle, session state, persistence, scheduling, and tool offloading — so that the agent code you wrote against [`Agent`](/versions/2.0.8/en/building-blocks/agent/overview) can serve production traffic without being rewritten. What sets it apart: * **Production backbone for live agents** — agent runs, background tasks, schedules, and the tool/MCP/skill/workspace lifecycle are managed end-to-end, with session streams that fan out to multiple subscribers and replay buffered history on reconnect. * **Schema-driven frontend** — credentials publish JSON schemas and models expose declarative cards (input/output types, context size, parameter schemas), so the UI can render forms and capability badges without coupling to provider-specific code. * **Multi-tenant by construction** — credentials, agents, sessions, schedules, and messages are all owned by the request's `user_id`, and ownership is enforced at the routing layer — one deployment serves many users with no per-tenant code paths. * **Modular and extensible** — authentication, chat protocols, workspace isolation strategy, storage backend, and the set of model providers and credential types are all open at the boundary, swappable without touching framework code. ### Capabilities | Capability | Description | | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Agent teams | A leader agent spawns worker agents and coordinates them through built-in team tools; see the [Agent Team](/versions/2.0.8/en/deploy/agent-team) chapter. | | Workspace management | The filesystem is allocated `per_agent` (default), `per_session`, or `per_user`; inside a shared workspace, MCPs stay isolated per agent + session and skills per agent. | | Knowledge bases (RAG) | Optional built-in knowledge base service with document ingestion, chunking, embedding, and natural-language search — enabled by passing a `knowledge_base_manager` to `create_app`. | | Background task offloading | Long-running tool calls move to background; their results are delivered back through the session's event stream when they finish. | | Cron scheduling | Time-based agent execution with stateful or stateless sessions; schedules persist across restarts. | | Session replay | Late-joining clients to the per-session SSE stream receive buffered history before live events, so multiple tabs or a reconnecting frontend stay in sync. | | Interruption | Running or HITL-parked chat runs can be cancelled from outside via `POST /sessions/{id}/interrupt`; the agent unwinds cleanly and remains ready for the next input. | | Protocol adaptation | Middleware-based conversion to external protocols (AG-UI, A2A, etc.) on top of AgentScope's native event stream. | | Distributed deployment WIP | All shared state lives in Redis (storage + message bus), so multiple worker processes — or multiple nodes — can serve one logical service. | The service does **not** include a built-in user authentication system. It provides a placeholder `X-User-ID` header dependency that you replace with your own auth middleware (JWT, OAuth, session tokens, etc.). ## Quickstart The fastest way to see Agent Service in action is to run the bundled example backend together with the example frontend — both ship inside the AgentScope repo. ### Try the bundled example The [`examples/agent_service`](https://github.com/agentscope-ai/agentscope/tree/main/examples/agent_service) directory boots a ready-to-use service, and [`examples/web_ui`](https://github.com/agentscope-ai/agentscope/tree/main/examples/web_ui) is a matching React frontend that talks to it. Together they give you a working playground for every capability above in a few minutes. Background tool offloading and wakeup demo Permission system in bypass mode Task planning demo Agent team coordination demo ```bash theme={null} git clone https://github.com/agentscope-ai/agentscope.git cd agentscope ``` Make sure a local Redis is reachable (the example expects `localhost:6379`), then launch the service: ```bash theme={null} cd examples/agent_service python main.py ``` The service comes up on `http://localhost:8000`. In another terminal, install and run the web UI: ```bash theme={null} cd examples/web_ui pnpm install pnpm dev ``` Open the URL the dev server prints (typically `http://localhost:5173`) and the frontend will connect to the backend you started in step 2. Once both are running, the same UI lets you exercise every capability the service ships with: * **Permission control** — tools that touch the system pause for confirmation; explore-mode locks the agent to read-only operations. * **Background task offloading** — long-running tool calls move to the background and their results stream in when they finish, without blocking the conversation. * **Task planning** — the agent breaks complex work into a tracked plan and updates it as it goes. * **Agent teams** — a leader agent spawns workers and coordinates them through the team tools. * **Scheduled runs** — cron-driven agents that fire on their own and report back to the same session stream. ### From your own code When you want to embed the service in your own deployment instead of running the example, build the FastAPI app yourself with `create_app`. The minimum to get a service running is a storage backend, a message bus, and a workspace manager. The examples below boot a service on port 8000 backed by Redis — pick the workspace backend that matches where you want the agent's tools to execute. ```python Local filesystem theme={null} import uvicorn from agentscope.app import create_app from agentscope.app.storage import RedisStorage from agentscope.app.message_bus import RedisMessageBus from agentscope.app.workspace_manager import LocalWorkspaceManager # Persistence layer for agents, sessions, credentials, messages, and schedules. # Its connection pool is opened on app startup and closed on shutdown. storage = RedisStorage(host="localhost", port=6379) # Redis-backed message bus: session locks, replay logs, inbox queues, and # wakeup signals that decouple chat triggering from event delivery and # let multiple worker processes share one logical service. message_bus = RedisMessageBus(host="localhost", port=6379) # Workspace lifecycle — working directory, MCP clients, skills. # The built-in manager isolates per agent: sessions of the same agent # share one workspace. Idle workspaces are evicted after `ttl` seconds. workspace_manager = LocalWorkspaceManager( basedir="/data/workspaces", ttl=3600.0, ) app = create_app( storage=storage, message_bus=message_bus, workspace_manager=workspace_manager, ) uvicorn.run(app, host="0.0.0.0", port=8000) ``` ```python Docker sandbox theme={null} import uvicorn from agentscope.app import create_app from agentscope.app.storage import RedisStorage from agentscope.app.message_bus import RedisMessageBus from agentscope.app.workspace_manager import DockerWorkspaceManager storage = RedisStorage(host="localhost", port=6379) message_bus = RedisMessageBus(host="localhost", port=6379) # Each workspace runs inside its own local Docker container for isolation. # Per-user/per-agent host workdirs live under `basedir` and are bind-mounted # into each container. workspace_manager = DockerWorkspaceManager(basedir="/data/docker-workspaces") app = create_app( storage=storage, message_bus=message_bus, workspace_manager=workspace_manager, ) uvicorn.run(app, host="0.0.0.0", port=8000) ``` ```python E2B theme={null} import uvicorn from agentscope.app import create_app from agentscope.app.storage import RedisStorage from agentscope.app.message_bus import RedisMessageBus from agentscope.app.workspace_manager import E2BWorkspaceManager storage = RedisStorage(host="localhost", port=6379) message_bus = RedisMessageBus(host="localhost", port=6379) # Each workspace runs inside a remote E2B cloud sandbox. # Provide `api_key` here or set the `E2B_API_KEY` environment variable. workspace_manager = E2BWorkspaceManager() app = create_app( storage=storage, message_bus=message_bus, workspace_manager=workspace_manager, ) uvicorn.run(app, host="0.0.0.0", port=8000) ``` ### create\_app parameters The storage backend for persisting agents, sessions, credentials, messages, schedules, teams, and knowledge base records. Its lifecycle (`__aenter__` / `__aexit__`) is managed by the app lifespan. Redis-backed primitives — session locks, replay logs, inbox queues, and wakeup signals — that decouple chat triggering from event delivery. Required because every code path that delivers events to the frontend (`POST /chat`, scheduled fires, team messages, background-tool completions) goes through it, and because it is what makes multi-process deployments possible. Manages workspaces (file storage, MCP clients, skills) with TTL-based caching. The built-in `LocalWorkspaceManager` supports three built-in isolation grains (`per_agent`, `per_session`, `per_user`); see [Workspace implementation and isolation](#workspace-implementation-and-isolation) for details. Manager that owns the knowledge base lifecycle and serves `KnowledgeBase` runtime handles to both HTTP endpoints and agent code. The manager carries its own vector store instance — its `__aenter__` / `__aexit__` enter and release it. Passing `None` disables every `/knowledge_bases` endpoint. Parsers registered for knowledge base document uploads. Pass a **list** to have the service route by each parser's `supported_media_types` (later entries override earlier ones for overlapping types, with a warning); pass a **dict** `media_type → parser` for explicit routing. Defaults to `[TextParser()]` when `knowledge_base_manager` is set. The chunker classes users can choose from when creating a knowledge base. The chunker type and parameters are stored in the knowledge base record, and the indexing worker rebuilds the instance from that schema. Defaults to `[ApproxTokenChunker]` when `knowledge_base_manager` is set. Backend that stores uploaded document bytes between the upload endpoint and the indexing worker. Required when `knowledge_base_manager` is set; defaults to `LocalBlobStore(root_dir="./blobs")`. Its lifecycle is managed by the app lifespan. When `True` (embedded deployment) the API process starts an `IndexWorker` and `IndexSweeper` in its lifespan and dispatches indexing tasks via an in-process queue. When `False` (dedicated deployment) the API process performs no indexing — a separate worker process is expected to consume tasks from the message bus. No effect when `knowledge_base_manager` is `None`. Hubs that supply installable MCPs. Passing them enables the MCP marketplace routes, pages and install flow; `None` leaves the feature off. See [MCP Hub](/versions/2.0.8/en/deploy/hub/mcp-hub). Hubs that supply installable skills. Passing them enables the skill marketplace routes, pages and install flow; `None` leaves the feature off. See [Skill Hub](/versions/2.0.8/en/deploy/hub/skill-hub). Whether this process holds the channels' long connections. `True` (embedded deployment) suits a desktop build or a single API process; set it `False` on every process when running dedicated channel workers, because a platform gives one bot's events to one connection, so every replica connecting would either waste connections or duplicate messages. The channel API, the client factory and outbound delivery stay available either way — only the connections move. Whether this process owns the schedule timers. APScheduler's jobstore is in memory, so every process holding the timers fires every cron tick and a schedule runs once per replica — keep exactly one process at `True`. The schedule endpoints and the agent's schedule tools stay available either way: those processes persist the record and notify the owner over the message bus. Additional credential types to register. Each class is registered with `CredentialFactory` before the app starts. Additional ASGI middlewares (e.g., protocol adapters, CORS, auth). Async factory `(user_id, agent_id, session_id, workspace) -> Awaitable[list[MiddlewareBase]]` invoked once per agent assembly (per chat turn or scheduled trigger). Returned middlewares are appended to the framework-supplied ones (e.g., `ToolOffloadMiddleware`) before the agent runs, so the factory can produce per-user / per-session middlewares such as audit logging, tenant isolation, or custom auth. `workspace` is the session's resolved `WorkspaceBase`, exposing `workdir` and `get_backend()` for filesystem-backed middleware such as `AgenticMemoryMiddleware`; factories written against the older three-argument signature keep working, since the service probes the signature and only passes `workspace` to those that accept it. Async factory `(user_id, agent_id, session_id) -> Awaitable[list[ToolBase]]` invoked once per agent assembly. Returned tools are merged into the toolkit's `"basic"` group alongside the workspace-derived tools, so tool availability can vary per caller (per-tenant integrations, user-specific credentials). Reusable blueprints for sub-agent creation within teams. Each template defines a sub-agent *type* (e.g. `"researcher"`, `"coder"`) with pre-configured system prompt, permission context, and task context. When registered, the `AgentCreate` tool exposes a `subagent_type` parameter so the leader agent can route to the appropriate template. See [Custom sub-agent types](/versions/2.0.8/en/deploy/agent-team#custom-sub-agent-types) for details. A custom `Agent` subclass to instantiate on every chat turn instead of the built-in `Agent`. Use this to swap in an agent implementation with different reasoning behaviour while keeping the rest of the service unchanged. Policy deciding whether a viewer may use credentials, agents and knowledge bases owned by another user. `None` installs `DenyAllResourceAccessPolicy`, which keeps the strictly owner-isolated behaviour. See [Resource Sharing](/versions/2.0.8/en/deploy/sharing). Channel adapter classes this service allows, e.g. `[FeishuChannel, DingTalkChannel, DiscordChannel]`. Each class describes its own `channel_type`, credentials and config, so the service registers it without a separate table; pass a custom `ChannelBase` subclass to add a platform. `None` registers no channel types and leaves the feature off. See [Channels](/versions/2.0.8/en/deploy/channel/overview). Signs the short-lived tokens that let a browser download a workspace file by navigation. Defaults to a per-process value, which is fine for a single instance but **must be set explicitly behind a load balancer** — otherwise a token minted by one replica is rejected by the next and downloads fail at random. OpenAPI title shown in the docs UI. API version shown in the docs UI. Defaults to the installed AgentScope package version. The default `X-User-ID` header provides no authentication. Replace it with a real auth integration before deploying — see [User authentication](#user-authentication). ### Typical operation flow Once the server is running, drive it through the resources defined in the resource model. The flow below is the path a chat session usually takes — each step is one or two REST calls. Register the agent's identity — display name, system prompt, and runtime configuration. The same agent can drive many sessions under different models. ```http theme={null} POST /agent ``` Discover each provider's form fields with `GET /credential/schemas`, then save the API key. One credential can be reused across many sessions and agents. ```http theme={null} GET /credential/schemas POST /credential ``` Create a session bound to the agent and attach a model configuration — provider, model name, parameters, and the credential to call it with. The session owns the runtime state from here on. ```http theme={null} POST /sessions ``` If the agent needs tools beyond its built-ins, configure MCPs for the current session and install skills for the current agent. Out of the box, every agent already has access to the workspace's built-in tools (filesystem, shell, search, …), task-planning tools, schedule and background-task controls, and — when the session is a team leader or member — the team coordination tools described in [Agent Team](/versions/2.0.8/en/deploy/agent-team). Anything you pass via `extra_agent_tools` in `create_app` is merged in alongside. ```http theme={null} POST /workspace/mcp POST /workspace/skill ``` Fire a chat run by posting a user `Msg` to `/chat`. The endpoint returns immediately with `{"status": "started", "session_id": "..."}` — events are delivered out-of-band on the per-session SSE stream `GET /sessions/{id}/stream`, which any number of clients can subscribe to and which replays buffered history to late joiners before serving live events. ```http theme={null} POST /chat GET /sessions/{session_id}/stream ``` Trigger a run: ```bash theme={null} curl -X POST http://localhost:8000/chat \ -H "X-User-ID: alice" \ -H "Content-Type: application/json" \ -d '{ "agent_id": "agent-xxx", "session_id": "session-xxx", "input": { "name": "alice", "role": "user", "content": [{"type": "text", "text": "Hello"}] } }' ``` Subscribe to the session's event stream in parallel (or before triggering — the stream stays open across runs and broadcasts everything the session produces, including scheduled fires and background-tool completions): ```bash theme={null} curl -N -H "X-User-ID: alice" \ "http://localhost:8000/sessions/session-xxx/stream?agent_id=agent-xxx" ``` For a **scheduled run**, complete steps 1 and 2, then create a schedule that targets the agent — the scheduler creates the session (stateful or stateless) and triggers the run on the cron expression you provide. No `/chat` call is needed; the agent runs autonomously when the cron fires. ```http theme={null} POST /schedule ``` To **interrupt** a running or HITL-parked chat run at any time, post to the session's interrupt endpoint. The agent unwinds cleanly and stays ready for the next `/chat` call. ```bash theme={null} curl -X POST -H "X-User-ID: alice" \ "http://localhost:8000/sessions/session-xxx/interrupt?agent_id=agent-xxx" ``` ## Resource Model Every operation in Agent Service is scoped to a `user_id` resolved from the request. Below that boundary, the service manages seven resource types — six persisted (left half of the diagram) plus the message bus that ties their runtime behavior together (right half). To let credentials, agents, or knowledge bases cross this boundary between users, see [Resource Sharing](/versions/2.0.8/en/deploy/sharing). ```mermaid theme={null} flowchart TB User([User]) User --> Cred[Credential] User --> Agent[Agent] User --> Sched[Schedule] Agent -- "1 : N" --> Session[Session] Session -- "references" --> Cred Session -- "bound to" --> WS[Workspace] Session -- "owns" --> Msg[Messages] Sched -- "targets" --> Agent Sched -- "triggers" --> Session Bus{{MessageBus}} Sched -. "inbox + wakeup" .-> Bus Bus -. "drives runs" .-> Session Session -. "publishes events" .-> Bus ``` | Resource | Description | | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **User** | Opaque tenant identifier resolved from the request. The service models no user system of its own; you plug yours in via `get_current_user_id`. | | **Credential** | Connection configuration for a model provider — an API key plus provider-specific settings. Reusable across many agents and sessions. | | **Agent** | Display name, system prompt, and runtime configuration (context, ReAct loop). The reusable template — identity belongs to the agent, runtime state belongs to the session. | | **Workspace** | The agent's runtime environment — working directory, MCP clients, skills, offloaded context. The workspace mapping policy decides how widely the filesystem is shared; inside a workspace, MCPs and skills stay isolated per session and per agent respectively. | | **Session** | One ongoing exchange between a user and an agent. Carries the agent state (working memory, in-flight reply, permission context), persisted message transcript, and the LLM configuration the session runs under. | | **Schedule** | Fires an agent on a cron expression. Each fire runs inside a session — fresh per execution (stateless) or reused so context accumulates (stateful). Schedules persist across restarts. | | **MessageBus** | Redis-backed runtime layer — session locks, replay logs, inbox queues, wakeup signals. The single delivery channel for scheduled fires, team messages, and background-tool completions to reach idle sessions; also what makes multi-process operation possible. | The shape to remember: **agents are reusable templates, sessions are the unit of runtime state**, and the message bus is what brings idle sessions back to life when something external (a schedule, a teammate, a background tool) has something to say. ### Session Origin and Naming Two fields on a `SessionRecord` say why the session exists and who owns its name. `origin` records where the session came from. It is a tagged union, so once the tag is known the matching ids are there: | Tag | Type | Carries | Created when | | ---------- | ---------------- | ----------------------- | ---------------------------------------------------------------------- | | `user` | `UserOrigin` | — | A person opens a session themselves | | `schedule` | `ScheduleOrigin` | `schedule_id` | A schedule fires on its due date | | `channel` | `ChannelOrigin` | `channel_id`, `chat_id` | A channel receives an inbound platform message | | `team` | `TeamOrigin` | — | A team mints a session for a member via `AgentCreate` or `AgentInvite` | One `isinstance(record.origin, ChannelOrigin)` is enough to identify the origin — no need to check that a tag and its ids agree. Team membership lives on `SessionRecord.team_id` instead: an origin is fixed when the session is created, while membership is granted afterwards and can be revoked. The old `source` field and `source_schedule_id` / `source_channel_id` / `source_chat_id` remain readable as deprecated properties, and records written earlier are folded into `origin` on load, so no data migration is needed. `config.name` is the session's display name, and `config.naming.auto` says who owns it. A session created without a name has `auto` set to `true`: once its first reply is on disk, the service hands the opening user message to the session's own model for a short title, falling back to an excerpt of that message when the call fails — nothing is retried and nothing is surfaced. As soon as the name is settled (the title landed, or the user renamed the session through `PATCH /sessions`), `auto` is cleared, so auto-naming happens at most once per session. `auto` defaults to `false`: the channel, schedule and team flows all create sessions with an explicit name, and records written before this existed carry no `naming` block, so both keep the names they have. When the opening turn carries no text (an image-only message, or a background wakeup), naming is skipped and left for a later turn. ## API Overview The service exposes the resources from the resource model as REST endpoints, plus the streaming chat endpoint. The table below groups them by category; full request and response shapes are documented in the service's OpenAPI specification. | Category | Endpoints | Description | | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Chat | `POST /chat` | Fire a chat run for a session; returns `ChatTriggerResponse` JSON. Accepts a new `Msg`, a `list[Msg]`, a `UserConfirmResultEvent` / `ExternalExecutionResultEvent` (HITL resume), or `None` (continue). Events are delivered out-of-band on the per-session stream. | | Session stream | `GET /sessions/{id}/stream` | Per-session SSE stream of `AgentEvent` objects, with buffered replay for late joiners and multi-subscriber fan-out. | | Session control | `POST /sessions/{id}/interrupt`, `GET /sessions/{id}/status` | Interrupt a running or HITL-parked chat run; probe the unified session status (running / parked / idle / gone). | | Sessions | `GET/POST/PATCH/DELETE /sessions` | Create and manage chat sessions, including model binding and permission level. | | Messages | `GET /sessions/{id}/messages` | Paginated message transcript for a session (`offset`, `limit`). | | Agents | `GET/POST/PATCH/DELETE /agent` | Manage agent records — display name, system prompt, runtime config. | | Agent schema | `GET /agent/schema/v2` | Full `AgentData` JSON Schema for rendering the agent form on the frontend. (`GET /agent/schema` remains for legacy clients but is deprecated.) | | Credentials | `GET/POST/PATCH/DELETE /credential` | CRUD for per-provider API keys and connection configs. | | Credential schemas | `GET /credential/schemas` | Discover all registered credential types and their JSON parameter schemas for form rendering. | | Models | `GET /model?provider=` | List candidate chat models for a provider, with their declarative `ModelCard` (capabilities and parameter schemas). | | TTS models | `GET /tts-model?provider=` | List candidate text-to-speech models for a provider that exposes them. | | Schedules | `GET/POST/PATCH/DELETE /schedule`, `GET /schedule/{id}/sessions` | Manage cron-based agent execution, stateful or stateless. | | Workspace MCPs | `GET/POST /workspace/mcp`, `DELETE /workspace/mcp/{mcp_name}` | Manage the MCP clients of the current agent + session. Each response entry includes live tool list and health status. | | Workspace skills | `GET/POST /workspace/skill`, `DELETE /workspace/skill/{skill_name}` | Manage the skill partition of the current agent. | | Knowledge bases | `GET/POST/PATCH/DELETE /knowledge_bases` | CRUD for knowledge bases. Enabled only when `knowledge_base_manager` is passed to `create_app`. | | KB documents | `GET/POST /knowledge_bases/{id}/documents`, `GET /knowledge_bases/{id}/documents/status`, `DELETE /knowledge_bases/{id}/documents/{doc_id}` | Upload, list, delete documents in a knowledge base and batch-query indexing status. | | KB search | `POST /knowledge_bases/{id}/search` | Natural-language search over a knowledge base. | | KB discovery | `GET /knowledge_bases/embedding_models`, `GET /knowledge_bases/supported_content_types`, `GET /knowledge_bases/middleware/parameters_schema` | Discover compatible embedding models, ingestable file types, and the KB middleware's tunable parameter schema for form rendering. | ## Customization The service is open at every infrastructure boundary. The sections below describe what is built in and how to plug in your own. ### Agent chat protocol The per-session stream endpoint (`GET /sessions/{id}/stream`) emits AgentScope's native [`AgentEvent`](/versions/2.0.8/en/building-blocks/message-and-event) stream over SSE. To serve the same agent under a different frontend protocol, install a protocol middleware that intercepts the SSE stream and rewrites each frame. AgentScope ships with `AGUIProtocolMiddleware` for the [AG-UI](https://docs.ag-ui.com/) protocol. Install it via `extra_middlewares`: ```python theme={null} from fastapi.middleware import Middleware from agentscope.app import create_app from agentscope.app.middleware import AGUIProtocolMiddleware app = create_app( storage=storage, extra_middlewares=[ Middleware(AGUIProtocolMiddleware), ], ) ``` To add a new protocol, subclass `ProtocolMiddlewareBase` and implement `_convert_to_protocol`: ```python theme={null} from agentscope.app.middleware import ProtocolMiddlewareBase from agentscope.event import AgentEvent class MyProtocolMiddleware(ProtocolMiddlewareBase): def _convert_to_protocol(self, event: AgentEvent) -> dict: # Convert AgentEvent to your protocol's frame format. return {"type": event.type, "data": event.model_dump()} ``` The middleware automatically intercepts `StreamingResponse` objects from the session stream endpoint, deserializes each SSE frame back into an `AgentEvent`, calls `_convert_to_protocol()` to produce the target format, and re-serializes the converted frame. ### User authentication The built-in `get_current_user_id` dependency extracts the caller identity from the `X-User-ID` request header — a placeholder, not authentication. Override it with your own dependency to integrate any identity system. JWT bearer token: ```python theme={null} from fastapi import Header, HTTPException, status async def get_current_user_id( authorization: str = Header(...), ) -> str: try: payload = decode_jwt(authorization.removeprefix("Bearer ")) return payload["sub"] except InvalidTokenError: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid authentication token.", ) ``` OAuth2 password flow: ```python theme={null} from fastapi import Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") async def get_current_user_id(token: str = Depends(oauth2_scheme)) -> str: user = await verify_oauth_token(token) if user is None: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) return user.id ``` Wire your override by replacing the dependency on the FastAPI app: ```python theme={null} from agentscope.app.deps import get_current_user_id as default_dependency app.dependency_overrides[default_dependency] = get_current_user_id ``` The default `X-User-ID` header provides no authentication. Always replace it with a secure mechanism before deploying to production. ### Workspace implementation and isolation Two independent axes are configurable: * **Workspace backend** — what runtime environment the agent runs in. Built-in implementations include `LocalWorkspace`, `DockerWorkspace`, and `E2BWorkspace`. New backends implement the workspace interface and can wrap container images, sandboxes, or remote VMs. * **Isolation strategy** — how workspaces map to users, agents, and sessions. The built-in `LocalWorkspaceManager` keys workspaces by `agent_id`: all sessions of the same agent share one workspace. To switch to per-user or per-session isolation, subclass `WorkspaceManagerBase` and override `get_workspace` with your own keying strategy. This strategy decides how widely the working directory and offloaded files are shared; it does not turn off the isolation inside a workspace: MCP connections stay separate per `agent_id + session_id`, and skills stay in a private partition per `agent_id`. ```python theme={null} from agentscope.app.workspace_manager import WorkspaceManagerBase from agentscope.workspace import WorkspaceBase class CustomWorkspaceManager(WorkspaceManagerBase): async def get_workspace( self, user_id: str, agent_id: str, session_id: str, workspace_id: str, ) -> WorkspaceBase: # Resolve an initialized workspace using your own keying strategy. ... async def close(self, workspace_id: str) -> None: # Close and evict a single workspace. ... async def close_all(self) -> None: # Close every cached workspace; called on app shutdown. ... ``` ### API credentials A new credential type is a pair of classes: a `CredentialBase` subclass that captures the connection config (and publishes its JSON schema for form rendering), and a `ChatModelBase` subclass that implements the actual streaming chat protocol against the provider's API. The credential class is the entry point — it tells the service which chat model class to instantiate. ```python theme={null} from agentscope.credential import CredentialBase from agentscope.model import ChatModelBase class MyProviderChatModel(ChatModelBase): # Implement the streaming chat interface against the provider's API. ... class MyProviderCredential(CredentialBase): api_key: str endpoint: str = "https://api.my-provider.com" @classmethod def get_chat_model_class(cls): return MyProviderChatModel ``` Register the credential class with the app — it becomes immediately usable by clients: ```python theme={null} app = create_app( storage=storage, extra_credentials=[MyProviderCredential], ) ``` The service automatically exposes the credential's JSON schema under `GET /credential/schemas`, and `GET /model?provider=` routes to the chat model class returned by `get_chat_model_class()`. ### Provider models The model list returned by `GET /model?provider=` is built from `ModelCard` instances — declarative metadata records that tell the frontend how to display each model and what request parameters are valid. Each chat model exposes its catalog through `list_models()`, which by default loads `ModelCard` entries from YAML files in the provider's model directory; `ModelCard.from_yaml()` parses each YAML and merges its overrides into the base parameter schema supplied by the chat model's parameters class. A model card carries the following fields: | Field | Description | | ---------------------- | ----------------------------------------------------------------------------- | | `name` | Provider-side model identifier. | | `label` | Display name shown in the UI. | | `status` | One of `active`, `deprecated`, `sunset`. | | `deprecated_at` | Deprecation timestamp, if any. | | `input_types` | MIME types the model accepts (e.g., `text/plain`, `image/png`, `video/mp4`). | | `output_types` | MIME types the model emits (e.g., `text/plain`, `application/x-thinking`). | | `context_size` | Maximum context window in tokens. | | `output_size` | Maximum output tokens. | | `parameter_schema` | JSON schema for the request parameters, auto-merged with per-model overrides. | | `parameters_overrides` | Per-model deltas applied on top of the base parameter schema. | Example YAML for a multimodal model that accepts text, images, and video and emits text plus thinking traces: ```yaml qwen3.6-plus.yaml theme={null} name: qwen3.6-plus label: Qwen3.6-Plus status: active input_types: - text/plain - application/x-thinking - image/bmp - image/jpeg - image/png - image/tiff - image/webp - image/heic - video/mp4 output_types: - text/plain - application/x-thinking context_size: 1000000 output_size: 65536 parameter_overrides: max_tokens: {"maximum": 65536} ``` To add a new model under an existing provider, drop a YAML file alongside the others in the provider's model directory — the loader picks it up automatically and the new entry shows up in `GET /model?provider=`. ### Storage backend The `StorageBase` abstract class defines the persistence contract for agents, sessions, credentials, messages, schedules, teams, and knowledge base records. AgentScope ships with two built-in implementations — `RedisStorage` (the default, used by every example above) and `AsyncSQLAlchemyStorage`. ```python Redis theme={null} from agentscope.app.storage import RedisStorage storage = RedisStorage( host="localhost", port=6379, db=0, password="your-password", ) ``` ```python SQL (SQLAlchemy) theme={null} from agentscope.app.storage import AsyncSQLAlchemyStorage # Any SQLAlchemy async URL — driver installed separately (see below). storage = AsyncSQLAlchemyStorage( "postgresql+asyncpg://user:password@localhost/agentscope", ) ``` `AsyncSQLAlchemyStorage` persists to any database SQLAlchemy's async engine supports (SQLite, PostgreSQL, MySQL, …). It is imported lazily, so `import agentscope.app.storage` stays cheap and never requires SQLAlchemy unless you actually reference this class; the backend opens its connection pool on `__aenter__` and disposes it on shutdown. It lives behind the optional `sql` extra, which pulls in SQLAlchemy and Alembic but **not** a driver — install the async driver that matches your database yourself: ```bash theme={null} pip install "agentscope[sql]" pip install aiosqlite # SQLite # pip install asyncpg # PostgreSQL # pip install asyncmy # MySQL ``` By default (`create_tables=True`) the backend runs `CREATE TABLE IF NOT EXISTS` for any missing tables at startup — convenient for tests and single-node dev deployments. For production, manage the schema with the packaged Alembic migrations instead: Create any missing tables at `__aenter__`. Idempotent (existing tables are left untouched). Turn it off when Alembic owns the schema. Run `alembic upgrade head` against the packaged migration scripts at `__aenter__`. Handy for single-node or dev deployments where every boot brings the schema up to date. **Not recommended for multi-replica production** — two replicas racing on the same migration is unsafe; keep it `False` and run `alembic upgrade head` as a discrete deploy step. An externally managed async engine to use as-is. When supplied it is **not** disposed on shutdown — the caller owns its lifecycle. When omitted, an engine is built from the URL on `__aenter__` and disposed on shutdown. Extra keyword arguments forwarded to `create_async_engine` when the engine is constructed internally (e.g. `pool_size`, `echo`). To use a database that neither backend covers, implement the same interface: ```python theme={null} from agentscope.app.storage import StorageBase class PostgresStorage(StorageBase): async def __aenter__(self): # Open connection pool. ... async def __aexit__(self, exc_type, exc_val, exc_tb): # Close connection pool. ... # Implement CRUD methods for each record type: # agents, sessions, credentials, messages, schedules, teams, # knowledge bases, knowledge documents. ... app = create_app( storage=PostgresStorage(dsn="postgresql://..."), message_bus=message_bus, workspace_manager=workspace_manager, ) ``` The records the storage layer manages: | Record | Description | | ------------------------- | ---------------------------------------------------------------------------------------------------------------------- | | `AgentRecord` | Agent configuration (name, system prompt, context config, react config, invite config). | | `SessionRecord` | Session state including `AgentState`, model config, and workspace binding. | | `CredentialRecord` | Encrypted model provider API keys. | | `ScheduleRecord` | Cron schedule definitions with execution history. | | `TeamRecord` | Team identity, leader binding, and worker member list. | | `KnowledgeBaseRecord` | Knowledge base identity, embedding-model binding, and middleware parameters. Only used when the KB feature is enabled. | | `KnowledgeDocumentRecord` | Per-document metadata and indexing status for KB uploads. Only used when the KB feature is enabled. | | `Msg` | Persisted messages per session with pagination support. | ## Service Internals For developers who need to extend or embed the actual implementation of Agent Service in AgentScope, this section describes how the FastAPI app is wired together — what runs at startup, which managers hold runtime state, where middlewares sit in the request path, and how routers get hold of those resources. ```mermaid theme={null} flowchart TB Client([Client / Frontend]) subgraph FastAPI ["FastAPI Application"] direction TB subgraph ASGI ["ASGI Middlewares"] PM[Protocol Middleware] OT[OpenTelemetry] end Router[API Routers] subgraph Lifespan ["Lifespan-bound Resources"] Bus[MessageBus] BTM[BackgroundTaskManager] SCH[SchedulerManager] WM[WorkspaceManager] WD[WakeupDispatcher] CS[ChatService] end subgraph AgentMW ["Agent-level Middlewares"] IM[InboxMiddleware] TOM[ToolOffloadMiddleware] SCM[StateChangeMiddleware] end end Storage[(Storage)] Client --> ASGI --> Router Router -- "Depends()" --> Lifespan Router --> CS CS --> Storage CS --> AgentMW --> Agent([Agent Instance]) SCH -- "inbox_push + enqueue_wakeup" --> Bus TOM -- "inbox_push + enqueue_wakeup" --> Bus Bus --> WD WD --> CS ``` ### Lifespan The lifespan context manager runs once per process. Built with `AsyncExitStack`, it enters resources in order — storage → message bus → workspace manager → optional blob store & knowledge base manager → background task manager → scheduler manager → chat run registry → chat / session / knowledge base services → optional index worker & sweeper → wakeup dispatcher — and tears them down in reverse on shutdown. If any startup step raises, every previously-entered resource is still cleaned up. The process that owns the timers (`enable_scheduler=True`) reconciles the persisted cron jobs into its job set on entry, so they survive restarts. ### Managers The following resources are bound to the FastAPI app state during the lifespan and shared across all requests: | Resource | Responsibility | | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `MessageBus` | Redis-backed primitives (session locks + replay log, inbox queues, wakeup signals). The single delivery channel for scheduled fires, team messages, and background-tool completions to reach idle sessions; also what enables multi-process operation. | | `WakeupDispatcher` | One per process. Subscribes to the wakeup signal and, for each enqueued wakeup, drives `ChatService.run` for the target session. | | `BackgroundTaskManager` | Pure asyncio task registry. `ToolOffloadMiddleware` spawns watcher tasks here; results are pushed back through the message bus (inbox + wakeup), not held in this manager. | | `ChatRunRegistry` | Per-process registry that enforces the single-run-per-session rule for `POST /chat`. A double-submit surfaces as HTTP 409. | | `SchedulerManager` | APScheduler-backed cron execution. On fire, the trigger pushes a `HintBlock` to the target session's inbox and enqueues a wakeup — no direct call into `ChatService`. Only a process with `enable_scheduler=True` holds the timers; the others still construct the manager but run nothing, persisting a schedule and notifying the owner over the message bus, which re-reads storage to reconcile its job set and does so unconditionally every 60s. | | `WorkspaceManager` | Workspace lifecycle and TTL-based caching; the isolation grain (`per_agent`, `per_session`, `per_user`) is set on the manager. | | `ChatService` | Single entry point for running or interrupting a session. Loads records, assembles the toolkit, builds middlewares, takes the bus session lock, and drives the agent's reply stream. | | `SessionService` | Composes storage and the message bus for session-level operations: create / update / delete, probing the unified `SessionStatus`, and dispatching interrupts. | | `KnowledgeBaseService` | Optional. When a `knowledge_base_manager` is passed to `create_app`, owns the CRUD, upload, and search endpoints under `/knowledge_bases`. | | `IndexWorker` / `IndexSweeper` | Optional. Started in-process when `enable_index_worker=True`; consume upload tasks and reclaim orphaned blobs. Skipped when running in a dedicated worker deployment. | ### Middlewares Two distinct middleware layers operate at different scopes. **ASGI middlewares** wrap every HTTP request. The two categories used in practice are **protocol middlewares** (e.g., `AGUIProtocolMiddleware`), which intercept SSE responses from the session stream endpoint and rewrite each frame into the target protocol, and **observability middlewares** (e.g., OpenTelemetry tracing). Both install via `extra_middlewares`. **Agent-level middlewares** wrap each call to the agent inside `ChatService`. They are exposed under `agentscope.app.middleware` and the framework always installs three: * `InboxMiddleware` — the sole owner of hint injection. Before each reasoning step it drains the session's inbox and yields the queued `HintBlock`s as `HintBlockEvent`s, so scheduled fires, team messages, and offloaded-tool results all flow into the agent's context through the same path. * `ToolOffloadMiddleware` — when a tool call exceeds its timeout, the call is moved to a background watcher task and a synthetic placeholder is yielded to the agent. When the watcher finishes, the result is pushed back to the session's inbox plus a wakeup, so the next run picks it up. * `StateChangeMiddleware` — emits `CustomEvent`s when the agent state changes (e.g., `tasks_context`, `permission_context`) so the frontend can react without reading raw state snapshots. To add your own (audit logging, tenant isolation, custom auth, …), pass an `extra_agent_middlewares` factory to `create_app`. The factory runs once per agent assembly and its middlewares are appended to the framework-supplied ones. ### Dependencies Routers receive application state through FastAPI's `Depends()`. The standard injectables (in `agentscope.app.deps`) are: | Dependency | Returns | | ----------------------------- | ----------------------------------------------------------------------------------------------------------- | | `get_current_user_id` | The caller's user id — overridable to integrate any auth system. | | `get_storage` | The `StorageBase` instance bound to the app. | | `get_message_bus` | The `MessageBus` instance bound to the app. | | `get_workspace_manager` | The lifespan-bound `WorkspaceManager`. | | `get_background_task_manager` | The lifespan-bound `BackgroundTaskManager`. | | `get_scheduler_manager` | The lifespan-bound `SchedulerManager`. | | `get_chat_run_registry` | The per-process `ChatRunRegistry` that enforces single-run-per-session. | | `get_chat_service` | The lifespan-bound `ChatService`. | | `get_session_service` | The `SessionService` that composes storage and bus for session lifecycle, status probing, and interruption. | | `get_extra_agent_middlewares` | The optional `AgentMiddlewareFactory` passed to `create_app`. | | `get_extra_agent_tools` | The optional `AgentToolFactory` passed to `create_app`. | | `get_knowledge_base_manager` | The `KnowledgeBaseManagerBase` (raises 503 if the KB feature was not enabled). | | `get_knowledge_base_service` | The `KnowledgeBaseService` (raises 503 if the KB feature was not enabled). | | `get_blob_store` | The `BlobStoreBase` backing KB uploads (raises 503 if the KB feature was not enabled). | | `get_knowledge_parsers` | The parser registry configured for KB uploads (raises 503 if the KB feature was not enabled). | ## Further Reading Core agent abstraction and the ReAct loop Event streaming and message reconstruction Built-in and custom tools including external execution Context compression and workspace offloading # Agent Team Source: https://docs.agentscope.io/versions/2.0.8/en/deploy/agent-team Leader agents that spawn and coordinate worker agents through built-in team tools Agent Team is the multi-agent layer built on top of [Agent Service](/versions/2.0.8/en/deploy/agent-service). A leader agent — the session the user talks to — can spawn worker agents on demand and exchange messages with them, while every member is just another session with its own state, workspace binding, and event stream. The whole coordination story is expressed through four built-in tools rather than a separate orchestration framework. ## Quickstart The bundled [`examples/agent_service`](https://github.com/agentscope-ai/agentscope/tree/main/examples/agent_service) backend ships with the team tools enabled, and the matching [`examples/web_ui`](https://github.com/agentscope-ai/agentscope/tree/main/examples/web_ui) frontend renders team membership and per-worker streams out of the box. Follow the [Agent Service quickstart](/versions/2.0.8/en/deploy/agent-service#try-the-bundled-example) to boot both — once they are running, ask the leader agent to assemble a team and you will see it call `TeamCreate` / `AgentCreate` automatically, watch workers come online, and observe them exchange messages in the UI. Agent team coordination demo ## Concepts | Concept | Description | | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Team** | A persistent group of agent members owned by one user. A `TeamRecord` carries the team's identity (name, description) and its member list. | | **Leader** | The session that created the team. Only the leader can add or remove members or end the team. | | **Worker** | A session spawned as a team member. Workers run their own ReAct loop in their own session and inherit the leader's chat model + workspace context. | | **Team message** | A message routed between members through the message bus. Delivered as a `HintBlock` wrapped in a `` tag so the recipient's LLM can disambiguate it from a regular user turn. | ## Usage ### Create a team The team feature is built into Agent Service — no extra configuration is required. When a user sends a task that benefits from multi-agent collaboration, the leader agent automatically uses the built-in team tools to assemble and coordinate a team of workers. Out of the box, the leader agent can: * **Create a team** with a name and description that frames the collaboration goal. * **Spawn workers** by giving each a name, role description, and an initial task. Workers begin executing immediately upon creation. * **Invite existing agents** to join the team, coordinate and communicate with them. * **Exchange messages** with workers to provide follow-up instructions or collect results. * **Dissolve the team** when the task is complete, cleaning up all worker sessions. Every worker runs concurrently in its own session with its own event stream, visible in the frontend UI alongside the leader's conversation. The leader orchestrates work by reading worker outputs and sending messages — all through the same chat interface. By default, all workers share the same system prompt template and permission settings. To give different worker roles different capabilities — for example, a read-only explorer versus a full-access coder — register custom sub-agent templates as described in the next section. Note the invited agents have their own workspace and agent lifecycle, which won't be affected when the team is dissolved. ### Custom sub-agent types By default, every worker spawned by `AgentCreate` uses the same built-in system prompt and permission context. In practice, different roles need different capability boundaries — an agent that only explores the codebase should not be able to modify files, while one that writes code needs full edit access. `SubAgentTemplate` solves this by letting you define reusable blueprints that the leader agent can choose from when creating workers. #### Registering templates Pass a list of `SubAgentTemplate` instances to `create_app` via the `sub_agent_templates` parameter: ```python theme={null} from agentscope.app import create_app, SubAgentTemplate from agentscope.permission import PermissionContext, PermissionMode app = create_app( storage=storage, message_bus=message_bus, workspace_manager=workspace_manager, sub_agent_templates=[ SubAgentTemplate( type="explorer", description=( "Read-only agents specialized in exploration tasks. " "Use this type when you need to investigate the " "codebase without making any changes." ), system_prompt_template="""You are {member_name}, an explorer \ agent in team '{team_name}' led by {leader_name}. Team purpose: {team_description} Your role: {member_description} ## Responsibilities - Complete the exploration tasks assigned by the team leader. - You are read-only: you may inspect files and the codebase, but \ you must never modify, create, or delete anything. ## Reporting - Always report the task result back to {leader_name} using the \ TeamSay tool, whether the task succeeds or fails.""", permission_context=PermissionContext( mode=PermissionMode.EXPLORE, ), ), ], ) ``` #### Template fields | Field | Required | Default | Description | | ------------------------ | -------- | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | `type` | Yes | — | Template identifier (e.g. `"explorer"`, `"coder"`). Becomes an enum value of the `subagent_type` parameter in `AgentCreate`. | | `description` | Yes | — | Agent-readable description exposed in the `AgentCreate` tool schema so the leader can choose the appropriate type. | | `system_prompt_template` | Yes | — | Python format string for the worker's system prompt. See [available placeholders](#system-prompt-placeholders) below. | | `permission_context` | No | `PermissionContext()` | Permission context applied to the worker. Controls what the worker is allowed to do (e.g. `PermissionMode.EXPLORE` for read-only). | | `context_config` | No | `ContextConfig()` | Context window configuration for the worker. | | `react_config` | No | `ReActConfig()` | ReAct loop configuration for the worker. | | `tasks_context` | No | `TaskContext()` | Pre-defined task context, allowing the template to seed an initial workflow. | #### System prompt placeholders The `system_prompt_template` string is formatted with these variables when a worker is created: | Placeholder | Value | | ---------------------- | ------------------------------------------------------ | | `{team_name}` | The team's name as set by `TeamCreate`. | | `{team_description}` | The team's description as set by `TeamCreate`. | | `{member_name}` | The worker's name as set by `AgentCreate`. | | `{member_description}` | The worker's role description as set by `AgentCreate`. | | `{leader_name}` | The leader agent's display name. | #### Runtime behavior * **No custom templates registered** — `AgentCreate` does not expose a `subagent_type` parameter at all. All workers use the built-in default template. This keeps the tool schema clean when templates are not needed. * **Custom templates registered** — `AgentCreate` automatically gains a `subagent_type` enum field listing all available types (including `"default"`). The leader agent sees each type's description and can choose which one to use. * **Overriding the default** — registering a template with `type="default"` replaces the built-in default template entirely. * **Uniqueness** — template type names must be unique. Duplicate types cause a `ValueError` at startup. ### Set invitation scope When creating/editing an agent, you can set if the agent can be invited to join a team by setting the `Invitable` and `Invite description` fields. * `Invitable`: If the other agents can invite this agent to join a team. * `Invite description`: The description of the agent that will be shown to the leader when inviting this agent to join a team. Both fields are required. `AgentInvite` builds its candidate pool from the agents **visible** to the leader — its own, plus those granted by the [resource sharing](/versions/2.0.8/en/deploy/sharing) policy — and only those with `Invitable` set and a non-empty `Invite description` enter it. When the pool is empty the leader does not receive the `AgentInvite` tool at all. When another user's agent is invited, the borrowed session takes its workspace and model from the **caller's own** session of that agent, so it never picks up the owner's MCPs, skills, cache or session permissions; the agent's definition (system prompt, context and ReAct configs) still comes from the original record. The target is resolved against the sharing policy again at invite time, so an invite fails if access was revoked in the meantime. ## Architecture ### Built-in tools A leader session is automatically given these tools. Workers see only `TeamSay`. | Tool | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | `TeamCreate` | Create a new team rooted at the current session and become its leader. | | `AgentCreate` | Spawn a new worker into the team with a name, role description, first task, and permission mode. The worker begins executing as soon as it is created. | | `TeamSay` | Send a message to a named member (or broadcast). The recipient's session receives the message through its inbox and resumes on the next wakeup. | | `TeamDelete` | Dissolve the team and clean up every member session. Only the leader can call this. | | `AgentInvite` | Invite an existing agent — your own, or one another user shared with you — to join the team. A new session will be created for the invited agent. | When deleting a team, agents created within the team (via `AgentCreate`) will be permanently deleted, while invited agents (via `AgentInvite`) will not be affected — only their association with the team will be removed. ### Coordination model Agent Team is designed for distributed deployments by default. All inter-member communication is mediated by the [message bus](/versions/2.0.8/en/deploy/agent-service#resource-model) — a Redis-backed abstraction — so leader and worker sessions can live in different processes or different nodes without any code change. The sender writes to the recipient's inbox; any wakeup dispatcher in the cluster can then claim the wakeup signal and drive that session on its own process. This is the same mechanism that powers scheduled fires and background-tool completions, which is why the team feature scales out the same way the rest of the service does. Team communication reuses the same inbox + wakeup primitives the service uses for scheduled fires and background-tool completions: 1. The sender's tool call (`TeamSay`, `AgentCreate`'s initial prompt, …) pushes a `HintBlock` onto the recipient session's inbox via the message bus. 2. A wakeup is enqueued for the recipient. 3. The wakeup dispatcher running on any process picks up the wakeup and drives `ChatService.run` for that session. 4. `InboxMiddleware` drains the inbox before the next reasoning step, so the queued team messages land in the recipient's context as `HintBlockEvent`s. This means workers run *concurrently* on the same service — they are not nested coroutines under the leader. The leader observes a worker's progress by reading its session stream, or by having the worker `TeamSay` back to it. ## See also The hosting layer that powers teams — sessions, message bus, workspace lifecycle. The agent abstraction each team member runs. # Custom Channel Source: https://docs.agentscope.io/versions/2.0.8/en/deploy/channel/custom Implement ChannelBase to connect a platform beyond the built-ins. To connect a platform beyond the built-in Feishu and Discord, subclass `ChannelBase` to write a channel class and hand it to `create_app`. A channel class is the translation layer between the IM platform and the agent service: all platform differences are contained here, and the framework handles the orchestration. A channel class does two things: **describe its type** (declare the type id, credentials, and config) and **implement its behavior** (keep the connection, normalize inbound messages and emit them, send replies back to the platform). ## Type Description A channel class carries its type information on the class itself, so the framework renders the frontend form, validates input, and constructs instances from it, with no separate registry. ```python A self-describing channel class theme={null} from pydantic import BaseModel, Field from agentscope.app.channel import ChannelBase class MyChannel(ChannelBase): channel_type = "my_platform" # unique type id display_name = "My Platform" # name shown in the management UI platform_bot_id_field = "token" # credential field used to de-duplicate connections description = "Connect agents to My Platform" # one-line UI description (optional) icon_url = "https://example.com/icon.png" # brand icon for the UI (optional) class Credentials(BaseModel): # secret fields; the frontend renders the credential form token: str = Field( title="Bot Token", json_schema_extra={"format": "password"}, # mark as secret ) class Config(BaseModel): # non-secret switches; leave empty if none only_at_reply: bool = True def __init__( self, channel_id: str, credentials: "MyChannel.Credentials", config: "MyChannel.Config", ) -> None: self._channel_id = channel_id self._token = credentials.token # read from the validated credentials self._config = config # the validated non-secret config ``` The framework builds every instance with a uniform `(channel_id, credentials, config)`: `credentials` and `config` are already validated against the `Credentials` / `Config` you declared. `Credentials` holds secrets (encrypted, redacted, immutable); `Config` holds non-secret switches (plaintext, updatable); users fill in both per channel in the management UI. ## Required Methods Besides the constructor, `ChannelBase` has three abstract methods you must implement; the rest have sensible defaults (a no-op or "not supported"). | Method | Responsibility | | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `channel_id` (property) | Return this channel instance's unique id | | `start_listening(emit)` | Store `emit`, connect, and loop receiving; normalize each platform message into a `ChannelEvent` and `await self._emit(event)`. Include auto-reconnect, and release resources in a `finally` | | `send_response(event, events)` | Consume one run's agent event stream `events`, accumulate a reply, and send it back to the platform | ## Receive Messages The framework calls `start_listening(emit)` when it starts the channel, passing in the inbound callback `emit`. The channel stores it as `self._emit`, normalizes each platform message into a `ChannelEvent`, and calls it; the framework handles the rest. A channel class neither holds nor imports the orchestration layer. ```python Store emit and normalize inbound messages theme={null} from collections.abc import Awaitable, Callable from agentscope.app.channel import ( ChannelConfirmationResultEvent, ChannelEvent, ChannelStatus, ) from agentscope.message import TextBlock # Inbound callback: hand a normalized event to the framework Emit = Callable[[ChannelEvent | ChannelConfirmationResultEvent], Awaitable[None]] async def start_listening(self, emit: Emit) -> None: self._emit = emit self.status = ChannelStatus(state="connecting") try: async for raw in self._connect(): # platform long connection self.status.state = "connected" await self._emit( ChannelEvent( channel_id=self._channel_id, channel_user_id=raw.user_id, # platform-side user id chat_id=raw.chat_id, # drives session grouping and routing content=[TextBlock(text=raw.text)], # same shape as Msg.content metadata={"chat_type": raw.chat_type}, # available to routing match_key ), ) finally: self.status.state = "stopped" # release resources ``` `ChannelEvent.content` reuses the same `TextBlock` / `DataBlock` types as `Msg.content`, so multimodal messages reach the agent with no extra conversion. ## Send Replies The framework doesn't hand the channel a finished reply; it hands `send_response` the run's **event stream** and lets the channel accumulate, render, and send. That way a channel can send one complete reply, or stream updates as Feishu does. The method has two parameters: `event` is the send target, used only for its `chat_id` to locate which chat to reply to; `events` is the agent event stream this run produces. The base class provides `_render()`, which folds an accumulated reply into deliverable text / data blocks and, per the channel's display switches, decides whether to include the thinking process and tool calls. ```python Accumulate the event stream and send it back theme={null} from collections.abc import AsyncIterator from pydantic import TypeAdapter from agentscope.app.channel import ChannelEvent from agentscope.event import AgentEvent, RequireUserConfirmEvent from agentscope.message import Msg _ADAPTER = TypeAdapter(AgentEvent) async def send_response( self, event: ChannelEvent, # send target: use its chat_id to locate the chat events: AsyncIterator[dict], # the agent event stream for this run, arriving one by one ) -> None: reply: Msg | None = None async for raw in events: evt = _ADAPTER.validate_python(raw) # restore the event object if isinstance(evt, RequireUserConfirmEvent): await self._present_confirm(event, evt) # needs approval, see below return if reply is None: reply = Msg(name="assistant", role="assistant", content=[]) reply.id = evt.reply_id reply.append_event(evt) # accumulate into one reply blocks = self._render(reply) # fold into deliverable blocks await self._deliver(event.chat_id, blocks) # your platform-send implementation ``` For a complete streaming and error-handling implementation, see the built-in `FeishuChannel` and `DiscordChannel`. ## Connection Status Each channel instance creates its own `self.status = ChannelStatus()` in `__init__` and updates `status.state` as it connects, reconnects, and stops (values `stopped` / `connecting` / `connected` / `retrying` / `failed`). This is exactly what the management UI and `GET /channels/{id}/status` read. If the first connection keeps failing, you can set `state` to `failed` and park, waiting for the user to change the config before reconnecting. ## Capability Declaration `capabilities` is the channel's declaration of what the platform supports, used by the framework and the channel itself when sending. Declare it truthfully for your platform: ```python Declare capabilities theme={null} from agentscope.app.channel import ChannelCapability class MyChannel(ChannelBase): capabilities = ChannelCapability( text=True, markdown=True, image=True, file=True, interactive=True, # can the platform present interactive confirmation UI streaming=True, # can it update a single message incrementally max_message_length=4000, # per-message character cap ) ``` | Field | Meaning | | -------------------- | --------------------------------------------------------------------------------------------- | | `text` / `markdown` | Whether plain text / Markdown is supported | | `image` / `file` | Whether images / files are supported; unsupported outbound media degrades to placeholder text | | `interactive` | Whether the platform can present interactive confirmation UI | | `streaming` | Whether a reply can be updated within a single message | | `max_message_length` | Per-message character cap; `_split_long_message()` splits by it automatically | ## Tool Confirmation When an agent calls a tool that needs approval, the run pauses and a `RequireUserConfirmEvent` appears in the event stream. The channel recognizes it in `send_response` and presents the request to the user: capable platforms use an interactive card or buttons, and a plain-text platform can send a "reply yes/no" prompt. `RequireUserConfirmEvent.tool_calls` is the list of tools awaiting approval, each with `id`, `name`, and `input`. Once the user decides, the channel normalizes it into a `ChannelConfirmationResultEvent` and emits it through the same `self._emit` entry as regular messages: ```python Present the confirmation and return the decision theme={null} from agentscope.app.channel import ChannelConfirmationResultEvent, ChannelEvent from agentscope.event import RequireUserConfirmEvent async def _present_confirm( self, event: ChannelEvent, req: RequireUserConfirmEvent, ) -> None: for tool in req.tool_calls: # each tool awaiting approval await self._send_buttons( event.chat_id, text=f"Allow running the tool {tool.name}?", value=tool.id, # embed tool_call_id in the button, returned verbatim on click ) async def _on_button_click(self, click) -> None: await self._emit( ChannelConfirmationResultEvent( channel_id=self._channel_id, chat_id=click.chat_id, channel_user_id=click.user_id, # who clicked tool_call_id=click.value, # returned verbatim; the channel need not understand it approved=click.approved, ), ) ``` `ChannelConfirmationResultEvent` carries only lookup keys: the authoritative awaiting tool call is read from session state on resume, never trusted from the card. This makes the round-trip distributed-safe by nature, no matter how long the user waits to click or which node the click lands on. When you present the confirmation, the `event.metadata` that `send_response` received carries the run's `agent_id` and `session_id`. Include them in the `ChannelConfirmationResultEvent` so a click resumes exactly the session that asked for approval, with no need to re-match routing rules. ## Optional Methods These all have default implementations; override them per your platform's capabilities: | Method | Purpose | | --------------------------------------- | --------------------------------------------------------------------------------------------- | | `send_reaction()` / `remove_reaction()` | Add / remove an emoji reaction on an inbound message (e.g. "working") | | `list_bot_chats()` | Return the chats the bot is in, for the management UI to pick from when configuring routing | | `chat_kind()` / `chat_name()` | Return whether a chat is a group or a DM, and its name, to enrich the agent's session context | | `list_tools()` | Expose platform-specific tools to the agent (e.g. send a file to a specific user / group) | ## Authorize by QR Code When a platform lets the operator hand over credentials elsewhere (scanning a QR code, clicking through a consent page), there is no need to make users copy an App Secret by hand. Attach a `credential_binding` to the channel class and the credential form in the management UI gains an "authorize by QR code" tab: ```python Declare a credential binding theme={null} from typing import Any from agentscope.app.channel import ( BindingState, BindingStep, ChannelBase, CredentialBindingBase, ) class MyCredentialBinding(CredentialBindingBase): async def begin(self) -> BindingStep: """Open an authorization session and return the URL for the operator.""" payload = await request_device_code() return BindingStep( verification_url=payload["verification_uri"], # rendered as a QR code provider_state={"device_code": payload["device_code"]}, # handed back verbatim retry_after_secs=5, # polling interval the platform asks for expires_in_secs=600, # lifetime of the session ) async def advance(self, provider_state: dict[str, Any]) -> BindingStep: """Called once per poll: ask the platform whether the operator confirmed.""" payload = await poll_device_code(provider_state["device_code"]) if payload.get("client_secret"): return BindingStep( state=BindingState.AUTHORIZED, credentials={"token": payload["client_secret"]}, # must match Credentials ) # Still waiting: return the provider_state as is and the session continues return BindingStep(provider_state=provider_state) class MyChannel(ChannelBase): channel_type = "my_platform" credential_binding = MyCredentialBinding # without it, only manual entry is offered ``` Four constraints to respect when implementing one: | Constraint | Description | | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Stateless | A provider is instantiated once per process, and the two steps of one authorization may well land on different replicas. Anything needed between the steps (device code, PKCE verifier, the domain you were redirected to) goes into the returned `provider_state`, which the next step receives verbatim | | Poll-driven | `advance()` is called once per client status query rather than looping on its own, so never wait or retry inside it | | Credential fields | The `credentials` returned with `AUTHORIZED` must line up with the channel class's `Credentials` fields; the service creates the channel straight from them | | Terminal states | `AUTHORIZED` / `FAILED` / `CANCELLED` are terminal and `advance()` is not called again; on failure put the reason in `error` so the operator sees it in the UI | ## Enable Channel Add your channel class to `create_app`'s `channels`, and the service accepts that platform. `channels` declares **which channel types the whole service accepts**; omitting it enables none, so list the built-in types and your custom types together. ```python Enable in create_app theme={null} from agentscope.app import create_app from agentscope.app.channel import DiscordChannel, FeishuChannel app = create_app( storage=..., message_bus=..., workspace_manager=..., # accepted channel types: the two built-ins + your custom type channels=[FeishuChannel, DiscordChannel, MyChannel], ) ``` Once enabled, `MyChannel` shows up in the platform-type list of the management UI, and users can fill in credentials, configure routing, and create channels just like a built-in platform. ## Further Reading Custom channels reuse the same routing model. Back to how channels work overall. # DingTalk Source: https://docs.agentscope.io/versions/2.0.8/en/deploy/channel/dingtalk Chat with your service's agents inside DingTalk. The DingTalk channel connects over the official Stream mode long connection, so it needs no public callback URL and works from a local or intranet deployment. The DingTalk channel currently supports: * **Interactive card approval**: when an agent calls a tool that needs approval, it asks for confirmation as a card in DingTalk; click a button to allow or deny; * **Streaming replies**: the answer updates within a single AI card as it is generated; * **Multimodal input**: receive images, files, voice, video, and rich-text messages from users and pass them to the agent; * **Outbound sending**: the agent can look up the directory and known chats, then send messages, images, and files to users or groups outside the current conversation. Connecting takes three steps: create an app on the [DingTalk Open Platform](https://open.dingtalk.com/) and get its credentials, start an agent service to host the channel, then add the DingTalk channel in the management UI. No card template needs preparing; the channel ships defaults. ## Prerequisites The DingTalk channel depends on `dingtalk-stream`, installed with the `channel` extra: ```bash Install the dependency theme={null} pip install "agentscope[channel]" ``` ## Create App Create and configure the enterprise internal app and its robot on the [DingTalk Open Platform](https://open.dingtalk.com/). In the developer console, create an "enterprise internal app" and fill in its name and icon. On the app's credentials and basic info page, copy the **Client ID** (AppKey) and **Client Secret** (AppSecret) to fill into the channel config later. The Client Secret is a secret; keep it safe. Under "App capabilities", add "Robot" and fill in its name and icon so the app can send and receive messages. On the robot config page, set the message delivery mode to **Stream mode**, with no HTTP callback URL. Under "Permission management", request: sending messages as an enterprise robot, creating and updating card instances, and uploading and downloading media files. If you want the `ListUsers` tool to search people by name, also request the directory permissions for user search and user profile reads. Refer to the [DingTalk docs](https://open.dingtalk.com/document/) for the exact scopes. Publish an app version so it becomes available in your organization. You can then add the robot to a group or DM it directly. ## Start Agent Service A channel runs on top of an [agent service](/versions/2.0.8/en/deploy/agent-service). Start the service with `create_app` and declare the accepted channel types via `channels`. Channels depend on a message bus (`message_bus`); use `InMemoryMessageBus` for single-machine development, and switch to `RedisMessageBus` for multi-process or multi-node deployment. ```python Start an agent service that hosts the channel theme={null} from agentscope.app import create_app from agentscope.app.channel import DingTalkChannel from agentscope.app.message_bus import InMemoryMessageBus from agentscope.app.storage import RedisStorage from agentscope.app.workspace_manager import LocalWorkspaceManager app = create_app( storage=RedisStorage(host="localhost", port=6379), # In-memory bus for single-machine dev; switch to RedisMessageBus for multi-node message_bus=InMemoryMessageBus(), workspace_manager=LocalWorkspaceManager(basedir="./workspaces"), channels=[DingTalkChannel], # channel types this service accepts ) # After starting with uvicorn, the channel feature is ready # uvicorn.run(app, host="0.0.0.0", port=8000) ``` ## Add Channel In the management UI (see the sample frontend [`examples/web_ui`](https://github.com/agentscope-ai/agentscope/tree/main/examples/web_ui)), add a channel through a visual form, with no config to write by hand. On the channel management page, create a channel and choose "DingTalk" as the platform type. Enter the **Client ID** and **Client Secret** from the previous step into the credential form. Choose which agent handles messages and how sessions are scoped. See [Message Routing](/versions/2.0.8/en/deploy/channel/routing) for what the rules mean. Save and enable the channel; the service opens the DingTalk Stream connection immediately and the robot comes online. Add it to a group or start a DM to begin. The management UI maps onto the `/channels` endpoints; call them directly when you need to create channels programmatically. See the [API](/versions/2.0.8/en/deploy/openapi.json) part of this chapter for the fields. ## Platform Config The DingTalk channel's platform-specific fields: | Field | Description | Default | | ---------------------------- | ----------------------------------------------------------------------------------------------- | -------------------------------------- | | `only_at_reply` | In group chats, reply only when the robot is @mentioned. DMs are unaffected and always answered | `true` | | `show_thinking` | Include the model's reasoning in the reply | `false` | | `show_tool_process` | Include tool calls and results in the reply | `false` | | `max_media_bytes` | Maximum bytes for one inbound or outbound attachment, capped at 100 MB | `10485760` (10 MB) | | `approval_card_template_id` | Template ID used for tool approval cards; empty means no approval card is delivered | DingTalk's published AI card | | `streaming_card_template_id` | AI card template ID used for streaming replies; empty means replies go out as regular Markdown | DingTalk's published streaming AI card | | `streaming_card_key` | Template variable name of the AI card streaming component | `content` | When routing matches on `chat_type`, group chats use `group` and DMs use `private`. ## Customising Cards The DingTalk channel delivers two kinds of card: an **approval card** when the agent calls a tool that needs confirmation, and a **streaming card** that carries a reply as it is generated. Both use a template DingTalk publishes, so **connecting the channel needs no card configuration at all**. What those templates look like is fixed: the layout, the button colours, and the button widths cannot be adjusted, and the card's built-in feedback row cannot be removed. To change any of that, build a template in the [Card Platform](https://open-dev.dingtalk.com/fe/card) and put its ID in the matching channel config field: | Card | Config field | Default | | -------------- | ---------------------------- | -------------------------------------- | | Approval card | `approval_card_template_id` | DingTalk's published general AI card | | Streaming card | `streaming_card_template_id` | DingTalk's published streaming AI card | Switching to a template of your own needs no channel code changes, but the template has to declare the variables the channel fills in. Each card's requirements follow. ### Approval Card The channel fills in these variables when it delivers an approval card. Bind whichever ones the template shows: | Variable | Content | Example | | ------------ | ------------------------------------------------ | --------------------------------- | | `title` | Which agent is asking | `Friday 提交的工具执行` | | `name` | The tool awaiting approval | `Bash` | | `input` | The tool's arguments, trimmed by bytes when long | `{"command": "ls -la"}` | | `created_at` | When the tool call was made | `2026-08-24 18:26:25` | | `status` | Card state | `pending` / `approved` / `denied` | `name`, `input`, and `created_at` come straight off the agent's tool call (the same-named fields of `ToolCallBlock`), so authoring a template needs no vocabulary beyond the call being approved. `status` is the card's own state: `pending` when delivered, then updated to `approved` or `denied` once someone decides. A template can switch its buttons and result text on it. Give the template an approve button and a deny button, both configured as callback buttons, and carry `action` in their callback parameters (`cardPrivateData.params`): | Decision | Accepted `action` values | | -------- | ------------------------------------------------- | | Approve | `allow`, `approve`, `approved`, `accept`, `agree` | | Deny | `deny`, `denied`, `reject` | A button carries **nothing but `action`**. The channel finds the tool call from the `outTrackId` it pinned when creating the card, and the chat from what the callback itself reports. The repository ships a template ready to import: [`assets/dingtalk/tool_approval_card.json`](https://github.com/agentscope-ai/agentscope/blob/main/assets/dingtalk/tool_approval_card.json). Import it when creating a template in the Card Platform, **publish** it, then put its ID in `approval_card_template_id`. Forgetting to publish makes card creation fail with `param.templateUnpublished`. Clearing `approval_card_template_id` turns approval cards off. Tool calls awaiting confirmation then cannot be answered from DingTalk; the channel says so in the chat and the session stays parked. ### Streaming Card A streaming card's template needs an AI card streaming component, which is where the channel writes the reply as it grows. Set that component's variable name as `streaming_card_key` in the channel config; on the built-in template the name is `content`. ## Agent Tools The channel gives the agent an extra set of DingTalk tools for sending to users or groups outside the current conversation. The lookup tools return a target of the form `user:` or `group:`, which the agent passes verbatim to a send tool. | Tool | Purpose | Permission | | ------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------- | | `ListConversations` | List the chats this process has received messages from; empty in a split deployment | Read-only, allowed directly | | `ListUsers` | Search the enterprise directory for users by name | Read-only, allowed directly | | `SendMessage` | Send Markdown text to a given user or group | Needs user confirmation | | `SendImage` | Send an image from the workspace to a given user or group, rendered inline | Needs user confirmation | | `SendFile` | Send a file from the workspace to a given user or group; doc, docx, pdf, rar, xlsx, and zip are supported | Needs user confirmation | A DingTalk enterprise robot cannot enumerate every group it belongs to, and the process answering this call is not the one holding the robot's connection, so in a split deployment `ListConversations` comes back empty and stays empty. Treat an empty result as the normal case and ask the user for the target. ## Verify and Troubleshoot * Check the channel status in the management UI, or call `GET /channels/{id}/status` to confirm the Stream connection is established. * If the robot doesn't respond in a group, first check `only_at_reply` and the @-mention behavior, then check that the send-message permission was requested and published with the version. * If the robot never comes online, check the Client ID / Client Secret and that the message delivery mode is set to Stream. * If clicking a card button does nothing, check that the button is configured as a callback request and that its `action` value is one of those listed above. * If group chats work but a DM fails with `chatbotId.notAllow.sendOTO`, the robot's one-to-one messaging is not enabled: enable the robot in the developer console and publish an app version. * If an attachment fails to send, check whether the file exceeds `max_media_bytes` and whether its extension is one DingTalk supports. ## Further Reading Route different groups to different agents. Connect Feishu with the same flow. # Discord Source: https://docs.agentscope.io/versions/2.0.8/en/deploy/channel/discord Chat with your service's agents inside Discord. The Discord channel connects over the Gateway WebSocket, with the bot sending and receiving messages on a long connection. The Discord channel currently supports: * **Interactive button approval**: when an agent calls a tool that needs approval, it asks for confirmation as buttons; click to allow or deny; * **Multimodal input**: receive image and file attachments from users and pass them to the agent; * **Markdown**: replies render as Markdown, capped at 2000 characters per message, with longer content split automatically. Connecting takes three steps: create an application and bot on the [Discord Developer Portal](https://discord.com/developers/applications) and get the token, start an agent service to host the channel, then add the Discord channel in the management UI. ## Prerequisites The Discord channel depends on `discord.py`, installed with the `channel` extra: ```bash Install the dependency theme={null} pip install "agentscope[channel]" ``` ## Create Bot Create and configure the bot on the [Discord Developer Portal](https://discord.com/developers/applications). Click "New Application" to create an app, and record the **Application ID** on the "General Information" page. Go to the "Bot" page, add a bot, and click "Reset Token" to generate and copy the **Bot Token**. The token is a secret and is shown only once; keep it safe. On the "Bot" page, under "Privileged Gateway Intents", enable **MESSAGE CONTENT INTENT**. This is required to read message text; without it, you won't receive message content. Under "OAuth2 → URL Generator", select the `bot` scope, then the permissions you need (at least "Send Messages" and "Read Message History"), and use the generated link to invite the bot to your server. ## Start Agent Service A channel runs on top of an [agent service](/versions/2.0.8/en/deploy/agent-service). Start the service with `create_app` and declare the accepted channel types via `channels`. Channels depend on a message bus (`message_bus`); use `InMemoryMessageBus` for single-machine development, and switch to `RedisMessageBus` for multi-process or multi-node deployment. ```python Start an agent service that hosts the channel theme={null} from agentscope.app import create_app from agentscope.app.channel import DiscordChannel from agentscope.app.message_bus import InMemoryMessageBus from agentscope.app.storage import RedisStorage from agentscope.app.workspace_manager import LocalWorkspaceManager app = create_app( storage=RedisStorage(host="localhost", port=6379), # In-memory bus for single-machine dev; switch to RedisMessageBus for multi-node message_bus=InMemoryMessageBus(), workspace_manager=LocalWorkspaceManager(basedir="./workspaces"), channels=[DiscordChannel], # channel types this service accepts ) # After starting with uvicorn, the channel feature is ready # uvicorn.run(app, host="0.0.0.0", port=8000) ``` ## Add Channel In the management UI (see the sample frontend [`examples/web_ui`](https://github.com/agentscope-ai/agentscope/tree/main/examples/web_ui)), add a channel through a visual form, with no config to write by hand. On the channel management page, create a channel and choose "Discord" as the platform type. Enter the **Application ID** and **Bot Token** from the previous step into the credential form. Choose which agent handles messages and how sessions are scoped. See [Message Routing](/versions/2.0.8/en/deploy/channel/routing) for what the rules mean. Save and enable the channel; the service connects to Discord immediately and the bot comes online. @mention it in a server channel, or DM it, to begin. The management UI maps onto the `/channels` endpoints; call them directly when you need to create channels programmatically. See the [API](/versions/2.0.8/en/deploy/openapi.json) part of this chapter for the fields. ## Platform Config The Discord channel has a few platform-specific switches: | Field | Description | Default | | ------------------- | ------------------------------------------------------------------------------------------------- | ------- | | `only_at_reply` | In server channels, reply only when the bot is @mentioned. DMs are unaffected and always answered | `true` | | `show_thinking` | Include the model's reasoning in the reply | `false` | | `show_tool_process` | Include tool calls and results in the reply | `false` | When routing on `chat_type`, a server channel's value is `guild` and a DM's is `dm`. ## Verify and Troubleshoot * Check the channel status in the management UI, or call `GET /channels/{id}/status` to confirm the connection is established. * If the bot doesn't respond in a server channel, first confirm **MESSAGE CONTENT INTENT** is enabled, then check `only_at_reply` and the @-mention behavior. * If the bot never comes online, check the Bot Token and that it has been invited to the server. ## Further Reading Route different server channels to different agents. Connect a platform beyond the built-ins. # Feishu Source: https://docs.agentscope.io/versions/2.0.8/en/deploy/channel/feishu Chat with your service's agents inside Feishu. The Feishu channel connects over a WebSocket long connection, so it needs no public callback URL and works from a local or intranet deployment. The Feishu channel currently supports: * **Interactive card approval**: when an agent calls a tool that needs approval, it asks for confirmation as a card in Feishu; click a button to allow or deny; * **Streaming replies**: the agent's answer updates within a single message as it is generated; * **Multimodal input**: receive images, files, and voice messages from users and pass them to the agent; * **Markdown**: replies render as Markdown, and overly long content is split automatically. Connecting takes three steps: get the credentials of a Feishu app, start an agent service to host the channel, then add the Feishu channel in the management UI. There are two ways to get the credentials: | Way | When to use it | | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | | Authorize by QR code | Scan and confirm in the management UI, and Feishu hands over the credentials directly, with no app to create by hand | | Create manually | Create a custom app on the [Feishu Open Platform](https://open.feishu.cn/) when you need your own permissions, event subscriptions, or app profile | ## Prerequisites The Feishu channel depends on `lark-oapi`, installed with the `channel` extra: ```bash Install the dependency theme={null} pip install "agentscope[channel]" ``` ## Create App Create and configure the bot on the [Feishu Open Platform](https://open.feishu.cn/); it takes a few minutes. If you plan to get the credentials by QR code, skip this section and jump to [Add Channel](#add-channel). In the developer console, create a "custom app" and fill in its name and icon. On the "Credentials & Basic Info" page, copy the **App ID** and **App Secret** to fill into the channel config later. The App Secret is a secret; keep it safe. Under "Add features", enable "Bot" so the app can send and receive messages. On the "Events & callbacks" page, set the delivery method to **long connection**, with no callback URL. Subscribe to the "receive message" event `im.message.receive_v1`; card approval also needs the card callback event `card.action.trigger`. Under "Permissions & Scopes", request the message permissions: receive messages, and send messages as the app (both DMs and groups). If you want to list the bot's groups while configuring routing, also request the group-list permission. Refer to the [Feishu docs](https://open.feishu.cn/document/) for the exact scopes. Create and publish an app version so it becomes available in your organization. You can then add the bot to a group or DM it directly. ## Start Agent Service A channel runs on top of an [agent service](/versions/2.0.8/en/deploy/agent-service). Start the service with `create_app` and declare the accepted channel types via `channels`. Channels depend on a message bus (`message_bus`); use `InMemoryMessageBus` for single-machine development, and switch to `RedisMessageBus` for multi-process or multi-node deployment. ```python Start an agent service that hosts the channel theme={null} from agentscope.app import create_app from agentscope.app.channel import FeishuChannel from agentscope.app.message_bus import InMemoryMessageBus from agentscope.app.storage import RedisStorage from agentscope.app.workspace_manager import LocalWorkspaceManager app = create_app( storage=RedisStorage(host="localhost", port=6379), # In-memory bus for single-machine dev; switch to RedisMessageBus for multi-node message_bus=InMemoryMessageBus(), workspace_manager=LocalWorkspaceManager(basedir="./workspaces"), channels=[FeishuChannel], # channel types this service accepts ) # After starting with uvicorn, the channel feature is ready # uvicorn.run(app, host="0.0.0.0", port=8000) ``` ## Add Channel In the management UI (see the sample frontend [`examples/web_ui`](https://github.com/agentscope-ai/agentscope/tree/main/examples/web_ui)), add a channel through a visual form, with no config to write by hand. On the channel management page, create a channel and choose "Feishu" as the platform type. The credential form has an "authorize by QR code" tab and a "fill in manually" tab. With **authorize by QR code**, the UI shows a QR code; scan and confirm it in the Feishu app, and Feishu hands over the credentials, which are filled in automatically. With **fill in manually**, enter the **App ID** and **App Secret** you got when creating the app. Choose which agent handles messages and how sessions are scoped. See [Message Routing](/versions/2.0.8/en/deploy/channel/routing) for what the rules mean. Save and enable the channel; the service opens the Feishu long connection immediately and the bot comes online. Add it to a group or start a DM to begin. The management UI maps onto the `/channels` endpoints; call them directly when you need to create channels programmatically. See the [API](/versions/2.0.8/en/deploy/openapi.json) part of this chapter for the fields. QR code authorization maps onto `POST /channels/bindings` (open an authorization session and return the URL to render), `GET /channels/bindings/{id}` (poll for the result), and `POST /channels/bindings/{id}/cancel` (abandon the session). Once authorized, pass `credential_binding_id` when creating the channel to claim the credentials, with no plaintext credentials in the request body. An authorization session belongs to the user who started it, and its credentials can be claimed only once: the session is void afterwards. An expired session, or a different user creating the channel, means scanning again. ## Platform Config The Feishu channel has a few platform-specific switches: | Field | Description | Default | | ------------------- | --------------------------------------------------------------------------------------------- | ------- | | `only_at_reply` | In group chats, reply only when the bot is @mentioned. DMs are unaffected and always answered | `true` | | `show_thinking` | Include the model's reasoning in the reply | `false` | | `show_tool_process` | Include tool calls and results in the reply | `false` | If you want the bot to join group discussions freely like any member, turn `only_at_reply` off; leaving it on (the default) keeps the bot from being too active in groups. ## Verify and Troubleshoot * Check the channel status in the management UI, or call `GET /channels/{id}/status` to confirm the long connection is established. * If the bot doesn't respond in a group, first check `only_at_reply` and the @-mention behavior, then check that the message-receive permission was requested and published with the version. * If the bot never comes online, check the App ID / App Secret and that event subscription is set to the long-connection method. * If no credentials arrive after scanning, the QR code has most likely expired; click "regenerate" and scan again. ## Further Reading Route different groups to different agents. Connect Discord with the same flow. # Overview Source: https://docs.agentscope.io/versions/2.0.8/en/deploy/channel/overview Bring the agents in your AgentScope service onto IM platforms. Channels connect the agents in an AgentScope service to external instant-messaging (IM) platforms, so an agent can talk to users directly inside the platform. Channels in an AgentScope service currently support: * Receiving and replying to user messages on IM platforms such as Feishu, DingTalk, and Discord; * Pushing messages to a chosen IM platform on the agent's own initiative; * Routing messages to different agents and sessions by rule; * Sending and receiving multimodal content such as images and files; * Approving an agent's tool calls right inside the conversation; * Injecting channel context (platform, chat name, group vs. private) into the agent automatically, so replies fit the setting. ## Supported Platforms Each platform has a built-in type id (`channel_type`) that you pick when creating a channel: | Platform | Type id | Connection | Status | | ------------- | ---------- | --------------------------- | ----------- | | Feishu (Lark) | `feishu` | WebSocket long connection | Available | | Discord | `discord` | Gateway WebSocket | Available | | DingTalk | `dingtalk` | Stream mode long connection | Available | | WeCom | `wecom` | App callback | Coming soon | Within an agent service, call `GET /channels/types` to fetch every type and its credential form schema (JSON Schema); the frontend renders the config form from it, with no per-platform hard-coding. ## Enable Channels Channels start with the [agent service](/versions/2.0.8/en/deploy/agent-service). Declare which channel types the service accepts through the `channels` argument of `create_app`: ```python Enable built-in channels theme={null} from agentscope.app import create_app from agentscope.app.channel import ( DingTalkChannel, DiscordChannel, FeishuChannel, ) app = create_app( storage=..., message_bus=..., workspace_manager=..., # channel types this service accepts channels=[FeishuChannel, DingTalkChannel, DiscordChannel], ) ``` Omitting `channels` enables no channel types and keeps the feature off. To connect a platform beyond the built-in ones, add your channel class to the list; see [Custom Channel](/versions/2.0.8/en/deploy/channel/custom). ## Distributed Deployment Channels support distributed multi-node deployment. Shared state (channel config, sessions, the message bus) all lives in Redis, and the nodes run as equals, each handling its own requests; swap `storage` and `message_bus` for their Redis versions to scale out across machines. At the connection level, every deployment node keeps its own long connection for each enabled channel. The platform may deliver one message to several nodes, but only one node is allowed to collect and send the reply for a given session at a time, so users never get a duplicate answer. ## HTTP Endpoints A channel's full lifecycle is managed through the endpoints under `/channels`: | Method and path | Purpose | | ----------------------------- | --------------------------------------------------------------- | | `GET /channels/types` | List supported platform types and their credential form schemas | | `POST /channels/` | Create a channel | | `GET /channels/` | List the current user's channels | | `GET /channels/{id}` | View a channel's details (credentials redacted) | | `PATCH /channels/{id}` | Update name / routing / session / platform config | | `DELETE /channels/{id}` | Delete a channel | | `POST /channels/{id}/enable` | Enable a channel | | `POST /channels/{id}/disable` | Disable a channel | | `GET /channels/{id}/status` | View the channel's live connection status | | `GET /channels/{id}/sessions` | List the sessions this channel has spawned | | `GET /channels/{id}/chat_ids` | List chats the bot knows, to help configure routing | For each endpoint's full request and response fields, see the [API](/versions/2.0.8/en/deploy/openapi.json) part of this chapter. ## Further Reading Decide which agent answers and which session a message joins. Create a Feishu bot and let agents chat in Feishu. Create a DingTalk robot and let agents chat in DingTalk. Create a Discord bot and let agents chat in Discord. Implement ChannelBase to connect a platform beyond the built-ins. # Message Routing Source: https://docs.agentscope.io/versions/2.0.8/en/deploy/channel/routing Decide which agent answers an incoming message and which session it joins. Routing decides: for a message arriving from an IM platform, which agent handles it and which of that agent's sessions it joins. "Which agent" decides who answers; "which session" decides which earlier messages this one shares conversation context with. Messages in the same session see each other; different sessions are isolated. A session can be scoped two ways today: **one session per chat**, or **one session per member in a group chat**. Because in a channel both chats and users only show up with their first message: the first time anyone DMs the bot, it brings a chat that was never seen before. So routing is not a lookup table written in advance, but an ordered list of rules, matched top to bottom, first match wins, with a catch-all rule to guarantee every message has a definite destination. ## Routing Rules A routing rule (binding) has four fields: the first two say "which messages to match", the last two say "what to do on a match". | Field | Description | Default | | --------------- | ------------------------------------------------------------------------------------------------------------------------------ | ---------- | | `match_key` | Which message field to match on: `chat_id` (which chat), `user_id` (who sent it), or a key in `metadata` (such as `chat_type`) | `chat_id` | | `match_value` | The exact value to match, or `"*"` for anything (catch-all) | `"*"` | | `agent_id` | Which agent handles a match | required | | `session_scope` | Which session a matched message joins (values below) | `per_chat` | `match_value` is an **exact match** (not a prefix, not a regex); only `"*"` is special and matches everything. When `match_key` points at a key in `metadata`, the matchable values are platform-defined: Feishu's `chat_type` is `group` / `p2p`, DingTalk's is `group` / `private`, Discord's is `guild` / `dm`; see each platform's page. ## Session Scope `session_scope` decides how messages that hit the same rule are grouped into sessions. Messages in the same session share context. | Value | Meaning | When to use | | --------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------- | | `per_chat` | One session per chat | Default. A DM is naturally one session per person; a group shares one context for the whole group | | `per_chat_user` | One session per user within the same chat | Only meaningful for groups: isolate each member into their own session | Even when they resolve to the same agent, `per_chat` and `per_chat_user` put messages into different sessions. So **after you change the scope, later messages are regrouped under the new scheme**. Different agents never share a session. ## Match Order Rules are matched top to bottom, **first match wins**, the same way firewall rules or Nginx `location` blocks work. Put specific exceptions first and the catch-all last. Saving a config runs three checks so that every message has a definite, unique destination: * There must be exactly one catch-all rule (`match_value` of `"*"`); * The catch-all must be last, or the rules after it would never be reached; * Duplicate `(match_key, match_value)` combinations are not allowed. ## Full Example Suppose you've deployed two agents: a general assistant `friday` and a product expert `product-expert`. Let's follow one real message and see which agent and session it lands in under different rules. The bot is in a Feishu group called "Product Team" and receives a line from a member, Alice. The message looks roughly like this (only routing-relevant fields shown): ```json Inbound message theme={null} { "channel_user_id": "ou_alice", "chat_id": "oc_product_team", "content": [ { "type": "text", "text": "Check today's schedule for me" } ], "metadata": { "chat_type": "group" } } ``` The same message, under different rules, goes to different agents and sessions. Each tab below is one configuration: ```json Everything to friday theme={null} { "bindings": [ // Use when: one general assistant serves every chat. // This catch-all matches any message and hands it to friday; // everyone in the "Product Team" group shares one session. { "match_key": "chat_id", "match_value": "*", "agent_id": "friday", "session_scope": "per_chat" } ] } ``` ```json A dedicated agent for the product group theme={null} { "bindings": [ // Use when: one group needs a dedicated agent, the rest use the general assistant. // Messages from the "Product Team" group hit this and go to product-expert. { "match_key": "chat_id", "match_value": "oc_product_team", "agent_id": "product-expert", "session_scope": "per_chat" }, // Every other chat misses the above and falls to this catch-all -> friday. { "match_key": "chat_id", "match_value": "*", "agent_id": "friday", "session_scope": "per_chat" } ] } ``` ```json Each person in the group chats separately theme={null} { "bindings": [ // Use when: many people in a group talk to the bot independently, isolated from each other. // Group messages hit this and go to friday; each person gets their own session, // so Alice's session is hers alone and no one else sees it. { "match_key": "chat_type", "match_value": "group", "agent_id": "friday", "session_scope": "per_chat_user" }, // DMs and everything else fall to the catch-all. { "match_key": "chat_id", "match_value": "*", "agent_id": "friday", "session_scope": "per_chat" } ] } ``` Every config must end with a catch-all rule (`match_value` of `"*"`) so that a message matching none of the earlier rules still has a definite destination. The last rule in all three examples above is exactly that: ```json Catch-all rule theme={null} { "match_key": "chat_id", "match_value": "*", "agent_id": "friday", "session_scope": "per_chat" } ``` Before configuring routing, call `GET /channels/{id}/chat_ids` to list the chats the bot knows and their `chat_id`; copy them directly instead of transcribing platform IDs by hand. ## Further Reading Create a Feishu bot and let agents chat in Feishu. Create a Discord bot and let agents chat in Discord. # MCP Hub Source: https://docs.agentscope.io/versions/2.0.8/en/deploy/hub/mcp-hub Let users install MCP servers from a registry with their own credentials. An MCP hub is a catalog of [MCP](/versions/2.0.8/en/building-blocks/tool/mcp) servers your users browse and install from inside the app. Register one and nobody has to hand-write an MCP configuration, or wait for a redeploy when the catalog grows. Installing does not equip an agent. It adds the server to the user's own pool of installed MCP servers, and equipping an agent is a separate step: The user picks a server, fills in whatever it asks for, and it joins their pool. Every hub feeds the same pool, so what came from which registry stops mattering once it is installed. In a chat, the user picks from the pool to add the server to that agent's [workspace](/versions/2.0.8/en/deploy/workspace-manager). Its tools reach the agent immediately. Splitting the two is what makes credentials a one-time cost. An API key is entered once at install time, and equipping that server in ten different workspaces never asks for it again. The pool belongs to the user, so it also outlives any single chat. ## Available MCP Hubs AgentScope ships one MCP hub today, and any other catalog can be wrapped with a [custom hub](#custom-mcp-hub): | Class | Registry | Description | | -------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------ | | `GitHubMCPHub` | [GitHub MCP Registry](https://github.com/mcp) | GitHub's public registry of MCP servers. Works anonymously; a token raises the rate limit. | | *Coming soon* | — | More registries are being built. | `GitHubMCPHub` works with no arguments, and every field below has a default: ```python Configure GitHubMCPHub theme={null} from agentscope.app.hub import GitHubMCPHub hub = GitHubMCPHub( # Addresses this hub in the API and the frontend URL. Keep it stable. hub_id="github", # Shown in the frontend's hub switcher. display_name="GitHub MCP Registry", # Optional token. Anonymous requests work, but are rate-limited. api_token=None, ) ``` | Parameter | Default | Description | | -------------- | ------------------------------ | ----------------------------------------------------- | | `hub_id` | `"github"` | The identifier addressing this hub in routes and URLs | | `display_name` | `"GitHub MCP Registry"` | The name shown in the hub switcher | | `description` | GitHub's registry blurb | The one-line description under the name | | `icon_url` | GitHub's avatar | The icon shown beside the name | | `base_url` | `"https://api.mcp.github.com"` | The registry endpoint | | `api_token` | `None` | A GitHub token, affecting rate limits only | | `timeout` | `30.0` | Per-request timeout in seconds | ## Quickstart MCP hubs are a feature of [Agent Service](/versions/2.0.8/en/deploy/agent-service), so you need a running service and a frontend to browse from. Follow the [Agent Service quickstart](/versions/2.0.8/en/deploy/agent-service#try-the-bundled-example) to boot the bundled [`examples/agent_service`](https://github.com/agentscope-ai/agentscope/tree/main/examples/agent_service) backend together with the [`examples/web_ui`](https://github.com/agentscope-ai/agentscope/tree/main/examples/web_ui) frontend. Pass your hubs to `create_app` through `mcp_hubs`. That one argument brings the routes, the store page, and the install flow with it: ```python Register an MCP hub theme={null} from agentscope.app import create_app from agentscope.app.hub import GitHubMCPHub app = create_app( # ...existing code... mcp_hubs=[GitHubMCPHub()], ) ``` Register several to give users a choice of catalogs, each with its own `hub_id`: ```python Register several MCP hubs theme={null} app = create_app( # ...existing code... mcp_hubs=[ GitHubMCPHub(), # A second instance pointed at your own registry. GitHubMCPHub( hub_id="internal", display_name="Internal Registry", base_url="https://mcp.corp.example.com", ), ], ) ``` Registering two MCP hubs under the same `hub_id` fails at startup rather than silently shadowing one. Restart the service and open the **MCP** page. The sidebar lists your registered hubs plus **Mine**, the user's own collection. Pick a hub, search, and open a card to see what the server does and what it needs. Browsing an MCP registry **Install** opens a form built from what the listing declares. On submit, the server is contacted straight away, so wrong credentials come back as an error on the form instead of a broken install. Everything installed shows up under **Installed MCPs**: Installed MCP servers Open a chat, expand the **MCP** panel, choose **Add**, and pick from **Installed MCPs**. The server's tools reach the agent immediately. Equipping an agent with an installed MCP server From **Installed MCPs**, users rename an install, turn it off without losing its configuration, update a rotated key, or delete it. Deleting leaves chats that already use it untouched. ## Custom MCP Hub Any catalog becomes a hub by subclassing `MCPHubBase`: a public registry, your company's approved list, or a fixed set of servers you want to offer. Two methods are required, one to browse and one to fetch a single listing: ```python A hub over an internal catalog theme={null} from agentscope.app.hub import MCPCard, MCPHubBase, MCPHubPage class InternalMCPHub(MCPHubBase): """The MCP servers approved for use inside our company.""" def __init__(self) -> None: super().__init__( hub_id="internal", display_name="Internal Registry", description="MCP servers approved by the platform team.", ) async def list_mcps( self, user_id: str, # The keyword the user typed, or `None` to browse everything. q: str | None = None, # The opaque cursor from the previous page, or `None` to start. cursor: str | None = None, limit: int = 20, ) -> MCPHubPage: """Return one page of listings.""" page = await fetch_our_catalog(query=q, after=cursor, limit=limit) return MCPHubPage( cards=[self._to_card(c) for c in page.items], # `None` tells the frontend there is nothing more to load. next_cursor=page.next_cursor, ) async def get_mcp(self, user_id: str, card_id: str) -> MCPCard: """Return one listing, or raise `KeyError` if there is no such card.""" return self._to_card(await fetch_one(card_id)) ``` Two more things shape how a hub behaves: | Point | Description | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | Cursor pagination | Return whatever opaque string lets you resume, and `None` when the catalog is exhausted. The frontend loads more as the user scrolls | | Missing cards | Raise `KeyError` from `get_mcp` for an unknown id, and the service answers 404 | ### Control Visibility Per User Both methods receive `user_id` as their first argument, so a catalog does not have to look the same to everybody. Filter on it to run a per-team allowlist, gate paid listings on entitlements from your billing system, or keep an unreleased server visible only to its authors: ```python A catalog that differs per user theme={null} class InternalMCPHub(MCPHubBase): async def list_mcps( self, user_id: str, q: str | None = None, cursor: str | None = None, limit: int = 20, ) -> MCPHubPage: """Return only the listings this user is allowed to see.""" # Whatever your own system says this user may install. allowed = await our_entitlements(user_id) page = await fetch_our_catalog(query=q, after=cursor, limit=limit) return MCPHubPage( cards=[ self._to_card(c) for c in page.items if c.id in allowed ], next_cursor=page.next_cursor, ) async def get_mcp(self, user_id: str, card_id: str) -> MCPCard: """Refuse a hidden card, so guessing an id gains nothing.""" if card_id not in await our_entitlements(user_id): raise KeyError(card_id) return self._to_card(await fetch_one(card_id)) ``` Apply the same filter in `get_mcp` that you apply in `list_mcps`. Hiding a card from the listing alone is not access control: `get_mcp` is reachable with any id the caller cares to guess. ### Declare Required Inputs A card is a template rather than a ready connection. Write the parts the user supplies as `${placeholder}`, then describe them with a standard [JSON Schema](https://json-schema.org/) so the frontend can render the form: ```python A card that asks for an API key theme={null} MCPCard( hub_id=self.hub_id, id="weather", name="weather", display_name="Weather", description="Current conditions and forecasts.", config_template=HttpMCPConfig( url="https://weather.example.com/mcp", # Placeholders work in any string: URLs, headers, env vars, args. headers={"Authorization": "Bearer ${api_key}"}, ), inputs_schema={ "type": "object", "required": ["api_key"], "properties": { "api_key": { "type": "string", "title": "API key", "description": "Found under Settings, API in your account.", # Renders as a masked field, and is never echoed back. "writeOnly": True, "format": "password", }, }, }, ) ``` Mark every credential field with `"writeOnly": true` and `"format": "password"`. That is what tells the frontend to mask the value and to leave the field blank when the user edits the install later. ### Reuse One HTTP Client A hub lives for the whole lifetime of the service, so it can hold a connection pool instead of opening one per request. Implement the async context manager methods and the service enters every hub on startup, closing them on shutdown: ```python Open and close a shared client theme={null} class InternalMCPHub(MCPHubBase): async def __aenter__(self) -> "InternalMCPHub": self._client = httpx.AsyncClient(timeout=30.0) return self async def __aexit__(self, *exc: object) -> None: await self._client.aclose() ``` ## Further Reading The same idea for skills. How the agent calls an MCP server's tools. # Overview Source: https://docs.agentscope.io/versions/2.0.8/en/deploy/hub/overview Plug sources of MCP servers and skills into your service. An MCP or skill hub is a source of MCP servers and skills that you register in code on your [Agent Service](/versions/2.0.8/en/deploy/agent-service), and that users then assign to a given agent in a chat. A hub can be a trusted third-party service, or your own implementation. Hubs come in two kinds, one per resource type: Install [MCP](/versions/2.0.8/en/building-blocks/tool/mcp) servers, and configure their credentials. Install [skills](/versions/2.0.8/en/building-blocks/tool/skill) published by others. ## How It Works Installing and equipping are two separate steps, so a user configures something once and reuses it everywhere afterwards: The user browses a hub and installs an MCP server or a skill, along with any credentials they entered. Every hub feeds the same pool, so what came from which source stops mattering once it is installed. In a chat, the user picks from the pool to add it to that agent's [workspace](/versions/2.0.8/en/deploy/workspace-manager). Its tools reach the agent immediately. The pool belongs to the user, not to a chat. The same MCP server can be equipped in ten different workspaces without entering its API key again, and it outlives every one of them. Hubs are optional. Without any registered, users can still add MCP servers by pasting a configuration and skills by uploading a folder. ## Further Reading The hosting layer a hub plugs into. Where an equipped MCP server or skill runs. # Skill Hub Source: https://docs.agentscope.io/versions/2.0.8/en/deploy/hub/skill-hub Let users install skills published by others and equip their agents with them. A skill hub is a catalog of [skills](/versions/2.0.8/en/building-blocks/tool/skill) your users browse and install from inside the app. A skill is a folder, a `SKILL.md` telling the agent how to do something plus any scripts it needs, so a hub is how users get one without cloning a repository or knowing where the files belong. Installing does not equip an agent. It adds the skill to the user's own pool of installed skills, and equipping an agent is a separate step: The user reads a skill's `SKILL.md` on its card and installs it, with nothing to fill in. Every hub feeds the same pool, so what came from which registry stops mattering once it is installed. In a chat, the user picks from the pool to unpack the skill into that agent's [workspace](/versions/2.0.8/en/deploy/workspace-manager). The agent can follow it from that point on. The pool belongs to the user rather than to any chat, so one install is equipped in as many workspaces as they like. A local folder can also be uploaded straight into a workspace, whether or not it came from a hub. ## Available Skill Hubs AgentScope ships one skill hub today, and any other catalog can be wrapped with a [custom hub](#custom-skill-hub): | Class | Registry | Description | | -------------- | ----------------------------- | -------------------------------------------------------------------------------------------------- | | `ClawSkillHub` | [ClawHub](https://clawhub.ai) | A public registry of community-published skills. Works anonymously; a token raises the rate limit. | | *Coming soon* | — | More registries are being built. | `ClawSkillHub` works with no arguments, and every field below has a default: ```python Configure ClawSkillHub theme={null} from agentscope.app.hub import ClawSkillHub hub = ClawSkillHub( # Addresses this hub in the API and the frontend URL. Keep it stable. hub_id="clawhub", # Shown in the frontend's hub switcher. display_name="ClawHub", # Optional token. Anonymous requests work, but are rate-limited. api_token=None, ) ``` | Parameter | Default | Description | | -------------- | ---------------------- | ----------------------------------------------------- | | `hub_id` | `"clawhub"` | The identifier addressing this hub in routes and URLs | | `display_name` | `"ClawHub"` | The name shown in the hub switcher | | `description` | ClawHub's blurb | The one-line description under the name | | `icon_url` | ClawHub's favicon | The icon shown beside the name | | `base_url` | `"https://clawhub.ai"` | The registry endpoint | | `api_token` | `None` | A ClawHub token, affecting rate limits only | | `timeout` | `30.0` | Per-request timeout in seconds | | `max_retries` | `3` | Retries before giving up on a rate-limited request | ## Quickstart Skill hubs are a feature of [Agent Service](/versions/2.0.8/en/deploy/agent-service), so you need a running service and a frontend to browse from. Follow the [Agent Service quickstart](/versions/2.0.8/en/deploy/agent-service#try-the-bundled-example) to boot the bundled [`examples/agent_service`](https://github.com/agentscope-ai/agentscope/tree/main/examples/agent_service) backend together with the [`examples/web_ui`](https://github.com/agentscope-ai/agentscope/tree/main/examples/web_ui) frontend. Pass your hubs to `create_app` through `skill_hubs`. That one argument brings the routes, the store page, and the install flow with it: ```python Register a skill hub theme={null} from agentscope.app import create_app from agentscope.app.hub import ClawSkillHub app = create_app( # ...existing code... skill_hubs=[ClawSkillHub()], ) ``` Register several to give users a choice of catalogs, each with its own `hub_id`. Registering two skill hubs under the same `hub_id` fails at startup rather than silently shadowing one. Restart the service and open the **Skill** page. The sidebar lists your registered hubs plus **Mine**, the user's own collection. Pick a hub, search, and open a card to read its `SKILL.md` before deciding. Browsing a skill registry **Install** adds it to the user's collection right away, with nothing to fill in. Everything installed shows up under **Installed skills**: Installed skills Open a chat, expand the **Skill** panel, and choose **Add**. Two tabs cover both sources: | Tab | Description | | --------------- | ---------------------------------------------------------------------------------------------- | | From installed | Pick from the collection. The files are fetched and unpacked into the agent's workspace | | Upload a folder | Pick a folder from disk, containing a `SKILL.md` at its root. A progress bar tracks the upload | Equipping an agent with a skill Uploads are bounded so one user cannot fill a workspace: at most 100 files, 50 MB per file, and 500 MB in total. A folder whose name is taken installs under a numbered suffix instead of overwriting. Equipping an installed skill fetches its files from the hub at that moment, because an install keeps the skill's description rather than a copy of its files. If the hub is unreachable, that skill is reported as failed with the reason, and the others in the same request are still equipped. ## Custom Skill Hub Any catalog of skill folders becomes a hub by subclassing `SkillHubBase`. Three methods are required, one to browse, one to fetch a single listing, and one to serve its archive: ```python A hub over an internal catalog theme={null} from agentscope.app.hub import ( SkillArchive, SkillCard, SkillHubBase, SkillHubPage, ) class InternalSkillHub(SkillHubBase): """The skills our platform team publishes internally.""" def __init__(self) -> None: super().__init__( hub_id="internal", display_name="Internal Skills", description="Skills published by the platform team.", ) async def list_skills( self, user_id: str, # The keyword the user typed, or `None` to browse everything. q: str | None = None, # The opaque cursor from the previous page, or `None` to start. cursor: str | None = None, limit: int = 20, ) -> SkillHubPage: """Return one page of listings, without their `SKILL.md` bodies.""" page = await fetch_our_catalog(query=q, after=cursor, limit=limit) return SkillHubPage( cards=[self._to_card(c) for c in page.items], # `None` tells the frontend there is nothing more to load. next_cursor=page.next_cursor, ) async def get_skill(self, user_id: str, card_id: str) -> SkillCard: """Return one listing, this time with its `SKILL.md` body.""" detail = await fetch_one(card_id) return self._to_card(detail, markdown=detail.readme) async def download( self, user_id: str, card_id: str, version: str | None = None, ) -> SkillArchive: """Open the skill's archive for streaming.""" response = await self._client.get(f"/skills/{card_id}/archive") # One of "zip", "tar", or "tar.gz". return SkillArchive(format="tar.gz", stream=response.aiter_bytes()) ``` Three more things shape how a hub behaves: | Point | Description | | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Cheap listing | Leave `SKILL.md` out of `list_skills`. Fetching a body per card multiplies one request by the page size and exhausts most registries on the first screen. Load it in `get_skill` | | Cursor pagination | Return whatever opaque string lets you resume, and `None` when the catalog is exhausted | | Missing cards | Raise `KeyError` from `get_skill` for an unknown id, and the service answers 404 | The archive should hold the skill's folder with a `SKILL.md` at its root, either as a single top-level directory or as the files directly. Both layouts work, and the folder is renamed to the installed skill's name. ### Control Visibility Per User All three methods receive `user_id` as their first argument, so a catalog does not have to look the same to everybody. Filter on it to run a per-team allowlist, gate paid listings on entitlements from your billing system, or keep a draft skill visible only to its authors: ```python A catalog that differs per user theme={null} class InternalSkillHub(SkillHubBase): async def list_skills( self, user_id: str, q: str | None = None, cursor: str | None = None, limit: int = 20, ) -> SkillHubPage: """Return only the listings this user is allowed to see.""" # Whatever your own system says this user may install. allowed = await our_entitlements(user_id) page = await fetch_our_catalog(query=q, after=cursor, limit=limit) return SkillHubPage( cards=[ self._to_card(c) for c in page.items if c.id in allowed ], next_cursor=page.next_cursor, ) async def get_skill(self, user_id: str, card_id: str) -> SkillCard: """Refuse a hidden card, so guessing an id gains nothing.""" if card_id not in await our_entitlements(user_id): raise KeyError(card_id) detail = await fetch_one(card_id) return self._to_card(detail, markdown=detail.readme) async def download( self, user_id: str, card_id: str, version: str | None = None, ) -> SkillArchive: """Refuse the archive of a hidden card as well.""" if card_id not in await our_entitlements(user_id): raise KeyError(card_id) response = await self._client.get(f"/skills/{card_id}/archive") return SkillArchive(format="tar.gz", stream=response.aiter_bytes()) ``` `download` takes `user_id` for exactly this reason, so apply the same filter there too. Hiding a card from the listing alone is not access control: both `get_skill` and `download` are reachable with any id the caller cares to guess. ### Reuse One HTTP Client A hub lives for the whole lifetime of the service, so it can hold a connection pool instead of opening one per request. Implement the async context manager methods and the service enters every hub on startup, closing them on shutdown: ```python Open and close a shared client theme={null} class InternalSkillHub(SkillHubBase): async def __aenter__(self) -> "InternalSkillHub": self._client = httpx.AsyncClient(timeout=30.0) return self async def __aexit__(self, *exc: object) -> None: await self._client.aclose() ``` ## Further Reading The same idea for MCP servers. How the agent discovers and follows a skill. # RAG Service Source: https://docs.agentscope.io/versions/2.0.8/en/deploy/rag A one-click multi-tenant, distributed RAG service The [RAG](/versions/2.0.8/en/building-blocks/rag) chapter covers the extension points and library-mode usage of AgentScope's RAG module. This chapter introduces the **multi-tenant, distribution-ready** RAG service layer included in the Agent Service. Building on top of those building blocks, the service layer provides the following capabilities around "multi-tenancy", "distribution", and "easy onboarding": | Capability | Description | | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Multi-tenant knowledge bases | Each user gets its own namespace, with knowledge bases fully isolated from each other — **a natural fit for multi-user SaaS scenarios, no extra permission isolation needed**. | | Full knowledge base / document management | Full CRUD endpoints for knowledge bases and documents; deletion cascades through vectors, records, and original files — **no stale data or orphan files build up over time**. | | Asynchronous uploads + live progress | Uploads return immediately, indexing runs in the background, and a batch status endpoint is exposed — **the front-end can render second-level progress bars and failure hints without blocking the user**. | | Pluggable file object store | Local / S3 / custom object-store backends — **large files do not need to stay in memory, multiple workers in a distributed deployment can share the same file source, and migrating to the cloud requires zero application changes**. | | Distributed indexing and horizontal scaling | Parsing / chunking / embedding can be deployed as independent worker processes — **as document volume or parse cost grows, scale the workers without affecting API throughput**. | | Built-in fault tolerance and self-healing | Task leases, heartbeat renewal, and periodic re-dispatch are all built in — **worker crashes, network blips, and duplicate enqueues never get stuck, and operational cost is essentially zero**. | | Automatic embedding-model fit | At knowledge-base creation time, the service automatically filters out models incompatible with the vector store's dimension policy — **the user does not need to worry about dimension matching; the front-end options *are* the usable set, eliminating indexing failures from picking the wrong model**. | | Out-of-the-box REST API and front-end UI | Every capability is exposed as a complete REST endpoint, with an official front-end implementation — **integrators get a ready-to-use upload / search / progress UI with no extra work**. | ## Quick Start The steps below bring the RAG service up — backend + the official front-end — and let you create knowledge bases, upload documents, and run searches from the UI. Pass a few RAG-related components to `create_app` to enable the full set of `/knowledge_bases` endpoints. The minimal example below shows the two configurations — **local blob store** and **S3 blob store** — assuming Redis and Qdrant are already running locally (or at a reachable address): ```python Local blob store theme={null} import uvicorn from agentscope.app import create_app from agentscope.app.rag.blob_store import LocalBlobStore from agentscope.app.rag.knowledge_base_manager import CollectionPerKbManager from agentscope.app.message_bus import RedisMessageBus from agentscope.app.storage import RedisStorage from agentscope.app.workspace_manager import LocalWorkspaceManager from agentscope.rag import ( ApproxTokenChunker, ImageParser, PDFParser, PPTParser, QdrantStore, TextParser, ) storage = RedisStorage(host="localhost", port=6379) message_bus = RedisMessageBus(host="localhost", port=6379) workspace_manager = LocalWorkspaceManager(basedir="/data/workspaces") vector_store = QdrantStore(url="http://localhost:6333") kb_manager = CollectionPerKbManager( storage=storage, vector_store=vector_store, ) app = create_app( storage=storage, message_bus=message_bus, workspace_manager=workspace_manager, knowledge_base_manager=kb_manager, # Route by IANA media type: text / PDF / PPT / images each go through their own parser knowledge_parsers=[ TextParser(), PDFParser(), PPTParser(), ImageParser(), ], knowledge_chunkers=[ApproxTokenChunker], blob_store=LocalBlobStore(root_dir="/data/blobs"), ) uvicorn.run(app, host="0.0.0.0", port=8000) ``` ```python S3 blob store theme={null} import uvicorn from agentscope.app import create_app from agentscope.app.rag.blob_store import S3BlobStore from agentscope.app.rag.knowledge_base_manager import CollectionPerKbManager from agentscope.app.message_bus import RedisMessageBus from agentscope.app.storage import RedisStorage from agentscope.app.workspace_manager import LocalWorkspaceManager from agentscope.rag import ( ApproxTokenChunker, ImageParser, PDFParser, PPTParser, QdrantStore, TextParser, ) storage = RedisStorage(host="localhost", port=6379) message_bus = RedisMessageBus(host="localhost", port=6379) workspace_manager = LocalWorkspaceManager(basedir="/data/workspaces") vector_store = QdrantStore(url="http://localhost:6333") kb_manager = CollectionPerKbManager( storage=storage, vector_store=vector_store, ) app = create_app( storage=storage, message_bus=message_bus, workspace_manager=workspace_manager, knowledge_base_manager=kb_manager, knowledge_parsers=[ TextParser(), PDFParser(), PPTParser(), ImageParser(), ], knowledge_chunkers=[ApproxTokenChunker], blob_store=S3BlobStore(bucket="my-rag-bucket"), ) uvicorn.run(app, host="0.0.0.0", port=8000) ``` The RAG-related `create_app` parameters are listed below; without `knowledge_base_manager`, none of the knowledge-base endpoints are registered. Owner of the knowledge base lifecycle; binds a vector store instance whose connection lifecycle the manager proxies. The built-in `CollectionPerKbManager` takes the "one collection per knowledge base" strategy, letting every knowledge base pick its own embedding dimension freely. Parsers registered to the upload path, dispatched by each parser's `supported_media_types`. In `list` form, later registrations override earlier ones for overlapping types (overrides log a warning); `dict` form `media_type → parser` is verbatim explicit routing (useful for binding one parser to multiple types or to custom aliases). The chunker classes users can choose from. Each knowledge base stores its own `chunker_type` plus parameters validated against the `Parameters` schema, and the indexing worker rebuilds the instance from that configuration. Binary store for uploaded files. Local is fine for single-host setups; in a distributed deployment use S3 or a custom shared backend, because workers must share the same file source as the API. When `True`, the API process also runs parsing / chunking / embedding (single-process mode); when `False`, the API only accepts uploads and enqueues tasks, leaving indexing to a dedicated worker (distributed mode) — see "Deployment topologies" below. The [`examples/web_ui`](https://github.com/agentscope-ai/agentscope/tree/main/examples/web_ui) directory in the AgentScope reposory ships a React front-end matching the backend above; just bring it up: ```bash theme={null} cd examples/web_ui pnpm install pnpm dev ``` Open the URL the dev server prints (usually `http://localhost:5173`); the front-end auto-connects to the service on port 8000. With the front-end open, you can complete the full flow inside the UI — create a knowledge base, upload documents, watch processing progress, and run search tests. ## Deployment Topologies The Quick Start above runs the API and the indexing pipeline in **the same process**, which is fine for local development and low-traffic scenarios. In production, however, parsing / chunking / embedding is a CPU- and IO-heavy pipeline; sharing the process with HTTP requests has two issues: 1. **Resource contention**: a single large PDF blocks the event loop on parsing and slows every other API request in the same process. 2. **Coarse scaling unit**: the only horizontal scaling unit is the API replica, but the real resource hog is the indexing pipeline — scaling the API as a whole is wasteful. To solve this, the service layer supports pulling the indexing pipeline into independent **worker** processes — each worker subscribes to the message bus, pulls files from the blob store, and runs the full "parse → chunk → embed → insert" pipeline. Once decoupled from the API, workers can scale independently and ship heavy parsing dependencies separately. The table below compares the two topologies so you can pick based on traffic and operational complexity: | Dimension | Single-process | Distributed | | --------------------- | ------------------------------------------ | ------------------------------------------------- | | Process topology | API + indexing in the same process | API + N workers | | Resource isolation | Heavy parsing competes for request threads | API is unaffected by parsing load | | Scaling | Scale the API replicas as a whole | Scale API and workers independently | | Deployment complexity | One configuration is enough | Need two images / services | | Use case | Local, prototype, light traffic | Production, parsing / embedding is the bottleneck | ### Single-process deployment `create_app`'s `enable_index_worker` defaults to `True`; the API process automatically starts an embedded worker coroutine in its lifespan — no extra configuration needed. This is exactly the form shown in "Quick Start". If you previously disabled it, set it back to `True`: ```python theme={null} app = create_app( ..., enable_index_worker=True, # default, can be omitted ) ``` ### Distributed deployment The API process disables the embedded worker and only accepts uploads, enqueues tasks, and runs the safety-net sweeper; one or more worker processes start independently, subscribe to the same message-bus channel, and pull tasks. API side: ```python theme={null} app = create_app( storage=storage, message_bus=message_bus, workspace_manager=workspace_manager, knowledge_base_manager=kb_manager, blob_store=blob_store, enable_index_worker=False, # ← no embedded worker in the API process ) ``` The worker side has two launch flavours: * **CLI**: `python -m agentscope.app.rag.index_worker`, combined with the environment variable `AGENTSCOPE_WORKER_BOOTSTRAP=module:callable` pointing at a factory that returns the backend dict. Operators can copy the same systemd / k8s unit to scale workers in bulk. * **Library**: in your own entry script, call `agentscope.app.rag.index_worker.run_worker(...)` (or `from agentscope.app.rag import run_worker`), sharing the same backend instances with whatever you wired into `create_app`. Here is the minimum library-mode example. **Critical convention**: the API and the workers must be configured against the same storage / message bus / blob store / knowledge base manager — they share the vector store collections, blob URIs, and document leases, and a mismatch on any of them will result in indexing failure or data corruption. ```python theme={null} import asyncio import os from agentscope.app.rag import run_worker from agentscope.app.rag.blob_store import S3BlobStore from agentscope.app.rag.knowledge_base_manager import CollectionPerKbManager from agentscope.app.message_bus import RedisMessageBus from agentscope.app.storage import RedisStorage from agentscope.rag import ( ApproxTokenChunker, ImageParser, PDFParser, PPTParser, QdrantStore, TextParser, ) async def main() -> None: storage = RedisStorage(url=os.environ["REDIS_URL"]) message_bus = RedisMessageBus(url=os.environ["REDIS_URL"]) blob_store = S3BlobStore(bucket=os.environ["S3_BUCKET"]) vector_store = QdrantStore(url=os.environ["QDRANT_URL"]) kb_manager = CollectionPerKbManager( storage=storage, vector_store=vector_store, ) await run_worker( storage=storage, message_bus=message_bus, blob_store=blob_store, knowledge_base_manager=kb_manager, # Keep the parser list identical to the API side parsers=[ TextParser(), PDFParser(), PPTParser(), ImageParser(), ], chunkers=[ApproxTokenChunker], worker_max_concurrency=4, # max documents this worker processes concurrently consumer_max_batch=32, # max entries pulled per bus signal ) if __name__ == "__main__": asyncio.run(main()) ``` The CLI form needs a bootstrap factory — same `run_worker(...)` call, just split into "build the kwargs" and "invoke" — returning the kwargs dict to be forwarded to `run_worker`: ```python theme={null} # mydeploy/worker_bootstrap.py import os from agentscope.app.rag.blob_store import S3BlobStore from agentscope.app.rag.knowledge_base_manager import CollectionPerKbManager from agentscope.app.message_bus import RedisMessageBus from agentscope.app.storage import RedisStorage from agentscope.rag import ( ApproxTokenChunker, ImageParser, PDFParser, PPTParser, QdrantStore, TextParser, ) def bootstrap() -> dict: storage = RedisStorage(url=os.environ["REDIS_URL"]) message_bus = RedisMessageBus(url=os.environ["REDIS_URL"]) vector_store = QdrantStore(url=os.environ["QDRANT_URL"]) return { "storage": storage, "message_bus": message_bus, "blob_store": S3BlobStore(bucket=os.environ["S3_BUCKET"]), "knowledge_base_manager": CollectionPerKbManager( storage=storage, vector_store=vector_store, ), "parsers": [ TextParser(), PDFParser(), PPTParser(), ImageParser(), ], "chunkers": [ApproxTokenChunker], } ``` Then launch like this: ```bash theme={null} AGENTSCOPE_WORKER_BOOTSTRAP=mydeploy.worker_bootstrap:bootstrap \ python -m agentscope.app.rag.index_worker ``` HA / replication of the vector store itself is the chosen backend's responsibility; the service layer only holds a connection handle. Pointing Qdrant at a cluster or S3 at a cross-region bucket is enough to scale the storage side without touching application code. ## How It Works The service layer's core design is **using the message bus (event bus) to fully decouple "upload" and "indexing"** — the former is a synchronous path optimised for millisecond responses, the latter is asynchronous, retryable, and distribution-friendly. The two paths only talk through a single `index_tasks` channel on the bus, which is why the same code runs single-process in "Quick Start" and scales across hosts in "Distributed deployment" with no business logic changes. The roles around the bus: | Role | Process | Relationship with the bus | Responsibility | | ---------------------------- | ----------------------------------------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------- | | Knowledge base service (API) | API process | **Publishes** index tasks | Handles HTTP requests, streams blobs, persists `pending` records, and pushes tasks to the bus | | Index consumer | Worker process (or embedded in the API process) | **Subscribes** to index signals | Listens to bus signals, batch-pulls tasks, hands them to the index worker | | Index worker | Worker process (or embedded in the API process) | Runs the full pipeline once it gets a task | Lease → parse → chunk → embed → write vector store → mark `ready` | | Index sweeper | API process | **Re-publishes** stuck tasks | Periodically scans for expired leases / long-lived `pending` records and re-enqueues them | The diagram below shows the full path from upload to retrievability; all cross-process communication goes through the bus, so pulling workers out into separate processes requires no wiring changes: ```mermaid theme={null} flowchart TB Client([Client]) subgraph API[API process] Router[KB router] Service[KB service] Sweeper[Index sweeper] end subgraph Worker[Worker process
embedded or standalone] Consumer[Index consumer] IW[Index worker:
parse → chunk → embed] end Blob[(Blob store)] Storage[(Storage)] Bus{{Message bus
index_tasks channel}} VDB[(Vector store)] Client -- "POST /documents" --> Router --> Service Service -- "stream bytes" --> Blob Service -- "upsert record (pending)" --> Storage Service -- "publish index_task" --> Bus Bus -- "signal" --> Consumer Consumer --> IW IW -- "read blob" --> Blob IW -- "status updates" --> Storage IW -- "insert vectors" --> VDB Sweeper -- "expired lease / orphan pending" --> Bus Client -- "POST /search" --> Router --> Service Service -- "KnowledgeBase.search" --> VDB ``` Key points: * **Upload path** (API process): the router forwards the request to the knowledge base service, which **streams** the file into the blob store, persists a `pending` record, and pushes one index task onto the bus; the HTTP response returns immediately, **without running parsing / embedding inside the request**. * **Indexing path** (worker process; can be embedded in the API or deployed standalone): the index consumer subscribes to bus signals, batch-pulls tasks, and hands them to the index worker, which then runs the full "lease → parse → chunk → embed → insert → mark ready" pipeline. Internally the indexing path calls `KnowledgeBaseManagerBase.get_knowledge(...)` to obtain a runtime `KnowledgeBase` handle and then calls `insert_document(...)` for embedding + insertion — the same code path library-mode callers run. * **Self-healing path** (always in the API process): the index sweeper periodically detects expired leases or long-stuck `pending` records and re-enqueues them on the bus; the CAS-based lease on the worker side guarantees no duplicate processing. ### Document state machine A document record's `status` field flows strictly through the following states; the front-end uses it to render progress bars and failure hints: | Status | Trigger | Meaning | | ---------- | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `pending` | Upload complete | File written to the blob store, record persisted; waiting for a worker to pick it up | | `parsing` | Worker acquires the lease | Streaming bytes from the blob store and handing them to the parser | | `chunking` | Parser returns | Chunking the parsed sections | | `indexing` | Chunker returns | Embedding and writing to the vector store | | `ready` | Vector-store write succeeded | Document is retrievable; `chunk_count` is populated at this moment | | `error` | Any stage raises | The error is reduced to one line and written into the `error` field; the blob and record are preserved so the front-end can investigate / the user can re-upload | ### Fault tolerance and self-healing The service layer ships a few designs around bus + lease that make long-running deployments uneventful: * **Lease + CAS prevents reentry**: the worker uses storage-layer CAS to acquire the lease; duplicate enqueues or multiple workers racing for the same document only execute once. * **Automatic lease renewal**: leases live for 90 seconds by default and a built-in heartbeat renews every 45 seconds, so long-document parses never time out. * **Race detection**: the worker runs the pipeline and the heartbeat in parallel; if the heartbeat detects a stolen lease (sweeper false positive / network blip), the pipeline is cancelled immediately to avoid double-writes against the vector store with the new owner. * **Safety-net re-dispatch**: documents whose lease expired (worker crash) or whose `pending` exceeded the grace period (API publish failed) are periodically re-enqueued. * **Errors isolated to the record**: an exception at any stage is recorded in the document's `error` field — visible in the front-end. The blob and the record are not auto-cleaned, so the user can investigate and re-upload. * **Idempotent delete path**: vector store → record → blob, in that order; a mid-way failure followed by a retry never leaves the state inconsistent. Parsers run on the event-loop thread by default. If you bring in CPU-intensive parsers (e.g. `PDFParser`, `PPTParser` or anything Office-related), **always** pass `parser_executor=ProcessPoolExecutor(...)` to the worker, otherwise other asyncio tasks in the same process (and in the single-process deployment, the API itself) will be blocked. ## REST API Overview The service exposes a full set of CRUD + upload + search endpoints under the `/knowledge_bases` prefix. Field-level request / response details live in the OpenAPI document; the table below groups endpoints by responsibility: | Category | Endpoint | Description | | -------------------- | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | Capability discovery | `GET /knowledge_bases/embedding_models` | Lists embedding models compatible with the vector store's dimension policy under the current user's credentials | | Capability discovery | `GET /knowledge_bases/supported_content_types` | Lists IANA media types and file extensions supported by the parsers currently mounted on the API; the front-end uses this for `` | | Capability discovery | `GET /knowledge_bases/chunkers` | Lists the available chunkers and the JSON Schema of their `Parameters` | | Capability discovery | `GET /knowledge_bases/middleware/parameters_schema` | Returns the JSON Schema of `RAGMiddleware.Parameters`; the front-end uses this for a dynamic form | | Knowledge base CRUD | `POST/GET/PATCH/DELETE /knowledge_bases` | Create / read / update / delete knowledge bases; deletion cascades through the collection, document records, and blobs | | Document management | `GET/POST/DELETE /knowledge_bases/{kb_id}/documents` | List / upload / delete documents; uploads return immediately with `pending` | | Status polling | `GET /knowledge_bases/{kb_id}/documents/status?ids=a,b,c` | Batch-query the current state of N in-flight documents; used by the front-end for progress rendering | | Search | `POST /knowledge_bases/{kb_id}/search` | Natural-language query, returns the top-K retrieval results | Upload and search both go through the **same** knowledge base handle — the service-layer `POST /search` endpoint internally calls `KnowledgeBaseService.search`, which delegates to `KnowledgeBase.search`. That is the same code path library-mode callers and the `RAGMiddleware` go through. In other words, debugging retrieval through the `/search` endpoint reproduces exactly what the agent sees at inference time. ## Further Reading Learn the atomic interfaces of parser / chunker / vector store / middleware and their library-mode usage. `create_app`'s global parameters, lifespan, dependency injection, and ASGI middleware layer. Which hooks `RAGMiddleware` uses to inject retrieval results. Embedding-model cards and dimension constraints decide which models a knowledge base can pick. # Resource Sharing Source: https://docs.agentscope.io/versions/2.0.8/en/deploy/sharing Share API credentials, agents, and knowledge bases as a team or organization Resource sharing makes one user's **credentials**, **agents**, and **knowledge bases** visible (usable) or editable to other users. By default, the Agent Service in AgentScope is strictly isolated per tenant — no user can see another user's records (see [Resource Model](/versions/2.0.8/en/deploy/agent-service#resource-model)). Resource sharing is the sanctioned way to share resources while keeping data secure. Typical scenarios include: * **Shared team API keys**: one administrator configures the API key once, and everyone on the team can use it without ever seeing the secret itself. * **Publishing an agent**: offer a well-tuned agent as a service to other users, or to a whole team / department. * **A shared knowledge base**: a single indexed knowledge base (a product handbook, a policy set) is queried by an entire team instead of each user rebuilding it. * **Co-maintaining a knowledge base**: a small group jointly maintains an agent or knowledge base, and every member can edit it. ## How it works AgentScope implements secure resource sharing through the **resource access policy**: it maps a viewer's `user_id` to the resources they can reach — think of it as a routing table for resources. The Agent Service itself **carries no user, group, or membership model**; this sharing relationship can come from any identity system (config, IAM, LDAP, database). On a resource-related request, the service first resolves the `viewer_id` (the *viewer's `user_id`*) and governs access through the policy's two interfaces: * `list_accessible` decides **what the viewer can see and use**, and * `can_edit` decides **whether the viewer may make changes**. The diagram below traces these two paths. ```mermaid theme={null} flowchart TD V([viewer_id resolved by the service]) Dir[(Identity system
IAM, LDAP, config, database)] V --> P[Resource access policy instance
ResourceAccessPolicyBase] Dir -.consumed by.-> P P -->|"list_accessible(viewer_id, kind)"| A["Accessible resources
kind · owner_id · resource_id · permission"] P -->|"can_edit(viewer_id, kind,
owner_id, resource_id)"| B{Edit allowed?} A --> Vis[[Visibility
shared resources + the viewer's own resources]] B --> Mut[[Edit permission
editable vs. read-only]] ``` In this flow, the service injects the viewer id and its storage instance into `list_accessible` and `can_edit`; the deployer implements the "which resources this viewer can reach" logic inside these two methods based on that input. | Method | Required | Description | | ----------------------------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `list_accessible(viewer_id, kind, storage)` | Yes | Return the `ResourceRef`s of `kind` that `viewer_id` may access, **excluding** the viewer's own resources. This single method drives list, get, and runtime resolution for every shared resource. | | `can_edit(viewer_id, kind, owner_id, resource_id, storage)` | No | Whether `viewer_id` may modify a resource. The default derives the answer from `list_accessible` (grant when a matching entry carries `EDIT`); override it to implement custom authorization logic. | The relevant types are: | Type | Role | | -------------------------- | ---------------------------------------------------------------------------- | | `ResourceAccessPolicyBase` | The abstract policy the deployer subclasses and passes to `create_app`. | | `ResourceKind` | The shared resource type: `CREDENTIAL`, `AGENT`, or `KNOWLEDGE_BASE`. | | `ResourcePermission` | The access level granted: `READ` (see and use) or `EDIT` (use and modify). | | `ResourceRef` | An index to a shared resource — `(kind, owner_id, resource_id, permission)`. | Developers / deployers implement their business-specific resource-sharing logic by subclassing and implementing `ResourceAccessPolicyBase`. A shared agent is not limited to direct conversation: as long as it has `Invitable` set, the viewer's leader agent can bring it into a team with `AgentInvite` — see [Set Invitation Scope](/versions/2.0.8/en/deploy/agent-team#set-invitation-scope). The default policy in the Agent Service is `DenyAllResourceAccessPolicy` — no resources are shared across users. ## Implementation In the Agent Service, the deployer supplies the resource-sharing policy at the `create_app` entry point, telling the service *who can see what*. Sharing a credential, an agent, or a knowledge base differs only in the `kind` of the `ResourceRef` you return. Subclass `ResourceAccessPolicyBase` and implement the sharing logic you need. `list_accessible` is the abstract interface you must implement; `can_edit` is optional. Both are async and receive a `storage` argument when called, giving access to the backing store — so your implementation can freely consult an external source (your company's user directory, an org chart, project-membership tables, an IAM service) and turn those relationships into resource grants. The class below shows the interface you fill in: ```python policy.py theme={null} from agentscope.app.access import ( ResourceAccessPolicyBase, ResourceKind, ResourceRef, ) from agentscope.app.storage import StorageBase class MyResourceAccessPolicy(ResourceAccessPolicyBase): """Map a user id to the resources visible to them.""" async def list_accessible( self, viewer_id: str, kind: ResourceKind, storage: StorageBase, ) -> list[ResourceRef]: # Look up, in your own system, the resources of the given `kind` # that `viewer_id` may access; no need to include the user's own # resources. ... async def can_edit( self, viewer_id: str, kind: ResourceKind, owner_id: str, resource_id: str, storage: StorageBase, ) -> bool: # Optional. Decide whether the user behind `viewer_id` may edit # this resource. By default it matches against the resources # returned by `list_accessible` and answers based on the # permission level. ... ``` Each shared resource is represented by one `ResourceRef` instance. `kind` is the resource type and `permission` decides read-only versus editable. The tabs below show shared-resource instances of each type: ```python Credential theme={null} from agentscope.app.access import ( ResourceKind, ResourcePermission, ResourceRef, ) # Share Alice's API credential with Bob as read-only. # Bob can use this credential to create agents and run sessions, # but never sees the credential's real value. ref = ResourceRef( kind=ResourceKind.CREDENTIAL, owner_id="alice", resource_id="cred-openai-prod", permission=ResourcePermission.READ, ) ``` ```python Agent theme={null} from agentscope.app.access import ( ResourceKind, ResourcePermission, ResourceRef, ) # Share the agent Alice created with Bob; Bob may also edit it. ref = ResourceRef( kind=ResourceKind.AGENT, owner_id="alice", resource_id="agent-support-bot", permission=ResourcePermission.EDIT, ) ``` ```python Knowledge Base theme={null} from agentscope.app.access import ( ResourceKind, ResourcePermission, ResourceRef, ) # Share Alice's knowledge base with Bob; defaults to read-only (query only). ref = ResourceRef( kind=ResourceKind.KNOWLEDGE_BASE, owner_id="alice", resource_id="kb-handbook", ) ``` Pass the policy instance to `create_app` via the `resource_access_policy` parameter. From then on, the list and get endpoints for credentials, agents, and knowledge bases merge each viewer's own resources with those shared to them. ```python app.py theme={null} from agentscope.app import create_app from policy import MyResourceAccessPolicy app = create_app( # ...existing code... resource_access_policy=MyResourceAccessPolicy(), ) ``` Shared credentials are **masked** in every list and get response — a viewer sees only the credential's `type` and `name`, never the secret payload. The raw secret is resolved only inside trusted runtime paths (chat / embedding / TTS model construction) when the viewer actually runs the agent. Do not add endpoints that echo a resolved credential back to the client. Views returned to a viewer carry an `editable` flag computed from the ref's permission, so a frontend can render read-only versus editable resources without a second authorization round-trip. A viewer with only `READ` who attempts a `PATCH`/`DELETE` receives `403`; a resource they cannot see at all returns `404`. Sharing an agent shares its **configuration** — display name, system prompt, and context / ReAct settings — but **not its workspace content**. MCP client setups, skills, and accumulated memory (`MEMORY.md`) live in the per-user [workspace](/versions/2.0.8/en/deploy/workspace-manager), which is provisioned fresh per viewer, so a shared agent starts from a clean workspace for each user rather than inheriting the owner's tools and memory. Sharing this workspace-resident state is a known gap we are actively working on; follow the tracking issue on GitHub for progress. # Workspace Manager Source: https://docs.agentscope.io/versions/2.0.8/en/deploy/workspace-manager Manage the lifecycle of agent harness workspace The **workspace manager** owns the isolation policy and lifecycle of the [`Workspace`](/versions/2.0.8/en/building-blocks/workspace/overview) instances the Agent Service hands to each agent. It is the service-side counterpart of the `agentscope.workspace` module: for every workspace subclass there is one matching manager class that provisions, caches, and evicts it. Highlights: * **Configurable isolation grain** — `PER_AGENT` (default), `PER_SESSION`, or `PER_USER` — decides how workspaces are shared or isolated across the `(user_id, agent_id, session_id)` dimensions, so one deployment can serve many tenants cleanly. * **Pluggable sandbox backends** — the same lifecycle contract wraps a local directory, a bubblewrap sandbox, a Docker container, an E2B or Daytona or OpenSandbox cloud sandbox, or a Kubernetes Pod. Swapping backend is a one-line change in `create_app`. ## Available managers | Class | Description | | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `LocalWorkspaceManager` | Bare-metal workspaces under a host directory. Zero infra, no sandboxing — the agent runs against your host filesystem. | | `BubblewrapWorkspaceManager` | One [bubblewrap](https://github.com/containers/bubblewrap) sandbox per workspace, under a host directory. Needs only the `bwrap` binary on a Linux host, but shares the host network namespace. | | `DockerWorkspaceManager` | One Docker container per workspace, host directory bind-mounted for persistence. Requires a reachable Docker daemon. | | `E2BWorkspaceManager` | Managed cloud sandboxes via [E2B](https://e2b.dev). Sandboxes auto-suspend when idle and resume on next access. | | `DaytonaWorkspaceManager` | Managed cloud sandboxes via [Daytona](https://www.daytona.io). Reattachment is by sandbox label, and `user_id` / `agent_id` are forwarded as extra labels for dashboard filtering. | | `OpenSandboxWorkspaceManager` | Managed cloud sandboxes via [OpenSandbox](https://github.com/agentscope-ai/opensandbox). Sandboxes carry their own filesystem across pause/resume; reattachment is by sandbox metadata, so any service replica can reconnect. | | `K8sWorkspaceManager` | One Pod + PVC per workspace on a Kubernetes cluster. Fits production clusters where you already run other workloads. | Every manager caches its workspaces and evicts them `ttl` seconds after the last access (3600 by default). `LocalWorkspaceManager` collects expired entries on the next `get_workspace()` call; the sandboxed managers additionally run a background sweeper every `sweep_interval` seconds, so an idle container, sandbox, or Pod is released without waiting for the next request. For an in-depth look at what a workspace *is* (filesystem layout, gateway, MCP wiring, builtin tools), see the [Workspace](/versions/2.0.8/en/building-blocks/workspace/overview) building-block chapter. ## Integrate with Agent Service Pick the manager that matches where you want the agent's tools to execute and pass it to `create_app`. ```python Local theme={null} from agentscope.app import create_app from agentscope.app.workspace_manager import ( IsolationPolicy, LocalWorkspaceManager, ) workspace_manager = LocalWorkspaceManager( # Host root; per-agent workdirs live at `//`. basedir="/data/workspaces", # `PER_AGENT` (default): sessions of the same agent share one workspace. # `PER_SESSION` / `PER_USER` change the grain; see below. isolation=IsolationPolicy.PER_AGENT, ) app = create_app( # ...existing code... workspace_manager=workspace_manager, ) ``` ```python Bubblewrap theme={null} from agentscope.app import create_app from agentscope.app.workspace_manager import ( BubblewrapWorkspaceManager, IsolationPolicy, ) workspace_manager = BubblewrapWorkspaceManager( # Host root; each workspace gets `//`, # mounted at `/workspace` inside the sandbox. basedir="/data/workspaces", isolation=IsolationPolicy.PER_AGENT, # `None` lets each workspace pick an available loopback port for its gateway. gateway_port=None, # Extra requirements installed into the in-sandbox gateway venv. extra_pip=[], ttl=3600.0, ) app = create_app( # ...existing code... workspace_manager=workspace_manager, ) ``` ```python Docker theme={null} from agentscope.app import create_app from agentscope.app.workspace_manager import ( DockerWorkspaceManager, IsolationPolicy, ) workspace_manager = DockerWorkspaceManager( # Host root bind-mounted to `/workspace` inside each container. basedir="/data/workspaces", isolation=IsolationPolicy.PER_AGENT, # Base image; must ship `python3`. The image is content-hashed and # rebuilt only when the Dockerfile inputs change. base_image="python:3.11-slim", # Node.js major version baked into the image (needed by npx-based MCPs). node_version="20", # Seconds an idle container stays cached before the sweeper evicts it. ttl=3600.0, ) app = create_app( # ...existing code... workspace_manager=workspace_manager, ) ``` ```python E2B theme={null} from agentscope.app import create_app from agentscope.app.workspace_manager import ( E2BWorkspaceManager, IsolationPolicy, ) workspace_manager = E2BWorkspaceManager( isolation=IsolationPolicy.PER_AGENT, # E2B template id that ships the runtime the agent needs. template="base", # `""` falls back to the `E2B_API_KEY` environment variable. api_key="", # Sandbox keep-alive timeout on the E2B side. timeout_seconds=300, ttl=3600.0, ) app = create_app( # ...existing code... workspace_manager=workspace_manager, ) ``` ```python Daytona theme={null} from agentscope.app import create_app from agentscope.app.workspace_manager import ( DaytonaWorkspaceManager, IsolationPolicy, ) workspace_manager = DaytonaWorkspaceManager( isolation=IsolationPolicy.PER_AGENT, # `""` lets the Daytona SDK read credentials from the environment. api_key="", # Optional API URL and target/region, for self-hosted deployments. api_url="", target="", # Sandbox operation timeout on the Daytona side. timeout_seconds=300, ttl=3600.0, ) app = create_app( # ...existing code... workspace_manager=workspace_manager, ) ``` ```python OpenSandbox theme={null} from agentscope.app import create_app from agentscope.app.workspace_manager import ( IsolationPolicy, OpenSandboxWorkspaceManager, ) workspace_manager = OpenSandboxWorkspaceManager( isolation=IsolationPolicy.PER_AGENT, # Image the sandbox boots from; the gateway venv is bootstrapped on top. image="python:3.11-slim", # `""` lets the `opensandbox` SDK fall back to its env-based config. api_key="", # Optional server domain, for self-hosted / on-prem deployments. domain="", protocol="http", # Sandbox keep-alive timeout on the OpenSandbox side. timeout_seconds=300, ttl=3600.0, ) app = create_app( # ...existing code... workspace_manager=workspace_manager, ) ``` ```python Kubernetes theme={null} from agentscope.app import create_app from agentscope.app.workspace_manager import ( IsolationPolicy, K8sWorkspaceManager, ) workspace_manager = K8sWorkspaceManager( isolation=IsolationPolicy.PER_AGENT, # K8s namespace for the workspace Pods and PVCs. namespace="agentscope", # `None` uses the in-cluster config; set a path for out-of-cluster use. kubeconfig=None, # Container image for each Pod. image="python:3.11-slim", # PVC size backing the workspace filesystem. storage_size="1Gi", ttl=3600.0, ) app = create_app( # ...existing code... workspace_manager=workspace_manager, ) ``` ## Isolation grain `isolation` decides how workspaces are shared or isolated across the `(user_id, agent_id, session_id)` triple. The manager mints a `workspace_id` at session-creation time under the selected policy; every request carrying the same `workspace_id` lands in the same underlying workspace. | Value | Sharing rule | Typical use | | --------------------- | --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | `PER_AGENT` (default) | All sessions of the same `(user_id, agent_id)` share one workspace | Give each agent a persistent, per-user working directory across chats — files, skills, MCP registrations survive between sessions. | | `PER_SESSION` | Every session gets its own workspace | Sessions must not leak state to each other — e.g. one-shot sandboxed evaluations or short-lived automation runs. | | `PER_USER` | All sessions of the same `user_id` share one workspace, regardless of which agent | Multiple agents of the same user collaborate on one filesystem (rare; use with care). | Explicit `workspace_id` on the session-creation request always overrides the policy. This is what the built-in team tools (`AgentCreate` / `AgentInvite`) rely on to make a sub-agent's session share the team leader's workspace. ## How it works in the Agent Service The workspace manager is a **singleton application-scoped resource**: one instance per Agent Service process, shared across every request. Its job is to (a) *decide* which workspace a request belongs to under the configured `isolation` policy, and (b) *serve* that workspace as a cheap dependency to the routers and services that need it. ### Wiring The instance you pass to `create_app(workspace_manager=...)` is attached to `app.state` and exposed to the rest of the codebase through a FastAPI dependency: ```python theme={null} # agentscope/app/_app.py app.state.workspace_manager = workspace_manager # agentscope/app/deps.py async def get_workspace_manager(request: Request) -> WorkspaceManagerBase: return request.app.state.workspace_manager ``` Every router, service, and built-in tool that touches a workspace consumes it via this dependency (or via constructor injection for the services): * **Routers** — `/session`, `/workspace/*` (MCP, skills). They call `Depends(get_workspace_manager)` and then `manager.get_workspace(...)` to resolve the caller's workspace. * **`ChatService`** — receives the manager in its constructor from the lifespan and calls `get_workspace` on every chat run to obtain the workspace the agent will act inside. * **Built-in team tools** (`AgentCreate` / `AgentInvite`) — receive the manager to call `assign_workspace_id(...)` when spinning up sub-agent sessions, so the sub-agent lands in the same workspace as the leader. ### Lifecycle The manager is an **async context manager**. The application's `lifespan` enters it on startup and exits it on shutdown through a single `AsyncExitStack`, so any background machinery it owns (e.g. the idle-TTL sweeper in the `Bubblewrap` / `Docker` / `E2B` / `Daytona` / `OpenSandbox` / `K8s` managers) starts and stops in lockstep with the rest of the service: ```python theme={null} # agentscope/app/_lifespan.py (excerpt) workspace_manager = app.state.workspace_manager async with AsyncExitStack() as stack: await stack.enter_async_context(storage) await stack.enter_async_context(message_bus) await stack.enter_async_context(workspace_manager) # ← here ... yield # ← on exit, `__aexit__` calls `close_all()` and tears down every cached workspace. ``` ### End-to-end request flow Once the service is running, a typical interaction goes: 1. **Session creation** — the `/session` router computes the workspace id once and persists it on the session record: ```python theme={null} # agentscope/app/_router/_session.py (excerpt) resolved_workspace_id = body.workspace_id or ( workspace_manager.assign_workspace_id( user_id=user_id, agent_id=agent_id, session_id=session_id, ) ) ``` An explicit `body.workspace_id` always wins (that's how team tools make a sub-agent share the leader's workspace); otherwise the manager mints one under the configured `isolation` policy. `assign_workspace_id` is a pure function — no I/O, no cache lookup — so it is cheap to call on the hot path. 2. **Every subsequent request** carrying that session — chat runs, MCP registration, skill upload, … — pulls the stored `workspace_id` off the session record and asks the manager to materialise it: ```python theme={null} workspace = await workspace_manager.get_workspace( user_id, agent_id, session_id, session_record.config.workspace_id, ) ``` `get_workspace` is the cache-hit path. It looks up `workspace_id` in the manager's internal `dict`, refreshes the last-access timestamp, and returns the initialised `Workspace` in O(1). Only on a miss does it acquire an `asyncio.Lock`, provision the backend (build the Docker image, start the E2B or OpenSandbox sandbox, create the K8s Pod, …), and cache the result — the lock prevents concurrent requests for the same id from racing to spin up two backends. 3. **Idle eviction** — the sandbox-backed managers run a background sweeper (started in `__aenter__`) that periodically walks the cache, closes any workspace whose last-access time exceeds `ttl`, and drops it. The next request for that id re-provisions transparently. 4. **Shutdown** — the lifespan exits the manager, which calls `close_all()` and tears down every cached workspace and its background sweeper. Because `workspace_id` is minted **once at session creation** and stored, changing the `isolation` policy on a running deployment does **not** re-partition existing sessions — they keep the id they were assigned. New sessions pick up the new policy. ## Core APIs Every manager implements the following contract from `WorkspaceManagerBase`. Custom subclasses only need to fill in the abstract methods; the isolation logic in `assign_workspace_id` is inherited from the base and driven by the `isolation` constructor argument. | Method | Purpose | | ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `assign_workspace_id(*, user_id, agent_id, session_id) -> str` | Mint a workspace id for a fresh session under the configured `isolation` policy. Pure function, no I/O. Called by the session-creation flow when the client omits `workspace_id`. | | `get_workspace(user_id, agent_id, session_id, workspace_id=None) -> WorkspaceBase` | Return an initialized workspace bound to `workspace_id`. Cache-hit path on the hot request loop; on miss the manager provisions the underlying backend and caches the result. `workspace_id=None` falls back to `assign_workspace_id`. | | `create_workspace(user_id, agent_id, session_id) -> WorkspaceBase` | Provision a brand-new workspace and track it. Used when the caller has no persisted id yet. | | `close(workspace_id)` | Evict one workspace from the cache and tear down its backend. | | `close_all()` | Evict every cached workspace — called on service shutdown. | | `async with manager: ...` | Enter/exit the manager's lifetime. Enter starts background machinery (e.g. the TTL sweeper); exit calls `close_all`. | ## Custom manager Any workspace class you build against [`WorkspaceBase`](/versions/2.0.8/en/building-blocks/workspace/overview) can be surfaced to the Agent Service by pairing it with a `WorkspaceManagerBase` subclass. In most cases you only need to fill in a small provisioning + cache scaffold — the isolation policy is inherited. ```python Custom manager theme={null} import asyncio import time from typing import Self from agentscope.app.workspace_manager import ( IsolationPolicy, WorkspaceManagerBase, ) from agentscope.workspace import WorkspaceBase class MyWorkspaceManager(WorkspaceManagerBase): """Thin manager over a custom `MyWorkspace` backend.""" def __init__( self, *, isolation: IsolationPolicy = IsolationPolicy.PER_AGENT, ttl: float = 3600.0, ) -> None: super().__init__(isolation=isolation) self._ttl = ttl # workspace_id -> (workspace, last_access_monotonic) self._cache: dict[str, tuple[WorkspaceBase, float]] = {} self._lock = asyncio.Lock() async def get_workspace( self, user_id: str, agent_id: str, session_id: str, workspace_id: str | None = None, ) -> WorkspaceBase: # Fall back to the manager's isolation policy when no explicit # binding was persisted for this session. if workspace_id is None: workspace_id = self.assign_workspace_id( user_id=user_id, agent_id=agent_id, session_id=session_id, ) async with self._lock: hit = self._cache.get(workspace_id) if hit is not None: ws, _ = hit self._cache[workspace_id] = (ws, time.monotonic()) return ws ws = MyWorkspace(workspace_id=workspace_id) # your backend await ws.initialize() self._cache[workspace_id] = (ws, time.monotonic()) return ws async def create_workspace( self, user_id: str, agent_id: str, session_id: str, ) -> WorkspaceBase: ws = MyWorkspace() # let the workspace mint its own id await ws.initialize() async with self._lock: self._cache[ws.workspace_id] = (ws, time.monotonic()) return ws async def close(self, workspace_id: str) -> None: async with self._lock: entry = self._cache.pop(workspace_id, None) if entry is not None: await entry[0].close() async def close_all(self) -> None: async with self._lock: entries = list(self._cache.values()) self._cache.clear() await asyncio.gather( *(ws.close() for ws, _ in entries), return_exceptions=True, ) ``` Pass an instance of `MyWorkspaceManager` to `create_app(workspace_manager=...)` and the service will use it exactly like the built-in ones. If your backend needs a background sweeper for idle eviction or long-running provisioning, override `__aenter__` / `__aexit__` to start/stop that machinery — see the source of `DockerWorkspaceManager` for a full example. ## Distributed deployment `LocalWorkspaceManager`, `BubblewrapWorkspaceManager`, and `DockerWorkspaceManager` are **single-node** — the workspace state lives on the host running the service process. In a horizontally scaled deployment (multiple Agent Service worker nodes behind a load balancer) a request for the same `workspace_id` can land on any node, and none of these managers can reach a workspace provisioned on another node. For distributed deployments use a **cloud-managed sandbox backend**: * **`E2BWorkspaceManager`** — sandboxes are addressable by metadata across nodes; the service simply reattaches to the existing sandbox on cache miss. * **`DaytonaWorkspaceManager`** — sandboxes carry the `agentscope.workspace.id` label; any service replica reattaches by that label on cache miss. * **`OpenSandboxWorkspaceManager`** — sandboxes carry the `agentscope.workspace.id` metadata tag; any service replica reattaches by filtering `list_sandbox_infos(...)` on cache miss, no host-local state involved. * **`K8sWorkspaceManager`** — Pods and PVCs are cluster-scoped resources; any service replica in the cluster reattaches to the same Pod by workspace-id-derived name. Keep `LocalWorkspaceManager` for local development, `BubblewrapWorkspaceManager` or `DockerWorkspaceManager` for a single-host production box, and pick E2B, Daytona, OpenSandbox, or K8s once you scale out. # What's AgentScope 2.0? Source: https://docs.agentscope.io/versions/2.0.8/en/index Secure, efficient, flexible, and complete. AgentScope 2.0 is a production-ready agent framework built for security, efficiency, flexibility, and completeness — with **multi-tenant**, **multi-session** management and **distributed deployment** as first-class citizens. AgentScope 2.0 is a breaking change from 1.0, with significant improvements. We recommend users to migrate to 2.0 to take advantage of the new features and improvements. * **Secure**: Triple-layer protection via tool-specific review, human-in-the-loop permissions, and sandboxing. * **Efficient**: Intelligent orchestration for concurrent or sequential tool execution based on tool properties. * **Flexible**: Non-invasive modification of agent runtime behavior via agent and tool middleware. * **Complete**: Full-stack coverage with development SDK, frontend UI, multi-tenant multi-session backend, and distributed deployment. ## Full-stack capabilities, production-hardened From reasoning agent to enterprise deployment, AgentScope covers the full agent development lifecycle. Autonomous agent with ReAct reasoning and tool execution. Built-in human-in-the-loop oversight and efficient tool orchestration. Agent self-managed tool system with Python functions, MCP and skills integration. Compression, offload and agentic retrieval for context management and long-term memory. Third-party integration with Mem0, ReMe and other vector databases. Tool execution in isolated environments (local filesystem, Docker, E2B, K8s, etc.) with multi-granularity isolation at user, agent, and session levels. One-click start of a production-ready backend with multi-tenant, multi-session management and distributed deployment, plus a frontend UI and development SDK. # Quickstart Source: https://docs.agentscope.io/versions/2.0.8/en/quickstart Get up and running with AgentScope 2.0 in minutes ## Installation AgentScope requires Python 3.11+, and you can install it from PyPI or from source. It's recommended to install AgentScope by using [uv](https://github.com/astral-sh/uv). ### From PyPI ```bash theme={null} uv pip install agentscope ``` ### From Source ```bash theme={null} git clone -b main https://github.com/agentscope-ai/agentscope cd agentscope uv pip install -e . ``` ### Verify Installation To ensure AgentScope is installed successfully, check via executing the following code: ```python theme={null} import agentscope print(agentscope.__version__) ``` ## Your First Agent The snippet below builds the minimal agent: a DashScope credential, the matching chat model, an empty toolkit, and an `Agent`. The agent exposes two entry points — `reply` returns the final message, while `reply_stream` yields incremental events as the agent reasons and acts. ```python theme={null} import asyncio import os from agentscope.agent import Agent from agentscope.credential import DashScopeCredential from agentscope.event import EventType from agentscope.message import UserMsg from agentscope.model import DashScopeChatModel from agentscope.tool import Toolkit, Bash, Read, Write, Edit async def main() -> None: agent = Agent( name="Friday", system_prompt="You are a helpful assistant named Friday.", model=DashScopeChatModel( credential=DashScopeCredential( api_key=os.getenv("DASHSCOPE_API_KEY"), ), model="qwen-plus", ), toolkit=Toolkit(tools=[Bash(), Read(), Write(), Edit()]), ) user_msg = UserMsg(name="user", content="Hello, who are you?") # Option 1: await the final assistant message. reply_msg = await agent.reply(user_msg) # `reply_msg` is an `AssistantMsg` whose `content` is a list of blocks. # Inspect text blocks, tool calls, etc. as needed. ... # Option 2: stream incremental events (text deltas, tool calls, ...). async for event in agent.reply_stream(user_msg): # Dispatch on `event.type` — each branch handles one event kind. match event.type: case EventType.TEXT_BLOCK_DELTA: # Streaming text chunk from the model — append to UI / stdout. ... case EventType.TOOL_CALL_START: # The agent is about to invoke a tool — surface the call. ... case _: # Other events: thinking blocks, tool results, reply end, ... ... asyncio.run(main()) ``` Set `DASHSCOPE_API_KEY` in your environment before running the script. To use a different provider, swap `DashScopeCredential` and `DashScopeChatModel` for the matching pair (e.g. `OpenAICredential` and `OpenAIChatModel`). ## Extra Dependencies To satisfy the requirements of different functionalities, AgentScope provides extra dependencies that can be installed based on your needs. * **full**: including extra dependencies for model APIs, tool functions and more. * **dev**: development dependencies, including testing and documentation tools. For example, when installing the full dependencies, the installation command varies depending on your operating system. * For Windows users: ```bash theme={null} uv pip install agentscope[full] ``` * For Mac and Linux users: ```bash theme={null} uv pip install agentscope\[full\] ``` # Release Notes Source: https://docs.agentscope.io/versions/2.0.8/en/release-notes Version history of AgentScope 2.x, including new features, changes, and bug fixes grouped by module for each release. For the full commit-level history and contributor list, see the [GitHub releases page](https://github.com/agentscope-ai/agentscope/releases). ## v2.0.8 *Released on 2026-09-08.* **Highlight**: This release adds * a realtime voice agent and the `agentscope.realtime` module, * `A2AAgent` for talking to remote A2A agents, * `GoalPipeline`, a pipeline with a verifier, * context compression the agent triggers itself, and * LLM reranking of RAG results. ### Added **Realtime** * **Realtime voice agent**: the new `RealtimeAgent` and `agentscope.realtime` module support spoken conversation with barge-in, tool calls, and per-turn latency metrics. ([#2547](https://github.com/agentscope-ai/agentscope/pull/2547), [#2550](https://github.com/agentscope-ai/agentscope/pull/2550)) * **Realtime models and audio transport**: DashScope's Qwen-Omni-Realtime and Qwen-Audio-3.0-Realtime models ship built in, along with a transport for the local microphone and speaker. ([#2547](https://github.com/agentscope-ai/agentscope/pull/2547)) **A2A** * **`A2AAgent`** lets an application drive a remote agent speaking A2A 1.0 as if it were local, holding the remote conversation and Task across turns. ([#2142](https://github.com/agentscope-ai/agentscope/pull/2142)) **Pipeline** * **New `pipeline` module**: its first member, `GoalPipeline`, runs an executor agent until a verifier agent accepts its work — for tasks with a clear acceptance bar. ([#2428](https://github.com/agentscope-ai/agentscope/pull/2428)) **Agent** * **Agent-driven context compression**: the new `CompressContext` tool lets an agent compress between tasks instead of waiting for a hard threshold to fire. ([#2143](https://github.com/agentscope-ai/agentscope/pull/2143)) * **Final summary at the iteration limit**: with no final text when `max_iters` is reached, one more model call runs with tools disabled, so the reply is no longer empty. ([#2443](https://github.com/agentscope-ai/agentscope/pull/2443)) * **Hint after repeated tool errors** tells the agent to try another approach once the same call keeps failing, instead of letting it retry as-is. ([#1816](https://github.com/agentscope-ai/agentscope/pull/1816)) **Model** * **Volcengine Ark**: the new `VolcengineChatModel`, credential and formatters give Doubao models first-class support instead of the generic OpenAI integration. ([#2532](https://github.com/agentscope-ai/agentscope/pull/2532)) **RAG** * **LLM reranking**: `RAGMiddleware` accepts a rerank model that reorders retrieved chunks by semantic relevance after the vector search, improving retrieval quality. ([#1975](https://github.com/agentscope-ai/agentscope/pull/1975)) **MCP** * **Runtime HTTP headers**: a Streamable HTTP client can swap its request headers while running, so a rotated token no longer means rebuilding the client; in Docker workspaces the update is relayed through the gateway. ([#2456](https://github.com/agentscope-ai/agentscope/pull/2456)) **Agent Service** * **Interactive channel credential binding** lets a platform hand over its credentials out of band, such as by scanning a QR code, instead of pasting secrets into a form. ([#2484](https://github.com/agentscope-ai/agentscope/pull/2484)) * **One node owns the schedule timers**: the new `enable_scheduler` argument to `create_app` makes a cron fire once across replicas rather than once per replica. ([#2477](https://github.com/agentscope-ai/agentscope/pull/2477)) **WebUI** * **Session auto-naming**: a session created without a name gets a model-generated title after its first reply, replacing an uninformative creation timestamp. ([#2503](https://github.com/agentscope-ai/agentscope/pull/2503)) * **Chat polish**: only the streaming reply re-renders per delta, plus a copy button on every message, a time marker after a long gap, and reopening the last agent and session. ([#2503](https://github.com/agentscope-ai/agentscope/pull/2503)) * **Task panel opens itself** the first time a session produces task data, so progress is visible without opening the panel by hand. ([#2431](https://github.com/agentscope-ai/agentscope/pull/2431)) ### Changed **Formatter** * **Native multimodal tool outputs**: `OpenAIResponseFormatter` writes images and files straight into the tool result instead of promoting them into a synthetic user message, keeping media associated with the call that produced it. ([#2389](https://github.com/agentscope-ai/agentscope/pull/2389)) **Agent Service** * **A session's origin is one tagged union**: `SessionRecord.origin` replaces the old enum plus four nullable ids, so no reader has to check whether the combination makes sense; the old fields remain as deprecated properties. ([#2536](https://github.com/agentscope-ai/agentscope/pull/2536)) **Channel** * **A channel that is starting reads as connecting**, not stopped, so a freshly created one no longer looks like it needs to be started by hand. ([#2487](https://github.com/agentscope-ai/agentscope/pull/2487)) **Tool** * **Schema-guided repair of tool arguments** also runs on valid JSON, so arguments whose types do not match the tool schema no longer fail the call. ([#2496](https://github.com/agentscope-ai/agentscope/pull/2496)) **Dependencies** * **ripgrep is pinned below 15.x**, which ships no wheels for Windows and other platforms and breaks the dev install outright. ([#2526](https://github.com/agentscope-ai/agentscope/pull/2526)) ### Fixed **Agent** * Tokens spent on context compression are now counted in usage instead of being dropped. ([#2433](https://github.com/agentscope-ai/agentscope/pull/2433)) **Tool** * `Edit` / `Write` right after a `Read` no longer fails with "you must first read it" in a sandboxed workspace. ([#2092](https://github.com/agentscope-ai/agentscope/pull/2092)) * A single oversized read-cache entry no longer evicts the others. ([#2534](https://github.com/agentscope-ai/agentscope/pull/2534)) * Concurrent read-cache hits no longer remove an unrelated entry. ([#2457](https://github.com/agentscope-ai/agentscope/pull/2457)) * Completing a task clears it from its dependents' `blocked_by`, so a task whose blockers are resolved no longer looks unavailable. ([#2541](https://github.com/agentscope-ai/agentscope/pull/2541)) **Model** * Gemini's server-side tool tokens count as input rather than output, which had distorted cost and budget accounting. ([#2406](https://github.com/agentscope-ai/agentscope/pull/2406)) * Nullable type arrays in Gemini tool schemas are sanitized before they are sent, so the API no longer rejects them. ([#2437](https://github.com/agentscope-ai/agentscope/pull/2437)) * Encrypted reasoning items survive OpenAI Responses round-trips intact. ([#2426](https://github.com/agentscope-ai/agentscope/pull/2426)) * xAI's reasoning tokens count towards output usage. ([#2461](https://github.com/agentscope-ai/agentscope/pull/2461)) **Formatter** * Audio format is derived from the media type, so signed and extensionless audio URLs are no longer rejected. ([#2301](https://github.com/agentscope-ai/agentscope/pull/2301)) * Multimodal tool results reuse the data block's own stable id, so reformatting the same history produces the same output and provider prefix caching keeps working. ([#2165](https://github.com/agentscope-ai/agentscope/pull/2165)) **RAG** * Distance scores are normalized, so the nearest match no longer ranks last on Qdrant and Milvus Lite. ([#2486](https://github.com/agentscope-ai/agentscope/pull/2486)) * `ExcelParser` escapes backslashes and line breaks in cells, so the generated Markdown table no longer loses columns. ([#2528](https://github.com/agentscope-ai/agentscope/pull/2528)) * `WordParser` preserves blank lines between paragraphs. ([#2538](https://github.com/agentscope-ai/agentscope/pull/2538)) **MCP** * A connection cancelled during initialization is cleaned up, so the same client can reconnect afterwards. ([#2499](https://github.com/agentscope-ai/agentscope/pull/2499)) **Agent Service** * The wake-up queue drains atomically, so one trigger no longer produces duplicate replies across replicas. ([#2476](https://github.com/agentscope-ai/agentscope/pull/2476)) * Schedules are validated before they are persisted, so an invalid cron neither lands in storage nor removes an existing working job. ([#2442](https://github.com/agentscope-ai/agentscope/pull/2442)) * Deleting a session clears its inbox consumer marker, so a session recreated under the same id still receives wake-ups. ([#2519](https://github.com/agentscope-ai/agentscope/pull/2519)) * An agent shared by another user can now be invited into a team. ([#2326](https://github.com/agentscope-ai/agentscope/pull/2326)) **WebUI** * Switching agents no longer shows or requests the previous agent's session. ([#2429](https://github.com/agentscope-ai/agentscope/pull/2429)) * The UI follows system dark mode, so sidebar buttons stay visible under a browser's dark mode. ([#2468](https://github.com/agentscope-ai/agentscope/pull/2468)) * The channel toggle no longer overflows its card. ([#2466](https://github.com/agentscope-ai/agentscope/pull/2466)) * Enter no longer sends a message while a reply is in progress. ([#2529](https://github.com/agentscope-ai/agentscope/pull/2529)) * Running MCP tool-call labels no longer render blank. ([#2490](https://github.com/agentscope-ai/agentscope/pull/2490)) **Tracing** * An interrupted model response is recorded as `interrupted` rather than `stop`. ([#2450](https://github.com/agentscope-ai/agentscope/pull/2450)) **Pipeline** * `GoalPipeline` now honors `verifier_reset_context`, so the verifier no longer carries its previous verdict into the next verification. ([#2544](https://github.com/agentscope-ai/agentscope/pull/2544)) **Prompts, Examples and Docs** * Fixed text defects that reach the model in the workspace instructions, the memory retrieval instructions, and a `GoalPipeline` field description. ([#2513](https://github.com/agentscope-ai/agentscope/pull/2513)) * Hot reload is disabled on Windows in the example service, so the built-in shell tools no longer raise `NotImplementedError`. ([#2434](https://github.com/agentscope-ai/agentscope/pull/2434)) * Corrected the package docstring, a class name in a middleware example, the mem0 example README link, full-width colons in `CONTRIBUTING_zh.md`, and Chinese news entries pointing at the English docs. ([#2502](https://github.com/agentscope-ai/agentscope/pull/2502), [#2493](https://github.com/agentscope-ai/agentscope/pull/2493), [#2438](https://github.com/agentscope-ai/agentscope/pull/2438), [#2511](https://github.com/agentscope-ai/agentscope/pull/2511), [#2546](https://github.com/agentscope-ai/agentscope/pull/2546)) ## v2.0.7 *Released on 2026-08-24.* **Highlight**: This release adds * a terminal console for trying and debugging an agent without launching the web service, * per-agent isolation of MCP sessions and skills inside a shared workspace, * the DingTalk channel, with the channel long connections moved into a dedicated worker, and * team middleware that keeps a member reporting back to its leader. ### Added **Console** * Add the `agentscope.console` module for driving an agent from the terminal. `ConsoleRenderer` consumes `Agent.reply_stream` and renders it — live text and thinking deltas, concurrently streamed tool calls buffered and printed as whole blocks so they never interleave, hint blocks, per-call token usage, and the permission rules suggested on a confirmation request — at three verbosity levels (`quiet` / `default` / `debug`), skipping unknown event types so it keeps working as the event protocol evolves. `launch_console(agent)` wraps it in a zero-code interactive loop with tool-call confirmation (`y` / `N` / `a`lways) and tiered Ctrl+C handling. `rich` becomes a core dependency. ([#2297](https://github.com/agentscope-ai/agentscope/pull/2297)) **Workspace** * Isolate MCP client sessions per agent, so several agents can share one workspace without sharing stateful MCP clients. `list_mcps()` / `add_mcp()` / `remove_mcp()` take an `agent_id`, a workspace's MCP storage becomes `{agent_id: [clients]}`, and each agent's clients are cloned from `default_mcps` and connected on first access. The directory, skills, and offload storage stay shared, and the old `.mcp` file format is migrated automatically. ([#1951](https://github.com/agentscope-ai/agentscope/pull/1951)) * Isolate skills per agent, and select them per session. `skills/` gains a directory level per agent, seeded from `skill_paths` on that agent's first skill call, so an agent editing its own skill no longer changes anyone else's behaviour and no longer pays context tokens for skills it does not use. `agent_id` is keyword-only; a caller that names none gets the `default` partition. ([#2283](https://github.com/agentscope-ai/agentscope/pull/2283)) * Add `WorkspacePrewarmMixin` and `PrewarmConfig`, which keep a small buffer of workspaces built ahead of demand so a session is handed one that is already running instead of waiting out an image pull and a sandbox bootstrap. A request arriving mid-build waits out the build already in flight rather than starting a second one, and `max_creating` caps concurrent builds so a burst of sessions queues instead of stampeding the provider. Wired into the Docker and E2B managers; `size: 0` (the default) disables it. ([#1755](https://github.com/agentscope-ai/agentscope/pull/1755)) **Agent** * An `on_reply` middleware can now veto the end of a reply: receiving `ReplyEndEvent` without yielding it forces another reasoning-acting round inside the same reply — same `reply_id`, no extra `ReplyStartEvent`, `max_iters` still applies — which is what task-constraint enforcement and late-arriving inbox messages need. A busy-loop guard raises when the event is swallowed repeatedly with no reasoning or acting in between. ([#2322](https://github.com/agentscope-ai/agentscope/pull/2322)) * Add `max_image_num` to `ContextConfig`, bounding the number of images kept in the context. `compress_context` enforces it before token counting; the oldest images over the limit are offloaded through `offloader.offload_data_block` and replaced by a hint recording the path, or dropped and replaced by the same hint when no offloader is configured. Defaults to `None`, i.e. no limit. ([#2362](https://github.com/agentscope-ai/agentscope/pull/2362)) **Agent Team** * Add `TeamMemberLoopMiddleware`, which requires a team member to end its reply by reporting to the leader with `TeamSay`, and guides it to ask the leader for permission to continue when it runs out of iterations. A member that keeps ending without reporting is nudged at most `max_nudges` times, after which the reply is released as an error rather than looping under the session lock. ([#2379](https://github.com/agentscope-ai/agentscope/pull/2379)) * Tell the leader when a member's turn ends as `ERROR` or `INTERRUPTED` without reporting. Such a turn previously left the leader waiting for an answer that was never coming; it now receives a hint block naming the member and the reason, delivered the same way `TeamSay` delivers a report. ([#2386](https://github.com/agentscope-ai/agentscope/pull/2386)) **Tool** * `FunctionTool` accepts an `input_schema` argument — a JSON schema dict, or a pydantic `BaseModel` subclass converted through `model_json_schema()` — which takes over the schema instead of extracting it from the function's annotations and docstring. This covers tool definitions migrated from other frameworks and functions whose signature cannot carry annotations, and aligns `FunctionTool` with `MCPTool`, which already passes an external `inputSchema` through. ([#2378](https://github.com/agentscope-ai/agentscope/pull/2378)) * The `Read` tool dispatches by file extension instead of decoding everything as UTF-8: images, audio, and video come back as base64 `DataBlock`s, and PDFs as extracted text per page selected with a new `pages` argument. Text files are unchanged. ([#2114](https://github.com/agentscope-ai/agentscope/pull/2114)) **Channel** * Add `DingTalkChannel`, which puts the agents of the agent service on DingTalk. It takes inbound robot and card callbacks over the official Stream SDK and sends everything outbound over the OpenAPI, so only a Client ID and Client Secret are configured. Direct and group messages are both supported, with optional mention filtering through `only_at_reply`; the agent is handed `ListConversations`, `ListUsers`, `SendMessage`, `SendFile`, and `SendImage`. Streaming replies and tool approval render as interactive cards, both preset to DingTalk's public templates so neither needs a Card Platform template of your own — set `streaming_card_template_id` or `approval_card_template_id` to use your own instead. ([#2285](https://github.com/agentscope-ai/agentscope/pull/2285), [#2409](https://github.com/agentscope-ai/agentscope/pull/2409)) * Hold the channels' long connections in a dedicated worker, enabled with `enable_channel_worker`. A platform hands one bot's events to one connection, so every replica connecting meant wasted connections on Feishu, DingTalk, and Slack and duplicated messages on Discord. Connection-bound state is now separated from the rest: `ChannelClients` builds a channel from its record without calling `start_listening`, so any process can send a reply, list chats, and attach the channel's platform tools. Cached instances are rebuilt when the record changes, so a credential rotation takes effect without a restart. ([#2390](https://github.com/agentscope-ai/agentscope/pull/2390)) * Support channel records on SQL storage, which previously inherited `NotImplementedError` and left any non-Redis deployment — the desktop build included — without channels at all. Adds the `channels` table with `UNIQUE(platform_bot_id)` in place of the Redis bot-index key, and Alembic revision `0003_channels`. ([#2388](https://github.com/agentscope-ai/agentscope/pull/2388)) **RAG** * Add `VectorStoreBase.list_chunks()`, returning one document's chunks ordered by `chunk_index`, implemented across all four vector stores. On top of it the agent service gains chunk browsing, a raw-document preview served from the blob store the upload already wrote, and a knowledge-base listing carrying the full configuration, together with the web UI for all three. ([#2372](https://github.com/agentscope-ai/agentscope/pull/2372)) * Chunkers declare their tunable parameters through a pydantic `Parameters` inner class, the same way model cards and RAG middleware already do, replacing the hand-written JSON schema dicts; the web UI renders them through the shared `SchemaForm`. ([#2083](https://github.com/agentscope-ai/agentscope/pull/2083)) **WebUI** * Show an alert on the chat page when a reply ends by exceeding the maximum number of iterations. ([#2381](https://github.com/agentscope-ai/agentscope/pull/2381)) ### Changed **Channel** * A reply is delivered by the node that produced it, instead of being queued for the node holding the inbound connection to drain, claim, re-subscribe to, and forward. Sending is plain REST against the stored credentials, so the round-trip #2390 made unnecessary — `enqueue_channel_output`, the `channel:outbound` queue and its wake signal, the forward lease, and the dispatcher's drain and forward paths — is removed. Two defects go with it: a 60-second timeout that truncated any reply whose run took longer than a minute, and a drained job silently dropped when the draining node did not host that channel. ([#2395](https://github.com/agentscope-ai/agentscope/pull/2395)) **Agent** * Deprecate `ExceedMaxItersEvent`. It is still emitted for backward compatibility but carries no semantics; the exit reason lives in `ReplyEndEvent.finished_reason == EXCEED_MAX_ITERS`. ([#2322](https://github.com/agentscope-ai/agentscope/pull/2322)) **RAG** * Choosing a chunker is mandatory when creating a knowledge base, mirroring embedding-model selection, and the implicit `default_chunker` fallback chain is removed. ([#2083](https://github.com/agentscope-ai/agentscope/pull/2083)) **Model** * Anthropic, DeepSeek, DashScope, and Moonshot disable thinking for structured-output calls, since those providers reject a forced `tool_choice` while thinking is active — which was making `generate_structured_output` and `compress_context` fail intermittently. `_compress_context_impl` also gains a truncation fallback: if summary generation still fails after every retry, older messages are discarded instead of raising, so the agent keeps running with a reduced context. ([#2140](https://github.com/agentscope-ai/agentscope/pull/2140)) ### Fixed **Agent Service** * Bind a real workspace to a scheduled session. The session was persisted with `workspace_id=""`, which every workspace manager treated as a literal cache key rather than "unbound", so every scheduled session in a deployment — across all users and all agents — resolved to the same workspace: the same sandbox, files, `.mcp` config, and installed skills. ([#2410](https://github.com/agentscope-ai/agentscope/pull/2410)) * Stop stranding session inbox payloads until the next user turn. A scheduled prompt, a team message, a new worker's first task, or a background tool result could sit unconsumed because producers inferred from the run lock whether a live run would drain it, and a run holding the lock while it unwinds has already drained for the last time. A run now registers as its inbox's consumer, and the hand-off is made explicit under a short lock, so a wake-up is produced exactly when nobody is registered — never dropping a payload, never spawning an empty turn. ([#2295](https://github.com/agentscope-ai/agentscope/pull/2295)) * Load the session state, assemble the agent, and check the parked state inside the distributed session lock, so a waiting run no longer reads and re-persists state left over from the run before it. ([#2232](https://github.com/agentscope-ai/agentscope/pull/2232)) **Tool** * `Bash` picks the shell from the backend's OS instead of the host's, so a Windows host driving a Linux sandbox no longer sends `cmd /c` into the container. ([#2366](https://github.com/agentscope-ai/agentscope/pull/2366)) * Resolve PEP 563 lazy annotations when extracting a tool schema, so a module using `from __future__ import annotations` no longer yields string-typed parameters. ([#2371](https://github.com/agentscope-ai/agentscope/pull/2371)) **MCP** * A stateful client can reconnect: `close()` clears the exhausted single-use transport and the next `connect()` builds a new one, instead of reusing the spent one and failing. ([#2308](https://github.com/agentscope-ai/agentscope/pull/2308)) **RAG** * Qdrant and Milvus write chunks under deterministic ids, so an index-worker retry after a mid-pipeline crash overwrites the partial ingest instead of leaving two copies of every chunk. ([#2372](https://github.com/agentscope-ai/agentscope/pull/2372)) **Agent** * Keep prompt-cache tokens in the reported usage. ([#2318](https://github.com/agentscope-ai/agentscope/pull/2318)) ## v2.0.6 *Released on 2026-08-07.* **Highlight**: This release adds * channels, which put the agents of the agent service on Feishu and Discord, * Apple Container as a workspace backend, * MCP and skill hubs in the agent service, and * the `on_check_permission` middleware hook. ### Added **Channel** * Add the `channel` module, which connects the agents in the agent service to IM platforms. `ChannelBase` abstracts a platform, with `FeishuChannel` and `DiscordChannel` as the built-in implementations; `ChannelGateway` orchestrates each inbound event, `ChannelTypeRegistry` publishes every platform's credential schema, and `ChannelLifecycleDispatcher` reconciles the live instances of a node with storage. The new `/channels/*` endpoints cover creation, enabling and disabling, status, chat ids, and routing rules. ([#1997](https://github.com/agentscope-ai/agentscope/pull/1997)) **Workspace** * Add `AppleContainerWorkspace`, `AppleContainerBackend`, and `AppleContainerWorkspaceManager` classes, supporting Apple's `container` CLI as a workspace backend (macOS 26+ on Apple silicon). ([#2068](https://github.com/agentscope-ai/agentscope/pull/2068)) * Add `WorkspaceBase.add_skill_archive()` method, which unpacks an uploaded skill archive into the workspace and registers the skill inside it. ([#2197](https://github.com/agentscope-ai/agentscope/pull/2197)) **Middleware** * Add the `on_check_permission` hook to `MiddlewareBase`, which intercepts the permission check of a single tool call after its input has been parsed and validated. The middleware can delegate with `next_handler(**input_kwargs)`, replace the returned `PermissionDecision`, or decide without delegating at all. ([#2001](https://github.com/agentscope-ai/agentscope/pull/2001)) **Message** * Content blocks carry `created_at` and `finished_at` timestamps, so consumers can tell when each block started and finished streaming. ([#2171](https://github.com/agentscope-ai/agentscope/pull/2171)) **Tool** * Add `scandir()`, `stat()`, `read_stream()`, and `write_stream()` methods and the `DirEntry` data class to the workspace backend classes, so a workspace file system can be listed and streamed through a uniform interface. ([#2187](https://github.com/agentscope-ai/agentscope/pull/2187)) **Agent Service** * Support installing MCP servers and skills from hubs: the `MCPHubBase` / `GitHubMCPHub` and `SkillHubBase` / `ClawSkillHub` classes browse external registries through the `/hub/mcp/*` and `/hub/skill/*` endpoints, installing a card writes it into the user's library (`/mcp`, `/skill`), and a session then loads it via `/workspace/mcp/from-library` or `/workspace/skill/from-library`. ([#2197](https://github.com/agentscope-ai/agentscope/pull/2197)) * Add `GET /workspace/directories` and `GET /workspace/files` endpoints for browsing the session workspace, with `POST /workspace/files/download-token` issuing a short-lived token for the download. ([#2187](https://github.com/agentscope-ai/agentscope/pull/2187)) * Add `GET /workspace/status` endpoint, which returns the session working directory together with its git status. ([#2257](https://github.com/agentscope-ai/agentscope/pull/2257)) * Add `GET /health` endpoint, which reports per-component readiness by inspecting the application state only, so probing it never touches the storage or a workspace backend. ([#2237](https://github.com/agentscope-ai/agentscope/pull/2237)) * Add `GET /embedding-model/` endpoint, which lists every embedding model available under a credential type. ([#2234](https://github.com/agentscope-ai/agentscope/pull/2234)) **Model** * Refresh the model cards of every API: add cards for Claude Opus 5 / Sonnet 5 / Fable 5, GPT-5.6 (luna, sol, terra), Gemini 3.5 / 3.6 Flash and the Flash-Lite line, Kimi K2.7 Code, Qwen3.8-Max and the Qwen Flash line, and Grok 4.5 / 4.20 / Build 0.1, and update the pricing and context windows of the existing cards. ([#2240](https://github.com/agentscope-ai/agentscope/pull/2240)) **WebUI** * Show the session working directory and its git status on the chat page. ([#2257](https://github.com/agentscope-ai/agentscope/pull/2257)) ### Changed **Agent Service** * The `extra_agent_middlewares` factory of `create_app()` receives a fourth argument, the session's resolved workspace, so filesystem-backed middleware such as `AgenticMemoryMiddleware` can be attached per session. Factories written against the previous three-argument signature keep working. ([#2263](https://github.com/agentscope-ai/agentscope/pull/2263)) **Model** * The OpenAI-compatible chat models reuse one `openai.AsyncClient` across calls instead of creating a new client for each call. ([#2063](https://github.com/agentscope-ai/agentscope/pull/2063)) * Stream accumulation joins the collected fragments in a single pass, replacing the O(n²) string concatenation. ([#2158](https://github.com/agentscope-ai/agentscope/pull/2158)) **WebUI** * Rebuild the chat pages on shadcn/ui components. ([#2171](https://github.com/agentscope-ai/agentscope/pull/2171)) * Unify the styling of the sidebar, the panels, and the form controls. ([#2234](https://github.com/agentscope-ai/agentscope/pull/2234)) **Docs** * Revamp the README with SDK and agent-service feature tables. ([#2262](https://github.com/agentscope-ai/agentscope/pull/2262)) * Update `README_zh.md`. ([#2182](https://github.com/agentscope-ai/agentscope/pull/2182)) ### Fixed **Agent** * Count a reasoning-acting round only once: a round whose tool calls are parked on a user confirmation or an external execution no longer consumes an iteration before its results arrive. ([#2217](https://github.com/agentscope-ai/agentscope/pull/2217)) * Do not emit a second `ToolResultStartEvent` for an external tool call that is already awaiting its result. ([#2167](https://github.com/agentscope-ai/agentscope/pull/2167)) **Tool** * `ToolResponse` keeps the `ERROR` state while accumulating chunks, instead of downgrading it to `INTERRUPTED` or `DENIED`. ([#2178](https://github.com/agentscope-ai/agentscope/pull/2178)) * An MCP server whose tool listing fails is skipped with a warning, so one unreachable server withdraws its own tools instead of ending the reply. ([#2197](https://github.com/agentscope-ai/agentscope/pull/2197)) **Model** * `ChatResponse.finished_reason` preserves the reason it was constructed with, so an interrupted response no longer reports `COMPLETED`. ([#2209](https://github.com/agentscope-ai/agentscope/pull/2209)) **Tracing** * Avoid the OpenTelemetry context detach error when a streaming span is closed from a different task. ([#2077](https://github.com/agentscope-ai/agentscope/pull/2077)) **Agent Service** * Scope the Claw skill hub card ids by owner, so skills sharing a name across owners no longer collide. ([#2214](https://github.com/agentscope-ai/agentscope/pull/2214)) **WebUI** * Reset the confirmation state between pending tool calls. ([#2243](https://github.com/agentscope-ai/agentscope/pull/2243)) * Fix the localization of the channel form fields. ([#2261](https://github.com/agentscope-ai/agentscope/pull/2261)) ## v2.0.5 *Released on 2026-07-23.* **Highlight**: This release adds * structured output and runtime state injection in the `Agent` class, * four new workspace backends (OpenSandbox, Daytona, Kubernetes, Bubblewrap), * MongoDB / Elasticsearch vector stores with Word and Excel parsers, and * SQLAlchemy-based storage and cross-user resource sharing in the agent service. ### Added **Agent Core (SDK)** * Support structured output via the `structured_schema` argument of `Agent.reply()` and `Agent.reply_stream()`. The agent generates the result through a built-in structured-output tool, and the validated dict is carried on the final message's `structured_output` attribute. ([#2150](https://github.com/agentscope-ai/agentscope/pull/2150)) * Support runtime state injection: the current time, plan task counts, and context usage are injected into the context as a `HintBlock` before each reasoning step, configured by the new `injection_config` argument and `InjectionConfig` class. ([#2134](https://github.com/agentscope-ai/agentscope/pull/2134)) **Workspace** * Add `OpenSandboxWorkspace`, `OpenSandboxBackend`, and `OpenSandboxWorkspaceManager` classes, supporting [OpenSandbox](https://github.com/agentscope-ai/opensandbox) as a workspace backend. ([#1953](https://github.com/agentscope-ai/agentscope/pull/1953)) * Add `DaytonaWorkspace`, `DaytonaBackend`, and `DaytonaWorkspaceManager` classes, supporting [Daytona](https://www.daytona.io) sandboxes. ([#1943](https://github.com/agentscope-ai/agentscope/pull/1943)) * Add `K8sWorkspace`, `K8sBackend`, and `K8sWorkspaceManager` classes, supporting Kubernetes Pod/PVC lifecycle management, a tar-stream file backend, and the MCP gateway. ([#1933](https://github.com/agentscope-ai/agentscope/pull/1933)) * Add `BubblewrapWorkspace`, `BubblewrapBackend`, and `BubblewrapWorkspaceManager` classes, supporting lightweight local sandboxing on Linux via bubblewrap. ([#2051](https://github.com/agentscope-ai/agentscope/pull/2051)) **Tool** * Add the built-in `PowerShell` tool, so agents can execute shell commands in Windows workspaces. ([#2132](https://github.com/agentscope-ai/agentscope/pull/2132)) **RAG** * Add `MongoDBStore` class, supporting MongoDB Atlas Vector Search as a vector store. ([#2008](https://github.com/agentscope-ai/agentscope/pull/2008)) * Add `ElasticsearchStore` class, supporting Elasticsearch as a vector store. ([#2129](https://github.com/agentscope-ai/agentscope/pull/2129)) * Add `WordParser` and `ExcelParser` classes, supporting Word and Excel documents as knowledge sources. ([#2025](https://github.com/agentscope-ai/agentscope/pull/2025), [#2026](https://github.com/agentscope-ai/agentscope/pull/2026)) **Agent Service** * Add `AsyncSQLAlchemyStorage` class, supporting any SQLAlchemy-compatible database as the storage backend, with Alembic migrations included. ([#2029](https://github.com/agentscope-ai/agentscope/pull/2029)) * Support sharing credentials, agents, and knowledge bases across users at the group or organization level, backed by a new resource access policy layer. ([#1998](https://github.com/agentscope-ai/agentscope/pull/1998)) * Surface reply errors to the frontend instead of failing silently. ([#2133](https://github.com/agentscope-ai/agentscope/pull/2133)) **Model** * Support the Kimi K3 model in the `MoonshotChatModel` class. ([#2141](https://github.com/agentscope-ai/agentscope/pull/2141)) * Add `qwen3.7-plus`, `deepseek-v4-pro`, and `glm-5.2` model cards for the `DashScopeChatModel` class. ([#2073](https://github.com/agentscope-ai/agentscope/pull/2073)) **TTS** * Add `GeminiTTSModel` class, supporting the Gemini TTS API. ([#1879](https://github.com/agentscope-ai/agentscope/pull/1879)) **WebUI** * Add a scroll-to-bottom button to the chat page. ([#2106](https://github.com/agentscope-ai/agentscope/pull/2106)) ### Changed **Prompt** * Refine the built-in instructions for the workspace, context compression, and the `TaskCreate` tool. The workspace prompt assembly is extracted into shared helpers so every workspace backend produces a consistent description. ([#2111](https://github.com/agentscope-ai/agentscope/pull/2111)) **WebUI** * Refactor the text input component. ([#2102](https://github.com/agentscope-ai/agentscope/pull/2102)) * Refactor tool call rendering. ([#2072](https://github.com/agentscope-ai/agentscope/pull/2072)) **Dependencies** * Reorganize the optional dependency groups in `pyproject.toml`, so each feature (RAG backends, workspaces, TTS, long-term memory) can be installed on its own. ([#2157](https://github.com/agentscope-ai/agentscope/pull/2157)) * Restrict the `mcp` dependency to versions below 2.0.0. ([#2091](https://github.com/agentscope-ai/agentscope/pull/2091)) ### Fixed **Agent** * Continue the reasoning-acting loop when the model returns a thinking-only response, instead of ending the reply. ([#2120](https://github.com/agentscope-ai/agentscope/pull/2120)) * Preserve paired tool calls and tool results during context compression when a single reasoning step issues multiple tool calls. ([#2093](https://github.com/agentscope-ai/agentscope/pull/2093)) **Permission** * Unify the per-mode evaluation logic in the permission engine, and propagate batch confirmation exemptions to the subsequent tool calls. ([#2117](https://github.com/agentscope-ai/agentscope/pull/2117)) **Model** * The OpenAI chat model sends `max_completion_tokens` instead of the deprecated `max_tokens`. ([#2065](https://github.com/agentscope-ai/agentscope/pull/2065)) * Preserve the reasoning history when replaying a conversation through the OpenAI Responses API. ([#2071](https://github.com/agentscope-ai/agentscope/pull/2071)) **Formatter** * Preserve `redacted_thinking` blocks in the Anthropic message round-trip. ([#2139](https://github.com/agentscope-ai/agentscope/pull/2139)) * Drop empty text blocks for Anthropic, which rejects them. ([#2007](https://github.com/agentscope-ai/agentscope/pull/2007)) * Include `tool_name` in Ollama tool result messages. ([#2006](https://github.com/agentscope-ai/agentscope/pull/2006)) * Strip `null` types from Gemini tool schemas. ([#2020](https://github.com/agentscope-ai/agentscope/pull/2020)) **Tool** * Built-in `Bash` no longer classifies mutating `find` commands (e.g. `-delete`, `-exec`) as read-only. ([#2004](https://github.com/agentscope-ai/agentscope/pull/2004)) **Skill** * Expand user-home (`~`) paths when loading skill directories. ([#2053](https://github.com/agentscope-ai/agentscope/pull/2053)) **Workspace** * Fix the OpenSandbox bootstrap process and its state filtering. ([#2046](https://github.com/agentscope-ai/agentscope/pull/2046)) * Restore the default path of the glob helper. ([#2056](https://github.com/agentscope-ai/agentscope/pull/2056)) **RAG** * Adapt `MilvusLiteStore` to the COSINE distance semantics of milvus-lite 3.1.0. ([#2089](https://github.com/agentscope-ai/agentscope/pull/2089)) **Agent Service** * Use cursor-based pagination in `list_messages`, so concurrent writes no longer shift the page boundaries. ([#2081](https://github.com/agentscope-ai/agentscope/pull/2081)) **WebUI** * Fix rendering errors in the `Read` / `Write` / `Edit` tool calls caused by incomplete JSON during streaming or interruption. ([#2075](https://github.com/agentscope-ai/agentscope/pull/2075)) * Scroll newly loaded sessions to the bottom. ([#2100](https://github.com/agentscope-ai/agentscope/pull/2100)) **Docs** * Fix docstring formatting errors in the `tool` module. ([#2127](https://github.com/agentscope-ai/agentscope/pull/2127)) * Use a separate LLM instance in the mem0 example. ([#2078](https://github.com/agentscope-ai/agentscope/pull/2078)) ## v2.0.4 *Released on 2026-07-07.* **Highlight**: This release adds * agent interruption, * two long-term memory middlewares, and * the ability to invite existing agents into a team. ### Added **Agent Core (SDK)** * Support realtime agent interruption and resumption. ([#1995](https://github.com/agentscope-ai/agentscope/pull/1995)) **Agent Service** * Add `POST /sessions/{session_id}/interrupt` endpoint. ([#1995](https://github.com/agentscope-ai/agentscope/pull/1995)) * Support to interrupt the generation in WebUI. ([#1995](https://github.com/agentscope-ai/agentscope/pull/1995)) * Support team leader to invite existing agents via a new `AgentInvite` tool. ([#1977](https://github.com/agentscope-ai/agentscope/pull/1977)) * Add a session status endpoint for polling session lifecycle. ([#1984](https://github.com/agentscope-ai/agentscope/pull/1984)) **Middleware — Long-term Memory** * Add `AgenticMemoryMiddleware` class, supporting Markdown-based long-term memory. ([#1927](https://github.com/agentscope-ai/agentscope/pull/1927)) * Add `ReMeMiddleware` class, supporting the AgentScope [ReMe](https://github.com/agentscope-ai/ReMe) toolkit as an in-process long-term memory backend. ([#1972](https://github.com/agentscope-ai/agentscope/pull/1972)) **RAG** * Add `MilvusLiteStore` class, supporting Milvus Lite as a local persistent vector store. ([#1969](https://github.com/agentscope-ai/agentscope/pull/1969)) **TTS** * Add `DashScopeCosyVoiceTTSModel` class, supporting DashScope CosyVoice V3 speech synthesis in both streaming and non-streaming modes. ([#1866](https://github.com/agentscope-ai/agentscope/pull/1866)) * Add `OpenAITTSModel` class, supporting the OpenAI TTS API in both streaming and non-streaming modes. ([#1878](https://github.com/agentscope-ai/agentscope/pull/1878)) ### Changed **Workspace** * Add a new `SandboxWorkspaceBase` class, which extracts the common logic of the cloud-based workspaces and serves as a new base class for `DockerWorkspace` and `E2BWorkspace`. ([#1971](https://github.com/agentscope-ai/agentscope/pull/1971)) **Model** * The `_call_api` method of `ChatModel` subclasses no longer needs to yield a final aggregated `ChatResponse` at the end. The accumulation logic is now handled automatically inside `ChatModelBase.__call__`. ([#1995](https://github.com/agentscope-ai/agentscope/pull/1995)) ### Fixed **Agent** * `Agent` middleware chain: kwargs omitted in `next_handler()` now inherit the current middleware state instead of resetting to the original chain input. ([#1966](https://github.com/agentscope-ai/agentscope/pull/1966)) * Fix the yielding order of the `ThinkingBlockEndEvent` and `TextBlockStartEvent` events. ([#1887](https://github.com/agentscope-ai/agentscope/pull/1887)) **Model** * Fix the OpenAI Response API model and its formatter. ([#1950](https://github.com/agentscope-ai/agentscope/pull/1950)) * `_sanitize_schema_for_gemini` converts `const: value` to `enum: [value]`, so tools with fixed-value parameters no longer fail Gemini schema validation. ([#2016](https://github.com/agentscope-ai/agentscope/pull/2016)) * `ChatModelBase.count_tokens` uses a conservative flat estimate for multimodal `DataBlock` inputs, avoiding huge overestimates from base64 payload length. ([#1899](https://github.com/agentscope-ai/agentscope/pull/1899)) **Formatter** * Anthropic, Gemini, and Ollama formatters now use `_json_loads_with_repair` on `ToolCallBlock.input`, so truncated tool-call JSON degrades to `{}` instead of raising `JSONDecodeError`. ([#2012](https://github.com/agentscope-ai/agentscope/pull/2012)) * Gemini formatter drops empty thinking blocks before sending. ([#2013](https://github.com/agentscope-ai/agentscope/pull/2013)) **Tool** * Built-in `Grep` rejects negative `head_limit` / `tail_limit` values. ([#1954](https://github.com/agentscope-ai/agentscope/pull/1954)) **Credential** * `CredentialFactory.register_credential` is now idempotent, avoiding duplicate registrations under `uvicorn --reload`. ([#1964](https://github.com/agentscope-ai/agentscope/pull/1964)) **Docs** * Fix mismatched docstrings in the `agent`, `state`, and `tool` modules. ([#1989](https://github.com/agentscope-ai/agentscope/pull/1989)) ## v2.0.3 *Released on 2026-06-19.* **Highlight**: This release adds * a new `rag` module with distributed / multi-tenant / multi-session support, * a mem0-backed long-term memory middleware, * a token-budget middleware, and * tool-level onion middleware in `ToolBase`. ### Added **Agent Core (SDK)** * `Agent.compress_context` accepts an optional `instructions: HintBlock` argument, letting callers inject compression-specific hints without mutating the agent's persistent context. ([#1942](https://github.com/agentscope-ai/agentscope/pull/1942)) **Agent Service** * Expose HITL events in the team leader's session. ([#1918](https://github.com/agentscope-ai/agentscope/pull/1918)) * Add an in-memory message bus for single-node deployment. ([#1925](https://github.com/agentscope-ai/agentscope/pull/1925)) **Middleware** * Add `Mem0Middleware` class, supporting mem0-backed long-term memory. ([#1775](https://github.com/agentscope-ai/agentscope/pull/1775)) * Add `BudgetControlMiddleware` class, supporting token-budget enforcement in ReAct loops. ([#1738](https://github.com/agentscope-ai/agentscope/pull/1738)) **RAG** * Add new `rag` module, supporting distributed, multi-tenant, and multi-session RAG service. ([#1926](https://github.com/agentscope-ai/agentscope/pull/1926)) **Tool** * Support tool-level onion middleware in `ToolBase` via a new `call()` entry point, wrapped by `__call__`. ([#1754](https://github.com/agentscope-ai/agentscope/pull/1754)) **Workspace** * Support built-in tools (`Bash`, `Read`, `Write`, `Edit`, `Glob`, `Grep`) for the e2b and docker workspaces. ([#1903](https://github.com/agentscope-ai/agentscope/pull/1903)) **TTS** * Add DashScope CosyVoice realtime TTS model. ([#1855](https://github.com/agentscope-ai/agentscope/pull/1855)) **Model** * Add model cards for `gpt-4o`, `gpt-4o-mini`, and `gpt-4.1-nano` under the OpenAI Response API. ([#1750](https://github.com/agentscope-ai/agentscope/pull/1750)) **Embedding** * Support `pass_dimensions` option for OpenAI embedding models. ([#1897](https://github.com/agentscope-ai/agentscope/pull/1897)) **Utils** * Support configurable ID factory via `set_id_factory()`, letting users override the default `uuid4` strategy. ([#1839](https://github.com/agentscope-ai/agentscope/pull/1839)) **WebUI** * Render `Write` and `Edit` tool diffs. ([#1856](https://github.com/agentscope-ai/agentscope/pull/1856)) * Add a right panel to display verbose task / permission context, MCPs, and skills. ([#1945](https://github.com/agentscope-ai/agentscope/pull/1945)) ### Changed **Message Bus** * Refactor and decouple the message bus from service logic. ([#1923](https://github.com/agentscope-ai/agentscope/pull/1923)) ### Fixed **Model** * Correct the qwen max 3.7 model id in DashScope. ([#1876](https://github.com/agentscope-ai/agentscope/pull/1876)) * Handle Gemini function calls without an id. ([#1883](https://github.com/agentscope-ai/agentscope/pull/1883)) * Add `_sanitize_schema_for_gemini` to strip Gemini-incompatible JSON Schema constructs from tool parameters. ([#1886](https://github.com/agentscope-ai/agentscope/pull/1886)) **Formatter** * `AnthropicChatFormatter` merges parallel tool\_results into a single user message. ([#1894](https://github.com/agentscope-ai/agentscope/pull/1894)) **Agent** * Avoid shared default configs across `Agent` instances. ([#1906](https://github.com/agentscope-ai/agentscope/pull/1906)) **Tool** * Merge base64 tool-response chunks by bytes instead of string concatenation. ([#1901](https://github.com/agentscope-ai/agentscope/pull/1901)) **Middleware** * Use the configured id factory for TTS audio blocks. ([#1930](https://github.com/agentscope-ai/agentscope/pull/1930)) **App** * Correctly convert AG-UI SSE stream events. ([#1917](https://github.com/agentscope-ai/agentscope/pull/1917)) **Schema** * Remove `max_length` constraints from `SummarySchema` fields and raise the tool-result size limit. ([#1891](https://github.com/agentscope-ai/agentscope/pull/1891)) ## v2.0.2 *Released on 2026-06-16.* ### Added **Agent Service & Team** * **Custom subagent templates** can now be registered in the agent service, so a team leader can spawn workers from user-defined templates instead of being limited to the built-in ones. ([#1833](https://github.com/agentscope-ai/agentscope/pull/1833)) * **Custom agent classes** are now accepted by the agent service, allowing users to plug their own `Agent` subclasses into the FastAPI runtime. ([#1838](https://github.com/agentscope-ai/agentscope/pull/1838)) **Tool** * **`Bash` tool now accepts a `cwd` argument**, letting agents scope shell execution to a specific working directory instead of always running from the workspace root. ([#1822](https://github.com/agentscope-ai/agentscope/pull/1822)) **Model & Multimodality** * **Streaming audio + live captions** for DashScope/OpenAI omni-modal models — audio chunks and caption events are now emitted incrementally during a single reply. ([#1701](https://github.com/agentscope-ai/agentscope/pull/1701)) **TTS** * **New `tts` module** with a DashScope backend and a streaming middleware that turns the agent's textual replies into spoken audio as tokens arrive. ([#1832](https://github.com/agentscope-ai/agentscope/pull/1832)) **WebUI** * **Credential sidebar** is now grouped by provider, making it easier to find and manage keys across many vendors. ([#1829](https://github.com/agentscope-ai/agentscope/pull/1829)) * **CI for WebUI**: added format and build checks so frontend regressions are caught at PR time. ([#1821](https://github.com/agentscope-ai/agentscope/pull/1821)) ### Changed **Agent Service Infrastructure** * **Embedding model layer refactored** for the agent service: the legacy single-file `_dashscope_embedding.py` / `_dashscope_multimodal_embedding.py` were replaced by a new `embedding/_dashscope/` package with per-model YAML cards (`text-embedding-v3/v4`, `qwen2.5-vl-embedding`, `qwen3-vl-embedding`, `multimodal-embedding-v1`, `tongyi-embedding-vision-flash/plus`), a new `EmbeddingModelCard` type, and a service-level `_embedding.py` endpoint. ([#1852](https://github.com/agentscope-ai/agentscope/pull/1852)) * **Background task manager refactored** to support multi-process and distributed deployment. The single-process scheduler was replaced by a message-bus based architecture (`message_bus/_base.py` + `_redis_message_bus.py`), and the cancel/wake-up dispatchers were rewritten to coordinate over the bus. ([#1849](https://github.com/agentscope-ai/agentscope/pull/1849)) ### Fixed **Model** * **Tool-choice fallback in thinking mode**: when an OpenAI-compatible structured output call rejects a forced `tool_choice` because thinking mode is on, the call now transparently falls back to `auto` instead of erroring out. ([#1830](https://github.com/agentscope-ai/agentscope/pull/1830)) * **Qwen thinking toggle** is now forwarded correctly to DashScope. ([#1774](https://github.com/agentscope-ai/agentscope/pull/1774)) * **Ollama embedding client** is created per call instead of being cached, avoiding "event loop is closed" errors when the embedding client outlives its original loop. ([#1836](https://github.com/agentscope-ai/agentscope/pull/1836)) **Permission & Team** * **Workspace MCP loader** now skips invalid MCP config entries with a warning instead of aborting initialization. ([#1819](https://github.com/agentscope-ai/agentscope/pull/1819)) * **Workspace root** is included in the permission context, so path-rule evaluation correctly resolves relative paths. ([#1823](https://github.com/agentscope-ai/agentscope/pull/1823)) * **Worker agents inherit leader permission rules**: `AgentCreate` is now state-injected, and a worker session is constructed by deep-copying the leader's working directories and allow/deny/ask rules. Previously workers received a blank `PermissionContext`, losing every user-confirmed directory and rule. ([#1815](https://github.com/agentscope-ai/agentscope/pull/1815)) **Tool** * **Glob patterns** now accept Windows-style separators. ([#1809](https://github.com/agentscope-ai/agentscope/pull/1809)) **Storage & Message Bus** * **Redis session IDs**: explicit session IDs are preserved instead of being overwritten with auto-generated ones. ([#1786](https://github.com/agentscope-ai/agentscope/pull/1786)) * **Redis message bus timeout** bug fixed so long-running tasks no longer drop their result messages. ([#1853](https://github.com/agentscope-ai/agentscope/pull/1853)) **WebUI** * Fixed nested `