> ## 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:00Asia/ShanghaiYou 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.
```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.
## 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.
**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**:
Open a chat, expand the **MCP** panel, choose **Add**, and pick from **Installed MCPs**. The server's tools reach the agent immediately.
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.
**Install** adds it to the user's collection right away, with nothing to fill in. Everything installed shows up under **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 |
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 `