ghostdrive1 commited on
Commit
9a23dbe
·
verified ·
1 Parent(s): 0b9dc2e

Upload folder using huggingface_hub (part 2)

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. src/agentscope/agent/_config.py +172 -0
  2. src/agentscope/agent/_utils.py +17 -0
  3. src/agentscope/app/__init__.py +12 -0
  4. src/agentscope/app/_app.py +263 -0
  5. src/agentscope/app/_bus_ops.py +167 -0
  6. src/agentscope/app/_lifespan.py +180 -0
  7. src/agentscope/app/_manager/__init__.py +17 -0
  8. src/agentscope/app/_manager/_background_task_manager.py +469 -0
  9. src/agentscope/app/_manager/_cancel_dispatcher.py +186 -0
  10. src/agentscope/app/_manager/_chat_run_registry.py +134 -0
  11. src/agentscope/app/_manager/_scheduler/__init__.py +8 -0
  12. src/agentscope/app/_manager/_scheduler/_scheduler_manager.py +432 -0
  13. src/agentscope/app/_manager/_scheduler/_tools/__init__.py +14 -0
  14. src/agentscope/app/_manager/_scheduler/_tools/_schedule_create.py +253 -0
  15. src/agentscope/app/_manager/_scheduler/_tools/_schedule_delete.py +154 -0
  16. src/agentscope/app/_manager/_scheduler/_tools/_schedule_list.py +115 -0
  17. src/agentscope/app/_manager/_scheduler/_tools/_schedule_view.py +138 -0
  18. src/agentscope/app/_manager/_wakeup_dispatcher.py +371 -0
  19. src/agentscope/app/_router/__init__.py +23 -0
  20. src/agentscope/app/_router/_agent.py +214 -0
  21. src/agentscope/app/_router/_chat.py +160 -0
  22. src/agentscope/app/_router/_credential.py +164 -0
  23. src/agentscope/app/_router/_knowledge_base.py +574 -0
  24. src/agentscope/app/_router/_model.py +40 -0
  25. src/agentscope/app/_router/_schedule.py +239 -0
  26. src/agentscope/app/_router/_schema/__init__.py +110 -0
  27. src/agentscope/app/_router/_schema/_agent.py +82 -0
  28. src/agentscope/app/_router/_schema/_chat.py +45 -0
  29. src/agentscope/app/_router/_schema/_credential.py +42 -0
  30. src/agentscope/app/_router/_schema/_knowledge_base.py +292 -0
  31. src/agentscope/app/_router/_schema/_mcp.py +127 -0
  32. src/agentscope/app/_router/_schema/_model.py +21 -0
  33. src/agentscope/app/_router/_schema/_schedule.py +118 -0
  34. src/agentscope/app/_router/_schema/_session.py +188 -0
  35. src/agentscope/app/_router/_schema/_tts_model.py +23 -0
  36. src/agentscope/app/_router/_session.py +680 -0
  37. src/agentscope/app/_router/_tts_model.py +40 -0
  38. src/agentscope/app/_router/_workspace.py +220 -0
  39. src/agentscope/app/_service/__init__.py +29 -0
  40. src/agentscope/app/_service/_chat.py +588 -0
  41. src/agentscope/app/_service/_embedding.py +101 -0
  42. src/agentscope/app/_service/_index_sweeper.py +150 -0
  43. src/agentscope/app/_service/_index_task_consumer.py +216 -0
  44. src/agentscope/app/_service/_index_worker.py +534 -0
  45. src/agentscope/app/_service/_knowledge_base.py +515 -0
  46. src/agentscope/app/_service/_model.py +50 -0
  47. src/agentscope/app/_service/_projectors/__init__.py +13 -0
  48. src/agentscope/app/_service/_projectors/_subagent_hitl.py +270 -0
  49. src/agentscope/app/_service/_session.py +473 -0
  50. src/agentscope/app/_service/_session_projection.py +186 -0
src/agentscope/agent/_config.py ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """The agent config classes."""
3
+
4
+ from pydantic import BaseModel, Field
5
+
6
+ from ..model import ChatModelBase
7
+
8
+
9
+ class SummarySchema(BaseModel):
10
+ """The compressed memory model, used to generate summary of old memories"""
11
+
12
+ task_overview: str = Field(
13
+ description=(
14
+ "The user's core request and success criteria.\n"
15
+ "Any clarifications or constraints they specified"
16
+ ),
17
+ )
18
+ current_state: str = Field(
19
+ description=(
20
+ "What has been completed so far.\n"
21
+ "File created, modified, or analyzed (with paths if relevant).\n"
22
+ "Key outputs or artifacts produced."
23
+ ),
24
+ )
25
+ important_discoveries: str = Field(
26
+ description=(
27
+ "Technical constraints or requirements uncovered.\n"
28
+ "Decisions made and their rationale.\n"
29
+ "Errors encountered and how they were resolved.\n"
30
+ "What approaches were tried that didn't work (and why)"
31
+ ),
32
+ )
33
+ next_steps: str = Field(
34
+ description=(
35
+ "Specific actions needed to complete the task.\n"
36
+ "Any blockers or open questions to resolve.\n"
37
+ "Priority order if multiple steps remain"
38
+ ),
39
+ )
40
+ context_to_preserve: str = Field(
41
+ description=(
42
+ "User preferences or style requirements.\n"
43
+ "Domain-specific details that aren't obvious.\n"
44
+ "Any promises made to the user"
45
+ ),
46
+ )
47
+ """Whether to execute multiple tool calls in parallel within one
48
+ reasoning step."""
49
+
50
+
51
+ class ContextConfig(BaseModel):
52
+ """The context related configuration in AgentScope"""
53
+
54
+ model_config = {"arbitrary_types_allowed": True}
55
+ """Allow arbitrary types in the pydantic model."""
56
+
57
+ trigger_ratio: float = Field(default=0.8, gt=0, lt=0.9)
58
+ """When the token exceeds this ratio of the maximum context length, the
59
+ context will be compressed. To reserve the context for context compression,
60
+ the maximum ratio is 0.9."""
61
+
62
+ reserve_ratio: float = Field(default=0.1, gt=0, lt=0.9)
63
+ """The ratio of the tokens to reserve in context compression, which should
64
+ be smaller than the trigger ratio."""
65
+
66
+ compression_prompt: str = Field(
67
+ default=(
68
+ "<system-hint>You have been working on the task described above "
69
+ "but have not yet completed it. "
70
+ "Now write a continuation summary that will allow you to resume "
71
+ "work efficiently in a future context window where the "
72
+ "conversation history will be replaced with this summary. "
73
+ "Your summary should be structured, concise, and actionable."
74
+ "</system-hint>"
75
+ ),
76
+ # ``format: textarea`` is a hint for schema-driven UI renderers
77
+ # to use a multi-line input. Plain JSON Schema doesn't natively
78
+ # express this, so we piggy-back on ``json_schema_extra``.
79
+ json_schema_extra={"format": "textarea"},
80
+ )
81
+ """The prompt used to guide the compression model to generate the
82
+ compressed summary, which will be wrapped into a user message and
83
+ attach to the end of the current memory."""
84
+
85
+ summary_template: str = Field(
86
+ default=(
87
+ "<system-info>Here is a summary of your previous work\n"
88
+ "# Task Overview\n"
89
+ "{task_overview}\n\n"
90
+ "# Current State\n"
91
+ "{current_state}\n\n"
92
+ "# Important Discoveries\n"
93
+ "{important_discoveries}\n\n"
94
+ "# Next Steps\n"
95
+ "{next_steps}\n\n"
96
+ "# Context to Preserve\n"
97
+ "{context_to_preserve}"
98
+ "</system-info>"
99
+ ),
100
+ json_schema_extra={"format": "textarea"},
101
+ )
102
+ """The string template to present the compressed summary to the agent,
103
+ which will be formatted with the fields from the
104
+ `compression_summary_model`."""
105
+
106
+ summary_schema: dict = Field(
107
+ default_factory=SummarySchema.model_json_schema,
108
+ )
109
+ """The structured model used to guide the agent to generate the
110
+ structured compressed summary."""
111
+
112
+ tool_result_limit: int = Field(
113
+ title="Tool Result Limit",
114
+ default=50000,
115
+ description=(
116
+ "The maximum length of the tool results in tokens. "
117
+ "If exceeded, the tool result will be truncated."
118
+ ),
119
+ )
120
+ """The tool result limit to avoid tool result bursting."""
121
+
122
+
123
+ class ReActConfig(BaseModel):
124
+ """The reasoning related configuration"""
125
+
126
+ max_iters: int = Field(
127
+ title="Max Iterations",
128
+ default=20,
129
+ description="The maximum number of reasoning-acting iterations in "
130
+ "one reply",
131
+ )
132
+ """The maximum number of iterations for the reasoning-acting loop."""
133
+
134
+ stop_on_reject: bool = Field(
135
+ title="Rejection Handling",
136
+ default=False,
137
+ description="Whether to stop replying when being rejected to "
138
+ "execute tools.",
139
+ )
140
+ """If stop reasoning when tool call(s) are rejected. If `True`, the agent
141
+ won't continue reasoning and wait for outside interaction from the user.
142
+ """
143
+
144
+
145
+ class ModelConfig(BaseModel):
146
+ """The model related configuration."""
147
+
148
+ # TODO: remove this line after PR #1564 is merged, where the ChatModel
149
+ # will be child class of BaseModel
150
+ model_config = {"arbitrary_types_allowed": True}
151
+
152
+ max_retries: int = Field(
153
+ default=0,
154
+ ge=0,
155
+ description=(
156
+ "Number of retries on top of the initial call before falling "
157
+ "over to the fallback model. ``0`` means call the model exactly "
158
+ "once and immediately move to the fallback on failure. Same "
159
+ "semantics as ``ChatModelBase.max_retries``. Defaults to 0 to "
160
+ "avoid compounding with the model's own inner retry loop."
161
+ ),
162
+ )
163
+ """Number of retries on top of the initial call before falling over to
164
+ the fallback model. ``0`` means a single attempt with no retries.
165
+ Mirrors the semantics of ``ChatModelBase.max_retries``."""
166
+
167
+ fallback_model: ChatModelBase | None = Field(
168
+ default=None,
169
+ description="The fallback model used when the main model fails.",
170
+ )
171
+ """The fallback model used when the main model fails. Also supports the
172
+ max_retries logic."""
src/agentscope/agent/_utils.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """The utility classes used in building the agent class."""
3
+ from dataclasses import dataclass
4
+ from typing import Literal
5
+
6
+ from ..message import ToolCallBlock
7
+
8
+
9
+ @dataclass
10
+ class _ToolCallBatch:
11
+ """A batch of tool calls that execute either sequentially or
12
+ concurrently."""
13
+
14
+ type: Literal["sequential", "concurrent"]
15
+ """The batch type"""
16
+ tool_calls: list[ToolCallBlock]
17
+ """The list of tool calls in the batch."""
src/agentscope/app/__init__.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """The FastAPI based agent service module, which contains all service-related
3
+ components and a configurable FastAPI app factory.
4
+ """
5
+
6
+ from ._app import create_app
7
+ from ._types import SubAgentTemplate
8
+
9
+ __all__ = [
10
+ "create_app",
11
+ "SubAgentTemplate",
12
+ ]
src/agentscope/app/_app.py ADDED
@@ -0,0 +1,263 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """AgentScope app factory."""
3
+ from typing import Type, TYPE_CHECKING, Any
4
+
5
+ from ._lifespan import lifespan
6
+ from .rag.blob_store import BlobStoreBase, LocalBlobStore
7
+ from .rag.knowledge_base_manager import KnowledgeBaseManagerBase
8
+ from .workspace_manager import WorkspaceManagerBase
9
+ from ._router import (
10
+ agent_router,
11
+ chat_router,
12
+ credential_router,
13
+ knowledge_base_router,
14
+ model_router,
15
+ tts_model_router,
16
+ schedule_router,
17
+ session_router,
18
+ workspace_router,
19
+ )
20
+ from ._types import AgentMiddlewareFactory, AgentToolFactory, SubAgentTemplate
21
+ from .message_bus import MessageBus
22
+ from .storage import StorageBase
23
+ from ..agent import Agent
24
+ from ..credential import CredentialFactory, CredentialBase
25
+ from ..rag import (
26
+ ApproxTokenChunker,
27
+ ChunkerBase,
28
+ ParserBase,
29
+ TextParser,
30
+ )
31
+ from .._version import __version__
32
+
33
+
34
+ if TYPE_CHECKING:
35
+ from fastapi import FastAPI
36
+ from fastapi.middleware import Middleware as FastAPIMiddleware
37
+ else:
38
+ FastAPI = Any
39
+ FastAPIMiddleware = Any
40
+
41
+
42
+ def create_app(
43
+ storage: StorageBase,
44
+ message_bus: MessageBus,
45
+ workspace_manager: WorkspaceManagerBase,
46
+ knowledge_base_manager: KnowledgeBaseManagerBase | None = None,
47
+ knowledge_parsers: list[ParserBase] | dict[str, ParserBase] | None = None,
48
+ knowledge_chunker: ChunkerBase | None = None,
49
+ blob_store: BlobStoreBase | None = None,
50
+ enable_index_worker: bool = True,
51
+ *,
52
+ extra_credentials: list[Type[CredentialBase]] | None = None,
53
+ extra_middlewares: list[FastAPIMiddleware] | None = None,
54
+ extra_agent_middlewares: AgentMiddlewareFactory | None = None,
55
+ extra_agent_tools: AgentToolFactory | None = None,
56
+ custom_subagent_templates: list[SubAgentTemplate] | None = None,
57
+ custom_agent_cls: Type[Agent] | None = None,
58
+ title: str = "AgentScope",
59
+ version: str = __version__,
60
+ ) -> FastAPI:
61
+ """Create and configure a FastAPI application.
62
+
63
+ This is the primary entry point for embedding AgentScope into an existing
64
+ service or running it standalone. All built-in routers are registered
65
+ automatically; pass ``extra_middlewares`` to add your own.
66
+
67
+ Usage — standalone::
68
+
69
+ app = create_app(
70
+ storage=RedisStorage(),
71
+ message_bus=RedisMessageBus(),
72
+ workspace_manager=LocalWorkspaceManager(),
73
+ )
74
+ uvicorn.run(app, host="0.0.0.0", port=8000)
75
+
76
+ Usage — mount onto an existing app::
77
+
78
+ root = FastAPI()
79
+ agentscope_app = create_app(
80
+ storage=RedisStorage(),
81
+ message_bus=RedisMessageBus(),
82
+ workspace_manager=LocalWorkspaceManager(),
83
+ )
84
+ root.mount("/agentscope", agentscope_app)
85
+
86
+ Args:
87
+ storage (`StorageBase`):
88
+ The storage backend. Its lifecycle (``__aenter__`` /
89
+ ``__aexit__``) is managed by the app lifespan.
90
+ message_bus (`MessageBus`):
91
+ The live message bus used for cross-session inbox delivery
92
+ and idle-session triggers. Required — the bus is intentionally
93
+ decoupled from ``storage`` so the persistence backend (e.g.
94
+ SQL) can differ from the transport backend (Redis). Its
95
+ lifecycle is also managed by the app lifespan.
96
+ workspace_manager (`WorkspaceManagerBase`):
97
+ The workspace manager. Required — every chat run and every
98
+ ``/workspace`` endpoint depends on it. Its lifecycle (
99
+ ``__aenter__`` / ``__aexit__``) is managed by the app
100
+ lifespan. Pass a :class:`~agentscope.app._manager.
101
+ LocalWorkspaceManager` for local-directory workspaces.
102
+ knowledge_base_manager (`KnowledgeBaseManagerBase | None`, \
103
+ optional):
104
+ The knowledge base manager that owns knowledge base
105
+ lifecycle and serves
106
+ :class:`~agentscope.rag.KnowledgeBase`
107
+ runtime handles to both HTTP service and agent code.
108
+ The manager carries its own vector store instance — its
109
+ ``__aenter__`` / ``__aexit__`` enter and release that
110
+ vector store, so the caller does not pass the vector
111
+ store separately. ``None`` disables knowledge base
112
+ endpoints entirely.
113
+ knowledge_parsers (`list[ParserBase] | dict[str, ParserBase] | \
114
+ None`, optional):
115
+ Parsers registered for knowledge base document uploads.
116
+ Pass a **list** to have the service route by each parser's
117
+ ``supported_media_types`` (later entries override earlier
118
+ ones for overlapping types, with a warning); pass a
119
+ **dict** ``media_type → parser`` for explicit routing
120
+ (one parser bound to multiple types, type aliases, ...).
121
+ Defaults to ``[TextParser()]`` when
122
+ ``knowledge_base_manager`` is set.
123
+ knowledge_chunker (`ChunkerBase | None`, optional):
124
+ The chunker shared across every knowledge base. Defaults
125
+ to :class:`~agentscope.rag.ApproxTokenChunker()` when
126
+ ``knowledge_base_manager`` is set.
127
+ blob_store (`BlobStoreBase | None`, optional):
128
+ Backend storing uploaded document bytes between the
129
+ upload endpoint and the indexing worker. Required when
130
+ ``knowledge_base_manager`` is set; defaults to
131
+ :class:`~agentscope.app.rag.blob_store.LocalBlobStore`
132
+ rooted at ``./blobs``. Its lifecycle (``__aenter__`` /
133
+ ``__aexit__``) is managed by the app lifespan.
134
+ enable_index_worker (`bool`, defaults to ``True``):
135
+ When ``True`` (embedded deployment) the API process starts
136
+ an :class:`~agentscope.app._service.IndexWorker` and an
137
+ :class:`~agentscope.app._service.IndexSweeper` in its
138
+ lifespan, and dispatches indexing tasks via an
139
+ in-process queue. When ``False`` (dedicated deployment)
140
+ the API process performs no indexing — a separate worker
141
+ process is expected to consume tasks from the message
142
+ bus. No effect when ``knowledge_base_manager`` is
143
+ ``None``.
144
+ extra_credentials (`list[Type[CredentialBase]] | None`, optional):
145
+ Additional :class:`~agentscope.credential.CredentialBase`
146
+ subclasses to register before the app starts. Equivalent to
147
+ calling :func:`~agentscope.credential.CredentialFactory.
148
+ register_credential` for each class.
149
+ extra_middlewares (`list[Middleware] | None`, optional):
150
+ Additional ASGI middlewares to add to the application.
151
+ extra_agent_middlewares (`AgentMiddlewareFactory | None`, optional):
152
+ An async factory ``(user_id, agent_id, session_id) -> awaitable
153
+ of list[MiddlewareBase]`` that produces extra
154
+ :class:`~agentscope.middleware.MiddlewareBase` instances to
155
+ attach to the agent on each invocation. Called once per agent
156
+ assembly (i.e. per chat turn / scheduled trigger), so it can
157
+ return user/session-specific middleware (auth, audit logging,
158
+ tenant isolation, etc.). The returned middlewares are appended
159
+ to the framework-supplied ones (e.g. ``ToolOffloadMiddleware``).
160
+ extra_agent_tools (`AgentToolFactory | None`, optional):
161
+ An async factory ``(user_id, agent_id, session_id) -> awaitable
162
+ of list[ToolBase]`` that produces extra
163
+ :class:`~agentscope.tool.ToolBase` instances to register in the
164
+ agent's toolkit on each invocation. Useful when tool
165
+ availability depends on the caller (per-tenant integrations,
166
+ user-specific credentials). The returned tools are added to
167
+ the workspace-derived tools in the toolkit's ``"basic"`` group.
168
+ custom_subagent_templates (`list[SubAgentTemplate] | None`, optional):
169
+ Reusable blueprints for sub-agent creation within teams.
170
+ Each template defines a sub-agent *type* (e.g. ``"researcher"``,
171
+ ``"coder"``) with pre-configured system prompt, context config,
172
+ ReAct config, permission context, and task context. When
173
+ registered, the ``AgentCreate`` tool exposes a
174
+ ``subagent_type`` parameter so the leader agent can route to
175
+ the appropriate template. See
176
+ :class:`~agentscope.app._types.SubAgentTemplate` for details.
177
+ custom_agent_cls (`Type[Agent] | None`, optional):
178
+ A custom :class:`~agentscope.agent.Agent` subclass to use
179
+ when assembling agents. When ``None`` (default), the
180
+ built-in :class:`~agentscope.agent.Agent` is used.
181
+ title (`str`, defaults to ``"AgentScope"``):
182
+ OpenAPI title shown in the docs UI.
183
+ version (`str`, defaults to the package version):
184
+ API version shown in the docs UI.
185
+
186
+ Returns:
187
+ `FastAPI`: A fully configured application ready to serve requests.
188
+ """
189
+ from fastapi import FastAPI
190
+
191
+ # Register any user-supplied credential types before the app starts
192
+ for cls in extra_credentials or []:
193
+ CredentialFactory.register_credential(cls)
194
+
195
+ app = FastAPI(title=title, version=version, lifespan=lifespan)
196
+
197
+ # Attach shared state that lifespan and dependencies read from app.state
198
+ app.state.storage = storage
199
+ app.state.message_bus = message_bus
200
+ app.state.workspace_manager = workspace_manager
201
+ app.state.knowledge_base_manager = knowledge_base_manager
202
+ app.state.extra_agent_middlewares = extra_agent_middlewares
203
+ app.state.extra_agent_tools = extra_agent_tools
204
+ app.state.custom_agent_cls = custom_agent_cls
205
+
206
+ # Parser / chunker / blob-store defaults only make sense when the
207
+ # KB feature is actually enabled. When ``knowledge_base_manager`` is
208
+ # ``None`` every KB endpoint is disabled, so leaving these as ``None``
209
+ # avoids unused imports being eagerly constructed at app startup.
210
+ if knowledge_base_manager is not None:
211
+ app.state.knowledge_parsers = (
212
+ knowledge_parsers
213
+ if knowledge_parsers is not None
214
+ else [TextParser()]
215
+ )
216
+ app.state.knowledge_chunker = knowledge_chunker or ApproxTokenChunker()
217
+ app.state.blob_store = (
218
+ blob_store
219
+ if blob_store is not None
220
+ else LocalBlobStore(root_dir="./blobs")
221
+ )
222
+ else:
223
+ app.state.knowledge_parsers = knowledge_parsers
224
+ app.state.knowledge_chunker = knowledge_chunker
225
+ app.state.blob_store = blob_store
226
+ app.state.enable_index_worker = (
227
+ enable_index_worker and knowledge_base_manager is not None
228
+ )
229
+
230
+ # Validate custom sub-agent templates for duplicate types and store in
231
+ # app.state
232
+ templates = custom_subagent_templates or []
233
+ seen_types: set[str] = set()
234
+ duplicates: set[str] = set()
235
+ for t in templates:
236
+ if t.type in seen_types:
237
+ duplicates.add(t.type)
238
+ seen_types.add(t.type)
239
+ if duplicates:
240
+ raise ValueError(
241
+ f"Duplicate sub_agent_template type(s): {duplicates}",
242
+ )
243
+ app.state.custom_subagent_templates = {t.type: t for t in templates}
244
+
245
+ # Built-in routers
246
+ for router in (
247
+ agent_router,
248
+ chat_router,
249
+ credential_router,
250
+ knowledge_base_router,
251
+ schedule_router,
252
+ session_router,
253
+ workspace_router,
254
+ model_router,
255
+ tts_model_router,
256
+ ):
257
+ app.include_router(router)
258
+
259
+ # Optional extra middlewares
260
+ for middleware in extra_middlewares or []:
261
+ app.add_middleware(middleware.cls, **middleware.kwargs)
262
+
263
+ return app
src/agentscope/app/_bus_ops.py ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """Business-level operations built on top of MessageBus primitives.
3
+
4
+ These helpers compose generic bus primitives (``log_append``, ``publish``,
5
+ ``queue_push``) with domain-specific key layouts from ``MessageBusKeys``.
6
+ They live here — between the transport layer (``message_bus``) and the
7
+ service layer (``_service``) — so that neither layer needs to know about the
8
+ other's internals.
9
+
10
+ .. list-table::
11
+ :widths: 30 70
12
+
13
+ * - :func:`publish_session_event`
14
+ - Append an event to the session replay log and fan it out live.
15
+ * - :func:`enqueue_run_trigger`
16
+ - Enqueue a typed run trigger and signal dispatchers.
17
+ * - :func:`enqueue_index_task`
18
+ - Enqueue a knowledge-document indexing task and signal consumers.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ from typing import TYPE_CHECKING, Literal
23
+
24
+ from .message_bus._keys import MessageBusKeys
25
+
26
+ if TYPE_CHECKING:
27
+ from .message_bus._base import MessageBus
28
+
29
+ from agentscope.event import (
30
+ ExternalExecutionResultEvent,
31
+ UserConfirmResultEvent,
32
+ )
33
+
34
+
35
+ # ── publish_session_event ──────────────────────────────────────────────
36
+
37
+
38
+ async def publish_session_event(
39
+ bus: "MessageBus",
40
+ session_id: str,
41
+ event: dict,
42
+ ) -> str:
43
+ """Append event to replay log + fan out live.
44
+
45
+ Args:
46
+ bus (`MessageBus`):
47
+ The application message bus.
48
+ session_id (`str`):
49
+ The session this event belongs to.
50
+ event (`dict`):
51
+ JSON-serializable event payload.
52
+
53
+ Returns:
54
+ `str`:
55
+ The replay-log entry id assigned by the backend.
56
+ """
57
+ key = MessageBusKeys.session_events(session_id)
58
+ entry_id = await bus.log_append(
59
+ key,
60
+ event,
61
+ max_len=MessageBusKeys.SESSION_REPLAY_MAX_LEN,
62
+ )
63
+ await bus.publish(key, {**event, "_entry_id": entry_id})
64
+ return entry_id
65
+
66
+
67
+ # ── enqueue_run_trigger ────────────────────────────────────────────────
68
+
69
+
70
+ async def enqueue_run_trigger(
71
+ bus: "MessageBus",
72
+ user_id: str,
73
+ session_id: str,
74
+ agent_id: str,
75
+ *,
76
+ kind: Literal["wake", "resume"] = MessageBusKeys.WAKEUP_KIND_WAKE,
77
+ inputs: UserConfirmResultEvent
78
+ | ExternalExecutionResultEvent
79
+ | None = None,
80
+ ) -> None:
81
+ """Enqueue a typed run trigger and signal dispatchers.
82
+
83
+ ``kind`` selects how the dispatcher handles the entry:
84
+
85
+ - ``wake`` — idle-session wake-up. The dispatcher skips the entry
86
+ when the session is already running (the live run drains the inbox
87
+ itself). ``inputs`` must be ``None``.
88
+ - ``resume`` — resume a HITL-parked session with a user confirmation
89
+ or external execution result. The dispatcher waits (with backoff)
90
+ until the parked run releases its lock, then spawns with
91
+ ``input_msg`` set to the deserialised event.
92
+
93
+ The payload is serialised to a plain dict before being pushed to the
94
+ wakeup queue; the ``MessageBus`` transport layer never sees event
95
+ types.
96
+
97
+ Args:
98
+ bus (`MessageBus`):
99
+ The application message bus.
100
+ user_id (`str`):
101
+ The owning user id.
102
+ session_id (`str`):
103
+ The session to trigger a run for.
104
+ agent_id (`str`):
105
+ The agent id that owns the session.
106
+ kind:
107
+ Trigger kind. Defaults to ``"wake"``.
108
+ inputs:
109
+ The input event for ``resume`` triggers. Ignored (and
110
+ should be ``None``) for ``wake``. The function calls
111
+ ``model_dump(mode="json")`` internally — callers pass the
112
+ event object, not a pre-serialised dict.
113
+ """
114
+ await bus.queue_push(
115
+ MessageBusKeys.wakeup_queue(),
116
+ {
117
+ "user_id": user_id,
118
+ "session_id": session_id,
119
+ "agent_id": agent_id,
120
+ "kind": kind,
121
+ "input": inputs.model_dump(mode="json") if inputs else None,
122
+ },
123
+ )
124
+ await bus.publish(MessageBusKeys.wakeup_signal(), {})
125
+
126
+
127
+ # ── enqueue_index_task ─────────────────────────────────────────────────
128
+
129
+
130
+ async def enqueue_index_task(
131
+ bus: "MessageBus",
132
+ user_id: str,
133
+ knowledge_base_id: str,
134
+ document_id: str,
135
+ ) -> None:
136
+ """Enqueue a knowledge-document indexing task and signal consumers.
137
+
138
+ Pushes a structured payload onto the durable index-task queue and
139
+ publishes a signal so any subscribed
140
+ :class:`~agentscope.app._service.IndexTaskConsumer` drains it within
141
+ one ``subscribe`` round-trip.
142
+
143
+ The push happens *before* the publish so a worker woken by the
144
+ signal is guaranteed to find the entry on its drain. Re-enqueuing
145
+ the same document is safe — the worker's lease CAS rejects
146
+ duplicates — so the queue may legitimately hold multiple entries
147
+ for the same document (one from upload, one from sweeper).
148
+
149
+ Args:
150
+ bus (`MessageBus`):
151
+ The application message bus.
152
+ user_id (`str`):
153
+ The owning user id.
154
+ knowledge_base_id (`str`):
155
+ The parent knowledge base id.
156
+ document_id (`str`):
157
+ The document id to index.
158
+ """
159
+ await bus.queue_push(
160
+ MessageBusKeys.index_tasks_queue(),
161
+ {
162
+ "user_id": user_id,
163
+ "knowledge_base_id": knowledge_base_id,
164
+ "document_id": document_id,
165
+ },
166
+ )
167
+ await bus.publish(MessageBusKeys.index_tasks_signal(), {})
src/agentscope/app/_lifespan.py ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """The lifespan of the agent service."""
3
+ import socket
4
+ import uuid
5
+ from contextlib import AsyncExitStack, asynccontextmanager
6
+ from typing import TYPE_CHECKING, Any, AsyncIterator
7
+
8
+ from ._manager import (
9
+ BackgroundTaskManager,
10
+ CancelDispatcher,
11
+ ChatRunRegistry,
12
+ SchedulerManager,
13
+ WakeupDispatcher,
14
+ )
15
+ from ._service import (
16
+ ChatService,
17
+ IndexSweeper,
18
+ IndexTaskConsumer,
19
+ IndexWorker,
20
+ KnowledgeBaseService,
21
+ SessionService,
22
+ )
23
+
24
+ if TYPE_CHECKING:
25
+ from fastapi import FastAPI
26
+ else:
27
+ FastAPI = Any
28
+
29
+
30
+ @asynccontextmanager
31
+ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
32
+ """Manage startup and shutdown of all application-wide resources.
33
+
34
+ Every resource with a lifecycle is an async context manager and is
35
+ entered through a single :class:`AsyncExitStack`. The stack tears
36
+ them down in reverse order on shutdown — including when an entry
37
+ later in the sequence raises during startup, so no resource leaks
38
+ on partial failure.
39
+
40
+ Service-layer ``ChatService`` and ``SessionService`` have no
41
+ lifecycle of their own and are constructed inline.
42
+ """
43
+ storage = app.state.storage
44
+ message_bus = app.state.message_bus
45
+ workspace_manager = app.state.workspace_manager
46
+ knowledge_base_manager = app.state.knowledge_base_manager
47
+ blob_store = app.state.blob_store
48
+ enable_index_worker = app.state.enable_index_worker
49
+
50
+ async with AsyncExitStack() as stack:
51
+ await stack.enter_async_context(storage)
52
+ await stack.enter_async_context(message_bus)
53
+ await stack.enter_async_context(workspace_manager)
54
+ if knowledge_base_manager is not None:
55
+ # ``KnowledgeBaseManagerBase.__aenter__`` enters the bound
56
+ # vector store too, so a single context covers both.
57
+ await stack.enter_async_context(knowledge_base_manager)
58
+ if blob_store is not None:
59
+ await stack.enter_async_context(blob_store)
60
+
61
+ bg_manager = await stack.enter_async_context(
62
+ BackgroundTaskManager(message_bus=message_bus),
63
+ )
64
+ app.state.background_task_manager = bg_manager
65
+
66
+ # Per-process registry of in-flight chat-run asyncio tasks.
67
+ # Entered before the wake-up + cancel dispatchers so they can
68
+ # share the same registry; exited last so its shutdown can
69
+ # cancel any leftover runs after the dispatchers stop.
70
+ chat_run_registry = await stack.enter_async_context(ChatRunRegistry())
71
+ app.state.chat_run_registry = chat_run_registry
72
+
73
+ # Scheduler is independent of ChatService now (its fire path
74
+ # pushes to inbox + enqueues wakeup via the bus), so we build it
75
+ # before ChatService and inject it via the constructor.
76
+ scheduler = await stack.enter_async_context(
77
+ SchedulerManager(
78
+ storage=storage,
79
+ message_bus=message_bus,
80
+ ),
81
+ )
82
+ app.state.scheduler_manager = scheduler
83
+
84
+ chat_service = ChatService(
85
+ storage=storage,
86
+ workspace_manager=workspace_manager,
87
+ scheduler_manager=scheduler,
88
+ background_task_manager=bg_manager,
89
+ message_bus=message_bus,
90
+ knowledge_base_manager=knowledge_base_manager,
91
+ extra_agent_middlewares=app.state.extra_agent_middlewares,
92
+ extra_agent_tools=app.state.extra_agent_tools,
93
+ custom_subagent_templates=app.state.custom_subagent_templates,
94
+ custom_agent_cls=app.state.custom_agent_cls,
95
+ )
96
+ app.state.chat_service = chat_service
97
+
98
+ app.state.session_service = SessionService(
99
+ storage=storage,
100
+ message_bus=message_bus,
101
+ )
102
+
103
+ # ---------------- Knowledge-base wiring ----------------
104
+ knowledge_base_service = None
105
+ if knowledge_base_manager is not None:
106
+ # Indexing is uniformly driven by the message bus: the
107
+ # service publishes an index-task entry, and a consumer
108
+ # (in-process or in a dedicated worker process) drains it.
109
+ #
110
+ # * Embedded — ``enable_index_worker=True``: this lifespan
111
+ # additionally starts an :class:`IndexWorker` plus an
112
+ # :class:`IndexTaskConsumer` that subscribes to the same
113
+ # channel. The ``InMemoryMessageBus`` makes the round
114
+ # trip near-free; everything runs in one binary.
115
+ #
116
+ # * Dedicated — ``enable_index_worker=False``: this
117
+ # lifespan does NOT start a worker. One or more separate
118
+ # processes (``python -m agentscope.app.rag.index_worker``)
119
+ # run their own consumer + worker pair subscribed to the
120
+ # same channel.
121
+ #
122
+ # The sweeper STILL runs in the API process either way,
123
+ # because the API is the only resource guaranteed to be
124
+ # live whenever uploads happen — if the publish ever races
125
+ # a worker restart the durable queue catches the task, and
126
+ # if the queue write itself failed the sweeper eventually
127
+ # re-enqueues from storage.
128
+ if enable_index_worker:
129
+ node_id = f"{socket.gethostname()}:{uuid.uuid4().hex[:8]}"
130
+ worker = IndexWorker(
131
+ storage=storage,
132
+ blob_store=blob_store,
133
+ knowledge_base_manager=knowledge_base_manager,
134
+ parsers=app.state.knowledge_parsers,
135
+ chunker=app.state.knowledge_chunker,
136
+ node_id=node_id,
137
+ )
138
+ await stack.enter_async_context(
139
+ IndexTaskConsumer(
140
+ message_bus=message_bus,
141
+ worker=worker,
142
+ ),
143
+ )
144
+
145
+ sweeper = IndexSweeper(
146
+ storage=storage,
147
+ message_bus=message_bus,
148
+ )
149
+ await sweeper.start()
150
+ stack.push_async_callback(sweeper.stop)
151
+
152
+ knowledge_base_service = KnowledgeBaseService(
153
+ storage=storage,
154
+ knowledge_base_manager=knowledge_base_manager,
155
+ blob_store=blob_store,
156
+ message_bus=message_bus,
157
+ )
158
+
159
+ app.state.knowledge_base_service = knowledge_base_service
160
+
161
+ # Dispatchers need live references somewhere, or they would be
162
+ # garbage-collected; the AsyncExitStack holds those references
163
+ # for us, so we don't need local bindings or app.state slots.
164
+ await stack.enter_async_context(
165
+ WakeupDispatcher(
166
+ message_bus=message_bus,
167
+ storage=storage,
168
+ chat_service=chat_service,
169
+ chat_run_registry=chat_run_registry,
170
+ ),
171
+ )
172
+ await stack.enter_async_context(
173
+ CancelDispatcher(
174
+ message_bus=message_bus,
175
+ registry=chat_run_registry,
176
+ bg_manager=bg_manager,
177
+ ),
178
+ )
179
+
180
+ yield
src/agentscope/app/_manager/__init__.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """The agent service managers, used in FastAPI lifespan to manage
3
+ application-wide resources."""
4
+
5
+ from ._scheduler import SchedulerManager
6
+ from ._wakeup_dispatcher import WakeupDispatcher
7
+ from ._cancel_dispatcher import CancelDispatcher
8
+ from ._chat_run_registry import ChatRunRegistry
9
+ from ._background_task_manager import BackgroundTaskManager
10
+
11
+ __all__ = [
12
+ "BackgroundTaskManager",
13
+ "CancelDispatcher",
14
+ "ChatRunRegistry",
15
+ "SchedulerManager",
16
+ "WakeupDispatcher",
17
+ ]
src/agentscope/app/_manager/_background_task_manager.py ADDED
@@ -0,0 +1,469 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """The background task manager."""
3
+ import asyncio
4
+ import json
5
+ import time
6
+ from collections import OrderedDict
7
+ from dataclasses import dataclass, field
8
+ from typing import Any, Self, TYPE_CHECKING
9
+
10
+ import shortuuid
11
+ from pydantic import BaseModel, Field
12
+
13
+ from agentscope.message import TextBlock, ToolResultState
14
+ from agentscope.permission import (
15
+ PermissionContext,
16
+ PermissionDecision,
17
+ PermissionBehavior,
18
+ )
19
+ from agentscope.tool import ToolBase, ToolChunk
20
+ from agentscope._logging import logger
21
+ from ..message_bus import MessageBusKeys
22
+
23
+ if TYPE_CHECKING:
24
+ from ..message_bus import MessageBus
25
+
26
+
27
+ @dataclass
28
+ class BackgroundTask:
29
+ """Metadata for a single background task.
30
+
31
+ Attributes:
32
+ asyncio_task (`asyncio.Task`):
33
+ The running asyncio task.
34
+ session_id (`str`):
35
+ The session id of the originating request.
36
+ agent_id (`str`):
37
+ The name of the agent that created the task.
38
+ user_id (`str`):
39
+ The user id of the originating request.
40
+ tool_name (`str`):
41
+ The name of the tool that was offloaded.
42
+ id (`str`):
43
+ Auto-generated unique task identifier.
44
+ """
45
+
46
+ asyncio_task: asyncio.Task
47
+ """The running asyncio task."""
48
+
49
+ session_id: str
50
+ """The session id of the background task."""
51
+
52
+ agent_id: str
53
+ """The agent that created the background task."""
54
+
55
+ user_id: str
56
+ """The user id of the originating request."""
57
+
58
+ tool_name: str
59
+ """The name of the offloaded tool."""
60
+
61
+ id: str = field(default_factory=shortuuid.uuid)
62
+ """The background task id."""
63
+
64
+
65
+ class _ToolStopParams(BaseModel):
66
+ """The params of the stop tool."""
67
+
68
+ task_id: str = Field(
69
+ description="The task id of the background tool to stop.",
70
+ )
71
+
72
+
73
+ class ToolStop(ToolBase):
74
+ """A tool to stop a running background tool execution."""
75
+
76
+ name: str = "ToolStop"
77
+ """The tool name."""
78
+
79
+ description: str = (
80
+ "Stop a background tool execution by its task id. "
81
+ "Use this when you want to cancel a previously offloaded tool "
82
+ "that is still running in the background."
83
+ )
84
+ """The tool description."""
85
+
86
+ input_schema: dict = _ToolStopParams.model_json_schema()
87
+ """The input schema."""
88
+
89
+ is_concurrency_safe: bool = True
90
+ is_read_only: bool = False
91
+ is_state_injected: bool = False
92
+ is_external_tool: bool = False
93
+ is_mcp: bool = False
94
+ mcp_name: str | None = None
95
+
96
+ def __init__(
97
+ self,
98
+ background_tasks: dict[str, BackgroundTask],
99
+ message_bus: "MessageBus",
100
+ session_id: str,
101
+ ) -> None:
102
+ """Initialize the ToolStop tool.
103
+
104
+ Args:
105
+ background_tasks (`dict[str, BackgroundTask]`):
106
+ A reference to the local background tasks managed by
107
+ the :class:`BackgroundTaskManager`.
108
+ message_bus (`MessageBus`):
109
+ The application message bus, used to check the global
110
+ registry and broadcast cross-worker cancel requests.
111
+ session_id (`str`):
112
+ The current session id, used to scope Redis registry
113
+ lookups.
114
+ """
115
+ self.background_tasks = background_tasks
116
+ self._message_bus = message_bus
117
+ self._session_id = session_id
118
+
119
+ async def check_permissions(
120
+ self,
121
+ tool_input: dict[str, Any],
122
+ context: PermissionContext,
123
+ ) -> PermissionDecision:
124
+ """Check permission for the tool usage.
125
+
126
+ Args:
127
+ tool_input (`dict[str, Any]`):
128
+ The tool input parameters.
129
+ context (`PermissionContext`):
130
+ The permission context.
131
+
132
+ Returns:
133
+ `PermissionDecision`:
134
+ Always returns ALLOW.
135
+ """
136
+ return PermissionDecision(
137
+ behavior=PermissionBehavior.ALLOW,
138
+ message=f"{self.name} is always allowed to be called.",
139
+ )
140
+
141
+ async def __call__(self, task_id: str) -> ToolChunk:
142
+ """Stop the background task.
143
+
144
+ Args:
145
+ task_id (`str`):
146
+ The task id.
147
+
148
+ Returns:
149
+ `ToolChunk`:
150
+ The tool chunk.
151
+ """
152
+ # Path 1: task is on this worker — cancel directly.
153
+ # Only cancel when the task belongs to the same session as this
154
+ # ToolStop instance, so a leaked/guessed task_id from another
155
+ # session cannot trigger cross-session cancellation on a shared
156
+ # worker.
157
+ local_task = self.background_tasks.get(task_id)
158
+ if (
159
+ local_task is not None
160
+ and local_task.session_id == self._session_id
161
+ ):
162
+ self.background_tasks.pop(task_id, None)
163
+ local_task.asyncio_task.cancel()
164
+ logger.info(
165
+ "Background task stopped via ToolStop (local): task_id=%s, "
166
+ "session_id=%s, agent_id=%s",
167
+ task_id,
168
+ local_task.session_id,
169
+ local_task.agent_id,
170
+ )
171
+ return ToolChunk(
172
+ content=[
173
+ TextBlock(text=f"Task {task_id} stopped successfully."),
174
+ ],
175
+ state=ToolResultState.SUCCESS,
176
+ )
177
+
178
+ # Path 2: task exists in the global registry (another worker, or
179
+ # a different session on this worker).
180
+ if await self._message_bus.registry_exists(
181
+ MessageBusKeys.bg_tasks(self._session_id),
182
+ task_id,
183
+ ):
184
+ await self._message_bus.publish(
185
+ MessageBusKeys.task_cancel_channel(),
186
+ {"task_id": task_id},
187
+ )
188
+ logger.info(
189
+ "Background task cancel broadcast via ToolStop (remote): "
190
+ "task_id=%s, session_id=%s",
191
+ task_id,
192
+ self._session_id,
193
+ )
194
+ return ToolChunk(
195
+ content=[
196
+ TextBlock(
197
+ text=f"Cancel request sent for task {task_id}. "
198
+ f"The owning worker will stop it shortly.",
199
+ ),
200
+ ],
201
+ state=ToolResultState.SUCCESS,
202
+ )
203
+
204
+ # Path 3: task not found anywhere.
205
+ return ToolChunk(
206
+ content=[
207
+ TextBlock(
208
+ text=f"TaskNotFoundError: The task {task_id} "
209
+ f"does not exist.",
210
+ ),
211
+ ],
212
+ state=ToolResultState.ERROR,
213
+ )
214
+
215
+
216
+ class BackgroundTaskManager:
217
+ """Tracks background asyncio task lifecycle within the agent service.
218
+
219
+ Responsibilities:
220
+
221
+ - **Global registry**: register/unregister tasks in Redis so any
222
+ process can query which tasks are alive for a session.
223
+ - **Local handle cache**: hold ``asyncio.Task`` references for
224
+ cancel and shutdown.
225
+ - **Task scheduling**: convenience method for creating a task from
226
+ a plain coroutine with a done callback that cleans up both sides.
227
+
228
+ Completion results are delivered via the :class:`MessageBus` inbox
229
+ + wakeup path (same as team messages), so any process's
230
+ :class:`WakeupDispatcher` can pick up the result.
231
+ """
232
+
233
+ def __init__(self, message_bus: "MessageBus") -> None:
234
+ """Initialise the background task manager.
235
+
236
+ Args:
237
+ message_bus (`MessageBus`):
238
+ The application message bus; used for the global BG
239
+ task registry (Redis Hash) and task-level cancel
240
+ broadcasts.
241
+ """
242
+ self._message_bus = message_bus
243
+ self.tasks: OrderedDict[str, BackgroundTask] = OrderedDict()
244
+
245
+ # ------------------------------------------------------------------
246
+ # Task registration
247
+ # ------------------------------------------------------------------
248
+
249
+ async def register_task(
250
+ self,
251
+ asyncio_task: asyncio.Task,
252
+ session_id: str,
253
+ agent_id: str,
254
+ user_id: str,
255
+ tool_name: str = "",
256
+ ) -> str:
257
+ """Register an already-running asyncio task.
258
+
259
+ Writes to both the local handle cache and the global Redis
260
+ registry. The task auto-removes from both when it finishes
261
+ (via ``add_done_callback``).
262
+
263
+ Args:
264
+ asyncio_task (`asyncio.Task`):
265
+ The already-running task to register.
266
+ session_id (`str`):
267
+ The originating session id.
268
+ agent_id (`str`):
269
+ The agent record id that owns the task.
270
+ user_id (`str`):
271
+ The user id of the originating request.
272
+ tool_name (`str`, optional):
273
+ The name of the offloaded tool.
274
+
275
+ Returns:
276
+ `str`:
277
+ The generated task id.
278
+ """
279
+ bg_task = BackgroundTask(
280
+ asyncio_task=asyncio_task,
281
+ session_id=session_id,
282
+ agent_id=agent_id,
283
+ user_id=user_id,
284
+ tool_name=tool_name,
285
+ )
286
+ task_id = bg_task.id
287
+ self.tasks[task_id] = bg_task
288
+
289
+ # Register in the global Redis registry.
290
+ metadata = json.dumps(
291
+ {
292
+ "tool_name": tool_name,
293
+ "agent_id": agent_id,
294
+ "started_at": time.time(),
295
+ },
296
+ )
297
+ await self._message_bus.registry_set(
298
+ MessageBusKeys.bg_tasks(session_id),
299
+ task_id,
300
+ metadata,
301
+ ttl_secs=MessageBusKeys.BG_TASKS_TTL_SECS,
302
+ )
303
+
304
+ logger.info(
305
+ "Background task registered: task_id=%s, session_id=%s, "
306
+ "agent_id=%s, tool_name=%s",
307
+ task_id,
308
+ session_id,
309
+ agent_id,
310
+ tool_name,
311
+ )
312
+
313
+ def _on_done(_t: asyncio.Task) -> None:
314
+ self.tasks.pop(task_id, None)
315
+ # Schedule async Redis cleanup (fire-and-forget). Wrap in a
316
+ # coroutine that logs failures so the bus error (e.g. Redis
317
+ # connection drop) does not surface as
318
+ # ``Task exception was never retrieved``.
319
+ try:
320
+ asyncio.ensure_future(
321
+ self._safe_bg_task_unregister(session_id, task_id),
322
+ )
323
+ except RuntimeError:
324
+ # Event loop already closed during shutdown.
325
+ pass
326
+
327
+ asyncio_task.add_done_callback(_on_done)
328
+ return task_id
329
+
330
+ async def _safe_bg_task_unregister(
331
+ self,
332
+ session_id: str,
333
+ task_id: str,
334
+ ) -> None:
335
+ """Unregister a finished background task, logging any failure.
336
+
337
+ Args:
338
+ session_id (`str`):
339
+ The session id of the finished task.
340
+ task_id (`str`):
341
+ The task id to unregister from the global registry.
342
+ """
343
+ try:
344
+ await self._message_bus.registry_del(
345
+ MessageBusKeys.bg_tasks(session_id),
346
+ task_id,
347
+ )
348
+ except Exception as e: # pylint: disable=broad-except
349
+ logger.exception(
350
+ "Failed to unregister background task from the global "
351
+ "registry: task_id=%s, session_id=%s, error=%s",
352
+ task_id,
353
+ session_id,
354
+ str(e),
355
+ )
356
+
357
+ # ------------------------------------------------------------------
358
+ # Tool listing
359
+ # ------------------------------------------------------------------
360
+
361
+ async def list_tools(self, session_id: str) -> list[ToolBase]:
362
+ """List the background task tools for a given session.
363
+
364
+ Args:
365
+ session_id (`str`):
366
+ The current session id (for ToolStop's registry
367
+ lookups).
368
+
369
+ Returns:
370
+ `list[ToolBase]`:
371
+ A list containing the :class:`ToolStop` tool.
372
+ """
373
+ return [ToolStop(self.tasks, self._message_bus, session_id)]
374
+
375
+ # ------------------------------------------------------------------
376
+ # Session-scoped cancel
377
+ # ------------------------------------------------------------------
378
+
379
+ def cancel_session_tasks(self, session_id: str) -> int:
380
+ """Cancel every locally-tracked task whose owner session matches.
381
+
382
+ Called by :class:`CancelDispatcher` on each incoming session
383
+ cancel broadcast. Returns the number of tasks cancelled on this
384
+ process.
385
+
386
+ Args:
387
+ session_id (`str`):
388
+ The session whose tasks should be cancelled.
389
+
390
+ Returns:
391
+ `int`:
392
+ Number of tasks cancelled locally.
393
+ """
394
+ cancelled = 0
395
+ for bg_task in list(self.tasks.values()):
396
+ if bg_task.session_id != session_id:
397
+ continue
398
+ logger.info(
399
+ "Cancelling background task for session cancel: "
400
+ "task_id=%s, session_id=%s, agent_id=%s",
401
+ bg_task.id,
402
+ bg_task.session_id,
403
+ bg_task.agent_id,
404
+ )
405
+ bg_task.asyncio_task.cancel()
406
+ cancelled += 1
407
+ return cancelled
408
+
409
+ # ------------------------------------------------------------------
410
+ # Single-task cancel (called by CancelDispatcher on bus signal)
411
+ # ------------------------------------------------------------------
412
+
413
+ def cancel_task(self, task_id: str) -> bool:
414
+ """Cancel a single locally-tracked task by its id.
415
+
416
+ Called by :class:`CancelDispatcher` when a task-level cancel
417
+ broadcast arrives. Returns whether the task was found and
418
+ cancelled on this process.
419
+
420
+ Args:
421
+ task_id (`str`):
422
+ The task to cancel.
423
+
424
+ Returns:
425
+ `bool`:
426
+ ``True`` if the task was found locally and cancelled.
427
+ """
428
+ bg_task = self.tasks.get(task_id)
429
+ if bg_task is None:
430
+ return False
431
+ logger.info(
432
+ "Cancelling background task via bus signal: "
433
+ "task_id=%s, session_id=%s, agent_id=%s",
434
+ task_id,
435
+ bg_task.session_id,
436
+ bg_task.agent_id,
437
+ )
438
+ bg_task.asyncio_task.cancel()
439
+ return True
440
+
441
+ # ------------------------------------------------------------------
442
+ # Lifecycle
443
+ # ------------------------------------------------------------------
444
+
445
+ async def __aenter__(self) -> Self:
446
+ """Enter the async context. No setup required.
447
+
448
+ Returns:
449
+ `Self`: This manager instance.
450
+ """
451
+ return self
452
+
453
+ async def __aexit__(self, *exc: object) -> None:
454
+ """Cancel all running background tasks on context exit."""
455
+ count = len(self.tasks)
456
+ logger.info(
457
+ "Shutting down BackgroundTaskManager: cancelling %d task(s).",
458
+ count,
459
+ )
460
+ for bg_task in list(self.tasks.values()):
461
+ logger.info(
462
+ "Cancelling background task on shutdown: task_id=%s, "
463
+ "session_id=%s, agent_id=%s",
464
+ bg_task.id,
465
+ bg_task.session_id,
466
+ bg_task.agent_id,
467
+ )
468
+ bg_task.asyncio_task.cancel()
469
+ self.tasks.clear()
src/agentscope/app/_manager/_cancel_dispatcher.py ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """Single per-process dispatcher for cross-process cancels.
3
+
4
+ Subscribes to two bus channels:
5
+
6
+ 1. **Session cancel** — cancel all local work for a session (chat run
7
+ + all BG tasks). Triggered by session deletion or explicit abort.
8
+ 2. **Task cancel** — cancel a single BG task by task_id. Triggered by
9
+ the :class:`ToolStop` agent tool when the target task lives on a
10
+ different worker.
11
+
12
+ Processes whose registry / BG-manager do not hold the targeted session
13
+ or task simply do no work — the publisher does not need to know which
14
+ worker holds what; it broadcasts and lets each holder self-select.
15
+ """
16
+ import asyncio
17
+ from typing import TYPE_CHECKING, Self
18
+
19
+ from ..._logging import logger
20
+ from ..message_bus import MessageBusKeys
21
+
22
+ if TYPE_CHECKING:
23
+ from ..message_bus import MessageBus
24
+ from ._background_task_manager import BackgroundTaskManager
25
+ from ._chat_run_registry import ChatRunRegistry
26
+
27
+
28
+ class CancelDispatcher:
29
+ """Subscribes to bus cancel channels and cancels matching local
30
+ tasks.
31
+
32
+ Args:
33
+ message_bus (`MessageBus`):
34
+ Application message bus.
35
+ registry (`ChatRunRegistry`):
36
+ The per-process chat-run registry whose tasks may be
37
+ cancelled.
38
+ bg_manager (`BackgroundTaskManager`):
39
+ The per-process background task manager.
40
+ """
41
+
42
+ def __init__(
43
+ self,
44
+ message_bus: "MessageBus",
45
+ registry: "ChatRunRegistry",
46
+ bg_manager: "BackgroundTaskManager",
47
+ ) -> None:
48
+ """Bind dependencies.
49
+
50
+ Args:
51
+ message_bus (`MessageBus`):
52
+ Application message bus.
53
+ registry (`ChatRunRegistry`):
54
+ The per-process chat-run registry.
55
+ bg_manager (`BackgroundTaskManager`):
56
+ The per-process background task manager.
57
+ """
58
+ self._bus = message_bus
59
+ self._registry = registry
60
+ self._bg_manager = bg_manager
61
+ self._session_task: asyncio.Task | None = None
62
+ self._task_cancel_task: asyncio.Task | None = None
63
+
64
+ async def __aenter__(self) -> Self:
65
+ """Start both dispatcher loops and wait until their bus
66
+ subscriptions are live.
67
+
68
+ Returns:
69
+ `Self`: This dispatcher instance.
70
+ """
71
+ session_ready = asyncio.Event()
72
+ task_ready = asyncio.Event()
73
+
74
+ self._session_task = asyncio.create_task(
75
+ self._session_cancel_loop(session_ready),
76
+ name="cancel-dispatcher:session",
77
+ )
78
+ self._task_cancel_task = asyncio.create_task(
79
+ self._task_cancel_loop(task_ready),
80
+ name="cancel-dispatcher:task",
81
+ )
82
+
83
+ await session_ready.wait()
84
+ await task_ready.wait()
85
+ return self
86
+
87
+ async def __aexit__(self, *exc: object) -> None:
88
+ """Cancel both dispatcher loops on context exit."""
89
+ for task in (self._session_task, self._task_cancel_task):
90
+ if task is None:
91
+ continue
92
+ task.cancel()
93
+ try:
94
+ await task
95
+ except asyncio.CancelledError:
96
+ pass
97
+ self._session_task = None
98
+ self._task_cancel_task = None
99
+
100
+ # ------------------------------------------------------------------
101
+ # Session-level cancel loop
102
+ # ------------------------------------------------------------------
103
+
104
+ async def _session_cancel_loop(self, ready: asyncio.Event) -> None:
105
+ """Subscribe to session cancel channel and act on each signal.
106
+
107
+ Args:
108
+ ready (`asyncio.Event`):
109
+ Signalled after the underlying SUBSCRIBE completes.
110
+ """
111
+ try:
112
+ async for payload in self._bus.subscribe(
113
+ MessageBusKeys.session_cancel_channel(),
114
+ on_ready=ready.set,
115
+ ):
116
+ sid = payload.get("session_id")
117
+ if isinstance(sid, str):
118
+ self._cancel_session(sid)
119
+ except Exception: # pylint: disable=broad-except
120
+ logger.exception(
121
+ "CancelDispatcher session-cancel loop crashed.",
122
+ )
123
+ finally:
124
+ # Unblock ``__aenter__`` even if subscribe failed before
125
+ # ``on_ready`` ran, so startup cannot deadlock.
126
+ ready.set()
127
+
128
+ def _cancel_session(self, session_id: str) -> None:
129
+ """Cancel every locally-tracked task for a session.
130
+
131
+ Args:
132
+ session_id (`str`):
133
+ The session whose runs and BG tasks should be cancelled.
134
+ """
135
+ task = self._registry.get(session_id)
136
+ if task is not None and not task.done():
137
+ logger.info(
138
+ "CancelDispatcher: cancelling local chat run for "
139
+ "session %s",
140
+ session_id,
141
+ )
142
+ task.cancel()
143
+
144
+ bg_cancelled = self._bg_manager.cancel_session_tasks(session_id)
145
+ if bg_cancelled:
146
+ logger.info(
147
+ "CancelDispatcher: cancelled %d local BG task(s) for "
148
+ "session %s",
149
+ bg_cancelled,
150
+ session_id,
151
+ )
152
+
153
+ # ------------------------------------------------------------------
154
+ # Task-level cancel loop
155
+ # ------------------------------------------------------------------
156
+
157
+ async def _task_cancel_loop(self, ready: asyncio.Event) -> None:
158
+ """Subscribe to the task cancel channel and act on each signal.
159
+
160
+ Args:
161
+ ready (`asyncio.Event`):
162
+ Signalled after the underlying SUBSCRIBE completes.
163
+ """
164
+ try:
165
+ async for payload in self._bus.subscribe(
166
+ MessageBusKeys.task_cancel_channel(),
167
+ on_ready=ready.set,
168
+ ):
169
+ task_id = payload.get("task_id")
170
+ if not isinstance(task_id, str):
171
+ continue
172
+ cancelled = self._bg_manager.cancel_task(task_id)
173
+ if cancelled:
174
+ logger.info(
175
+ "CancelDispatcher: cancelled local BG task %s "
176
+ "via task-level broadcast.",
177
+ task_id,
178
+ )
179
+ except Exception: # pylint: disable=broad-except
180
+ logger.exception(
181
+ "CancelDispatcher task-cancel loop crashed.",
182
+ )
183
+ finally:
184
+ # Unblock ``__aenter__`` even if subscribe failed before
185
+ # ``on_ready`` ran, so startup cannot deadlock.
186
+ ready.set()
src/agentscope/app/_manager/_chat_run_registry.py ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """Per-process registry of in-flight ``ChatService.run`` asyncio tasks.
3
+
4
+ Owns the asyncio.Task handles only — it is not the public cancel
5
+ entry point. The cross-process cancel path goes through the bus's
6
+ :meth:`~agentscope.app.message_bus.MessageBus.session_publish_cancel`
7
+ broadcast, picked up locally by
8
+ :class:`~agentscope.app._manager.CancelDispatcher`, which then looks
9
+ up the task here and calls ``.cancel()`` on it.
10
+
11
+ A given ``session_id`` can have at most one entry. Concurrent runs for
12
+ the same session are already prevented at a cluster level by
13
+ :meth:`~agentscope.app.message_bus.MessageBus.session_run` (the
14
+ distributed lock), so a second :meth:`spawn` for the same id is treated
15
+ as a programming error.
16
+ """
17
+ import asyncio
18
+ from typing import Coroutine, Self
19
+
20
+ from ..._logging import logger
21
+
22
+
23
+ class ChatRunRegistry:
24
+ """In-process index of active chat-run asyncio tasks, keyed by
25
+ session id.
26
+
27
+ Used by :class:`~agentscope.app._manager.CancelDispatcher` to find
28
+ and cancel the local task for a given session, and by the lifespan
29
+ to cancel any leftover runs on application shutdown.
30
+ """
31
+
32
+ def __init__(self) -> None:
33
+ """Initialise an empty registry."""
34
+ self._tasks: dict[str, asyncio.Task] = {}
35
+
36
+ def spawn(
37
+ self,
38
+ coro: Coroutine,
39
+ *,
40
+ session_id: str,
41
+ name: str | None = None,
42
+ ) -> asyncio.Task:
43
+ """Create and register an asyncio task that runs ``coro``.
44
+
45
+ The task auto-removes from the registry when it finishes (via
46
+ ``add_done_callback``).
47
+
48
+ Args:
49
+ coro (`Coroutine`):
50
+ A coroutine — typically ``chat_service.run(...)`` — to
51
+ run as a background task.
52
+ session_id (`str`):
53
+ The session this run belongs to. Used as the registry
54
+ key for later cancel lookup.
55
+ name (`str | None`, optional):
56
+ Optional task name passed through to
57
+ :func:`asyncio.create_task`. Defaults to
58
+ ``f"chat-run:{session_id}"``.
59
+
60
+ Returns:
61
+ `asyncio.Task`:
62
+ The created task. Callers normally do not need to keep
63
+ the reference — the registry holds it for the task's
64
+ lifetime.
65
+
66
+ Raises:
67
+ `RuntimeError`:
68
+ When a non-finished task is already registered for
69
+ ``session_id``. Callers are expected to coordinate via
70
+ the distributed session lock before spawning.
71
+ """
72
+ existing = self._tasks.get(session_id)
73
+ if existing is not None and not existing.done():
74
+ raise RuntimeError(
75
+ f"Session {session_id!r} already has an active chat run "
76
+ "in this process.",
77
+ )
78
+
79
+ task = asyncio.create_task(
80
+ coro,
81
+ name=name or f"chat-run:{session_id}",
82
+ )
83
+ self._tasks[session_id] = task
84
+
85
+ def _cleanup(t: asyncio.Task) -> None:
86
+ # Only remove the entry if it still points at this task —
87
+ # a fresh spawn for the same sid may have replaced it.
88
+ if self._tasks.get(session_id) is t:
89
+ self._tasks.pop(session_id, None)
90
+
91
+ task.add_done_callback(_cleanup)
92
+ return task
93
+
94
+ def get(self, session_id: str) -> asyncio.Task | None:
95
+ """Return the registered task for ``session_id``, or ``None``.
96
+
97
+ Args:
98
+ session_id (`str`):
99
+ The session whose task to look up.
100
+
101
+ Returns:
102
+ `asyncio.Task | None`:
103
+ The task if one is currently registered for the
104
+ session, else ``None``.
105
+ """
106
+ return self._tasks.get(session_id)
107
+
108
+ async def __aenter__(self) -> Self:
109
+ """No-op enter; the registry has no startup work.
110
+
111
+ Returns:
112
+ `Self`: This registry instance.
113
+ """
114
+ return self
115
+
116
+ async def __aexit__(self, *exc: object) -> None:
117
+ """Cancel every still-running task on application shutdown.
118
+
119
+ Each task is cancelled and awaited so its ``finally`` blocks
120
+ and any ``async with`` cleanups (notably the bus's session
121
+ run-lock release) execute before the process exits.
122
+ """
123
+ if not self._tasks:
124
+ return
125
+ logger.info(
126
+ "ChatRunRegistry shutdown: cancelling %d in-flight chat run(s).",
127
+ len(self._tasks),
128
+ )
129
+ tasks = list(self._tasks.values())
130
+ for task in tasks:
131
+ task.cancel()
132
+ # Wait for every cancel to land; swallow CancelledError per task.
133
+ await asyncio.gather(*tasks, return_exceptions=True)
134
+ self._tasks.clear()
src/agentscope/app/_manager/_scheduler/__init__.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """The scheduler related components."""
3
+
4
+ from ._scheduler_manager import SchedulerManager
5
+
6
+ __all__ = [
7
+ "SchedulerManager",
8
+ ]
src/agentscope/app/_manager/_scheduler/_scheduler_manager.py ADDED
@@ -0,0 +1,432 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """The cron scheduler manager class."""
3
+ import json
4
+ from collections.abc import Callable, Coroutine
5
+
6
+ from typing import Self
7
+
8
+ from ....message import HintBlock
9
+ from ....permission import PermissionContext
10
+ from ....state import AgentState
11
+ from ....tool import ToolBase
12
+ from ...._logging import logger
13
+ from ._tools import ScheduleCreate, ScheduleDelete, ScheduleList, ScheduleView
14
+ from ...message_bus import MessageBus, MessageBusKeys
15
+ from ..._bus_ops import enqueue_run_trigger
16
+ from ...storage import (
17
+ StorageBase,
18
+ ScheduleRecord,
19
+ ChatModelConfig,
20
+ SessionConfig,
21
+ SessionSource,
22
+ )
23
+
24
+
25
+ class SchedulerManager:
26
+ """The cron scheduler manager, responsible for managing scheduled-task
27
+ lifecycle within the agent service.
28
+
29
+ The manager owns both the in-memory APScheduler instance and the trigger
30
+ logic that fires scheduled tasks. Triggers do not call ``ChatService``
31
+ directly; instead they push a :class:`HintBlock` to the target session's
32
+ inbox and enqueue a wakeup, so that the application-wide
33
+ :class:`WakeupDispatcher` (running on any process) picks up the work.
34
+ This keeps the scheduler decoupled from ``ChatService`` and makes the
35
+ fire path consistent with team / background-tool result delivery.
36
+ """
37
+
38
+ def __init__(
39
+ self,
40
+ storage: StorageBase,
41
+ message_bus: MessageBus,
42
+ ) -> None:
43
+ """Initialize the scheduler manager.
44
+
45
+ Args:
46
+ storage (`StorageBase`):
47
+ The storage backend used for persistence and session
48
+ creation.
49
+ message_bus (`MessageBus`):
50
+ The application message bus. Each scheduled fire pushes
51
+ a :class:`HintBlock` to the target session's inbox and
52
+ enqueues a wakeup via this bus.
53
+ """
54
+ from apscheduler.schedulers.asyncio import AsyncIOScheduler
55
+
56
+ self._storage = storage
57
+ self._message_bus = message_bus
58
+ self._scheduler = AsyncIOScheduler()
59
+
60
+ # ------------------------------------------------------------------
61
+ # Lifecycle
62
+ # ------------------------------------------------------------------
63
+
64
+ async def __aenter__(self) -> Self:
65
+ """Start APScheduler and re-register persisted schedules.
66
+
67
+ Reading all schedules from storage and restoring them is the
68
+ only thing a caller would ever do right after starting this
69
+ manager, so the work lives inside the context entry — the
70
+ lifespan does not need to remember to call :meth:`restore`.
71
+
72
+ Returns:
73
+ `Self`: This manager instance.
74
+ """
75
+ logger.info("SchedulerManager starting APScheduler")
76
+ self._scheduler.start()
77
+ logger.info("SchedulerManager APScheduler started")
78
+
79
+ records = await self._storage.list_all_schedules()
80
+ if records:
81
+ await self.restore(records)
82
+
83
+ return self
84
+
85
+ async def __aexit__(self, *exc: object) -> None:
86
+ """Shut down the underlying APScheduler on context exit."""
87
+ logger.info("SchedulerManager shutting down APScheduler")
88
+ self._scheduler.shutdown()
89
+ logger.info("SchedulerManager APScheduler shut down")
90
+
91
+ # ------------------------------------------------------------------
92
+ # Trigger construction
93
+ # ------------------------------------------------------------------
94
+
95
+ def _build_trigger(
96
+ self,
97
+ record: ScheduleRecord,
98
+ ) -> Callable[[], Coroutine]:
99
+ """Build the zero-argument coroutine executed by APScheduler on each
100
+ trigger fire.
101
+
102
+ The returned coroutine:
103
+
104
+ 1. Skips execution when the schedule is disabled.
105
+ 2. Resolves or creates the target session (stateful reuses a fixed
106
+ session; non-stateful creates a fresh one on every fire).
107
+ 3. Calls :class:`~agentscope.app._service._chat.ChatService` and
108
+ drains the response stream (fire-and-forget).
109
+ 4. Catches and logs all exceptions to prevent APScheduler from
110
+ removing the job on failure.
111
+
112
+ Args:
113
+ record (`ScheduleRecord`):
114
+ The persisted schedule record that describes what to run.
115
+
116
+ Returns:
117
+ `Callable[[], Coroutine]`:
118
+ A zero-argument async callable suitable for APScheduler.
119
+ """
120
+ # Closure-friendly references so APScheduler doesn't have to
121
+ # re-look these up on every fire.
122
+ storage = self._storage
123
+ message_bus = self._message_bus
124
+
125
+ async def _trigger() -> None:
126
+ logger.info(
127
+ "[Schedule:%s(%s)] Trigger fired",
128
+ record.id,
129
+ record.data.name,
130
+ )
131
+
132
+ if not record.data.enabled:
133
+ logger.info(
134
+ "[Schedule:%s(%s)] Skipped — schedule disabled",
135
+ record.id,
136
+ record.data.name,
137
+ )
138
+ return
139
+
140
+ try:
141
+ if record.data.stateful:
142
+ stateful_session_id = f"{record.id}_stateful"
143
+ logger.info(
144
+ "[Schedule:%s(%s)] Stateful mode, "
145
+ "looking up session %s",
146
+ record.id,
147
+ record.data.name,
148
+ stateful_session_id,
149
+ )
150
+ session = await storage.get_session(
151
+ record.user_id,
152
+ record.agent_id,
153
+ stateful_session_id,
154
+ )
155
+ if session is None:
156
+ logger.info(
157
+ "[Schedule:%s(%s)] First fire, "
158
+ "creating stateful session",
159
+ record.id,
160
+ record.data.name,
161
+ )
162
+ state = AgentState()
163
+ state.permission_context = PermissionContext(
164
+ mode=record.data.permission_mode,
165
+ )
166
+ session_config = SessionConfig(
167
+ workspace_id="",
168
+ chat_model_config=record.data.chat_model_config,
169
+ )
170
+ session = await storage.upsert_session(
171
+ user_id=record.user_id,
172
+ agent_id=record.agent_id,
173
+ config=session_config,
174
+ state=state,
175
+ session_id=stateful_session_id,
176
+ source=SessionSource.SCHEDULE,
177
+ source_schedule_id=record.id,
178
+ )
179
+ else:
180
+ logger.info(
181
+ "[Schedule:%s(%s)] Reusing existing "
182
+ "stateful session %s",
183
+ record.id,
184
+ record.data.name,
185
+ session.id,
186
+ )
187
+ else:
188
+ logger.info(
189
+ "[Schedule:%s(%s)] Non-stateful mode, "
190
+ "creating fresh session",
191
+ record.id,
192
+ record.data.name,
193
+ )
194
+ state = AgentState()
195
+ state.permission_context = PermissionContext(
196
+ mode=record.data.permission_mode,
197
+ )
198
+ session = await storage.upsert_session(
199
+ user_id=record.user_id,
200
+ agent_id=record.agent_id,
201
+ config=SessionConfig(
202
+ workspace_id="",
203
+ chat_model_config=record.data.chat_model_config,
204
+ ),
205
+ state=state,
206
+ source=SessionSource.SCHEDULE,
207
+ source_schedule_id=record.id,
208
+ )
209
+
210
+ logger.info(
211
+ "[Schedule:%s(%s)] Session ready: %s, "
212
+ "delivering prompt via inbox + wakeup",
213
+ record.id,
214
+ record.data.name,
215
+ session.id,
216
+ )
217
+
218
+ # Wrap the schedule prompt in an XML tag so the LLM
219
+ # recognises it as a system-driven trigger rather than
220
+ # a regular user turn — same shape as team / system
221
+ # notification hints.
222
+ hint = HintBlock(
223
+ hint=(
224
+ f"<scheduled-task>\n"
225
+ f"{record.data.description}\n"
226
+ f"</scheduled-task>"
227
+ ),
228
+ source=json.dumps(
229
+ {
230
+ "label": "schedule",
231
+ "sublabel": record.data.name,
232
+ },
233
+ ensure_ascii=False,
234
+ ),
235
+ )
236
+ await message_bus.queue_push(
237
+ MessageBusKeys.inbox(session.id),
238
+ hint.model_dump(mode="json"),
239
+ )
240
+ await enqueue_run_trigger(
241
+ message_bus,
242
+ user_id=record.user_id,
243
+ session_id=session.id,
244
+ agent_id=record.agent_id,
245
+ )
246
+
247
+ logger.info(
248
+ "[Schedule:%s(%s)] Wakeup enqueued for session %s",
249
+ record.id,
250
+ record.data.name,
251
+ session.id,
252
+ )
253
+
254
+ except Exception:
255
+ logger.exception(
256
+ "[Schedule:%s(%s)] Trigger failed",
257
+ record.id,
258
+ record.data.name,
259
+ )
260
+
261
+ return _trigger
262
+
263
+ # ------------------------------------------------------------------
264
+ # Schedule management
265
+ # ------------------------------------------------------------------
266
+
267
+ async def register_schedule(self, record: ScheduleRecord) -> str:
268
+ """Persist-and-register a schedule record with APScheduler.
269
+
270
+ Builds the trigger coroutine via :meth:`_build_trigger` and adds the
271
+ job to APScheduler. This is the single entry point used by both the
272
+ HTTP API and the :class:`ScheduleCreate` agent tool.
273
+
274
+ Args:
275
+ record (`ScheduleRecord`):
276
+ The fully-populated record (already persisted to storage).
277
+
278
+ Returns:
279
+ `str`:
280
+ The APScheduler job ID (equal to ``record.id``).
281
+ """
282
+
283
+ from apscheduler.triggers.cron import CronTrigger
284
+
285
+ logger.info(
286
+ "Registering schedule %s(%s) cron=%s tz=%s",
287
+ record.id,
288
+ record.data.name,
289
+ record.data.cron_expression,
290
+ record.data.timezone,
291
+ )
292
+
293
+ # ``CronTrigger.from_crontab`` is a thin helper that only forwards
294
+ # the 5 parsed fields and ``timezone`` — it has no parameter for
295
+ # ``start_date`` / ``end_date``. Parse the expression ourselves so
296
+ # the configured activation window is honoured.
297
+ fields = record.data.cron_expression.split()
298
+ if len(fields) != 5:
299
+ raise ValueError(
300
+ "Expected a 5-field cron expression, got "
301
+ f"{record.data.cron_expression!r}",
302
+ )
303
+ minute, hour, day, month, day_of_week = fields
304
+
305
+ trigger = self._build_trigger(record)
306
+ job = self._scheduler.add_job(
307
+ trigger,
308
+ trigger=CronTrigger(
309
+ minute=minute,
310
+ hour=hour,
311
+ day=day,
312
+ month=month,
313
+ day_of_week=day_of_week,
314
+ timezone=record.data.timezone,
315
+ start_date=record.data.started_at,
316
+ end_date=record.data.ended_at,
317
+ ),
318
+ id=record.id,
319
+ name=record.data.name,
320
+ misfire_grace_time=300,
321
+ )
322
+ logger.info(
323
+ "Schedule %s(%s) registered, next_run=%s",
324
+ record.id,
325
+ record.data.name,
326
+ job.next_run_time,
327
+ )
328
+ return job.id
329
+
330
+ async def remove_schedule(self, job_id: str) -> None:
331
+ """Remove a job from APScheduler.
332
+
333
+ Args:
334
+ job_id (`str`):
335
+ The APScheduler job ID to remove.
336
+ """
337
+ from apscheduler.jobstores.base import JobLookupError
338
+
339
+ logger.info("Removing schedule job %s", job_id)
340
+ try:
341
+ self._scheduler.remove_job(job_id)
342
+ logger.info("Schedule job %s removed", job_id)
343
+ except JobLookupError:
344
+ logger.warning("Schedule job %s not found in APScheduler", job_id)
345
+
346
+ async def restore(self, records: list[ScheduleRecord]) -> None:
347
+ """Re-register persisted schedules on service startup.
348
+
349
+ Only enabled schedules are restored.
350
+
351
+ Args:
352
+ records (`list[ScheduleRecord]`):
353
+ All schedule records loaded from storage on startup.
354
+ """
355
+ enabled = [r for r in records if r.data.enabled]
356
+ logger.info(
357
+ "Restoring schedules: %d total, %d enabled",
358
+ len(records),
359
+ len(enabled),
360
+ )
361
+ for record in enabled:
362
+ await self.register_schedule(record)
363
+ logger.info("Schedule restore complete")
364
+
365
+ async def list_tasks(self) -> list[dict]:
366
+ """Return a summary of all currently registered APScheduler jobs.
367
+
368
+ Returns:
369
+ `list[dict]`:
370
+ Each entry contains ``id``, ``name``, and ``next_run``.
371
+ """
372
+ return [
373
+ {
374
+ "id": job.id,
375
+ "name": job.name,
376
+ "next_run": job.next_run_time,
377
+ }
378
+ for job in self._scheduler.get_jobs()
379
+ ]
380
+
381
+ # ------------------------------------------------------------------
382
+ # Agent tools
383
+ # ------------------------------------------------------------------
384
+
385
+ async def list_tools(
386
+ self,
387
+ user_id: str,
388
+ agent_id: str,
389
+ chat_model_config: ChatModelConfig,
390
+ ) -> list[ToolBase]:
391
+ """Return the agent-facing tools provided by the scheduler manager.
392
+
393
+ Args:
394
+ user_id (`str`):
395
+ The authenticated user who owns the schedules.
396
+ agent_id (`str`):
397
+ The agent that will be run by newly created schedules.
398
+ chat_model_config (`ChatModelConfig`):
399
+ Model configuration inherited from the current session and
400
+ stored on new :class:`~...ScheduleRecord` objects.
401
+
402
+ Returns:
403
+ `list[ToolBase]`:
404
+ The four schedule tools: :class:`ScheduleCreate`,
405
+ :class:`ScheduleView`, :class:`ScheduleDelete`, and
406
+ :class:`ScheduleList`.
407
+ """
408
+ return [
409
+ ScheduleCreate(
410
+ user_id=user_id,
411
+ agent_id=agent_id,
412
+ chat_model_config=chat_model_config,
413
+ storage=self._storage,
414
+ scheduler_manager=self,
415
+ ),
416
+ ScheduleView(
417
+ user_id=user_id,
418
+ scheduler=self._scheduler,
419
+ storage=self._storage,
420
+ ),
421
+ ScheduleDelete(
422
+ user_id=user_id,
423
+ scheduler=self._scheduler,
424
+ storage=self._storage,
425
+ message_bus=self._message_bus,
426
+ ),
427
+ ScheduleList(
428
+ user_id=user_id,
429
+ scheduler=self._scheduler,
430
+ storage=self._storage,
431
+ ),
432
+ ]
src/agentscope/app/_manager/_scheduler/_tools/__init__.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """The schedule related tools."""
3
+
4
+ from ._schedule_create import ScheduleCreate
5
+ from ._schedule_delete import ScheduleDelete
6
+ from ._schedule_list import ScheduleList
7
+ from ._schedule_view import ScheduleView
8
+
9
+ __all__ = [
10
+ "ScheduleCreate",
11
+ "ScheduleDelete",
12
+ "ScheduleList",
13
+ "ScheduleView",
14
+ ]
src/agentscope/app/_manager/_scheduler/_tools/_schedule_create.py ADDED
@@ -0,0 +1,253 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """The schedule create tool."""
3
+ from datetime import datetime
4
+ from typing import Any
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+ from .....message import ToolResultState, TextBlock
9
+ from .....permission import (
10
+ PermissionContext,
11
+ PermissionDecision,
12
+ PermissionBehavior,
13
+ PermissionMode,
14
+ )
15
+ from .....state import AgentState
16
+ from .....tool import ToolBase, ToolChunk
17
+ from ....storage import (
18
+ ScheduleData,
19
+ ScheduleRecord,
20
+ ScheduleSource,
21
+ ChatModelConfig,
22
+ )
23
+
24
+
25
+ class _ScheduleCreateParams(BaseModel):
26
+ """The params for the schedule create tool."""
27
+
28
+ name: str = Field(description="Display name of the schedule.")
29
+
30
+ description: str = Field(
31
+ default="",
32
+ description="Description of the schedule, including its purpose.",
33
+ )
34
+
35
+ cron_expression: str = Field(
36
+ description="Standard 5-field cron expression, e.g. '0 9 * * 1-5'.",
37
+ )
38
+
39
+ timezone: str = Field(
40
+ default="UTC",
41
+ description="IANA timezone name used to evaluate the cron expression, "
42
+ "e.g. 'America/New_York' or 'Asia/Shanghai'.",
43
+ )
44
+
45
+ enabled: bool = Field(
46
+ default=True,
47
+ description="Whether the schedule is active immediately after "
48
+ "creation. Set to False to create a disabled schedule.",
49
+ )
50
+
51
+ started_at: datetime | None = Field(
52
+ default=None,
53
+ description="ISO-8601 datetime at which the schedule becomes active. "
54
+ "Defaults to the current time when not specified.",
55
+ )
56
+
57
+ ended_at: datetime | None = Field(
58
+ default=None,
59
+ description="ISO-8601 datetime at which the schedule stops firing. "
60
+ "If not set the schedule runs indefinitely.",
61
+ )
62
+
63
+ stateful: bool = Field(
64
+ default=False,
65
+ description="If True, consecutive executions share the same session "
66
+ "context. If False, each execution gets a fresh session.",
67
+ )
68
+
69
+ permission_mode: str = Field(
70
+ default=PermissionMode.DONT_ASK.value,
71
+ description=(
72
+ "Permission mode for the agent during scheduled execution. "
73
+ f"Allowed values: {[m.value for m in PermissionMode]}. "
74
+ "Defaults to 'dont_ask' since no user is present."
75
+ ),
76
+ )
77
+
78
+
79
+ class ScheduleCreate(ToolBase):
80
+ """The schedule create tool.
81
+
82
+ Creates a new scheduled task that will execute the current agent at a
83
+ given cron interval. The record is persisted to storage and immediately
84
+ registered with the in-memory APScheduler.
85
+
86
+ The schedule inherits the model configuration of the current session.
87
+ The agent that creates the schedule is also the agent that will be run
88
+ on each trigger.
89
+ """
90
+
91
+ name: str = "ScheduleCreate"
92
+
93
+ description: str = """Create a new recurring scheduled task for yourself. \
94
+ You will be notified in a new session each time the schedule is triggered.
95
+
96
+ **About the cron expression:**
97
+ - Determine your current timezone first, that's very important for setting a \
98
+ correct cron expression. Get it by bash command like `date +%z`, \
99
+ `cat /etc/timezone` or directly ask the user.
100
+ - Determine whether the task should run once or recur at an interval, \
101
+ then set the cron expression accordingly.
102
+ - For a one-off task, query the current time first and set the cron \
103
+ expression to fire at that specific moment.
104
+ - Set `started_at` and `ended_at` to match the user's requirements. \
105
+ When in doubt, ask for clarification before creating the schedule.
106
+
107
+ **About the description field:**
108
+ - The `description` is the only context available to you when the \
109
+ schedule fires in a new session. Include all necessary details: the goal, \
110
+ expected output, constraints, relevant file paths, and anything else needed \
111
+ to complete the task independently.
112
+ """
113
+
114
+ input_schema: dict = _ScheduleCreateParams.model_json_schema()
115
+
116
+ is_concurrency_safe: bool = False
117
+ is_read_only: bool = False
118
+ is_state_injected: bool = True
119
+ is_external_tool: bool = False
120
+ is_mcp: bool = False
121
+ mcp_name: str | None = None
122
+
123
+ def __init__(
124
+ self,
125
+ user_id: str,
126
+ agent_id: str,
127
+ chat_model_config: ChatModelConfig,
128
+ storage: Any,
129
+ scheduler_manager: Any,
130
+ ) -> None:
131
+ """Initialize the schedule create tool.
132
+
133
+ Args:
134
+ user_id (`str`):
135
+ The authenticated user who owns this schedule.
136
+ agent_id (`str`):
137
+ The agent that will be executed on each trigger.
138
+ chat_model_config (`ChatModelConfig`):
139
+ Model configuration inherited from the current session.
140
+ storage (`Any`):
141
+ The storage backend used to persist the schedule record.
142
+ scheduler_manager (`Any`):
143
+ The scheduler manager used to register the APScheduler job.
144
+ Must expose a ``register_schedule(record)`` coroutine.
145
+ """
146
+ self._user_id = user_id
147
+ self._agent_id = agent_id
148
+ self._chat_model_config = chat_model_config
149
+ self._storage = storage
150
+ self._scheduler_manager = scheduler_manager
151
+
152
+ async def check_permissions(
153
+ self,
154
+ tool_input: dict[str, Any],
155
+ context: PermissionContext,
156
+ ) -> PermissionDecision:
157
+ """Check permission for the tool usage."""
158
+ return PermissionDecision(
159
+ behavior=PermissionBehavior.ALLOW,
160
+ message=f"{self.name} is always allowed to be called.",
161
+ )
162
+
163
+ async def __call__( # type: ignore[override]
164
+ self,
165
+ name: str,
166
+ cron_expression: str,
167
+ description: str = "",
168
+ timezone: str = "UTC",
169
+ enabled: bool = True,
170
+ started_at: datetime | None = None,
171
+ ended_at: datetime | None = None,
172
+ stateful: bool = False,
173
+ permission_mode: str = PermissionMode.DONT_ASK.value,
174
+ _agent_state: AgentState | None = None,
175
+ ) -> ToolChunk:
176
+ """Create a new scheduled task.
177
+
178
+ Args:
179
+ name (`str`):
180
+ Display name of the schedule.
181
+ cron_expression (`str`):
182
+ Standard 5-field cron expression, e.g. ``'0 9 * * 1-5'``.
183
+ description (`str`, optional):
184
+ Human-readable description of what this schedule does.
185
+ timezone (`str`, optional):
186
+ IANA timezone name, e.g. ``'Asia/Shanghai'``.
187
+ enabled (`bool`, optional):
188
+ Whether the schedule is active immediately after creation.
189
+ started_at (`datetime | None`, optional):
190
+ Datetime at which the schedule becomes active. Defaults to
191
+ the current time when not specified.
192
+ ended_at (`datetime | None`, optional):
193
+ Datetime at which the schedule stops firing. If not set the
194
+ schedule runs indefinitely.
195
+ stateful (`bool`, optional):
196
+ Whether consecutive executions share the same session context.
197
+ permission_mode (`str`, optional):
198
+ Permission mode value string.
199
+ _agent_state (`AgentState | None`, optional):
200
+ Injected agent state; provides the source session ID.
201
+
202
+ Returns:
203
+ `ToolChunk`:
204
+ A chunk with the new schedule ID on success, or an error
205
+ description on failure.
206
+ """
207
+ try:
208
+ perm_mode = PermissionMode(permission_mode)
209
+ except ValueError:
210
+ perm_mode = PermissionMode.DONT_ASK
211
+
212
+ source_session_id = (
213
+ _agent_state.session_id if _agent_state is not None else ""
214
+ )
215
+
216
+ record = ScheduleRecord(
217
+ user_id=self._user_id,
218
+ agent_id=self._agent_id,
219
+ data=ScheduleData(
220
+ name=name,
221
+ description=description,
222
+ enabled=enabled,
223
+ cron_expression=cron_expression,
224
+ timezone=timezone,
225
+ started_at=started_at or datetime.now(),
226
+ ended_at=ended_at,
227
+ stateful=stateful,
228
+ permission_mode=perm_mode,
229
+ source=ScheduleSource.AGENT,
230
+ source_session_id=source_session_id,
231
+ chat_model_config=self._chat_model_config,
232
+ ),
233
+ )
234
+
235
+ await self._storage.upsert_schedule(self._user_id, record)
236
+ await self._scheduler_manager.register_schedule(record)
237
+
238
+ return ToolChunk(
239
+ content=[
240
+ TextBlock(
241
+ text=(
242
+ f"Schedule {name!r} created successfully.\n"
243
+ f"Schedule ID: {record.id}\n"
244
+ f"Cron: {cron_expression} (timezone: {timezone})\n"
245
+ f"Enabled: {enabled}\n"
246
+ f"Started at: {record.data.started_at}\n"
247
+ f"Ended at: {ended_at or '(no end time)'}\n"
248
+ f"Stateful: {stateful}"
249
+ ),
250
+ ),
251
+ ],
252
+ state=ToolResultState.SUCCESS,
253
+ )
src/agentscope/app/_manager/_scheduler/_tools/_schedule_delete.py ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """Schedule delete tool – removes a job from the scheduler and storage."""
3
+ from typing import Any
4
+
5
+ from pydantic import BaseModel, Field
6
+ from apscheduler.jobstores.base import JobLookupError
7
+
8
+ from .....message import ToolResultState, TextBlock
9
+ from .....permission import (
10
+ PermissionContext,
11
+ PermissionDecision,
12
+ PermissionBehavior,
13
+ )
14
+ from .....tool import ToolBase, ToolChunk
15
+ from ....message_bus import MessageBus
16
+ from ....storage._base import StorageBase
17
+
18
+
19
+ class _ScheduleDeleteParams(BaseModel):
20
+ """The params for the schedule delete tool."""
21
+
22
+ schedule_id: str = Field(
23
+ description="The schedule ID to delete (permanently remove).",
24
+ )
25
+
26
+
27
+ class ScheduleDelete(ToolBase):
28
+ """The schedule delete tool.
29
+
30
+ Permanently removes the given scheduled job from APScheduler,
31
+ storage, and the message bus. Every execution session spawned by
32
+ the schedule is cancelled (if running) and has its bus state
33
+ purged. The job cannot be recovered after removal.
34
+ """
35
+
36
+ name: str = "ScheduleDelete"
37
+
38
+ description: str = (
39
+ "Permanently delete a scheduled task by its schedule ID. "
40
+ "After this call the task will no longer be executed and its record "
41
+ "will be deleted from storage."
42
+ )
43
+ input_schema: dict = _ScheduleDeleteParams.model_json_schema()
44
+
45
+ is_concurrency_safe: bool = False
46
+ is_read_only: bool = False
47
+ is_state_injected: bool = False
48
+ is_external_tool: bool = False
49
+ is_mcp: bool = False
50
+ mcp_name: str | None = None
51
+
52
+ def __init__(
53
+ self,
54
+ user_id: str,
55
+ scheduler: Any,
56
+ storage: StorageBase,
57
+ message_bus: MessageBus,
58
+ ) -> None:
59
+ """Initialize the schedule delete tool.
60
+
61
+ Args:
62
+ user_id (`str`):
63
+ The authenticated user; used to scope the storage deletion.
64
+ scheduler (`Any`):
65
+ The ``AsyncIOScheduler`` instance whose job will be removed.
66
+ storage (`StorageBase`):
67
+ The storage backend used to delete the persisted record.
68
+ message_bus (`MessageBus`):
69
+ The message bus used to cancel in-flight chat runs for
70
+ any execution session spawned by this schedule and to
71
+ purge their per-session bus state.
72
+ """
73
+ self._user_id = user_id
74
+ self._scheduler = scheduler
75
+ self._storage = storage
76
+ self._message_bus = message_bus
77
+
78
+ async def check_permissions(
79
+ self,
80
+ tool_input: dict[str, Any],
81
+ context: PermissionContext,
82
+ ) -> PermissionDecision:
83
+ """Check permission for the tool usage."""
84
+ return PermissionDecision(
85
+ behavior=PermissionBehavior.ALLOW,
86
+ message=f"{self.name} is always allowed to be called.",
87
+ )
88
+
89
+ async def __call__(
90
+ self,
91
+ schedule_id: str,
92
+ ) -> ToolChunk: # type: ignore[override]
93
+ """Permanently delete the scheduled task with the given ID.
94
+
95
+ Delegates the storage + bus cascade to
96
+ :meth:`SessionService.delete_schedule`, which cancels in-flight
97
+ runs for any session this schedule spawned and purges their
98
+ bus state before dropping the schedule record. The APScheduler
99
+ job is removed separately because it lives in-process and the
100
+ service layer is bus/storage-only.
101
+
102
+ Args:
103
+ schedule_id (`str`):
104
+ The unique identifier of the schedule to delete.
105
+
106
+ Returns:
107
+ `ToolChunk`:
108
+ A chunk describing the result of the delete operation.
109
+ """
110
+
111
+ # Remove from the in-memory scheduler (best-effort; may already be
112
+ # absent if the job finished naturally or the server restarted)
113
+ try:
114
+ self._scheduler.remove_job(schedule_id)
115
+ except JobLookupError:
116
+ pass
117
+
118
+ # Local import to avoid a circular dependency between
119
+ # ``_manager`` and ``_service`` at module load.
120
+ from ...._service import SessionService # noqa: PLC0415
121
+
122
+ session_service = SessionService(
123
+ storage=self._storage,
124
+ message_bus=self._message_bus,
125
+ )
126
+ deleted = await session_service.delete_schedule(
127
+ self._user_id,
128
+ schedule_id,
129
+ )
130
+
131
+ if not deleted:
132
+ return ToolChunk(
133
+ content=[
134
+ TextBlock(
135
+ text=(
136
+ f"ScheduleNotFoundError: Schedule with id "
137
+ f"{schedule_id!r} not found in storage."
138
+ ),
139
+ ),
140
+ ],
141
+ state=ToolResultState.ERROR,
142
+ )
143
+
144
+ return ToolChunk(
145
+ content=[
146
+ TextBlock(
147
+ text=(
148
+ f"Schedule {schedule_id!r} has been permanently "
149
+ f"deleted."
150
+ ),
151
+ ),
152
+ ],
153
+ state=ToolResultState.SUCCESS,
154
+ )
src/agentscope/app/_manager/_scheduler/_tools/_schedule_list.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """The tool to list the scheduled jobs in the cron scheduler manager."""
3
+ from typing import Any
4
+
5
+ from pydantic import BaseModel
6
+
7
+ from .....message import ToolResultState, TextBlock
8
+ from .....permission import (
9
+ PermissionContext,
10
+ PermissionDecision,
11
+ PermissionBehavior,
12
+ )
13
+ from .....tool import ToolBase, ToolChunk
14
+ from ....storage import StorageBase
15
+
16
+
17
+ class _ScheduleListParams(BaseModel):
18
+ """The params for the schedule list tool."""
19
+
20
+
21
+ class ScheduleList(ToolBase):
22
+ """The schedule list tool.
23
+
24
+ Lists all scheduled tasks owned by the current user. Each entry is
25
+ fetched from storage (rich :class:`ScheduleData`) and augmented with
26
+ ``next_run_time`` from the in-memory APScheduler job when available.
27
+ """
28
+
29
+ name: str = "ScheduleList"
30
+
31
+ description: str = (
32
+ "List all scheduled tasks for the current user. "
33
+ "Shows schedule ID, name, cron expression, timezone, next run time, "
34
+ "enabled/disabled status, and whether the schedule is stateful."
35
+ )
36
+ input_schema: dict = _ScheduleListParams.model_json_schema()
37
+
38
+ is_concurrency_safe: bool = True
39
+ is_read_only: bool = True
40
+ is_state_injected: bool = False
41
+ is_external_tool: bool = False
42
+ is_mcp: bool = False
43
+ mcp_name: str | None = None
44
+
45
+ def __init__(
46
+ self,
47
+ user_id: str,
48
+ scheduler: Any,
49
+ storage: StorageBase,
50
+ ) -> None:
51
+ """Initialize the schedule list tool.
52
+
53
+ Args:
54
+ user_id (`str`):
55
+ The authenticated user; used to scope the storage lookup.
56
+ scheduler (`Any`):
57
+ The ``AsyncIOScheduler`` instance for reading ``next_run_time``
58
+ storage (`StorageBase`):
59
+ The storage backend that holds the persisted schedule records.
60
+ """
61
+ self._user_id = user_id
62
+ self._scheduler = scheduler
63
+ self._storage = storage
64
+
65
+ async def check_permissions(
66
+ self,
67
+ tool_input: dict[str, Any],
68
+ context: PermissionContext,
69
+ ) -> PermissionDecision:
70
+ """Check permission for the tool usage."""
71
+ return PermissionDecision(
72
+ behavior=PermissionBehavior.ALLOW,
73
+ message=f"{self.name} is always allowed to be called.",
74
+ )
75
+
76
+ async def __call__(self) -> ToolChunk: # type: ignore[override]
77
+ """List all scheduled tasks for the current user.
78
+
79
+ Returns:
80
+ `ToolChunk`:
81
+ A chunk containing a formatted list of all scheduled tasks,
82
+ or a message indicating none exist.
83
+ """
84
+ records = await self._storage.list_schedules(self._user_id)
85
+
86
+ if not records:
87
+ return ToolChunk(
88
+ content=[TextBlock(text="No scheduled tasks found.")],
89
+ state=ToolResultState.SUCCESS,
90
+ )
91
+
92
+ # Build a map of schedule_id -> next_run_time from the live scheduler
93
+ next_run_map: dict[str, str] = {
94
+ job.id: str(job.next_run_time)
95
+ for job in self._scheduler.get_jobs()
96
+ }
97
+
98
+ lines: list[str] = [f"Found {len(records)} scheduled task(s):\n"]
99
+ for record in records:
100
+ enabled_str = "enabled" if record.data.enabled else "disabled"
101
+ next_run = next_run_map.get(record.id, "not in scheduler")
102
+ lines.append(
103
+ f"- [{enabled_str}] {record.data.name!r} (ID: {record.id})\n"
104
+ f" Cron: {record.data.cron_expression}"
105
+ f" ({record.data.timezone})\n"
106
+ f" Next run: {next_run}\n"
107
+ f" Stateful: {record.data.stateful}"
108
+ f" | Agent: {record.agent_id}\n"
109
+ f" Source: {record.data.source.value}\n",
110
+ )
111
+
112
+ return ToolChunk(
113
+ content=[TextBlock(text="\n".join(lines))],
114
+ state=ToolResultState.SUCCESS,
115
+ )
src/agentscope/app/_manager/_scheduler/_tools/_schedule_view.py ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """The schedule view tool."""
3
+ from typing import Any
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ from .....message import ToolResultState, TextBlock
8
+ from .....permission import (
9
+ PermissionContext,
10
+ PermissionDecision,
11
+ PermissionBehavior,
12
+ )
13
+ from .....tool import ToolBase, ToolChunk
14
+ from ....storage import StorageBase
15
+
16
+
17
+ class _ScheduleViewParams(BaseModel):
18
+ """The params for the schedule view tool."""
19
+
20
+ schedule_id: str = Field(
21
+ description="The schedule ID.",
22
+ )
23
+
24
+
25
+ class ScheduleView(ToolBase):
26
+ """The schedule view tool.
27
+
28
+ Fetches the persisted :class:`ScheduleRecord` from storage and enriches
29
+ it with the ``next_run_time`` from the in-memory APScheduler job.
30
+ """
31
+
32
+ name: str = "ScheduleView"
33
+
34
+ description: str = (
35
+ "View the full details of a scheduled task by its schedule ID, "
36
+ "including cron expression, timezone, stateful flag, permission "
37
+ "mode, and the next scheduled run time."
38
+ )
39
+ input_schema: dict = _ScheduleViewParams.model_json_schema()
40
+
41
+ is_concurrency_safe: bool = True
42
+ is_read_only: bool = True
43
+ is_state_injected: bool = False
44
+ is_external_tool: bool = False
45
+ is_mcp: bool = False
46
+ mcp_name: str | None = None
47
+
48
+ def __init__(
49
+ self,
50
+ user_id: str,
51
+ scheduler: Any,
52
+ storage: StorageBase,
53
+ ) -> None:
54
+ """Initialize the schedule view tool.
55
+
56
+ Args:
57
+ user_id (`str`):
58
+ The authenticated user; used to scope the storage lookup.
59
+ scheduler (`Any`):
60
+ The ``AsyncIOScheduler`` instance for
61
+ reading ``next_run_time``.
62
+ storage (`StorageBase`):
63
+ The storage backend that holds the persisted schedule records.
64
+ """
65
+ self._user_id = user_id
66
+ self._scheduler = scheduler
67
+ self._storage = storage
68
+
69
+ async def check_permissions(
70
+ self,
71
+ tool_input: dict[str, Any],
72
+ context: PermissionContext,
73
+ ) -> PermissionDecision:
74
+ """Check permission for the tool usage."""
75
+ return PermissionDecision(
76
+ behavior=PermissionBehavior.ALLOW,
77
+ message=f"{self.name} is always allowed to be called.",
78
+ )
79
+
80
+ async def __call__(
81
+ self,
82
+ schedule_id: str,
83
+ ) -> ToolChunk: # type: ignore[override]
84
+ """View the full details of a scheduled task.
85
+
86
+ Args:
87
+ schedule_id (`str`):
88
+ The unique identifier of the schedule to view.
89
+
90
+ Returns:
91
+ `ToolChunk`:
92
+ A chunk containing the formatted schedule details, or an
93
+ error description if the schedule is not found.
94
+ """
95
+ record = await self._storage.get_schedule(self._user_id, schedule_id)
96
+
97
+ if record is None:
98
+ return ToolChunk(
99
+ content=[
100
+ TextBlock(
101
+ text=(
102
+ f"ScheduleNotFoundError: Schedule with "
103
+ f"id {schedule_id!r} not found."
104
+ ),
105
+ ),
106
+ ],
107
+ state=ToolResultState.ERROR,
108
+ )
109
+
110
+ job = self._scheduler.get_job(schedule_id)
111
+ next_run = (
112
+ str(job.next_run_time)
113
+ if job is not None
114
+ else "not in scheduler (may be disabled)"
115
+ )
116
+ enabled_str = "enabled" if record.data.enabled else "disabled"
117
+
118
+ text = (
119
+ f"Schedule ID: {record.id}\n"
120
+ f"Name: {record.data.name}\n"
121
+ f"Description: {record.data.description or '(none)'}\n"
122
+ f"Status: {enabled_str}\n"
123
+ f"Cron: {record.data.cron_expression}"
124
+ f" (timezone: {record.data.timezone})\n"
125
+ f"Next run: {next_run}\n"
126
+ f"Stateful: {record.data.stateful}\n"
127
+ f"Permission mode: {record.data.permission_mode.value}\n"
128
+ f"Source: {record.data.source.value}\n"
129
+ f"Source session: {record.data.source_session_id or '(none)'}\n"
130
+ f"Agent ID: {record.agent_id}\n"
131
+ f"Created at: {record.created_at}\n"
132
+ f"Updated at: {record.updated_at}\n"
133
+ )
134
+
135
+ return ToolChunk(
136
+ content=[TextBlock(text=text)],
137
+ state=ToolResultState.SUCCESS,
138
+ )
src/agentscope/app/_manager/_wakeup_dispatcher.py ADDED
@@ -0,0 +1,371 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """Single per-process dispatcher for all cross-session run triggers.
3
+
4
+ One asyncio task per process. Subscribes to the shared trigger signal
5
+ channel and drains the durable trigger queue on each signal. It is the
6
+ **sole** site that spawns :meth:`ChatService.run` into the shared
7
+ :class:`ChatRunRegistry`, which is what makes concurrent-spawn races
8
+ (two writers contending for one session's run slot → a spurious "already
9
+ has an active chat run" 409) structurally impossible: every run trigger
10
+ funnels through this one serial consumer.
11
+
12
+ Each queue entry carries a ``kind`` that selects how a busy session is
13
+ handled:
14
+
15
+ - ``wake`` (idle-session wake-up, ``input_msg=None``): skipped while the
16
+ session is already running — the live run will drain the inbox.
17
+ - ``resume`` (a parked HITL run being fed its result): must *not* be
18
+ skipped while running, because the session is typically still running
19
+ the parked tail at trigger time. It is re-queued after a short backoff
20
+ until the parked run releases its session lock, then spawned with the
21
+ carried input event.
22
+
23
+ All bus keys live on the :class:`MessageBus` base class (see
24
+ ``enqueue_wakeup`` / ``enqueue_input``, ``dequeue_wakeups``,
25
+ ``subscribe_wakeup_signal``, ``session_is_running``), so this file has
26
+ no hard-coded key strings.
27
+ """
28
+ import asyncio
29
+ from typing import TYPE_CHECKING, Self
30
+
31
+ from pydantic import TypeAdapter
32
+
33
+ from ..._logging import logger
34
+ from ...event import UserConfirmResultEvent, ExternalExecutionResultEvent
35
+ from ..message_bus import MessageBusKeys
36
+ from .._bus_ops import enqueue_run_trigger
37
+
38
+ if TYPE_CHECKING:
39
+ from ..message_bus import MessageBus
40
+ from ..storage import StorageBase
41
+ from .._service import ChatService
42
+ from ._chat_run_registry import ChatRunRegistry
43
+
44
+ # Parses a queued ``resume`` input dict back into its concrete event,
45
+ # discriminated by the ``type`` field shared by both result events.
46
+ _RESUME_INPUT_ADAPTER: TypeAdapter = TypeAdapter(
47
+ UserConfirmResultEvent | ExternalExecutionResultEvent,
48
+ )
49
+
50
+ # Delay before re-queuing a ``resume`` trigger whose target session is
51
+ # still running (the parked run is finishing and about to free its
52
+ # lock). Short enough to feel instant to the user, long enough to avoid
53
+ # a hot re-enqueue loop while the lock is held.
54
+ _RESUME_RETRY_BACKOFF_SECS = 0.1
55
+
56
+
57
+ class WakeupDispatcher:
58
+ """One asyncio task per process, draining the shared trigger queue.
59
+
60
+ Args:
61
+ message_bus (`MessageBus`):
62
+ Application message bus. Used for signal subscription,
63
+ queue drain, ``session_is_running`` checks, and re-queuing
64
+ deferred ``resume`` triggers.
65
+ storage (`StorageBase`):
66
+ Persistent storage backend. Consulted before spawning a
67
+ run so triggers whose target session has been deleted are
68
+ dropped instead of crashing :class:`ChatService.run`.
69
+ chat_service (`ChatService`):
70
+ Drives the actual chat run when a trigger fires.
71
+ chat_run_registry (`ChatRunRegistry`):
72
+ Per-process registry that holds the spawned task handle so
73
+ it can be located by :class:`CancelDispatcher`.
74
+ """
75
+
76
+ def __init__(
77
+ self,
78
+ message_bus: "MessageBus",
79
+ storage: "StorageBase",
80
+ chat_service: "ChatService",
81
+ chat_run_registry: "ChatRunRegistry",
82
+ ) -> None:
83
+ """Bind dependencies.
84
+
85
+ Args:
86
+ message_bus (`MessageBus`):
87
+ Application message bus.
88
+ storage (`StorageBase`):
89
+ Persistent storage backend.
90
+ chat_service (`ChatService`):
91
+ Drives session runs via :meth:`ChatService.run`.
92
+ chat_run_registry (`ChatRunRegistry`):
93
+ Shared chat-run registry to spawn into.
94
+ """
95
+ self._bus = message_bus
96
+ self._storage = storage
97
+ self._chat_service = chat_service
98
+ self._registry = chat_run_registry
99
+ self._task: asyncio.Task | None = None
100
+ # Detached backoff timers for deferred ``resume`` re-enqueues.
101
+ # Held so they are not garbage-collected mid-sleep and can be
102
+ # cancelled on shutdown.
103
+ self._retry_tasks: set[asyncio.Task] = set()
104
+
105
+ async def __aenter__(self) -> Self:
106
+ """Start the dispatcher loop and wait until its bus
107
+ subscription is live.
108
+
109
+ Also performs an initial drain right after subscription so
110
+ triggers produced while this process was down (durable in
111
+ the queue) are picked up immediately on startup.
112
+
113
+ Returns:
114
+ `Self`: This dispatcher instance.
115
+ """
116
+ ready = asyncio.Event()
117
+ self._task = asyncio.create_task(
118
+ self._loop(ready),
119
+ name="wakeup-dispatcher",
120
+ )
121
+ await ready.wait()
122
+ await self._drain_and_dispatch()
123
+ return self
124
+
125
+ async def __aexit__(self, *exc: object) -> None:
126
+ """Cancel the dispatcher loop and any pending retries."""
127
+ retries = list(self._retry_tasks)
128
+ for retry in retries:
129
+ retry.cancel()
130
+ for retry in retries:
131
+ try:
132
+ await retry
133
+ except asyncio.CancelledError:
134
+ pass
135
+ self._retry_tasks.clear()
136
+ if self._task is None:
137
+ return
138
+ self._task.cancel()
139
+ try:
140
+ await self._task
141
+ except asyncio.CancelledError:
142
+ pass
143
+ self._task = None
144
+
145
+ # ------------------------------------------------------------------
146
+ # Internals
147
+ # ------------------------------------------------------------------
148
+
149
+ async def _loop(self, ready: asyncio.Event) -> None:
150
+ """Long-lived loop: subscribe to the signal channel and drain
151
+ the queue on every received signal.
152
+
153
+ Args:
154
+ ready (`asyncio.Event`):
155
+ Signalled after the underlying SUBSCRIBE completes.
156
+ :meth:`start` blocks on this so callers can publish a
157
+ trigger immediately after start without racing.
158
+ """
159
+ try:
160
+ async for _signal in self._bus.subscribe(
161
+ MessageBusKeys.wakeup_signal(),
162
+ on_ready=ready.set,
163
+ ):
164
+ await self._drain_and_dispatch()
165
+ except Exception: # pylint: disable=broad-except
166
+ logger.exception(
167
+ "WakeupDispatcher loop crashed; subscription ended.",
168
+ )
169
+
170
+ async def _drain_and_dispatch(self) -> None:
171
+ """Read up to a batch of trigger entries and dispatch each."""
172
+ try:
173
+ raw_entries = await self._bus.queue_drain(
174
+ MessageBusKeys.wakeup_queue(),
175
+ max_count=64,
176
+ )
177
+ entries = [payload for _entry_id, payload in raw_entries]
178
+ except Exception: # pylint: disable=broad-except
179
+ logger.exception("WakeupDispatcher: dequeue_wakeups failed.")
180
+ return
181
+
182
+ for payload in entries:
183
+ try:
184
+ user_id = payload["user_id"]
185
+ session_id = payload["session_id"]
186
+ agent_id = payload["agent_id"]
187
+ except (KeyError, TypeError):
188
+ logger.warning(
189
+ "WakeupDispatcher: skipping malformed trigger entry %r",
190
+ payload,
191
+ )
192
+ continue
193
+ # Entries from older producers omit ``kind`` — treat as wake.
194
+ kind = payload.get("kind", MessageBusKeys.WAKEUP_KIND_WAKE)
195
+ await self._dispatch_one(
196
+ user_id=user_id,
197
+ session_id=session_id,
198
+ agent_id=agent_id,
199
+ kind=kind,
200
+ raw_input=payload.get("input"),
201
+ )
202
+
203
+ async def _dispatch_one(
204
+ self,
205
+ user_id: str,
206
+ session_id: str,
207
+ agent_id: str,
208
+ kind: str,
209
+ raw_input: dict | None,
210
+ ) -> None:
211
+ """Dispatch a single trigger entry by its ``kind``.
212
+
213
+ Args:
214
+ user_id (`str`):
215
+ The owning user id.
216
+ session_id (`str`):
217
+ The session to trigger.
218
+ agent_id (`str`):
219
+ The agent that owns the session.
220
+ kind (`str`):
221
+ Trigger kind (``wake`` / ``resume``); see module docstring.
222
+ raw_input (`dict | None`):
223
+ Serialised input event for ``resume`` triggers, else
224
+ ``None``.
225
+ """
226
+ is_resume = kind == MessageBusKeys.WAKEUP_KIND_RESUME
227
+
228
+ # Parse the resume input early so every downstream path
229
+ # (lock-retry, spawn-retry) receives a typed event object
230
+ # rather than a raw dict.
231
+ input_msg: UserConfirmResultEvent | ExternalExecutionResultEvent | None
232
+ input_msg = None
233
+ if is_resume:
234
+ if raw_input is None:
235
+ logger.warning(
236
+ "WakeupDispatcher: dropping resume trigger for session "
237
+ "%s — no input event carried.",
238
+ session_id,
239
+ )
240
+ return
241
+ try:
242
+ input_msg = _RESUME_INPUT_ADAPTER.validate_python(raw_input)
243
+ except Exception: # pylint: disable=broad-except
244
+ logger.exception(
245
+ "WakeupDispatcher: dropping resume trigger for session "
246
+ "%s — input event failed to parse: %r",
247
+ session_id,
248
+ raw_input,
249
+ )
250
+ return
251
+
252
+ if await self._bus.is_locked(
253
+ MessageBusKeys.session_lock(session_id),
254
+ ):
255
+ if is_resume:
256
+ # The session is busy finishing its parked tail. Do NOT
257
+ # drop the resume — re-queue it after a short backoff so
258
+ # it lands once the parked run releases its lock.
259
+ self._schedule_resume_retry(
260
+ user_id,
261
+ session_id,
262
+ agent_id,
263
+ input_msg,
264
+ )
265
+ # ``wake`` triggers are safe to drop while running — the
266
+ # live run drains the inbox itself.
267
+ return
268
+
269
+ # Orphan guard: the queue is unaware of session lifecycle. A
270
+ # trigger enqueued before the session was deleted (e.g. by a
271
+ # BG-task completion callback or a schedule trigger) will still
272
+ # arrive here. Drop it rather than letting ChatService.run crash
273
+ # on a missing storage record.
274
+ if (
275
+ await self._storage.get_session(user_id, agent_id, session_id)
276
+ is None
277
+ ):
278
+ logger.warning(
279
+ "WakeupDispatcher: dropping %s trigger for session %s "
280
+ "(agent %s, user %s) — session no longer exists in "
281
+ "storage; it was likely enqueued before the session was "
282
+ "deleted.",
283
+ kind,
284
+ session_id,
285
+ agent_id,
286
+ user_id,
287
+ )
288
+ return
289
+
290
+ try:
291
+ self._registry.spawn(
292
+ self._chat_service.run(
293
+ user_id=user_id,
294
+ session_id=session_id,
295
+ agent_id=agent_id,
296
+ input_msg=input_msg,
297
+ ),
298
+ session_id=session_id,
299
+ name=f"{kind}-run:{session_id}",
300
+ )
301
+ except RuntimeError:
302
+ # A local run was registered between the running-check and
303
+ # the spawn. For ``wake`` that run will drain the inbox; for
304
+ # ``resume`` re-queue so the result is not lost.
305
+ if is_resume:
306
+ self._schedule_resume_retry(
307
+ user_id,
308
+ session_id,
309
+ agent_id,
310
+ input_msg,
311
+ )
312
+ else:
313
+ logger.debug(
314
+ "WakeupDispatcher: skipping wake trigger for session "
315
+ "%s; a local run is already registered.",
316
+ session_id,
317
+ )
318
+
319
+ def _schedule_resume_retry(
320
+ self,
321
+ user_id: str,
322
+ session_id: str,
323
+ agent_id: str,
324
+ input_msg: UserConfirmResultEvent
325
+ | ExternalExecutionResultEvent
326
+ | None,
327
+ ) -> None:
328
+ """Re-enqueue a ``resume`` trigger after a short backoff.
329
+
330
+ Spawns a detached timer that sleeps, then re-enqueues the resume
331
+ (which re-fires the signal, re-driving the drain). This keeps the
332
+ resume alive across the window where the parked run still holds
333
+ the session lock, without a hot re-enqueue loop.
334
+
335
+ Args:
336
+ user_id (`str`):
337
+ The owning user id.
338
+ session_id (`str`):
339
+ The session to resume.
340
+ agent_id (`str`):
341
+ The agent that owns the session.
342
+ input_msg:
343
+ The parsed input event to redeliver.
344
+ """
345
+
346
+ async def _retry() -> None:
347
+ try:
348
+ await asyncio.sleep(_RESUME_RETRY_BACKOFF_SECS)
349
+ await enqueue_run_trigger(
350
+ self._bus,
351
+ user_id=user_id,
352
+ session_id=session_id,
353
+ agent_id=agent_id,
354
+ kind=MessageBusKeys.WAKEUP_KIND_RESUME,
355
+ inputs=input_msg,
356
+ )
357
+ except asyncio.CancelledError:
358
+ pass
359
+ except Exception: # pylint: disable=broad-except
360
+ logger.exception(
361
+ "WakeupDispatcher: failed to re-enqueue resume trigger "
362
+ "for session %s.",
363
+ session_id,
364
+ )
365
+
366
+ task = asyncio.create_task(
367
+ _retry(),
368
+ name=f"resume-retry:{session_id}",
369
+ )
370
+ self._retry_tasks.add(task)
371
+ task.add_done_callback(self._retry_tasks.discard)
src/agentscope/app/_router/__init__.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """App routers."""
3
+ from ._agent import agent_router
4
+ from ._chat import chat_router
5
+ from ._credential import credential_router
6
+ from ._knowledge_base import knowledge_base_router
7
+ from ._schedule import schedule_router
8
+ from ._session import session_router
9
+ from ._model import model_router
10
+ from ._tts_model import tts_model_router
11
+ from ._workspace import workspace_router
12
+
13
+ __all__ = [
14
+ "agent_router",
15
+ "model_router",
16
+ "tts_model_router",
17
+ "chat_router",
18
+ "credential_router",
19
+ "knowledge_base_router",
20
+ "schedule_router",
21
+ "session_router",
22
+ "workspace_router",
23
+ ]
src/agentscope/app/_router/_agent.py ADDED
@@ -0,0 +1,214 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """Agent router — CRUD endpoints for agent configurations."""
3
+ from datetime import datetime
4
+
5
+ from fastapi import APIRouter, Depends, HTTPException, status
6
+
7
+ from ...agent import ContextConfig, ReActConfig
8
+ from ..deps import get_current_user_id, get_session_service, get_storage
9
+ from ._schema import (
10
+ AgentSchemaResponse,
11
+ ListAgentsResponse,
12
+ CreateAgentRequest,
13
+ CreateAgentResponse,
14
+ UpdateAgentRequest,
15
+ )
16
+ from .._service import SessionService
17
+ from ..storage import StorageBase, AgentData, AgentRecord
18
+
19
+ agent_router = APIRouter(
20
+ prefix="/agent",
21
+ tags=["agent"],
22
+ responses={404: {"description": "Not found"}},
23
+ )
24
+
25
+
26
+ @agent_router.get(
27
+ "/schema",
28
+ response_model=AgentSchemaResponse,
29
+ summary="Get JSON Schema fragments for the agent form",
30
+ )
31
+ async def get_agent_schema() -> AgentSchemaResponse:
32
+ """Return the JSON Schema fragments used by the frontend to render
33
+ the agent create / edit forms.
34
+
35
+ The frontend uses three sections — identity, context config, and
36
+ react config — so we return them as separate self-contained schemas
37
+ rather than a single ``AgentData`` schema with ``$ref``s.
38
+
39
+ Returns:
40
+ `AgentSchemaResponse`:
41
+ Schemas for the three form sections.
42
+ """
43
+ # Slice ``AgentData``'s schema down to the identity-relevant fields.
44
+ # Going through ``AgentData.model_json_schema()`` (rather than building
45
+ # a dict by hand) keeps Pydantic as the single source of truth for
46
+ # defaults, titles, descriptions, and the ``format: textarea`` hint.
47
+ agent_schema = AgentData.model_json_schema()
48
+ identity_keys = ("name", "system_prompt")
49
+ identity = {
50
+ "type": "object",
51
+ "title": "Identity",
52
+ "properties": {
53
+ k: v
54
+ for k, v in agent_schema.get("properties", {}).items()
55
+ if k in identity_keys
56
+ },
57
+ "required": [
58
+ r for r in agent_schema.get("required", []) if r in identity_keys
59
+ ],
60
+ }
61
+
62
+ context_schema = ContextConfig.model_json_schema()
63
+ # ``summary_schema`` holds a Pydantic JSON Schema describing how the
64
+ # compression model should structure its output. The end-user is not
65
+ # expected to edit it from the form, so we hide it.
66
+ context_schema.get("properties", {}).pop("summary_schema", None)
67
+
68
+ return AgentSchemaResponse(
69
+ identity=identity,
70
+ context_config=context_schema,
71
+ react_config=ReActConfig.model_json_schema(),
72
+ )
73
+
74
+
75
+ @agent_router.get(
76
+ "/",
77
+ response_model=ListAgentsResponse,
78
+ summary="List all agents",
79
+ )
80
+ async def list_agents(
81
+ user_id: str = Depends(get_current_user_id),
82
+ storage: StorageBase = Depends(get_storage),
83
+ ) -> ListAgentsResponse:
84
+ """Return all agent records belonging to the authenticated user.
85
+
86
+ Args:
87
+ user_id (`str`):
88
+ Injected authenticated user ID.
89
+ storage (`StorageBase`):
90
+ Injected storage backend.
91
+
92
+ Returns:
93
+ `ListAgentsResponse`:
94
+ All agent records and their total count.
95
+ """
96
+ agents = await storage.list_agents(user_id)
97
+ return ListAgentsResponse(agents=agents, total=len(agents))
98
+
99
+
100
+ @agent_router.post(
101
+ "/",
102
+ response_model=CreateAgentResponse,
103
+ status_code=status.HTTP_201_CREATED,
104
+ summary="Create a new agent",
105
+ )
106
+ async def create_agent(
107
+ body: CreateAgentRequest,
108
+ user_id: str = Depends(get_current_user_id),
109
+ storage: StorageBase = Depends(get_storage),
110
+ ) -> CreateAgentResponse:
111
+ """Create and persist a new agent configuration.
112
+
113
+ Args:
114
+ body (`CreateAgentRequest`):
115
+ Agent configuration to store.
116
+ user_id (`str`):
117
+ Injected authenticated user ID.
118
+ storage (`StorageBase`):
119
+ Injected storage backend.
120
+
121
+ Returns:
122
+ `CreateAgentResponse`:
123
+ The server-assigned agent identifier.
124
+ """
125
+ record = AgentRecord(
126
+ user_id=user_id,
127
+ data=AgentData(
128
+ name=body.name,
129
+ system_prompt=body.system_prompt,
130
+ context_config=body.context_config,
131
+ react_config=body.react_config,
132
+ ),
133
+ )
134
+ agent_id = await storage.upsert_agent(user_id, record)
135
+ return CreateAgentResponse(agent_id=agent_id)
136
+
137
+
138
+ @agent_router.patch(
139
+ "/{agent_id}",
140
+ response_model=AgentRecord,
141
+ summary="Update an agent",
142
+ )
143
+ async def update_agent(
144
+ agent_id: str,
145
+ body: UpdateAgentRequest,
146
+ user_id: str = Depends(get_current_user_id),
147
+ storage: StorageBase = Depends(get_storage),
148
+ ) -> AgentRecord:
149
+ """Partially update an existing agent configuration.
150
+
151
+ Only the fields present in the request body are updated; all other fields
152
+ keep their current values.
153
+
154
+ Args:
155
+ agent_id (`str`): The agent to update.
156
+ body (`UpdateAgentRequest`): Fields to update.
157
+ user_id (`str`): Injected authenticated user ID.
158
+ storage (`StorageBase`): Injected storage backend.
159
+
160
+ Returns:
161
+ `AgentRecord`: The full agent record after the update.
162
+
163
+ Raises:
164
+ `HTTPException`: 404 if the agent does not exist or does not belong
165
+ to the authenticated user.
166
+ """
167
+ agents = await storage.list_agents(user_id)
168
+ existing = next((a for a in agents if a.id == agent_id), None)
169
+ if existing is None:
170
+ raise HTTPException(
171
+ status_code=status.HTTP_404_NOT_FOUND,
172
+ detail=f"Agent '{agent_id}' not found.",
173
+ )
174
+
175
+ updates = body.model_dump(exclude_none=True)
176
+ updated_data = existing.data.model_copy(update=updates)
177
+ updated_agent = existing.model_copy(
178
+ update={"data": updated_data, "updated_at": datetime.now()},
179
+ )
180
+ await storage.upsert_agent(user_id, updated_agent)
181
+ return updated_agent
182
+
183
+
184
+ @agent_router.delete(
185
+ "/{agent_id}",
186
+ status_code=status.HTTP_204_NO_CONTENT,
187
+ summary="Delete an agent",
188
+ )
189
+ async def delete_agent(
190
+ agent_id: str,
191
+ user_id: str = Depends(get_current_user_id),
192
+ session_service: SessionService = Depends(get_session_service),
193
+ ) -> None:
194
+ """Permanently delete an agent configuration.
195
+
196
+ Cascades through every session owned by this agent (and, for team
197
+ leaders, through every worker session) — cancelling any in-flight
198
+ chat run, removing storage records, and purging bus state.
199
+
200
+ Args:
201
+ agent_id (`str`): The agent to delete.
202
+ user_id (`str`): Injected authenticated user ID.
203
+ session_service (`SessionService`): Injected session service.
204
+
205
+ Raises:
206
+ `HTTPException`: 404 if the agent does not exist or does not belong
207
+ to the authenticated user.
208
+ """
209
+ deleted = await session_service.delete_agent(user_id, agent_id)
210
+ if not deleted:
211
+ raise HTTPException(
212
+ status_code=status.HTTP_404_NOT_FOUND,
213
+ detail=f"Agent '{agent_id}' not found.",
214
+ )
src/agentscope/app/_router/_chat.py ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """Chat router — fire-and-forget trigger for chat runs.
3
+
4
+ The endpoint no longer returns an SSE stream. Instead, it kicks off a
5
+ chat run as a background task and returns immediately. Events produced
6
+ by the run are published to the message bus and delivered to the
7
+ frontend via the long-lived ``GET /sessions/{sid}/stream`` SSE
8
+ connection provided by the session router.
9
+
10
+ Two trigger paths, deliberately asymmetric:
11
+
12
+ - **New user message(s)** are spawned directly into the
13
+ :class:`ChatRunRegistry`. The registry's single-run-per-session rule
14
+ surfaces as a 409, which is exactly the desired double-submit guard.
15
+ - **HITL results** (``UserConfirmResultEvent`` /
16
+ ``ExternalExecutionResultEvent``) are *enqueued* onto the shared
17
+ run-trigger queue and drained by the single
18
+ :class:`WakeupDispatcher`. Routing the resume through the queue keeps
19
+ the dispatcher the sole spawn site, so a resume can never collide with
20
+ the worker's still-finishing parked run (the old 409 race) — the
21
+ dispatcher serialises them.
22
+ """
23
+ from fastapi import APIRouter, Depends, HTTPException, status
24
+
25
+ from ..deps import (
26
+ get_chat_run_registry,
27
+ get_chat_service,
28
+ get_current_user_id,
29
+ get_message_bus,
30
+ )
31
+ from ._schema import ChatRequest, ChatTriggerResponse
32
+ from .._manager import ChatRunRegistry
33
+ from .._service import (
34
+ ChatService,
35
+ SessionProjection,
36
+ SubagentHitlProjector,
37
+ )
38
+ from ..message_bus import MessageBus, MessageBusKeys
39
+ from .._bus_ops import enqueue_run_trigger
40
+ from ...event import UserConfirmResultEvent, ExternalExecutionResultEvent
41
+
42
+ chat_router = APIRouter(
43
+ prefix="/chat",
44
+ tags=["chat"],
45
+ responses={404: {"description": "Not found"}},
46
+ )
47
+
48
+
49
+ @chat_router.post(
50
+ "/",
51
+ response_model=ChatTriggerResponse,
52
+ summary="Trigger a chat run (fire-and-forget)",
53
+ )
54
+ async def chat(
55
+ request: ChatRequest,
56
+ user_id: str = Depends(get_current_user_id),
57
+ chat_service: ChatService = Depends(get_chat_service),
58
+ chat_run_registry: ChatRunRegistry = Depends(get_chat_run_registry),
59
+ message_bus: MessageBus = Depends(get_message_bus),
60
+ ) -> ChatTriggerResponse:
61
+ """Trigger a chat run for the specified session.
62
+
63
+ Events produced during the run are published to the message bus and
64
+ delivered to any active ``GET /sessions/{session_id}/stream`` SSE
65
+ subscriber. The caller does **not** receive events from this
66
+ endpoint's response body.
67
+
68
+ Accepts the same ``input`` payloads as before:
69
+
70
+ - ``Msg`` / ``list[Msg]``: new user message(s) — spawned directly.
71
+ - ``UserConfirmResultEvent`` / ``ExternalExecutionResultEvent``:
72
+ resume a paused tool call (human-in-the-loop) — routed to the
73
+ owning session and enqueued for the dispatcher.
74
+ - ``None``: continue from current state — spawned directly.
75
+
76
+ Args:
77
+ request (`ChatRequest`):
78
+ JSON body with ``agent_id``, ``session_id``, and ``input``.
79
+ user_id (`str`):
80
+ Injected user id.
81
+ chat_service (`ChatService`):
82
+ Injected application-wide chat service.
83
+ chat_run_registry (`ChatRunRegistry`):
84
+ Injected per-process chat-run registry.
85
+ message_bus (`MessageBus`):
86
+ Injected message bus, used to resolve subagent-confirm
87
+ routing and to enqueue resume triggers.
88
+
89
+ Returns:
90
+ `ChatTriggerResponse`:
91
+ Confirms the run was scheduled (for a resume, that it was
92
+ enqueued).
93
+
94
+ Raises:
95
+ `HTTPException`:
96
+ 409 if a chat run for this session is already in flight in
97
+ this process (the registry enforces single-run-per-session).
98
+ Only direct-spawn paths (new messages / ``None``) can raise
99
+ this; the enqueued resume path never does.
100
+ """
101
+ # ------------------------------------------------------------------
102
+ # HITL resume — route to the owning session, then enqueue.
103
+ #
104
+ # A confirmation / external-result POSTed to a *leader* session may
105
+ # actually belong to a team *member*: the leader is the single front
106
+ # door clients talk to. Resolve the owning worker HERE, then enqueue
107
+ # a ``resume`` trigger for that session. The single WakeupDispatcher
108
+ # drains it — spawning under the *worker* session id, serialised
109
+ # behind any still-finishing parked run, so there is no registry
110
+ # collision (no 409) and the leader's run slot is never occupied by
111
+ # the worker's resume.
112
+ # ------------------------------------------------------------------
113
+ if isinstance(
114
+ request.input,
115
+ (UserConfirmResultEvent, ExternalExecutionResultEvent),
116
+ ):
117
+ run_session_id = request.session_id
118
+ run_agent_id = request.agent_id
119
+ target = await SubagentHitlProjector.resolve(
120
+ SessionProjection(message_bus),
121
+ request.session_id,
122
+ request.input.reply_id,
123
+ )
124
+ if target is not None:
125
+ run_session_id = target["worker_session_id"]
126
+ run_agent_id = target["worker_agent_id"]
127
+
128
+ await enqueue_run_trigger(
129
+ message_bus,
130
+ user_id=user_id,
131
+ session_id=run_session_id,
132
+ agent_id=run_agent_id,
133
+ kind=MessageBusKeys.WAKEUP_KIND_RESUME,
134
+ inputs=request.input,
135
+ )
136
+ return ChatTriggerResponse(status="started", session_id=run_session_id)
137
+
138
+ # ------------------------------------------------------------------
139
+ # New user message(s) / None — spawn directly. The registry's
140
+ # single-run-per-session rule is the desired double-submit guard.
141
+ # ------------------------------------------------------------------
142
+ try:
143
+ chat_run_registry.spawn(
144
+ chat_service.run(
145
+ user_id=user_id,
146
+ session_id=request.session_id,
147
+ agent_id=request.agent_id,
148
+ input_msg=request.input,
149
+ ),
150
+ session_id=request.session_id,
151
+ )
152
+ except RuntimeError as e:
153
+ raise HTTPException(
154
+ status_code=status.HTTP_409_CONFLICT,
155
+ detail=str(e),
156
+ ) from e
157
+ return ChatTriggerResponse(
158
+ status="started",
159
+ session_id=request.session_id,
160
+ )
src/agentscope/app/_router/_credential.py ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """Credential router — CRUD endpoints for API key credentials."""
3
+ from fastapi import APIRouter, Depends, HTTPException, status
4
+
5
+ from ..deps import get_current_user_id, get_storage
6
+ from ._schema import (
7
+ CreateCredentialRequest,
8
+ CreateCredentialResponse,
9
+ ListCredentialsResponse,
10
+ ListCredentialSchemasResponse,
11
+ UpdateCredentialRequest,
12
+ )
13
+ from ..storage import StorageBase, CredentialRecord
14
+ from ...credential import CredentialFactory
15
+
16
+ credential_router = APIRouter(
17
+ prefix="/credential",
18
+ tags=["credential"],
19
+ responses={404: {"description": "Not found"}},
20
+ )
21
+
22
+
23
+ @credential_router.get(
24
+ "/schemas",
25
+ response_model=ListCredentialSchemasResponse,
26
+ summary="List JSON schemas for all credential types",
27
+ )
28
+ async def list_credential_schemas() -> ListCredentialSchemasResponse:
29
+ """Return JSON schemas for all registered credential types.
30
+
31
+ Used by the frontend to render credential creation forms dynamically.
32
+ """
33
+
34
+ return ListCredentialSchemasResponse(
35
+ schemas=CredentialFactory.list_schemas(),
36
+ )
37
+
38
+
39
+ @credential_router.get(
40
+ "/",
41
+ response_model=ListCredentialsResponse,
42
+ summary="List all credentials",
43
+ )
44
+ async def list_credentials(
45
+ user_id: str = Depends(get_current_user_id),
46
+ storage: StorageBase = Depends(get_storage),
47
+ ) -> ListCredentialsResponse:
48
+ """Return all credential records belonging to the authenticated user.
49
+
50
+ Args:
51
+ user_id (`str`):
52
+ Injected authenticated user ID.
53
+ storage (`StorageBase`):
54
+ Injected storage backend.
55
+
56
+ Returns:
57
+ `ListCredentialsResponse`:
58
+ All credential records and their total count.
59
+ """
60
+ credentials = await storage.list_credentials(user_id)
61
+ return ListCredentialsResponse(
62
+ credentials=credentials,
63
+ total=len(credentials),
64
+ )
65
+
66
+
67
+ @credential_router.post(
68
+ "/",
69
+ response_model=CreateCredentialResponse,
70
+ status_code=status.HTTP_201_CREATED,
71
+ summary="Create a new credential",
72
+ )
73
+ async def create_credential(
74
+ body: CreateCredentialRequest,
75
+ user_id: str = Depends(get_current_user_id),
76
+ storage: StorageBase = Depends(get_storage),
77
+ ) -> CreateCredentialResponse:
78
+ """Store a new credential.
79
+
80
+ Args:
81
+ body (`CreateCredentialRequest`): Credential payload to store.
82
+ user_id (`str`): Injected authenticated user ID.
83
+ storage (`StorageBase`): Injected storage backend.
84
+
85
+ Returns:
86
+ `CreateCredentialResponse`: The server-assigned credential identifier.
87
+ """
88
+ credential_id = await storage.upsert_credential(
89
+ user_id,
90
+ CredentialFactory.from_dict(body.data),
91
+ )
92
+ return CreateCredentialResponse(credential_id=credential_id)
93
+
94
+
95
+ @credential_router.patch(
96
+ "/{credential_id}",
97
+ response_model=CredentialRecord,
98
+ summary="Update a credential",
99
+ )
100
+ async def update_credential(
101
+ credential_id: str,
102
+ body: UpdateCredentialRequest,
103
+ user_id: str = Depends(get_current_user_id),
104
+ storage: StorageBase = Depends(get_storage),
105
+ ) -> CredentialRecord:
106
+ """Replace the payload of an existing credential.
107
+
108
+ Args:
109
+ credential_id (`str`): The credential to update.
110
+ body (`UpdateCredentialRequest`): New credential payload.
111
+ user_id (`str`): Injected authenticated user ID.
112
+ storage (`StorageBase`): Injected storage backend.
113
+
114
+ Returns:
115
+ `CredentialRecord`: The updated credential record.
116
+
117
+ Raises:
118
+ `HTTPException`: 404 if the credential does not exist or does not
119
+ belong to the authenticated user.
120
+ """
121
+ credentials = await storage.list_credentials(user_id)
122
+ existing = next((c for c in credentials if c.id == credential_id), None)
123
+ if existing is None:
124
+ raise HTTPException(
125
+ status_code=status.HTTP_404_NOT_FOUND,
126
+ detail=f"Credential '{credential_id}' not found.",
127
+ )
128
+
129
+ credential = CredentialFactory.from_dict(body.data)
130
+ credential.id = credential_id
131
+ await storage.upsert_credential(user_id, credential)
132
+ # Re-fetch to return the persisted record with updated timestamps.
133
+ credentials = await storage.list_credentials(user_id)
134
+ updated = next(c for c in credentials if c.id == credential_id)
135
+ return updated
136
+
137
+
138
+ @credential_router.delete(
139
+ "/{credential_id}",
140
+ status_code=status.HTTP_204_NO_CONTENT,
141
+ summary="Delete a credential",
142
+ )
143
+ async def delete_credential(
144
+ credential_id: str,
145
+ user_id: str = Depends(get_current_user_id),
146
+ storage: StorageBase = Depends(get_storage),
147
+ ) -> None:
148
+ """Permanently delete a credential.
149
+
150
+ Args:
151
+ credential_id (`str`): The credential to delete.
152
+ user_id (`str`): Injected authenticated user ID.
153
+ storage (`StorageBase`): Injected storage backend.
154
+
155
+ Raises:
156
+ `HTTPException`: 404 if the credential does not exist or does not
157
+ belong to the authenticated user.
158
+ """
159
+ deleted = await storage.delete_credential(user_id, credential_id)
160
+ if not deleted:
161
+ raise HTTPException(
162
+ status_code=status.HTTP_404_NOT_FOUND,
163
+ detail=f"Credential '{credential_id}' not found.",
164
+ )
src/agentscope/app/_router/_knowledge_base.py ADDED
@@ -0,0 +1,574 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """Knowledge base router — manage knowledge bases and their documents.
3
+
4
+ A knowledge base is the user-facing concept; physically each one maps
5
+ to a single vector store collection (in the MVP isolation strategy).
6
+ The HTTP layer is intentionally thin — every endpoint translates the
7
+ request into a single :class:`~agentscope.app._service.
8
+ KnowledgeBaseService` call and returns the result.
9
+ """
10
+ from fastapi import (
11
+ APIRouter,
12
+ Depends,
13
+ File,
14
+ Form,
15
+ Path,
16
+ Query,
17
+ UploadFile,
18
+ status,
19
+ )
20
+
21
+ from ..deps import (
22
+ get_current_user_id,
23
+ get_knowledge_base_manager,
24
+ get_knowledge_base_service,
25
+ get_knowledge_parsers,
26
+ get_storage,
27
+ )
28
+ from ._schema import (
29
+ CreateKnowledgeBaseRequest,
30
+ CreateKnowledgeBaseResponse,
31
+ KbEmbeddingProvider,
32
+ KbMiddlewareParametersSchemaResponse,
33
+ KnowledgeBaseView,
34
+ KnowledgeDocumentView,
35
+ ListKbEmbeddingModelsResponse,
36
+ ListKnowledgeBasesResponse,
37
+ ListKnowledgeDocumentsResponse,
38
+ ListKnowledgeDocumentStatusResponse,
39
+ ListSupportedContentTypesResponse,
40
+ SearchKnowledgeBaseRequest,
41
+ SearchKnowledgeBaseResponse,
42
+ UpdateKnowledgeBaseRequest,
43
+ UploadKnowledgeDocumentResponse,
44
+ )
45
+ from ...credential import CredentialFactory
46
+ from ..rag.knowledge_base_manager import KnowledgeBaseManagerBase
47
+ from ..storage import StorageBase
48
+ from .._service import KnowledgeBaseService
49
+ from ...middleware import RAGMiddleware
50
+ from ...rag import ParserBase
51
+
52
+
53
+ knowledge_base_router = APIRouter(
54
+ prefix="/knowledge_bases",
55
+ tags=["knowledge_bases"],
56
+ responses={404: {"description": "Not found"}},
57
+ )
58
+
59
+
60
+ @knowledge_base_router.get(
61
+ "/embedding_models",
62
+ response_model=ListKbEmbeddingModelsResponse,
63
+ summary="List embedding models compatible with the KB dimension policy",
64
+ )
65
+ async def list_kb_embedding_models(
66
+ user_id: str = Depends(get_current_user_id),
67
+ storage: "StorageBase" = Depends(get_storage),
68
+ manager: "KnowledgeBaseManagerBase" = Depends(
69
+ get_knowledge_base_manager,
70
+ ),
71
+ ) -> ListKbEmbeddingModelsResponse:
72
+ """List embedding models the user can pick at KB-creation time.
73
+
74
+ Walks the caller's credentials, looks up each provider's
75
+ embedding model class, gathers its model cards, and projects
76
+ each card through the manager's :class:`DimensionPolicy`.
77
+ Incompatible cards are dropped; matryoshka cards under a
78
+ ``FIXED`` / ``LOCKED_BY_EXISTING`` policy are narrowed to the
79
+ locked dimension. Providers that end up with zero compatible
80
+ models are omitted from the response entirely.
81
+
82
+ Args:
83
+ user_id (`str`):
84
+ Injected authenticated user ID.
85
+ storage (`StorageBase`):
86
+ Injected storage backend used to enumerate credentials.
87
+ manager (`KnowledgeBaseManagerBase`):
88
+ Injected knowledge base manager.
89
+
90
+ Returns:
91
+ `ListKbEmbeddingModelsResponse`:
92
+ One entry per credential with at least one compatible
93
+ embedding model, plus the policy used for filtering.
94
+ """
95
+ policy = await manager.get_dimension_policy()
96
+ credentials = await storage.list_credentials(user_id)
97
+
98
+ providers: list[KbEmbeddingProvider] = []
99
+ for credential in credentials:
100
+ credential_type = credential.data.get("type")
101
+ if not credential_type:
102
+ continue
103
+ credential_cls = CredentialFactory.get_credential_class(
104
+ credential_type,
105
+ )
106
+ if credential_cls is None:
107
+ continue
108
+ embedding_cls = credential_cls.get_embedding_model_class()
109
+ if embedding_cls is None:
110
+ continue
111
+
112
+ filtered = []
113
+ for card in embedding_cls.list_models():
114
+ projected = policy.filter_card(card)
115
+ if projected is not None:
116
+ filtered.append(projected)
117
+ if not filtered:
118
+ continue
119
+ providers.append(
120
+ KbEmbeddingProvider(credential=credential, models=filtered),
121
+ )
122
+
123
+ return ListKbEmbeddingModelsResponse(providers=providers, policy=policy)
124
+
125
+
126
+ @knowledge_base_router.get(
127
+ "/middleware/parameters_schema",
128
+ response_model=KbMiddlewareParametersSchemaResponse,
129
+ summary="JSON Schema for the KB middleware's tunable parameters",
130
+ )
131
+ async def get_kb_middleware_parameters_schema(
132
+ _: str = Depends(get_current_user_id),
133
+ ) -> KbMiddlewareParametersSchemaResponse:
134
+ """Return the parameter schema for
135
+ :class:`agentscope.middleware.RAGMiddleware`.
136
+
137
+ The schema is shaped like every other ``parameter_schema`` served
138
+ by this service — title / description / default / enum / minimum
139
+ / maximum — so the front-end can render the session-level KB
140
+ attachment form with the same schema-driven component used for
141
+ model parameters.
142
+
143
+ Args:
144
+ _ (`str`):
145
+ Injected authenticated user ID; only used to gate the
146
+ endpoint behind authentication.
147
+
148
+ Returns:
149
+ `KbMiddlewareParametersSchemaResponse`:
150
+ The JSON Schema describing the middleware's
151
+ user-tunable parameters.
152
+ """
153
+ return KbMiddlewareParametersSchemaResponse(
154
+ parameter_schema=(RAGMiddleware.Parameters.model_json_schema()),
155
+ )
156
+
157
+
158
+ @knowledge_base_router.get(
159
+ "/supported_content_types",
160
+ response_model=ListSupportedContentTypesResponse,
161
+ summary="List file types the configured parsers can ingest",
162
+ )
163
+ async def list_supported_content_types(
164
+ _: str = Depends(get_current_user_id),
165
+ parsers: list[ParserBase]
166
+ | dict[str, ParserBase] = Depends(
167
+ get_knowledge_parsers,
168
+ ),
169
+ ) -> ListSupportedContentTypesResponse:
170
+ """Advertise the union of media types and filename extensions every
171
+ registered parser accepts.
172
+
173
+ Used by the front-end to populate the document picker's ``accept``
174
+ attribute and to reject drag-dropped files whose extension lies
175
+ outside the supported set before the upload starts. Routing on
176
+ upload still goes through the media type — this endpoint is a
177
+ capability hint, not authoritative dispatch.
178
+
179
+ Args:
180
+ _ (`str`):
181
+ Injected authenticated user ID; only used to gate the
182
+ endpoint behind authentication.
183
+ parsers (`list[ParserBase] | dict[str, ParserBase]`):
184
+ Injected parser registry — the same value the index worker
185
+ uses to dispatch uploads.
186
+
187
+ Returns:
188
+ `ListSupportedContentTypesResponse`:
189
+ Deduplicated, sorted unions of ``media_types`` and
190
+ ``extensions``.
191
+ """
192
+ parser_iter = parsers.values() if isinstance(parsers, dict) else parsers
193
+ media_types: set[str] = set()
194
+ extensions: set[str] = set()
195
+ for parser in parser_iter:
196
+ media_types.update(parser.supported_media_types)
197
+ extensions.update(parser.supported_extensions())
198
+ return ListSupportedContentTypesResponse(
199
+ media_types=sorted(media_types),
200
+ extensions=sorted(extensions),
201
+ )
202
+
203
+
204
+ # ----------------------------------------------------------------------
205
+ # Knowledge base management
206
+ # ----------------------------------------------------------------------
207
+
208
+
209
+ @knowledge_base_router.post(
210
+ "/",
211
+ response_model=CreateKnowledgeBaseResponse,
212
+ status_code=status.HTTP_201_CREATED,
213
+ summary="Create a new knowledge base",
214
+ )
215
+ async def create_knowledge_base(
216
+ body: CreateKnowledgeBaseRequest,
217
+ user_id: str = Depends(get_current_user_id),
218
+ service: "KnowledgeBaseService" = Depends(get_knowledge_base_service),
219
+ ) -> CreateKnowledgeBaseResponse:
220
+ """Create a new knowledge base for the authenticated user.
221
+
222
+ Allocates a fresh vector store collection sized to the embedding
223
+ model's output dimension and persists the knowledge base record.
224
+
225
+ Args:
226
+ body (`CreateKnowledgeBaseRequest`):
227
+ Knowledge base name, description, and embedding model
228
+ configuration.
229
+ user_id (`str`):
230
+ Injected authenticated user ID.
231
+ service (`KnowledgeBaseService`):
232
+ Injected knowledge base service.
233
+
234
+ Returns:
235
+ `CreateKnowledgeBaseResponse`:
236
+ The server-assigned knowledge base identifier.
237
+ """
238
+ record = await service.create_knowledge_base(
239
+ user_id=user_id,
240
+ name=body.name,
241
+ description=body.description,
242
+ embedding_model_config=body.embedding_model_config,
243
+ )
244
+ return CreateKnowledgeBaseResponse(knowledge_base_id=record.id)
245
+
246
+
247
+ @knowledge_base_router.get(
248
+ "/",
249
+ response_model=ListKnowledgeBasesResponse,
250
+ summary="List the caller's knowledge bases",
251
+ )
252
+ async def list_knowledge_bases(
253
+ user_id: str = Depends(get_current_user_id),
254
+ service: "KnowledgeBaseService" = Depends(get_knowledge_base_service),
255
+ ) -> ListKnowledgeBasesResponse:
256
+ """Return all knowledge bases owned by the authenticated user.
257
+
258
+ Args:
259
+ user_id (`str`):
260
+ Injected authenticated user ID.
261
+ service (`KnowledgeBaseService`):
262
+ Injected knowledge base service.
263
+
264
+ Returns:
265
+ `ListKnowledgeBasesResponse`:
266
+ The user's knowledge bases.
267
+ """
268
+ records = await service.list_knowledge_bases(user_id)
269
+ views = [
270
+ KnowledgeBaseView(
271
+ id=record.id,
272
+ name=record.name,
273
+ description=record.description,
274
+ embedding_model_config=record.embedding_model_config,
275
+ created_at=record.created_at,
276
+ updated_at=record.updated_at,
277
+ )
278
+ for record in records
279
+ ]
280
+ return ListKnowledgeBasesResponse(knowledge_bases=views, total=len(views))
281
+
282
+
283
+ @knowledge_base_router.patch(
284
+ "/{knowledge_base_id}",
285
+ response_model=KnowledgeBaseView,
286
+ summary="Update mutable fields on a knowledge base",
287
+ )
288
+ async def update_knowledge_base(
289
+ body: UpdateKnowledgeBaseRequest,
290
+ knowledge_base_id: str = Path(description="The knowledge base id."),
291
+ user_id: str = Depends(get_current_user_id),
292
+ service: "KnowledgeBaseService" = Depends(get_knowledge_base_service),
293
+ ) -> KnowledgeBaseView:
294
+ """Update mutable fields on a knowledge base.
295
+
296
+ Only ``name`` and ``description`` can be updated. The embedding
297
+ model configuration is pinned at creation time and cannot be
298
+ changed.
299
+
300
+ Args:
301
+ body (`UpdateKnowledgeBaseRequest`):
302
+ The fields to update; omitted fields stay unchanged.
303
+ knowledge_base_id (`str`):
304
+ The knowledge base to update.
305
+ user_id (`str`):
306
+ Injected authenticated user ID.
307
+ service (`KnowledgeBaseService`):
308
+ Injected knowledge base service.
309
+
310
+ Returns:
311
+ `KnowledgeBaseView`:
312
+ The knowledge base record after the update.
313
+ """
314
+ record = await service.update_knowledge_base(
315
+ user_id=user_id,
316
+ knowledge_base_id=knowledge_base_id,
317
+ name=body.name,
318
+ description=body.description,
319
+ )
320
+ return KnowledgeBaseView(
321
+ id=record.id,
322
+ name=record.name,
323
+ description=record.description,
324
+ embedding_model_config=record.embedding_model_config,
325
+ created_at=record.created_at,
326
+ updated_at=record.updated_at,
327
+ )
328
+
329
+
330
+ @knowledge_base_router.delete(
331
+ "/{knowledge_base_id}",
332
+ status_code=status.HTTP_204_NO_CONTENT,
333
+ summary="Delete a knowledge base",
334
+ )
335
+ async def delete_knowledge_base(
336
+ knowledge_base_id: str = Path(description="The knowledge base id."),
337
+ user_id: str = Depends(get_current_user_id),
338
+ service: "KnowledgeBaseService" = Depends(get_knowledge_base_service),
339
+ ) -> None:
340
+ """Permanently delete a knowledge base.
341
+
342
+ Drops the underlying vector store collection together with every
343
+ associated document and the knowledge base record itself.
344
+
345
+ Args:
346
+ knowledge_base_id (`str`):
347
+ The knowledge base to delete.
348
+ user_id (`str`):
349
+ Injected authenticated user ID.
350
+ service (`KnowledgeBaseService`):
351
+ Injected knowledge base service.
352
+ """
353
+ await service.delete_knowledge_base(user_id, knowledge_base_id)
354
+
355
+
356
+ # ----------------------------------------------------------------------
357
+ # Document management
358
+ # ----------------------------------------------------------------------
359
+
360
+
361
+ @knowledge_base_router.get(
362
+ "/{knowledge_base_id}/documents",
363
+ response_model=ListKnowledgeDocumentsResponse,
364
+ summary="List documents registered in a knowledge base",
365
+ )
366
+ async def list_knowledge_documents(
367
+ knowledge_base_id: str = Path(description="The knowledge base id."),
368
+ user_id: str = Depends(get_current_user_id),
369
+ service: "KnowledgeBaseService" = Depends(get_knowledge_base_service),
370
+ ) -> ListKnowledgeDocumentsResponse:
371
+ """List every document registered against a knowledge base.
372
+
373
+ Reads from the storage backend (service-mode source of truth), so
374
+ documents in any lifecycle state — including ``pending`` /
375
+ ``parsing`` / ``error`` — are returned alongside ``ready`` ones.
376
+
377
+ Args:
378
+ knowledge_base_id (`str`):
379
+ The target knowledge base id.
380
+ user_id (`str`):
381
+ Injected authenticated user ID.
382
+ service (`KnowledgeBaseService`):
383
+ Injected knowledge base service.
384
+
385
+ Returns:
386
+ `ListKnowledgeDocumentsResponse`:
387
+ One view per registered document.
388
+ """
389
+ records = await service.list_documents(user_id, knowledge_base_id)
390
+ views = [KnowledgeDocumentView.from_record(r) for r in records]
391
+ return ListKnowledgeDocumentsResponse(
392
+ documents=views,
393
+ total=len(views),
394
+ )
395
+
396
+
397
+ @knowledge_base_router.get(
398
+ "/{knowledge_base_id}/documents/status",
399
+ response_model=ListKnowledgeDocumentStatusResponse,
400
+ summary="Batch-query indexing status of one or more documents",
401
+ )
402
+ async def list_knowledge_document_status(
403
+ knowledge_base_id: str = Path(description="The knowledge base id."),
404
+ ids: str = Query(
405
+ description=(
406
+ "Comma-separated list of document ids to query. "
407
+ "Missing ids are silently omitted from the response."
408
+ ),
409
+ ),
410
+ user_id: str = Depends(get_current_user_id),
411
+ service: "KnowledgeBaseService" = Depends(get_knowledge_base_service),
412
+ ) -> ListKnowledgeDocumentStatusResponse:
413
+ """Return the current lifecycle state of a batch of documents.
414
+
415
+ Designed for the front-end's status polling loop: the page sends
416
+ every in-flight document id at once so per-document round-trips
417
+ do not multiply with concurrency.
418
+
419
+ Args:
420
+ knowledge_base_id (`str`):
421
+ The target knowledge base id.
422
+ ids (`str`):
423
+ Comma-separated document ids.
424
+ user_id (`str`):
425
+ Injected authenticated user ID.
426
+ service (`KnowledgeBaseService`):
427
+ Injected knowledge base service.
428
+
429
+ Returns:
430
+ `ListKnowledgeDocumentStatusResponse`:
431
+ Views for the matched documents.
432
+ """
433
+ document_ids = [tok for tok in (s.strip() for s in ids.split(",")) if tok]
434
+ records = await service.get_document_status(
435
+ user_id,
436
+ knowledge_base_id,
437
+ document_ids,
438
+ )
439
+ return ListKnowledgeDocumentStatusResponse(
440
+ items=[KnowledgeDocumentView.from_record(r) for r in records],
441
+ )
442
+
443
+
444
+ @knowledge_base_router.post(
445
+ "/{knowledge_base_id}/documents",
446
+ response_model=UploadKnowledgeDocumentResponse,
447
+ status_code=status.HTTP_201_CREATED,
448
+ summary="Upload a document into a knowledge base",
449
+ )
450
+ async def upload_knowledge_document(
451
+ knowledge_base_id: str = Path(description="The knowledge base id."),
452
+ file: UploadFile = File(
453
+ description="The document to index (PDF, TXT, Markdown, …).",
454
+ ),
455
+ content_type: str
456
+ | None = Form(
457
+ default=None,
458
+ description=(
459
+ "Override the IANA media type used to route the upload. "
460
+ "Defaults to the type guessed from the filename."
461
+ ),
462
+ ),
463
+ user_id: str = Depends(get_current_user_id),
464
+ service: "KnowledgeBaseService" = Depends(get_knowledge_base_service),
465
+ ) -> UploadKnowledgeDocumentResponse:
466
+ """Register an uploaded document and dispatch it for indexing.
467
+
468
+ The HTTP connection covers only the upload phase: the request body
469
+ is streamed into the blob store, a ``pending`` document record is
470
+ persisted, the indexing task is dispatched, and the response is
471
+ returned. Parsing / chunking / embedding happen asynchronously in
472
+ a worker; the client tracks progress via
473
+ :func:`list_knowledge_document_status`.
474
+
475
+ Args:
476
+ knowledge_base_id (`str`):
477
+ The knowledge base to receive the document.
478
+ file (`UploadFile`):
479
+ The uploaded file (multipart/form-data).
480
+ content_type (`str | None`, optional):
481
+ Override the IANA media type used to route the upload.
482
+ user_id (`str`):
483
+ Injected authenticated user ID.
484
+ service (`KnowledgeBaseService`):
485
+ Injected knowledge base service.
486
+
487
+ Returns:
488
+ `UploadKnowledgeDocumentResponse`:
489
+ The server-assigned document id, filename, and the
490
+ initial lifecycle state (always ``"pending"``).
491
+ """
492
+ record = await service.register_document(
493
+ user_id=user_id,
494
+ knowledge_base_id=knowledge_base_id,
495
+ filename=file.filename or "uploaded_file",
496
+ stream=file.file,
497
+ size=file.size or 0,
498
+ content_type=content_type or file.content_type,
499
+ )
500
+ return UploadKnowledgeDocumentResponse(
501
+ document_id=record.id,
502
+ filename=record.data.filename,
503
+ status=record.data.status,
504
+ )
505
+
506
+
507
+ @knowledge_base_router.delete(
508
+ "/{knowledge_base_id}/documents/{document_id}",
509
+ status_code=status.HTTP_204_NO_CONTENT,
510
+ summary="Delete a document from a knowledge base",
511
+ )
512
+ async def delete_knowledge_document(
513
+ knowledge_base_id: str = Path(description="The knowledge base id."),
514
+ document_id: str = Path(description="The document id."),
515
+ user_id: str = Depends(get_current_user_id),
516
+ service: "KnowledgeBaseService" = Depends(get_knowledge_base_service),
517
+ ) -> None:
518
+ """Remove a document and all its chunks from a knowledge base.
519
+
520
+ Args:
521
+ knowledge_base_id (`str`):
522
+ The knowledge base the document belongs to.
523
+ document_id (`str`):
524
+ The document to delete.
525
+ user_id (`str`):
526
+ Injected authenticated user ID.
527
+ service (`KnowledgeBaseService`):
528
+ Injected knowledge base service.
529
+ """
530
+ await service.delete_document(user_id, knowledge_base_id, document_id)
531
+
532
+
533
+ # ----------------------------------------------------------------------
534
+ # Search
535
+ # ----------------------------------------------------------------------
536
+
537
+
538
+ @knowledge_base_router.post(
539
+ "/{knowledge_base_id}/search",
540
+ response_model=SearchKnowledgeBaseResponse,
541
+ summary="Search a knowledge base by natural-language query",
542
+ )
543
+ async def search_knowledge_base(
544
+ body: SearchKnowledgeBaseRequest,
545
+ knowledge_base_id: str = Path(description="The knowledge base id."),
546
+ user_id: str = Depends(get_current_user_id),
547
+ service: "KnowledgeBaseService" = Depends(get_knowledge_base_service),
548
+ ) -> SearchKnowledgeBaseResponse:
549
+ """Run a similarity search over a knowledge base.
550
+
551
+ Embeds the query with the knowledge base's configured embedding
552
+ model and returns the top-K most similar chunks.
553
+
554
+ Args:
555
+ body (`SearchKnowledgeBaseRequest`):
556
+ The query text and ``top_k``.
557
+ knowledge_base_id (`str`):
558
+ The knowledge base to search.
559
+ user_id (`str`):
560
+ Injected authenticated user ID.
561
+ service (`KnowledgeBaseService`):
562
+ Injected knowledge base service.
563
+
564
+ Returns:
565
+ `SearchKnowledgeBaseResponse`:
566
+ Matched chunks ordered by descending similarity.
567
+ """
568
+ results = await service.search(
569
+ user_id=user_id,
570
+ knowledge_base_id=knowledge_base_id,
571
+ query=body.query,
572
+ top_k=body.top_k,
573
+ )
574
+ return SearchKnowledgeBaseResponse(results=results, total=len(results))
src/agentscope/app/_router/_model.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """The model router."""
3
+
4
+ from fastapi import APIRouter, Depends, HTTPException, status
5
+
6
+ from ._schema import ListModelsResponse, ListModelsRequest
7
+ from ...credential import CredentialFactory
8
+
9
+ model_router = APIRouter(
10
+ prefix="/model",
11
+ tags=["model"],
12
+ responses={404: {"description": "Not found"}},
13
+ )
14
+
15
+
16
+ @model_router.get(
17
+ "/",
18
+ response_model=ListModelsResponse,
19
+ summary="List all candidate models under the given credential type",
20
+ )
21
+ async def list_models(
22
+ body: ListModelsRequest = Depends(),
23
+ ) -> ListModelsResponse:
24
+ """Return all candidate models under the given credential type.
25
+
26
+ Args:
27
+ body (ListModelsRequest): The request body.
28
+
29
+ Returns:
30
+ `ListModelsResponse`: The response body.
31
+ """
32
+ credential_cls = CredentialFactory.get_credential_class(body.provider)
33
+ if credential_cls is None:
34
+ raise HTTPException(
35
+ status_code=status.HTTP_404_NOT_FOUND,
36
+ detail=f"Provider '{body.provider}' not found.",
37
+ )
38
+
39
+ models = credential_cls.get_chat_model_class().list_models()
40
+ return ListModelsResponse(models=models, total=len(models))
src/agentscope/app/_router/_schedule.py ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """Schedule router — CRUD endpoints for scheduled agent tasks."""
3
+ from datetime import datetime
4
+
5
+ from fastapi import APIRouter, Depends, HTTPException, status
6
+
7
+ from .._manager import SchedulerManager
8
+ from ..deps import (
9
+ get_current_user_id,
10
+ get_scheduler_manager,
11
+ get_session_service,
12
+ get_storage,
13
+ )
14
+ from ._schema import (
15
+ CreateScheduleRequest,
16
+ CreateScheduleResponse,
17
+ ListSchedulesResponse,
18
+ ScheduleSessionsResponse,
19
+ UpdateScheduleRequest,
20
+ )
21
+ from .._service import SessionService
22
+ from ..storage import (
23
+ StorageBase,
24
+ ScheduleData,
25
+ ScheduleRecord,
26
+ ScheduleSource,
27
+ )
28
+
29
+ schedule_router = APIRouter(
30
+ prefix="/schedule",
31
+ tags=["schedule"],
32
+ responses={404: {"description": "Not found"}},
33
+ )
34
+
35
+
36
+ @schedule_router.get(
37
+ "/",
38
+ response_model=ListSchedulesResponse,
39
+ summary="List all schedules",
40
+ )
41
+ async def list_schedules(
42
+ user_id: str = Depends(get_current_user_id),
43
+ storage: StorageBase = Depends(get_storage),
44
+ ) -> ListSchedulesResponse:
45
+ """List all schedules owned by the current user.
46
+
47
+ Args:
48
+ user_id (`str`): Authenticated user ID.
49
+ storage (`StorageBase`): Storage instance.
50
+
51
+ Returns:
52
+ `ListSchedulesResponse`:
53
+ Paginated list of schedule records.
54
+ """
55
+ schedules = await storage.list_schedules(user_id)
56
+ return ListSchedulesResponse(schedules=schedules, total=len(schedules))
57
+
58
+
59
+ @schedule_router.post(
60
+ "/",
61
+ response_model=CreateScheduleResponse,
62
+ status_code=status.HTTP_201_CREATED,
63
+ summary="Create a new schedule",
64
+ )
65
+ async def create_schedule(
66
+ body: CreateScheduleRequest,
67
+ user_id: str = Depends(get_current_user_id),
68
+ storage: StorageBase = Depends(get_storage),
69
+ scheduler: SchedulerManager = Depends(get_scheduler_manager),
70
+ ) -> CreateScheduleResponse:
71
+ """Create a new schedule and register it with the scheduler.
72
+
73
+ Args:
74
+ body (`CreateScheduleRequest`): Schedule configuration.
75
+ user_id (`str`): Authenticated user ID.
76
+ storage (`StorageBase`): Storage instance.
77
+ scheduler (`SchedulerManager`): Scheduler manager.
78
+
79
+ Returns:
80
+ `CreateScheduleResponse`:
81
+ The ID of the newly created schedule.
82
+
83
+ Raises:
84
+ `HTTPException`: 404 if the specified agent does not exist.
85
+ """
86
+ agent = await storage.get_agent(user_id, body.agent_id)
87
+ if agent is None or agent.user_id != user_id:
88
+ raise HTTPException(
89
+ status_code=status.HTTP_404_NOT_FOUND,
90
+ detail=f"Agent '{body.agent_id}' not found.",
91
+ )
92
+
93
+ record = ScheduleRecord(
94
+ user_id=user_id,
95
+ agent_id=body.agent_id,
96
+ data=ScheduleData(
97
+ name=body.name,
98
+ description=body.description,
99
+ cron_expression=body.cron_expression,
100
+ timezone=body.timezone,
101
+ enabled=body.enabled,
102
+ stateful=body.stateful,
103
+ permission_mode=body.permission_mode,
104
+ chat_model_config=body.chat_model_config,
105
+ source=ScheduleSource.USER,
106
+ started_at=datetime.now(),
107
+ ),
108
+ )
109
+ await storage.upsert_schedule(user_id, record)
110
+
111
+ if record.data.enabled:
112
+ await scheduler.register_schedule(record)
113
+
114
+ return CreateScheduleResponse(schedule_id=record.id)
115
+
116
+
117
+ @schedule_router.patch(
118
+ "/{schedule_id}",
119
+ response_model=ScheduleRecord,
120
+ summary="Update a schedule",
121
+ )
122
+ async def update_schedule(
123
+ schedule_id: str,
124
+ body: UpdateScheduleRequest,
125
+ user_id: str = Depends(get_current_user_id),
126
+ storage: StorageBase = Depends(get_storage),
127
+ scheduler: SchedulerManager = Depends(get_scheduler_manager),
128
+ ) -> ScheduleRecord:
129
+ """Partially update a schedule.
130
+
131
+ Fields omitted from the request body keep their current values.
132
+ Changing ``cron_expression`` or ``timezone`` immediately reschedules the
133
+ APScheduler job. Setting ``enable=False`` removes the job from the
134
+ scheduler without deleting the record.
135
+
136
+ Args:
137
+ schedule_id (`str`): ID of the schedule to update.
138
+ body (`UpdateScheduleRequest`): Fields to update.
139
+ user_id (`str`): Authenticated user ID.
140
+ storage (`StorageBase`): Storage instance.
141
+ scheduler (`SchedulerManager`): Scheduler manager.
142
+
143
+ Returns:
144
+ `ScheduleRecord`:
145
+ The updated schedule record.
146
+
147
+ Raises:
148
+ `HTTPException`: 404 if the schedule does not exist.
149
+ """
150
+ existing = await storage.get_schedule(user_id, schedule_id)
151
+ if existing is None:
152
+ raise HTTPException(
153
+ status_code=status.HTTP_404_NOT_FOUND,
154
+ detail=f"Schedule '{schedule_id}' not found.",
155
+ )
156
+
157
+ updates = body.model_dump(exclude_none=True)
158
+ updated_data = existing.data.model_copy(update=updates)
159
+ updated_record = existing.model_copy(
160
+ update={"data": updated_data, "updated_at": datetime.now()},
161
+ )
162
+ await storage.upsert_schedule(user_id, updated_record)
163
+
164
+ # Always remove the existing job first; re-register only if still enabled.
165
+ await scheduler.remove_schedule(schedule_id)
166
+ if updated_record.data.enabled:
167
+ await scheduler.register_schedule(updated_record)
168
+
169
+ return updated_record
170
+
171
+
172
+ @schedule_router.delete(
173
+ "/{schedule_id}",
174
+ status_code=status.HTTP_204_NO_CONTENT,
175
+ summary="Delete a schedule",
176
+ )
177
+ async def delete_schedule(
178
+ schedule_id: str,
179
+ user_id: str = Depends(get_current_user_id),
180
+ session_service: SessionService = Depends(get_session_service),
181
+ scheduler: SchedulerManager = Depends(get_scheduler_manager),
182
+ ) -> None:
183
+ """Permanently delete a schedule.
184
+
185
+ Cancels any in-flight chat run for sessions this schedule has
186
+ triggered, removes their records via the session service, and
187
+ finally unregisters the APScheduler job.
188
+
189
+ Args:
190
+ schedule_id (`str`): ID of the schedule to delete.
191
+ user_id (`str`): Authenticated user ID.
192
+ session_service (`SessionService`): Injected session service.
193
+ scheduler (`SchedulerManager`): Scheduler manager.
194
+
195
+ Raises:
196
+ `HTTPException`: 404 if the schedule does not exist.
197
+ """
198
+ deleted = await session_service.delete_schedule(user_id, schedule_id)
199
+ if not deleted:
200
+ raise HTTPException(
201
+ status_code=status.HTTP_404_NOT_FOUND,
202
+ detail=f"Schedule '{schedule_id}' not found.",
203
+ )
204
+ await scheduler.remove_schedule(schedule_id)
205
+
206
+
207
+ @schedule_router.get(
208
+ "/{schedule_id}/sessions",
209
+ response_model=ScheduleSessionsResponse,
210
+ summary="List execution sessions for a schedule",
211
+ )
212
+ async def list_schedule_sessions(
213
+ schedule_id: str,
214
+ user_id: str = Depends(get_current_user_id),
215
+ storage: StorageBase = Depends(get_storage),
216
+ ) -> ScheduleSessionsResponse:
217
+ """Return all sessions triggered by a given schedule.
218
+
219
+ Args:
220
+ schedule_id (`str`): ID of the schedule.
221
+ user_id (`str`): Authenticated user ID.
222
+ storage (`StorageBase`): Storage instance.
223
+
224
+ Returns:
225
+ `ScheduleSessionsResponse`:
226
+ List of execution sessions ordered by creation time (newest first).
227
+
228
+ Raises:
229
+ `HTTPException`: 404 if the schedule does not exist.
230
+ """
231
+ existing = await storage.get_schedule(user_id, schedule_id)
232
+ if existing is None:
233
+ raise HTTPException(
234
+ status_code=status.HTTP_404_NOT_FOUND,
235
+ detail=f"Schedule '{schedule_id}' not found.",
236
+ )
237
+
238
+ sessions = await storage.list_sessions_by_schedule(user_id, schedule_id)
239
+ return ScheduleSessionsResponse(sessions=sessions, total=len(sessions))
src/agentscope/app/_router/_schema/__init__.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """Schema models for the agent service."""
3
+
4
+ from ._chat import ChatRequest, ChatTriggerResponse
5
+ from ._model import ListModelsResponse, ListModelsRequest
6
+ from ._tts_model import ListTTSModelsResponse, ListTTSModelsRequest
7
+ from ._schedule import (
8
+ CreateScheduleRequest,
9
+ CreateScheduleResponse,
10
+ ListSchedulesResponse,
11
+ ScheduleSessionsResponse,
12
+ UpdateScheduleRequest,
13
+ )
14
+ from ._agent import (
15
+ AgentSchemaResponse,
16
+ ListAgentsResponse,
17
+ CreateAgentRequest,
18
+ CreateAgentResponse,
19
+ UpdateAgentRequest,
20
+ )
21
+ from ._credential import (
22
+ CreateCredentialRequest,
23
+ CreateCredentialResponse,
24
+ UpdateCredentialRequest,
25
+ ListCredentialsResponse,
26
+ ListCredentialSchemasResponse,
27
+ )
28
+ from ._knowledge_base import (
29
+ CreateKnowledgeBaseRequest,
30
+ CreateKnowledgeBaseResponse,
31
+ KbEmbeddingProvider,
32
+ KbMiddlewareParametersSchemaResponse,
33
+ KnowledgeBaseView,
34
+ KnowledgeDocumentView,
35
+ ListKbEmbeddingModelsResponse,
36
+ ListKnowledgeBasesResponse,
37
+ ListKnowledgeDocumentsResponse,
38
+ ListKnowledgeDocumentStatusResponse,
39
+ ListSupportedContentTypesResponse,
40
+ SearchKnowledgeBaseRequest,
41
+ SearchKnowledgeBaseResponse,
42
+ UpdateKnowledgeBaseRequest,
43
+ UploadKnowledgeDocumentResponse,
44
+ )
45
+ from ._session import (
46
+ CreateSessionRequest,
47
+ CreateSessionResponse,
48
+ UpdateSessionRequest,
49
+ ListSessionsResponse,
50
+ ListMessagesResponse,
51
+ SessionView,
52
+ TeamDetailResponse,
53
+ TeamMemberView,
54
+ )
55
+
56
+ __all__ = [
57
+ # Agent
58
+ "AgentSchemaResponse",
59
+ "ListAgentsResponse",
60
+ "CreateAgentRequest",
61
+ "CreateAgentResponse",
62
+ "UpdateAgentRequest",
63
+ "ListSchedulesResponse",
64
+ # Chat
65
+ "ChatRequest",
66
+ "ChatTriggerResponse",
67
+ # Credential
68
+ "CreateCredentialRequest",
69
+ "CreateCredentialResponse",
70
+ "UpdateCredentialRequest",
71
+ "ListCredentialsResponse",
72
+ "ListCredentialSchemasResponse",
73
+ # Knowledge base
74
+ "CreateKnowledgeBaseRequest",
75
+ "CreateKnowledgeBaseResponse",
76
+ "KbEmbeddingProvider",
77
+ "KbMiddlewareParametersSchemaResponse",
78
+ "KnowledgeBaseView",
79
+ "KnowledgeDocumentView",
80
+ "ListKbEmbeddingModelsResponse",
81
+ "ListKnowledgeBasesResponse",
82
+ "ListKnowledgeDocumentsResponse",
83
+ "ListKnowledgeDocumentStatusResponse",
84
+ "ListSupportedContentTypesResponse",
85
+ "SearchKnowledgeBaseRequest",
86
+ "SearchKnowledgeBaseResponse",
87
+ "UpdateKnowledgeBaseRequest",
88
+ "UploadKnowledgeDocumentResponse",
89
+ # Model
90
+ "ListModelsRequest",
91
+ "ListModelsResponse",
92
+ # TTS Model
93
+ "ListTTSModelsRequest",
94
+ "ListTTSModelsResponse",
95
+ # Schedule
96
+ "CreateScheduleRequest",
97
+ "CreateScheduleResponse",
98
+ "ListSchedulesResponse",
99
+ "ScheduleSessionsResponse",
100
+ "UpdateScheduleRequest",
101
+ # Session
102
+ "CreateSessionRequest",
103
+ "CreateSessionResponse",
104
+ "UpdateSessionRequest",
105
+ "ListSessionsResponse",
106
+ "ListMessagesResponse",
107
+ "SessionView",
108
+ "TeamDetailResponse",
109
+ "TeamMemberView",
110
+ ]
src/agentscope/app/_router/_schema/_agent.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """Request / response schemas for the agent router."""
3
+ from pydantic import BaseModel, Field
4
+
5
+ from ....agent import ContextConfig, ReActConfig
6
+ from ...storage import AgentRecord
7
+
8
+
9
+ class CreateAgentRequest(BaseModel):
10
+ """Request body for creating a new agent."""
11
+
12
+ name: str = Field(description="Display name of the agent.")
13
+ system_prompt: str = Field(
14
+ default="You're a helpful assistant.",
15
+ description="Base system prompt fed to the agent.",
16
+ )
17
+ context_config: ContextConfig = Field(
18
+ default_factory=ContextConfig,
19
+ description="Context-window management configuration.",
20
+ )
21
+ react_config: ReActConfig = Field(
22
+ default_factory=ReActConfig,
23
+ description="ReAct loop configuration.",
24
+ )
25
+
26
+
27
+ class CreateAgentResponse(BaseModel):
28
+ """Response body after creating an agent."""
29
+
30
+ agent_id: str = Field(description="Server-assigned agent identifier.")
31
+
32
+
33
+ class UpdateAgentRequest(BaseModel):
34
+ """Request body for partially updating an agent.
35
+
36
+ Omit any field to keep its current value.
37
+ """
38
+
39
+ name: str | None = Field(default=None, description="New display name.")
40
+ system_prompt: str | None = Field(
41
+ default=None,
42
+ description="New system prompt.",
43
+ )
44
+ context_config: ContextConfig | None = Field(
45
+ default=None,
46
+ description="New context configuration.",
47
+ )
48
+ react_config: ReActConfig | None = Field(
49
+ default=None,
50
+ description="New ReAct loop configuration.",
51
+ )
52
+
53
+
54
+ class ListAgentsResponse(BaseModel):
55
+ """Response body for listing agents."""
56
+
57
+ agents: list[AgentRecord] = Field(description="Agent records.")
58
+ total: int = Field(description="Total number of agents.")
59
+
60
+
61
+ class AgentSchemaResponse(BaseModel):
62
+ """JSON Schema fragments used by the frontend to render the agent
63
+ create / edit forms.
64
+
65
+ Each fragment is a self-contained JSON Schema object so the frontend
66
+ doesn't need to follow ``$ref`` links across fragments. The frontend
67
+ pairs each property with an i18n key derived from its path, so labels
68
+ and descriptions remain localizable independently of the backend.
69
+ """
70
+
71
+ identity: dict = Field(
72
+ description=(
73
+ "Schema for the agent's identity fields (``name``, "
74
+ "``system_prompt``)."
75
+ ),
76
+ )
77
+ context_config: dict = Field(
78
+ description="Schema for ``ContextConfig``.",
79
+ )
80
+ react_config: dict = Field(
81
+ description="Schema for ``ReActConfig``.",
82
+ )
src/agentscope/app/_router/_schema/_chat.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """The chat endpoint schema."""
3
+
4
+ from pydantic import BaseModel, Field
5
+
6
+ from ....message import Msg
7
+ from ....event import UserConfirmResultEvent, ExternalExecutionResultEvent
8
+
9
+
10
+ class ChatRequest(BaseModel):
11
+ """Request body for the chat endpoint."""
12
+
13
+ agent_id: str = Field(
14
+ description="Agent ID for the chat endpoint.",
15
+ )
16
+
17
+ session_id: str = Field(
18
+ description="The session to send the message to.",
19
+ )
20
+
21
+ input: (
22
+ Msg
23
+ | list[Msg]
24
+ | UserConfirmResultEvent
25
+ | ExternalExecutionResultEvent
26
+ | None
27
+ ) = Field(
28
+ description="The input message(s), or agent event, or None.",
29
+ )
30
+
31
+
32
+ class ChatTriggerResponse(BaseModel):
33
+ """Response body for the fire-and-forget chat trigger.
34
+
35
+ Confirms that the chat run was scheduled. Events produced by the
36
+ run arrive separately via the session's SSE stream endpoint.
37
+ """
38
+
39
+ status: str = Field(
40
+ default="started",
41
+ description='Always ``"started"`` when the trigger succeeded.',
42
+ )
43
+ session_id: str = Field(
44
+ description="Echo of the session id the run was started for.",
45
+ )
src/agentscope/app/_router/_schema/_credential.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """Request / response schemas for the credential router."""
3
+ from pydantic import BaseModel, Field
4
+
5
+ from ...storage import CredentialRecord
6
+
7
+
8
+ class CreateCredentialRequest(BaseModel):
9
+ """Request body for creating a new credential."""
10
+
11
+ data: dict = Field(description="Credential payload (e.g. API keys).")
12
+
13
+
14
+ class CreateCredentialResponse(BaseModel):
15
+ """Response body after creating a credential."""
16
+
17
+ credential_id: str = Field(
18
+ description="Server-assigned credential identifier.",
19
+ )
20
+
21
+
22
+ class UpdateCredentialRequest(BaseModel):
23
+ """Request body for updating an existing credential."""
24
+
25
+ data: dict = Field(description="New credential payload.")
26
+
27
+
28
+ class ListCredentialsResponse(BaseModel):
29
+ """Response body for listing credentials."""
30
+
31
+ credentials: list[CredentialRecord] = Field(
32
+ description="Credential records.",
33
+ )
34
+ total: int = Field(description="Total number of credentials.")
35
+
36
+
37
+ class ListCredentialSchemasResponse(BaseModel):
38
+ """Response body for listing credential type schemas."""
39
+
40
+ schemas: list[dict] = Field(
41
+ description="JSON schemas for all registered credential types.",
42
+ )
src/agentscope/app/_router/_schema/_knowledge_base.py ADDED
@@ -0,0 +1,292 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """Request / response schemas for the knowledge base router."""
3
+ from datetime import datetime
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ from ...storage import (
8
+ CredentialRecord,
9
+ EmbeddingModelConfig,
10
+ KnowledgeDocumentRecord,
11
+ KnowledgeDocumentStatus,
12
+ )
13
+ from ....embedding import EmbeddingModelCard
14
+ from ....rag import VectorSearchResult
15
+ from ...rag.knowledge_base_manager._dimension_policy import DimensionPolicy
16
+
17
+
18
+ class CreateKnowledgeBaseRequest(BaseModel):
19
+ """Request body for creating a new knowledge base."""
20
+
21
+ name: str = Field(description="Display name of the knowledge base.")
22
+ description: str = Field(
23
+ default="",
24
+ description="Free-form description shown in the UI.",
25
+ )
26
+ embedding_model_config: EmbeddingModelConfig = Field(
27
+ description=(
28
+ "Embedding model used both at indexing and at query time. "
29
+ "Cannot be changed after creation — switching would "
30
+ "invalidate every previously inserted vector."
31
+ ),
32
+ )
33
+
34
+
35
+ class CreateKnowledgeBaseResponse(BaseModel):
36
+ """Response body after creating a knowledge base."""
37
+
38
+ knowledge_base_id: str = Field(
39
+ description="Server-assigned knowledge base identifier.",
40
+ )
41
+
42
+
43
+ class UpdateKnowledgeBaseRequest(BaseModel):
44
+ """Request body for updating a knowledge base.
45
+
46
+ Only mutable fields can be set here. The embedding model
47
+ configuration is pinned at creation time and cannot be changed —
48
+ switching it would invalidate every previously inserted vector.
49
+ """
50
+
51
+ name: str | None = Field(
52
+ default=None,
53
+ description="New display name; omit to leave unchanged.",
54
+ )
55
+ description: str | None = Field(
56
+ default=None,
57
+ description="New free-form description; omit to leave unchanged.",
58
+ )
59
+
60
+
61
+ class KnowledgeBaseView(BaseModel):
62
+ """A knowledge base record as exposed to API clients.
63
+
64
+ Mirrors :class:`KnowledgeBaseRecord` with the internal
65
+ ``user_id`` / ``collection_name`` fields stripped — clients have
66
+ no business introspecting either.
67
+ """
68
+
69
+ id: str = Field(description="The knowledge base identifier.")
70
+ name: str = Field(description="Display name of the knowledge base.")
71
+ description: str = Field(description="Free-form description.")
72
+ embedding_model_config: EmbeddingModelConfig = Field(
73
+ description="Embedding model configuration pinned at creation.",
74
+ )
75
+ created_at: datetime = Field(description="Creation timestamp.")
76
+ updated_at: datetime = Field(description="Last-update timestamp.")
77
+
78
+
79
+ class ListKnowledgeBasesResponse(BaseModel):
80
+ """Response body for listing the caller's knowledge bases."""
81
+
82
+ knowledge_bases: list[KnowledgeBaseView] = Field(
83
+ description="All knowledge bases owned by the caller.",
84
+ )
85
+ total: int = Field(description="Total number of returned items.")
86
+
87
+
88
+ class KnowledgeDocumentView(BaseModel):
89
+ """A document record as exposed to API clients.
90
+
91
+ Surfaces both the static fields the UI needs to render a row
92
+ (``filename`` / ``size``) and the live lifecycle fields the front
93
+ end polls (``status`` / ``error`` / ``chunk_count``). Internal
94
+ fields (``user_id`` / ``blob_uri`` / ``processing_node`` / lease)
95
+ are deliberately omitted — clients have no business introspecting
96
+ them.
97
+ """
98
+
99
+ id: str = Field(description="The document identifier.")
100
+ filename: str = Field(description="Original filename at upload time.")
101
+ size: int = Field(description="Document size in bytes.")
102
+ content_type: str | None = Field(
103
+ default=None,
104
+ description="IANA media type recorded at upload time, if any.",
105
+ )
106
+ status: KnowledgeDocumentStatus = Field(
107
+ description="Current lifecycle state of the document.",
108
+ )
109
+ error: str | None = Field(
110
+ default=None,
111
+ description=(
112
+ "Human-readable failure reason when ``status == 'error'``."
113
+ ),
114
+ )
115
+ chunk_count: int = Field(
116
+ default=0,
117
+ description="Number of chunks indexed so far.",
118
+ )
119
+ created_at: datetime = Field(description="Upload timestamp.")
120
+ updated_at: datetime = Field(
121
+ description="Last status transition timestamp.",
122
+ )
123
+
124
+ @classmethod
125
+ def from_record(
126
+ cls,
127
+ record: KnowledgeDocumentRecord,
128
+ ) -> "KnowledgeDocumentView":
129
+ """Project a storage record onto the API view.
130
+
131
+ Centralised so router code stays a one-liner and the field
132
+ mapping has exactly one source of truth.
133
+ """
134
+ return cls(
135
+ id=record.id,
136
+ filename=record.data.filename,
137
+ size=record.data.size,
138
+ content_type=record.data.content_type,
139
+ status=record.data.status,
140
+ error=record.data.error,
141
+ chunk_count=record.data.chunk_count,
142
+ created_at=record.created_at,
143
+ updated_at=record.updated_at,
144
+ )
145
+
146
+
147
+ class ListKnowledgeDocumentsResponse(BaseModel):
148
+ """Response body for listing documents inside a knowledge base."""
149
+
150
+ documents: list[KnowledgeDocumentView] = Field(
151
+ description="One view per registered document.",
152
+ )
153
+ total: int = Field(description="Total number of returned items.")
154
+
155
+
156
+ class ListKnowledgeDocumentStatusResponse(BaseModel):
157
+ """Response body for batch document-status polling."""
158
+
159
+ items: list[KnowledgeDocumentView] = Field(
160
+ description=(
161
+ "Subset of the requested documents that still exist. "
162
+ "Missing ids are silently omitted — clients may legitimately "
163
+ "ask about a document that was deleted between two polls."
164
+ ),
165
+ )
166
+
167
+
168
+ class UploadKnowledgeDocumentResponse(BaseModel):
169
+ """Response body after uploading a document into a knowledge base."""
170
+
171
+ document_id: str = Field(
172
+ description="Server-assigned document identifier.",
173
+ )
174
+ filename: str = Field(
175
+ description="The original filename of the uploaded document.",
176
+ )
177
+ status: KnowledgeDocumentStatus = Field(
178
+ description=(
179
+ "Lifecycle state immediately after upload — always "
180
+ "``'pending'`` in the happy path; surfaced so the client "
181
+ "can seed its progress tracker without an extra round-trip."
182
+ ),
183
+ )
184
+
185
+
186
+ class SearchKnowledgeBaseRequest(BaseModel):
187
+ """Request body for searching a knowledge base."""
188
+
189
+ query: str = Field(description="The natural-language search query.")
190
+ top_k: int = Field(
191
+ default=5,
192
+ ge=1,
193
+ le=50,
194
+ description="Maximum number of results to return.",
195
+ )
196
+
197
+
198
+ class SearchKnowledgeBaseResponse(BaseModel):
199
+ """Response body for a knowledge base search."""
200
+
201
+ results: list[VectorSearchResult] = Field(
202
+ description="Matched chunks ordered by descending similarity score.",
203
+ )
204
+ total: int = Field(description="Total number of returned results.")
205
+
206
+
207
+ class KbEmbeddingProvider(BaseModel):
208
+ """One credential and the embedding models it can serve.
209
+
210
+ The model cards have been projected through the manager's
211
+ dimension policy: incompatible models are removed and matryoshka
212
+ cards are narrowed to the locked dimension when applicable.
213
+ """
214
+
215
+ credential: CredentialRecord = Field(
216
+ description="The credential record exposing these models.",
217
+ )
218
+ models: list[EmbeddingModelCard] = Field(
219
+ description=(
220
+ "Embedding model cards available under this credential, "
221
+ "filtered to those compatible with the manager's "
222
+ "dimension policy."
223
+ ),
224
+ )
225
+
226
+
227
+ class ListKbEmbeddingModelsResponse(BaseModel):
228
+ """Response body listing KB-compatible embedding models.
229
+
230
+ The list is pre-filtered server-side against the manager's
231
+ dimension policy. The policy itself is also returned so the
232
+ front-end can render an explanatory banner and lock the dimension
233
+ selector when applicable.
234
+ """
235
+
236
+ providers: list[KbEmbeddingProvider] = Field(
237
+ description=(
238
+ "One entry per credential that has at least one "
239
+ "compatible embedding model."
240
+ ),
241
+ )
242
+ policy: DimensionPolicy = Field(
243
+ description=(
244
+ "The dimension policy used to filter the cards; surfaced "
245
+ "verbatim so the UI can explain *why* models were filtered."
246
+ ),
247
+ )
248
+
249
+
250
+ class KbMiddlewareParametersSchemaResponse(BaseModel):
251
+ """Response body exposing the KB middleware's parameters schema.
252
+
253
+ The schema is derived from
254
+ :class:`agentscope.middleware.RAGMiddleware.Parameters`
255
+ via ``model_json_schema()`` so the front-end can render the
256
+ session-level KB attachment form with the same schema-driven
257
+ component used for model parameters.
258
+ """
259
+
260
+ parameter_schema: dict = Field(
261
+ description=(
262
+ "JSON Schema produced by `RAGMiddleware.Parameters"
263
+ "model_json_schema()`. Shaped identically to the "
264
+ "`parameter_schema` field on `ModelCard`."
265
+ ),
266
+ )
267
+
268
+
269
+ class ListSupportedContentTypesResponse(BaseModel):
270
+ """Response body advertising the parser-supported upload types.
271
+
272
+ Aggregated across every parser registered on the app — the union of
273
+ each parser's :attr:`supported_media_types` and
274
+ :meth:`supported_extensions`. The front-end uses this to populate
275
+ ``<input accept>`` and to reject unsupported drops on the client
276
+ before the file leaves the browser.
277
+ """
278
+
279
+ media_types: list[str] = Field(
280
+ description=(
281
+ "Union of IANA media types every registered parser claims "
282
+ "to handle. Deduplicated and sorted."
283
+ ),
284
+ )
285
+ extensions: list[str] = Field(
286
+ description=(
287
+ "Filename extensions (each starting with `.`) every "
288
+ "registered parser claims to handle. Deduplicated and "
289
+ "sorted. Derived from `mimetypes` by the base parser; "
290
+ "subclasses may override the default."
291
+ ),
292
+ )
src/agentscope/app/_router/_schema/_mcp.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """MCP schemas for API requests and responses."""
3
+ from enum import Enum
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ from ....mcp import StdioMCPConfig, HttpMCPConfig
8
+
9
+
10
+ class ConnectionScope(str, Enum):
11
+ """MCP connection scope and lifecycle strategy.
12
+
13
+ This determines how MCP connections are managed in the service layer.
14
+ """
15
+
16
+ SHARED = "shared"
17
+ """Shared connection across all agents/users.
18
+ - One connection per MCP config, shared globally
19
+ - Created on first use, destroyed on application shutdown
20
+ - Use case: Stateless HTTP MCP (e.g., weather API, web search)
21
+ """
22
+
23
+ ISOLATED = "isolated"
24
+ """Isolated connection per agent.
25
+ - One connection per (MCP config, agent)
26
+ - Created on first use per agent, destroyed on agent session end
27
+ - Use case: Stateful MCP (e.g., browser-use), STDIO MCP
28
+ """
29
+
30
+ EPHEMERAL = "ephemeral"
31
+ """Ephemeral connection per request.
32
+ - New connection created for each request, destroyed immediately after
33
+ - No connection pooling
34
+ - Use case: Low-frequency stateless HTTP MCP
35
+ """
36
+
37
+
38
+ class MCPBase(BaseModel):
39
+ """Base MCP fields shared across request/response schemas."""
40
+
41
+ name: str = Field(
42
+ title="MCP Name",
43
+ description="The unique name to identify this MCP configuration.",
44
+ )
45
+
46
+ connection_scope: ConnectionScope = Field(
47
+ title="Connection Scope",
48
+ description="The connection scope and lifecycle strategy.",
49
+ )
50
+
51
+ mcp_config: StdioMCPConfig | HttpMCPConfig = Field(
52
+ discriminator="type",
53
+ title="MCP Config",
54
+ description="The base MCP server configuration.",
55
+ )
56
+
57
+ def validate_config(self) -> None:
58
+ """Validate the configuration.
59
+
60
+ Raises:
61
+ ValueError: If the configuration is invalid.
62
+ """
63
+ # STDIO MCP cannot use ephemeral mode
64
+ if (
65
+ self.mcp_config.type == "stdio_mcp"
66
+ and self.connection_scope == ConnectionScope.EPHEMERAL
67
+ ):
68
+ raise ValueError(
69
+ "STDIO MCP does not support ephemeral mode. "
70
+ "Use 'shared' or 'isolated' instead.",
71
+ )
72
+
73
+
74
+ class MCPCreateRequest(MCPBase):
75
+ """Request body for creating a new MCP configuration.
76
+
77
+ Used in POST /mcp endpoint. Does not include server-generated fields
78
+ like creator_id, created_at, updated_at.
79
+ """
80
+
81
+
82
+ class MCPUpdateRequest(BaseModel):
83
+ """Request body for partially updating an MCP configuration.
84
+
85
+ Used in PATCH /mcp/{name} endpoint. All fields are optional.
86
+ """
87
+
88
+ connection_scope: ConnectionScope | None = Field(
89
+ default=None,
90
+ description="New connection scope.",
91
+ )
92
+ mcp_config: StdioMCPConfig | HttpMCPConfig | None = Field(
93
+ default=None,
94
+ discriminator="type",
95
+ description="New MCP server configuration.",
96
+ )
97
+
98
+
99
+ class MCPResponse(MCPBase):
100
+ """Response model for MCP configuration with server-generated metadata.
101
+
102
+ Used in GET /mcp/{name}, GET /mcp (list), and POST /mcp responses.
103
+ Includes all fields from MCPBase plus server-assigned metadata.
104
+ """
105
+
106
+ creator_id: str = Field(
107
+ description="User ID of the creator.",
108
+ )
109
+
110
+ created_at: float = Field(
111
+ description="Creation timestamp (Unix epoch).",
112
+ )
113
+
114
+ updated_at: float = Field(
115
+ description="Last-updated timestamp (Unix epoch).",
116
+ )
117
+
118
+
119
+ class ListMCPsResponse(BaseModel):
120
+ """Response model for listing MCP configurations."""
121
+
122
+ mcps: list[MCPResponse] = Field(
123
+ description="List of MCP configurations.",
124
+ )
125
+ total: int = Field(
126
+ description="Total number of MCP configurations.",
127
+ )
src/agentscope/app/_router/_schema/_model.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """The chat model configuration, used as DTO layer."""
3
+
4
+ from pydantic import BaseModel, Field
5
+
6
+ from ....model import ModelCard
7
+
8
+
9
+ class ListModelsResponse(BaseModel):
10
+ """List the candidate models response."""
11
+
12
+ models: list[ModelCard] = Field(description="The candidate models.")
13
+ total: int = Field(description="The total number of candidates.")
14
+
15
+
16
+ class ListModelsRequest(BaseModel):
17
+ """List the candidate models request."""
18
+
19
+ provider: str = Field(
20
+ description="The provider type, e.g. openai, dashscope, etc.",
21
+ )
src/agentscope/app/_router/_schema/_schedule.py ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """Request / response schemas for the schedule router."""
3
+ from pydantic import BaseModel, Field
4
+
5
+ from ...storage import (
6
+ ScheduleRecord,
7
+ SessionRecord,
8
+ ChatModelConfig,
9
+ )
10
+ from ....permission import PermissionMode
11
+
12
+
13
+ class CreateScheduleRequest(BaseModel):
14
+ """Request body for creating a new schedule."""
15
+
16
+ name: str = Field(description="Display name of the schedule.")
17
+
18
+ description: str = Field(default="", description="Optional description.")
19
+
20
+ cron_expression: str = Field(
21
+ description="Standard 5-field cron expression, e.g. '0 9 * * 1-5'.",
22
+ )
23
+
24
+ timezone: str = Field(
25
+ default="UTC",
26
+ description="IANA timezone name, e.g. 'America/New_York' or "
27
+ "'Asia/Shanghai'.",
28
+ )
29
+
30
+ agent_id: str = Field(description="Agent to run when the schedule fires.")
31
+
32
+ chat_model_config: ChatModelConfig = Field(
33
+ description="Model configuration for the auto-created session.",
34
+ )
35
+
36
+ enabled: bool = Field(
37
+ default=True,
38
+ description="Whether the schedule is active immediately "
39
+ "after creation.",
40
+ )
41
+
42
+ stateful: bool = Field(
43
+ default=False,
44
+ description="If True, consecutive executions share the same session "
45
+ "context.",
46
+ )
47
+
48
+ permission_mode: PermissionMode = Field(
49
+ default=PermissionMode.DONT_ASK,
50
+ description="Permission level for the agent during "
51
+ "scheduled execution.",
52
+ )
53
+
54
+
55
+ class CreateScheduleResponse(BaseModel):
56
+ """Response body after creating a schedule."""
57
+
58
+ schedule_id: str = Field(
59
+ description="Server-assigned schedule identifier.",
60
+ )
61
+
62
+
63
+ class UpdateScheduleRequest(BaseModel):
64
+ """Request body for partially updating a schedule.
65
+
66
+ Omit any field to keep its current value. Changing ``cron_expression``
67
+ or ``timezone`` will reschedule the APScheduler job immediately.
68
+ Changing ``enable`` to ``False`` removes the job from the scheduler
69
+ without deleting the record; setting it back to ``True`` re-registers it.
70
+ """
71
+
72
+ name: str | None = Field(default=None, description="New display name.")
73
+
74
+ description: str | None = Field(
75
+ default=None,
76
+ description="New description.",
77
+ )
78
+
79
+ cron_expression: str | None = Field(
80
+ default=None,
81
+ description="New cron expression. Reschedules the task immediately.",
82
+ )
83
+
84
+ timezone: str | None = Field(
85
+ default=None,
86
+ description="New IANA timezone name.",
87
+ )
88
+
89
+ enabled: bool | None = Field(
90
+ default=None,
91
+ description="Set to False to pause the schedule without deleting it.",
92
+ )
93
+
94
+ stateful: bool | None = Field(
95
+ default=None,
96
+ description="Change whether executions share session context.",
97
+ )
98
+
99
+ permission_mode: PermissionMode | None = Field(
100
+ default=None,
101
+ description="New permission mode.",
102
+ )
103
+
104
+
105
+ class ListSchedulesResponse(BaseModel):
106
+ """Response body for listing schedules."""
107
+
108
+ schedules: list[ScheduleRecord] = Field(description="Schedule records.")
109
+ total: int = Field(description="Total number of schedules.")
110
+
111
+
112
+ class ScheduleSessionsResponse(BaseModel):
113
+ """Response body for listing execution sessions of a schedule."""
114
+
115
+ sessions: list[SessionRecord] = Field(
116
+ description="Sessions triggered by this schedule.",
117
+ )
118
+ total: int = Field(description="Total number of execution sessions.")
src/agentscope/app/_router/_schema/_session.py ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """Request / response schemas for the session router."""
3
+ from pydantic import BaseModel, Field
4
+
5
+ from ....permission import PermissionMode
6
+ from ...storage import (
7
+ AgentRecord,
8
+ ChatModelConfig,
9
+ SessionKnowledgeConfig,
10
+ TTSModelConfig,
11
+ SessionRecord,
12
+ TeamRecord,
13
+ )
14
+
15
+
16
+ class TeamMemberView(BaseModel):
17
+ """One row in :attr:`TeamDetailResponse.members`.
18
+
19
+ Pairs each member's :class:`AgentRecord` with its single
20
+ ``session_id`` so the UI can subscribe to the worker's chat
21
+ stream without a separate lookup.
22
+ """
23
+
24
+ agent: AgentRecord = Field(
25
+ description="The worker agent record.",
26
+ )
27
+ session_id: str | None = Field(
28
+ default=None,
29
+ description=(
30
+ "The worker's session id. ``None`` if the agent is in an "
31
+ "inconsistent state (worker without a session)."
32
+ ),
33
+ )
34
+
35
+
36
+ class TeamDetailResponse(BaseModel):
37
+ """Resolved team detail embedded inside :class:`SessionView.team`."""
38
+
39
+ team: TeamRecord = Field(description="The team record.")
40
+ leader_agent: AgentRecord | None = Field(
41
+ default=None,
42
+ description=(
43
+ "Leader's agent record (resolved from the team's "
44
+ "``session_id`` → session.agent_id)."
45
+ ),
46
+ )
47
+ members: list[TeamMemberView] = Field(
48
+ default_factory=list,
49
+ description=(
50
+ "Worker agents listed in :attr:`TeamData.member_ids`, each "
51
+ "paired with its single session id when available."
52
+ ),
53
+ )
54
+
55
+
56
+ class CreateSessionRequest(BaseModel):
57
+ """Request body for creating a new session."""
58
+
59
+ agent_id: str = Field(description="Agent this session belongs to.")
60
+ workspace_id: str | None = Field(
61
+ default=None,
62
+ description="Workspace this session belongs to.",
63
+ )
64
+ name: str | None = Field(
65
+ default=None,
66
+ description="Display name. Defaults to current datetime if omitted.",
67
+ )
68
+ chat_model_config: ChatModelConfig | None = Field(
69
+ default=None,
70
+ description="Model provider and parameters. "
71
+ "Can be set later via PATCH.",
72
+ )
73
+ fallback_chat_model_config: ChatModelConfig | None = Field(
74
+ default=None,
75
+ description="Fallback model used when the primary model fails. "
76
+ "Can be set later via PATCH.",
77
+ )
78
+ tts_model_config: TTSModelConfig | None = Field(
79
+ default=None,
80
+ description="TTS model configuration. Can be set later via PATCH.",
81
+ )
82
+ knowledge_config: SessionKnowledgeConfig | None = Field(
83
+ default=None,
84
+ description=(
85
+ "Knowledge bases attached to this session plus the "
86
+ "`RAGMiddleware` parameters. Can be set later "
87
+ "via PATCH."
88
+ ),
89
+ )
90
+
91
+
92
+ class CreateSessionResponse(BaseModel):
93
+ """Response body after creating a session."""
94
+
95
+ session_id: str = Field(description="Server-assigned session identifier.")
96
+
97
+
98
+ class UpdateSessionRequest(BaseModel):
99
+ """Request body for updating an existing session.
100
+
101
+ Omit any field to keep its current value.
102
+ """
103
+
104
+ name: str | None = Field(
105
+ default=None,
106
+ description="New display name.",
107
+ )
108
+ chat_model_config: ChatModelConfig | None = Field(
109
+ default=None,
110
+ description="New model configuration. "
111
+ "Replaces the existing one entirely. "
112
+ "Pass null to clear; omit to leave unchanged.",
113
+ )
114
+ fallback_chat_model_config: ChatModelConfig | None = Field(
115
+ default=None,
116
+ description="New fallback model configuration. "
117
+ "Pass null to clear; omit to leave unchanged.",
118
+ )
119
+ tts_model_config: TTSModelConfig | None = Field(
120
+ default=None,
121
+ description="New TTS model configuration. "
122
+ "Pass null to clear; omit to leave unchanged.",
123
+ )
124
+ knowledge_config: SessionKnowledgeConfig | None = Field(
125
+ default=None,
126
+ description=(
127
+ "New knowledge base attachment + middleware parameters. "
128
+ "Pass null to clear; omit to leave unchanged."
129
+ ),
130
+ )
131
+ permission_mode: PermissionMode | None = Field(
132
+ default=None,
133
+ description="New permission mode for the session.",
134
+ )
135
+
136
+
137
+ class SessionView(BaseModel):
138
+ """Per-session bundle with everything the frontend needs to
139
+ render either the list view or open a session.
140
+
141
+ Bundles three orthogonal pieces of information so opening a
142
+ session does not require a waterfall of follow-up requests:
143
+
144
+ - the persisted :class:`SessionRecord` itself (config + state),
145
+ - whether the session has an active chat run right now,
146
+ - the team detail (resolved leader + members) when the session
147
+ participates in a team.
148
+
149
+ Messages are intentionally **not** included here — they are
150
+ paginated separately via ``GET /sessions/{id}/messages``.
151
+ """
152
+
153
+ session: SessionRecord = Field(
154
+ description=(
155
+ "The persisted session record. Includes ``state`` "
156
+ "(``permission_context`` / ``tool_context`` / "
157
+ "``tasks_context``) inline."
158
+ ),
159
+ )
160
+ is_running: bool = Field(
161
+ description="Whether a chat run is currently active on this session.",
162
+ )
163
+ team: TeamDetailResponse | None = Field(
164
+ default=None,
165
+ description=(
166
+ "Resolved team detail when ``session.team_id`` is set "
167
+ "(leader agent + member agents with their session ids). "
168
+ "``None`` when the session does not participate in any team."
169
+ ),
170
+ )
171
+
172
+
173
+ class ListSessionsResponse(BaseModel):
174
+ """Response body for listing sessions."""
175
+
176
+ sessions: list[SessionView] = Field(
177
+ description="Session views (record + is_running + team).",
178
+ )
179
+ total: int = Field(description="Total number of sessions.")
180
+
181
+
182
+ class ListMessagesResponse(BaseModel):
183
+ """Response body for listing messages in a session."""
184
+
185
+ messages: list = Field(description="Messages in chronological order.")
186
+ is_running: bool = Field(
187
+ description="Whether the session is currently running.",
188
+ )
src/agentscope/app/_router/_schema/_tts_model.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """The TTS model configuration, used as DTO layer."""
3
+
4
+ from pydantic import BaseModel, Field
5
+
6
+ from ....tts import TTSModelCard
7
+
8
+
9
+ class ListTTSModelsResponse(BaseModel):
10
+ """List the candidate TTS models response."""
11
+
12
+ models: list[TTSModelCard] = Field(
13
+ description="The candidate TTS models.",
14
+ )
15
+ total: int = Field(description="The total number of candidates.")
16
+
17
+
18
+ class ListTTSModelsRequest(BaseModel):
19
+ """List the candidate TTS models request."""
20
+
21
+ provider: str = Field(
22
+ description="The provider type, e.g. dashscope_credential.",
23
+ )
src/agentscope/app/_router/_session.py ADDED
@@ -0,0 +1,680 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """Session router — create, list, update, delete, stream, and get messages."""
3
+ import asyncio
4
+ import json
5
+ from typing import AsyncGenerator
6
+
7
+ from fastapi import APIRouter, Depends, HTTPException, Query, status
8
+ from fastapi.responses import StreamingResponse
9
+
10
+ from ..._utils._common import _generate_id
11
+ from ..deps import (
12
+ get_current_user_id,
13
+ get_message_bus,
14
+ get_session_service,
15
+ get_storage,
16
+ )
17
+ from ._schema import (
18
+ CreateSessionRequest,
19
+ CreateSessionResponse,
20
+ ListMessagesResponse,
21
+ ListSessionsResponse,
22
+ SessionView,
23
+ TeamDetailResponse,
24
+ TeamMemberView,
25
+ UpdateSessionRequest,
26
+ )
27
+ from ..message_bus import MessageBus, MessageBusKeys
28
+ from .._service import SessionService, SessionProjection, SubagentHitlProjector
29
+ from ..storage import (
30
+ AgentRecord,
31
+ ChatModelConfig,
32
+ SessionKnowledgeConfig,
33
+ TTSModelConfig,
34
+ SessionConfig,
35
+ SessionRecord,
36
+ StorageBase,
37
+ TeamRecord,
38
+ )
39
+ from ...message import ToolCallState
40
+ from ...event import CustomEvent
41
+
42
+
43
+ async def _build_team_detail(
44
+ storage: StorageBase,
45
+ user_id: str,
46
+ team: TeamRecord,
47
+ ) -> TeamDetailResponse:
48
+ """Resolve a team's leader agent + member agents into a
49
+ :class:`TeamDetailResponse` for the session list endpoint.
50
+
51
+ Args:
52
+ storage (`StorageBase`):
53
+ Application storage. Used to look up the leader session,
54
+ each member agent, and each member's session.
55
+ user_id (`str`):
56
+ The owner user id.
57
+ team (`TeamRecord`):
58
+ The team to resolve. Caller has already loaded it.
59
+
60
+ Returns:
61
+ `TeamDetailResponse`:
62
+ The team plus its resolved leader and member agents (each
63
+ member paired with its session id when available).
64
+ """
65
+ leader_agent: AgentRecord | None = None
66
+ leader_session = await storage.get_session(user_id, "", team.session_id)
67
+ if leader_session is not None:
68
+ leader_agent = await storage.get_agent(
69
+ user_id,
70
+ leader_session.agent_id,
71
+ )
72
+
73
+ members: list[TeamMemberView] = []
74
+ for member_id in team.data.member_ids:
75
+ agent = await storage.get_agent(user_id, member_id)
76
+ if agent is None:
77
+ continue
78
+ sessions = await storage.list_sessions(user_id, member_id)
79
+ session_id = sessions[0].id if sessions else None
80
+ members.append(TeamMemberView(agent=agent, session_id=session_id))
81
+
82
+ return TeamDetailResponse(
83
+ team=team,
84
+ leader_agent=leader_agent,
85
+ members=members,
86
+ )
87
+
88
+
89
+ session_router = APIRouter(
90
+ prefix="/sessions",
91
+ tags=["sessions"],
92
+ responses={404: {"description": "Not found"}},
93
+ )
94
+
95
+
96
+ async def _ensure_credential_exists(
97
+ storage: StorageBase,
98
+ user_id: str,
99
+ config: ChatModelConfig | TTSModelConfig | None,
100
+ ) -> None:
101
+ """Validate that the credential referenced by ``config`` belongs to the
102
+ given user. No-op when ``config`` is ``None``.
103
+
104
+ Args:
105
+ storage (`StorageBase`): Injected storage backend.
106
+ user_id (`str`): The authenticated user ID.
107
+ config (`ChatModelConfig | TTSModelConfig | None`): Model config to
108
+ validate. Pass ``None`` to skip the check.
109
+
110
+ Raises:
111
+ `HTTPException`: 404 if the credential does not exist or does not
112
+ belong to the user.
113
+ """
114
+ if config is None:
115
+ return
116
+ credentials = await storage.list_credentials(user_id)
117
+ if not any(c.id == config.credential_id for c in credentials):
118
+ raise HTTPException(
119
+ status_code=status.HTTP_404_NOT_FOUND,
120
+ detail=f"Credential '{config.credential_id}' not found.",
121
+ )
122
+
123
+
124
+ async def _ensure_knowledge_bases_exist(
125
+ storage: StorageBase,
126
+ user_id: str,
127
+ config: SessionKnowledgeConfig | None,
128
+ ) -> None:
129
+ """Validate every KB id in ``config`` belongs to the given user.
130
+
131
+ No-op when ``config`` is ``None`` or its ``knowledge_base_ids``
132
+ list is empty.
133
+
134
+ Args:
135
+ storage (`StorageBase`): Injected storage backend.
136
+ user_id (`str`): The authenticated user ID.
137
+ config (`SessionKnowledgeConfig | None`):
138
+ Knowledge config to validate. Pass ``None`` to skip.
139
+
140
+ Raises:
141
+ `HTTPException`: 404 if any KB id does not exist or is not
142
+ owned by the user.
143
+ """
144
+ if config is None or not config.knowledge_base_ids:
145
+ return
146
+ for kb_id in config.knowledge_base_ids:
147
+ kb = await storage.get_knowledge_base(user_id, kb_id)
148
+ if kb is None:
149
+ raise HTTPException(
150
+ status_code=status.HTTP_404_NOT_FOUND,
151
+ detail=f"Knowledge base '{kb_id}' not found.",
152
+ )
153
+
154
+
155
+ @session_router.get(
156
+ "/",
157
+ response_model=ListSessionsResponse,
158
+ summary="List sessions for an agent",
159
+ )
160
+ async def list_sessions(
161
+ agent_id: str = Query(description="Filter sessions by agent ID."),
162
+ user_id: str = Depends(get_current_user_id),
163
+ storage: StorageBase = Depends(get_storage),
164
+ message_bus: MessageBus = Depends(get_message_bus),
165
+ ) -> ListSessionsResponse:
166
+ """Return all sessions for an agent as enriched
167
+ :class:`SessionView` entries.
168
+
169
+ Each entry bundles three things the chat UI needs to render
170
+ without follow-up requests: the session record (incl.
171
+ ``state``), whether a chat run is currently active, and — when
172
+ the session participates in a team — the resolved team detail
173
+ (leader agent + member agents with their session ids).
174
+
175
+ Args:
176
+ agent_id (`str`):
177
+ Agent whose sessions to list.
178
+ user_id (`str`):
179
+ Injected authenticated user ID.
180
+ storage (`StorageBase`):
181
+ Injected storage backend.
182
+ message_bus (`MessageBus`):
183
+ Injected message bus (used for ``session_is_running``).
184
+
185
+ Returns:
186
+ `ListSessionsResponse`:
187
+ Enriched session views and their count.
188
+
189
+ Raises:
190
+ `HTTPException`: 404 if the agent does not exist or does not
191
+ belong to the authenticated user.
192
+ """
193
+ # Direct ownership check via get_agent — handles both source=user
194
+ # and source=team agents (the latter aren't returned by
195
+ # storage.list_agents but are still owned by the user; reachable
196
+ # via team navigation).
197
+ agent = await storage.get_agent(user_id, agent_id)
198
+ if agent is None or agent.user_id != user_id:
199
+ raise HTTPException(
200
+ status_code=status.HTTP_404_NOT_FOUND,
201
+ detail=f"Agent '{agent_id}' not found.",
202
+ )
203
+
204
+ sessions = await storage.list_sessions(user_id, agent_id)
205
+ views: list[SessionView] = []
206
+ for session in sessions:
207
+ team_detail = None
208
+ if session.team_id:
209
+ team_record = await storage.get_team(user_id, session.team_id)
210
+ if team_record is not None:
211
+ team_detail = await _build_team_detail(
212
+ storage,
213
+ user_id,
214
+ team_record,
215
+ )
216
+ views.append(
217
+ SessionView(
218
+ session=session,
219
+ is_running=await message_bus.is_locked(
220
+ MessageBusKeys.session_lock(session.id),
221
+ ),
222
+ team=team_detail,
223
+ ),
224
+ )
225
+ return ListSessionsResponse(sessions=views, total=len(views))
226
+
227
+
228
+ @session_router.post(
229
+ "/",
230
+ response_model=CreateSessionResponse,
231
+ status_code=status.HTTP_201_CREATED,
232
+ summary="Create a new session",
233
+ )
234
+ async def create_session(
235
+ body: CreateSessionRequest,
236
+ user_id: str = Depends(get_current_user_id),
237
+ storage: StorageBase = Depends(get_storage),
238
+ ) -> CreateSessionResponse:
239
+ """Create (or resume) a session for a given agent and workspace.
240
+
241
+ At most one session exists per ``(user_id, agent_id, workspace_id)``
242
+ triple — a second call with the same triple updates the existing session
243
+ rather than creating a duplicate.
244
+
245
+ Args:
246
+ body (`CreateSessionRequest`): Agent, workspace, and model config.
247
+ user_id (`str`): Injected authenticated user ID.
248
+ storage (`StorageBase`): Injected storage backend.
249
+
250
+ Returns:
251
+ `CreateSessionResponse`: The session identifier.
252
+
253
+ Raises:
254
+ `HTTPException`: 404 if the agent or credential does not exist or
255
+ does not belong to the authenticated user.
256
+ """
257
+ agent = await storage.get_agent(user_id, body.agent_id)
258
+ if agent is None or agent.user_id != user_id:
259
+ raise HTTPException(
260
+ status_code=status.HTTP_404_NOT_FOUND,
261
+ detail=f"Agent '{body.agent_id}' not found.",
262
+ )
263
+
264
+ await _ensure_credential_exists(storage, user_id, body.chat_model_config)
265
+ await _ensure_credential_exists(
266
+ storage,
267
+ user_id,
268
+ body.fallback_chat_model_config,
269
+ )
270
+ await _ensure_credential_exists(storage, user_id, body.tts_model_config)
271
+ await _ensure_knowledge_bases_exist(
272
+ storage,
273
+ user_id,
274
+ body.knowledge_config,
275
+ )
276
+
277
+ session_record = await storage.upsert_session(
278
+ user_id=user_id,
279
+ agent_id=body.agent_id,
280
+ config=SessionConfig(
281
+ workspace_id=body.workspace_id or _generate_id(),
282
+ chat_model_config=body.chat_model_config,
283
+ fallback_chat_model_config=body.fallback_chat_model_config,
284
+ tts_model_config=body.tts_model_config,
285
+ knowledge_config=body.knowledge_config,
286
+ **({"name": body.name} if body.name is not None else {}),
287
+ ),
288
+ )
289
+ return CreateSessionResponse(session_id=session_record.id)
290
+
291
+
292
+ @session_router.delete(
293
+ "/{session_id}",
294
+ status_code=status.HTTP_204_NO_CONTENT,
295
+ summary="Delete a session",
296
+ )
297
+ async def delete_session(
298
+ session_id: str,
299
+ agent_id: str = Query(description="Agent the session belongs to."),
300
+ user_id: str = Depends(get_current_user_id),
301
+ session_service: SessionService = Depends(get_session_service),
302
+ ) -> None:
303
+ """Permanently delete a session and all its associated state.
304
+
305
+ Cancels any in-flight chat run for this session (and for every
306
+ worker session if this one is a team leader) before dropping
307
+ storage records and bus state. The cancel path is cross-process:
308
+ whichever worker is actually running the session will receive the
309
+ cancel broadcast and abort.
310
+
311
+ Args:
312
+ session_id (`str`): The session to delete.
313
+ agent_id (`str`): The agent the session belongs to.
314
+ user_id (`str`): Injected authenticated user ID.
315
+ session_service (`SessionService`): Injected session service.
316
+
317
+ Raises:
318
+ `HTTPException`: 404 if the session does not exist or does not belong
319
+ to the authenticated user.
320
+ """
321
+ deleted = await session_service.delete_session(
322
+ user_id,
323
+ agent_id,
324
+ session_id,
325
+ )
326
+ if not deleted:
327
+ raise HTTPException(
328
+ status_code=status.HTTP_404_NOT_FOUND,
329
+ detail=f"Session '{session_id}' not found.",
330
+ )
331
+
332
+
333
+ @session_router.patch(
334
+ "/{session_id}",
335
+ response_model=SessionRecord,
336
+ summary="Update a session",
337
+ )
338
+ async def update_session(
339
+ session_id: str,
340
+ body: UpdateSessionRequest,
341
+ agent_id: str = Query(description="Agent the session belongs to."),
342
+ user_id: str = Depends(get_current_user_id),
343
+ storage: StorageBase = Depends(get_storage),
344
+ ) -> SessionRecord:
345
+ """Update the model configuration of an existing session.
346
+
347
+ Args:
348
+ session_id (`str`): The session to update.
349
+ body (`UpdateSessionRequest`): Fields to update.
350
+ user_id (`str`): Injected authenticated user ID.
351
+ storage (`StorageBase`): Injected storage backend.
352
+
353
+ Returns:
354
+ `SessionRecord`: The full session record after the update.
355
+
356
+ Raises:
357
+ `HTTPException`: 404 if the session, agent, or credential does not
358
+ exist or does not belong to the authenticated user.
359
+ """
360
+ existing = await storage.get_session(user_id, agent_id, session_id)
361
+ if existing is None:
362
+ raise HTTPException(
363
+ status_code=status.HTTP_404_NOT_FOUND,
364
+ detail=f"Session '{session_id}' not found.",
365
+ )
366
+
367
+ await _ensure_credential_exists(storage, user_id, body.chat_model_config)
368
+ await _ensure_credential_exists(
369
+ storage,
370
+ user_id,
371
+ body.fallback_chat_model_config,
372
+ )
373
+ await _ensure_credential_exists(storage, user_id, body.tts_model_config)
374
+ await _ensure_knowledge_bases_exist(
375
+ storage,
376
+ user_id,
377
+ body.knowledge_config,
378
+ )
379
+
380
+ updated_state = existing.state
381
+ if body.permission_mode is not None:
382
+ updated_ctx = existing.state.permission_context.model_copy(
383
+ update={"mode": body.permission_mode},
384
+ )
385
+
386
+ updated_state = existing.state.model_copy(
387
+ update={
388
+ "permission_context": updated_ctx,
389
+ },
390
+ )
391
+
392
+ # PATCH semantics: only fields explicitly present in the request body are
393
+ # applied. ``exclude_unset=True`` lets clients distinguish "leave
394
+ # unchanged" (omit) from "clear" (send ``null``) — required for clearing
395
+ # ``fallback_chat_model_config``.
396
+ config_updates = body.model_dump(
397
+ exclude_unset=True,
398
+ exclude={"permission_mode"},
399
+ )
400
+
401
+ return await storage.upsert_session(
402
+ user_id=user_id,
403
+ agent_id=agent_id,
404
+ config=SessionConfig.model_validate(
405
+ {**existing.config.model_dump(mode="json"), **config_updates},
406
+ ),
407
+ state=updated_state,
408
+ session_id=session_id,
409
+ )
410
+
411
+
412
+ # ----------------------------------------------------------------------
413
+ # Messages: fetch persisted messages for a session
414
+ # ----------------------------------------------------------------------
415
+
416
+
417
+ @session_router.get(
418
+ "/{session_id}/messages",
419
+ response_model=ListMessagesResponse,
420
+ summary="List messages for a session",
421
+ )
422
+ async def list_messages(
423
+ session_id: str,
424
+ agent_id: str = Query(description="Agent the session belongs to."),
425
+ offset: int = Query(0, ge=0, description="Pagination offset."),
426
+ limit: int = Query(50, ge=1, le=200, description="Max messages."),
427
+ user_id: str = Depends(get_current_user_id),
428
+ storage: StorageBase = Depends(get_storage),
429
+ message_bus: MessageBus = Depends(get_message_bus),
430
+ ) -> ListMessagesResponse:
431
+ """Return persisted messages for a session.
432
+
433
+ Args:
434
+ session_id: The session to query.
435
+ agent_id: Agent the session belongs to.
436
+ offset: Pagination offset.
437
+ limit: Maximum number of messages to return.
438
+ user_id: Injected authenticated user ID.
439
+ storage: Injected storage backend.
440
+ message_bus: Injected message bus.
441
+
442
+ Returns:
443
+ Messages and running status.
444
+ """
445
+ existing = await storage.get_session(user_id, agent_id, session_id)
446
+ if existing is None:
447
+ raise HTTPException(
448
+ status_code=status.HTTP_404_NOT_FOUND,
449
+ detail=f"Session '{session_id}' not found.",
450
+ )
451
+
452
+ messages = await storage.list_messages(
453
+ user_id,
454
+ session_id,
455
+ offset=offset,
456
+ limit=limit,
457
+ )
458
+ return ListMessagesResponse(
459
+ messages=messages,
460
+ is_running=await message_bus.is_locked(
461
+ MessageBusKeys.session_lock(session_id),
462
+ ),
463
+ )
464
+
465
+
466
+ # ----------------------------------------------------------------------
467
+ # Stream: live SSE connection for session events
468
+ # ----------------------------------------------------------------------
469
+
470
+ _HEARTBEAT_INTERVAL_SECS = 30
471
+ # Interval between SSE heartbeat comment frames (``:\\n\\n``).
472
+
473
+
474
+ async def _worker_still_asking(
475
+ storage: StorageBase,
476
+ user_id: str,
477
+ worker_agent_id: str,
478
+ worker_session_id: str,
479
+ reply_id: str,
480
+ ) -> bool:
481
+ """Return whether a worker session is still parked on the ASKING
482
+ tool call identified by ``reply_id``.
483
+
484
+ This is the reconcile-on-read check (design §3.5): the worker
485
+ session's own ``state.context`` is the single source of truth for
486
+ "does this confirmation still need answering". A leader-side
487
+ pending projection whose worker has already resolved / cancelled
488
+ the call is a ghost and must not be replayed.
489
+
490
+ Mirrors the wakeup guard in
491
+ :meth:`ChatService._run_impl` — a request is "still asking" when
492
+ the tail ``AssistantMsg`` of the worker carries a tool call in
493
+ ``ASKING`` or ``SUBMITTED`` state for the matching ``reply_id``.
494
+
495
+ Args:
496
+ storage (`StorageBase`):
497
+ Application storage.
498
+ user_id (`str`):
499
+ The owner user id.
500
+ worker_agent_id (`str`):
501
+ The worker agent that owns the session.
502
+ worker_session_id (`str`):
503
+ The worker session to inspect.
504
+ reply_id (`str`):
505
+ The reply id the pending request belongs to.
506
+
507
+ Returns:
508
+ `bool`:
509
+ ``True`` if the worker is still awaiting confirmation for
510
+ ``reply_id``; ``False`` otherwise (resolved, cancelled, or
511
+ the session/record is gone).
512
+ """
513
+ session = await storage.get_session(
514
+ user_id,
515
+ worker_agent_id,
516
+ worker_session_id,
517
+ )
518
+ if session is None or not session.state.context:
519
+ return False
520
+ last_msg = session.state.context[-1]
521
+ if last_msg.role != "assistant" or last_msg.id != reply_id:
522
+ return False
523
+ return any(
524
+ tc.state in (ToolCallState.ASKING, ToolCallState.SUBMITTED)
525
+ for tc in last_msg.get_content_blocks("tool_call")
526
+ )
527
+
528
+
529
+ @session_router.get(
530
+ "/{session_id}/stream",
531
+ summary="Subscribe to a session's event stream (SSE)",
532
+ response_description="Server-Sent Events stream of AgentEvent objects",
533
+ )
534
+ async def stream_session_events(
535
+ session_id: str,
536
+ agent_id: str = Query(description="Agent the session belongs to."),
537
+ user_id: str = Depends(get_current_user_id),
538
+ storage: StorageBase = Depends(get_storage),
539
+ message_bus: MessageBus = Depends(get_message_bus),
540
+ ) -> StreamingResponse:
541
+ """Subscribe to a session's live event stream.
542
+
543
+ Returns a ``text/event-stream`` that first replays any buffered
544
+ events from the current run's replay log (if a run is in progress
545
+ or just finished), then streams live events as they are produced
546
+ by :meth:`ChatService.run`. The connection stays open
547
+ until the client disconnects — subsequent runs on the same session
548
+ are delivered over the same connection.
549
+
550
+ A heartbeat comment frame (``:\\n\\n``) is sent every 30 seconds to
551
+ keep the connection alive through reverse proxies.
552
+
553
+ Args:
554
+ session_id (`str`):
555
+ The session to subscribe to.
556
+ agent_id (`str`):
557
+ The agent that owns the session (used for ownership
558
+ validation).
559
+ user_id (`str`):
560
+ Injected authenticated user id.
561
+ storage (`StorageBase`):
562
+ Injected storage backend (ownership check only).
563
+ message_bus (`MessageBus`):
564
+ Injected message bus (replay + live subscription).
565
+
566
+ Returns:
567
+ `StreamingResponse`:
568
+ SSE stream of AgentEvent frames + periodic heartbeats.
569
+ """
570
+ existing = await storage.get_session(user_id, agent_id, session_id)
571
+ if existing is None:
572
+ raise HTTPException(
573
+ status_code=status.HTTP_404_NOT_FOUND,
574
+ detail=f"Session '{session_id}' not found.",
575
+ )
576
+
577
+ async def _sse_generator() -> AsyncGenerator[str, None]:
578
+ # 1. Replay buffered events from the current run (if any).
579
+ for _entry_id, event in await message_bus.log_read(
580
+ MessageBusKeys.session_events(session_id),
581
+ max_count=MessageBusKeys.SESSION_REPLAY_MAX_LEN,
582
+ ):
583
+ yield f"data: {json.dumps(event)}\n\n"
584
+
585
+ # 1b. Inject pending subagent HITL cards projected onto this
586
+ # session as a team leader (design §3.5). These live in a
587
+ # durable Redis hash — NOT in the replay log (trimmed per
588
+ # run) nor in the leader's own Msg history — so a fresh
589
+ # reconnect after the worker parked still surfaces them.
590
+ #
591
+ # Reconcile-on-read: the worker session's own context is the
592
+ # SSOT. Inject only when the worker is still ASKING; drop and
593
+ # delete ghosts (worker resolved/cancelled without clearing).
594
+ projection = SessionProjection(message_bus)
595
+ for payload in await projection.list(
596
+ session_id,
597
+ SubagentHitlProjector.KIND,
598
+ ):
599
+ if not await _worker_still_asking(
600
+ storage,
601
+ user_id,
602
+ payload["worker_agent_id"],
603
+ payload["worker_session_id"],
604
+ payload["reply_id"],
605
+ ):
606
+ await projection.delete(
607
+ session_id,
608
+ SubagentHitlProjector.KIND,
609
+ SubagentHitlProjector.entry_id(
610
+ payload["worker_session_id"],
611
+ payload["reply_id"],
612
+ ),
613
+ )
614
+ continue
615
+ custom = CustomEvent(
616
+ name=SubagentHitlProjector.EVT_REQUIRE,
617
+ value=payload,
618
+ )
619
+ yield f"data: {json.dumps(custom.model_dump(mode='json'))}\n\n"
620
+
621
+ # 2. Live subscribe via a background feeder task that pushes
622
+ # events into a queue. The main loop reads from the queue
623
+ # with a timeout so we can interleave heartbeat frames.
624
+ #
625
+ # We avoid calling ``wait_for(__anext__())`` on the async
626
+ # generator directly because cancelling a suspended
627
+ # ``__anext__`` leaves the generator in a "running" state
628
+ # that prevents ``aclose()`` from working.
629
+ queue: asyncio.Queue[dict | None] = asyncio.Queue()
630
+
631
+ async def _feeder() -> None:
632
+ """Read from the bus subscription and forward to the queue.
633
+
634
+ Pushes ``None`` as a sentinel when the subscription ends
635
+ (which in practice only happens if the bus shuts down).
636
+ """
637
+ try:
638
+ async for evt in message_bus.subscribe(
639
+ MessageBusKeys.session_events(session_id),
640
+ ):
641
+ await queue.put(
642
+ {k: v for k, v in evt.items() if k != "_entry_id"},
643
+ )
644
+ except asyncio.CancelledError:
645
+ pass
646
+ finally:
647
+ await queue.put(None)
648
+
649
+ feeder_task = asyncio.create_task(
650
+ _feeder(),
651
+ name=f"sse-feeder:{session_id}",
652
+ )
653
+
654
+ try:
655
+ while True:
656
+ try:
657
+ item = await asyncio.wait_for(
658
+ queue.get(),
659
+ timeout=_HEARTBEAT_INTERVAL_SECS,
660
+ )
661
+ if item is None:
662
+ break
663
+ yield f"data: {json.dumps(item)}\n\n"
664
+ except asyncio.TimeoutError:
665
+ yield ":\n\n"
666
+ finally:
667
+ feeder_task.cancel()
668
+ try:
669
+ await feeder_task
670
+ except asyncio.CancelledError:
671
+ pass
672
+
673
+ return StreamingResponse(
674
+ _sse_generator(),
675
+ media_type="text/event-stream",
676
+ headers={
677
+ "Cache-Control": "no-cache",
678
+ "X-Accel-Buffering": "no",
679
+ },
680
+ )
src/agentscope/app/_router/_tts_model.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """The TTS model router."""
3
+
4
+ from fastapi import APIRouter, Depends, HTTPException, status
5
+
6
+ from ._schema import ListTTSModelsResponse, ListTTSModelsRequest
7
+ from ...credential import CredentialFactory
8
+
9
+ tts_model_router = APIRouter(
10
+ prefix="/tts-model",
11
+ tags=["tts-model"],
12
+ responses={404: {"description": "Not found"}},
13
+ )
14
+
15
+
16
+ @tts_model_router.get(
17
+ "/",
18
+ response_model=ListTTSModelsResponse,
19
+ summary="List all candidate TTS models under the given credential type",
20
+ )
21
+ async def list_tts_models(
22
+ body: ListTTSModelsRequest = Depends(),
23
+ ) -> ListTTSModelsResponse:
24
+ """Return all candidate TTS models under the given credential type.
25
+
26
+ Args:
27
+ body (ListTTSModelsRequest): The request body.
28
+
29
+ Returns:
30
+ `ListTTSModelsResponse`: The response body.
31
+ """
32
+ credential_cls = CredentialFactory.get_credential_class(body.provider)
33
+ if credential_cls is None:
34
+ raise HTTPException(
35
+ status_code=status.HTTP_404_NOT_FOUND,
36
+ detail=f"Provider '{body.provider}' not found.",
37
+ )
38
+
39
+ models = credential_cls.list_tts_models()
40
+ return ListTTSModelsResponse(models=models, total=len(models))
src/agentscope/app/_router/_workspace.py ADDED
@@ -0,0 +1,220 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """Workspace router — manage MCP clients and skills on a workspace."""
3
+ from fastapi import APIRouter, Depends, HTTPException, Query, status
4
+ from pydantic import BaseModel, Field
5
+
6
+ from ..deps import (
7
+ get_current_user_id,
8
+ get_storage,
9
+ get_workspace_manager,
10
+ )
11
+ from ..workspace_manager import WorkspaceManagerBase
12
+ from ..storage import StorageBase
13
+ from ...mcp import MCPClient
14
+ from ...skill import Skill
15
+ from ...workspace import WorkspaceBase
16
+
17
+ workspace_router = APIRouter(prefix="/workspace", tags=["workspace"])
18
+
19
+
20
+ class AddSkillRequest(BaseModel):
21
+ """The request to add skill."""
22
+
23
+ skill_path: str
24
+
25
+
26
+ class ToolInfo(BaseModel):
27
+ """The tool info."""
28
+
29
+ name: str
30
+ description: str | None = None
31
+
32
+
33
+ class MCPClientStatus(MCPClient):
34
+ """MCPClient enriched with live tool list and health status."""
35
+
36
+ is_healthy: bool = False
37
+ tools: list[ToolInfo] = Field(default_factory=list)
38
+
39
+
40
+ async def _resolve_workspace(
41
+ user_id: str,
42
+ agent_id: str,
43
+ session_id: str,
44
+ storage: StorageBase,
45
+ workspace_manager: WorkspaceManagerBase,
46
+ ) -> WorkspaceBase:
47
+ """Resolve the workspace for the given session, raising 404 if not
48
+ found."""
49
+ session_record = await storage.get_session(user_id, agent_id, session_id)
50
+ if session_record is None:
51
+ raise HTTPException(
52
+ status_code=status.HTTP_404_NOT_FOUND,
53
+ detail=f"Session {session_id!r} not found.",
54
+ )
55
+ return await workspace_manager.get_workspace(
56
+ user_id,
57
+ agent_id,
58
+ session_id,
59
+ session_record.config.workspace_id,
60
+ )
61
+
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # MCP endpoints
65
+ # ---------------------------------------------------------------------------
66
+
67
+
68
+ @workspace_router.get("/mcp")
69
+ async def list_mcps(
70
+ agent_id: str = Query(...),
71
+ session_id: str = Query(...),
72
+ user_id: str = Depends(get_current_user_id),
73
+ storage: StorageBase = Depends(get_storage),
74
+ workspace_manager: WorkspaceManagerBase = Depends(get_workspace_manager),
75
+ ) -> list[MCPClientStatus]:
76
+ """Return all MCP clients with live tool list and health status."""
77
+ workspace = await _resolve_workspace(
78
+ user_id,
79
+ agent_id,
80
+ session_id,
81
+ storage,
82
+ workspace_manager,
83
+ )
84
+ clients = await workspace.list_mcps()
85
+
86
+ results = []
87
+ for client in clients:
88
+ base = client.model_dump()
89
+ try:
90
+ mcp_tools = await client.list_tools()
91
+ tools = [
92
+ ToolInfo(name=t.name, description=t.description)
93
+ for t in mcp_tools
94
+ ]
95
+ results.append(
96
+ MCPClientStatus(
97
+ **base,
98
+ is_healthy=True,
99
+ tools=tools,
100
+ ),
101
+ )
102
+ except Exception:
103
+ results.append(
104
+ MCPClientStatus(
105
+ **base,
106
+ is_healthy=False,
107
+ ),
108
+ )
109
+
110
+ return results
111
+
112
+
113
+ @workspace_router.post("/mcp", status_code=status.HTTP_201_CREATED)
114
+ async def add_mcp(
115
+ mcp: MCPClient,
116
+ agent_id: str = Query(...),
117
+ session_id: str = Query(...),
118
+ user_id: str = Depends(get_current_user_id),
119
+ storage: StorageBase = Depends(get_storage),
120
+ workspace_manager: WorkspaceManagerBase = Depends(get_workspace_manager),
121
+ ) -> None:
122
+ """Add an MCP client to the session's workspace."""
123
+ workspace = await _resolve_workspace(
124
+ user_id,
125
+ agent_id,
126
+ session_id,
127
+ storage,
128
+ workspace_manager,
129
+ )
130
+ await workspace.add_mcp(mcp)
131
+
132
+
133
+ @workspace_router.delete(
134
+ "/mcp/{mcp_name}",
135
+ status_code=status.HTTP_204_NO_CONTENT,
136
+ )
137
+ async def remove_mcp(
138
+ mcp_name: str,
139
+ agent_id: str = Query(...),
140
+ session_id: str = Query(...),
141
+ user_id: str = Depends(get_current_user_id),
142
+ storage: StorageBase = Depends(get_storage),
143
+ workspace_manager: WorkspaceManagerBase = Depends(get_workspace_manager),
144
+ ) -> None:
145
+ """Remove an MCP client from the session's workspace by name."""
146
+ workspace = await _resolve_workspace(
147
+ user_id,
148
+ agent_id,
149
+ session_id,
150
+ storage,
151
+ workspace_manager,
152
+ )
153
+ await workspace.remove_mcp(mcp_name)
154
+
155
+
156
+ # ---------------------------------------------------------------------------
157
+ # Skill endpoints
158
+ # ---------------------------------------------------------------------------
159
+
160
+
161
+ @workspace_router.get("/skill")
162
+ async def list_skills(
163
+ agent_id: str = Query(...),
164
+ session_id: str = Query(...),
165
+ user_id: str = Depends(get_current_user_id),
166
+ storage: StorageBase = Depends(get_storage),
167
+ workspace_manager: WorkspaceManagerBase = Depends(get_workspace_manager),
168
+ ) -> list[Skill]:
169
+ """Return all skills available in the session's workspace."""
170
+ workspace = await _resolve_workspace(
171
+ user_id,
172
+ agent_id,
173
+ session_id,
174
+ storage,
175
+ workspace_manager,
176
+ )
177
+ return await workspace.list_skills()
178
+
179
+
180
+ @workspace_router.post("/skill", status_code=status.HTTP_201_CREATED)
181
+ async def add_skill(
182
+ body: AddSkillRequest,
183
+ agent_id: str = Query(...),
184
+ session_id: str = Query(...),
185
+ user_id: str = Depends(get_current_user_id),
186
+ storage: StorageBase = Depends(get_storage),
187
+ workspace_manager: WorkspaceManagerBase = Depends(get_workspace_manager),
188
+ ) -> None:
189
+ """Add a skill to the session's workspace from the given path."""
190
+ workspace = await _resolve_workspace(
191
+ user_id,
192
+ agent_id,
193
+ session_id,
194
+ storage,
195
+ workspace_manager,
196
+ )
197
+ await workspace.add_skill(body.skill_path)
198
+
199
+
200
+ @workspace_router.delete(
201
+ "/skill/{skill_name}",
202
+ status_code=status.HTTP_204_NO_CONTENT,
203
+ )
204
+ async def remove_skill(
205
+ skill_name: str,
206
+ agent_id: str = Query(...),
207
+ session_id: str = Query(...),
208
+ user_id: str = Depends(get_current_user_id),
209
+ storage: StorageBase = Depends(get_storage),
210
+ workspace_manager: WorkspaceManagerBase = Depends(get_workspace_manager),
211
+ ) -> None:
212
+ """Remove a skill from the session's workspace by name."""
213
+ workspace = await _resolve_workspace(
214
+ user_id,
215
+ agent_id,
216
+ session_id,
217
+ storage,
218
+ workspace_manager,
219
+ )
220
+ await workspace.remove_skill(skill_name)
src/agentscope/app/_service/__init__.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """Service layer for the AgentScope app."""
3
+ from ._chat import ChatService
4
+ from ._embedding import get_embedding_model
5
+ from ._index_sweeper import IndexSweeper
6
+ from ._index_task_consumer import IndexTaskConsumer
7
+ from ._index_worker import IndexWorker
8
+ from ._knowledge_base import KnowledgeBaseService
9
+ from ._model import get_model
10
+ from ._tts_model import get_tts_model
11
+ from ._session import SessionService
12
+ from ._session_projection import SessionProjection
13
+ from ._projectors import SubagentHitlProjector
14
+ from ._toolkit import get_toolkit
15
+
16
+ __all__ = [
17
+ "ChatService",
18
+ "IndexSweeper",
19
+ "IndexTaskConsumer",
20
+ "IndexWorker",
21
+ "KnowledgeBaseService",
22
+ "SessionService",
23
+ "SessionProjection",
24
+ "SubagentHitlProjector",
25
+ "get_embedding_model",
26
+ "get_model",
27
+ "get_tts_model",
28
+ "get_toolkit",
29
+ ]
src/agentscope/app/_service/_chat.py ADDED
@@ -0,0 +1,588 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """Chat service encapsulating agent execution + persistence logic.
3
+
4
+ This is the single source of truth for running an agent against a
5
+ session. Both the HTTP chat endpoint and the wakeup dispatcher call
6
+ :meth:`ChatService.run`, guaranteeing identical message persistence,
7
+ middleware wiring, and state handling.
8
+
9
+ Events produced by the agent are not exposed back through this method
10
+ — they are published to the message bus inside the run, and any client
11
+ that wants them subscribes through the
12
+ ``GET /sessions/{sid}/stream`` SSE endpoint.
13
+ """
14
+ from fastapi import HTTPException
15
+
16
+ from ..message_bus import MessageBus, MessageBusKeys
17
+ from .._bus_ops import publish_session_event
18
+ from ..rag.knowledge_base_manager import KnowledgeBaseManagerBase
19
+ from ..storage import StorageBase, AgentRecord, SessionRecord
20
+ from .._manager import BackgroundTaskManager, SchedulerManager
21
+ from ..workspace_manager import WorkspaceManagerBase
22
+ from ..middleware import (
23
+ InboxMiddleware,
24
+ StateChangeMiddleware,
25
+ ToolOffloadMiddleware,
26
+ )
27
+ from ...middleware import TTSMiddleware, RAGMiddleware
28
+ from ...rag import KnowledgeBase
29
+ from .._types import (
30
+ AgentMiddlewareFactory,
31
+ AgentToolFactory,
32
+ EventProjector,
33
+ SubAgentTemplate,
34
+ )
35
+ from ._model import get_model
36
+ from ._tts_model import get_tts_model
37
+ from ._toolkit import get_toolkit
38
+ from ._session_projection import SessionProjection
39
+ from ._projectors import SubagentHitlProjector
40
+
41
+ from ..._logging import logger
42
+ from ...agent import Agent, ModelConfig
43
+ from ...event import (
44
+ AgentEvent,
45
+ ReplyStartEvent,
46
+ UserConfirmResultEvent,
47
+ ExternalExecutionResultEvent,
48
+ )
49
+ from ...message import AssistantMsg, Msg, ToolCallState
50
+ from ...permission import AdditionalWorkingDirectory
51
+
52
+
53
+ class ChatService:
54
+ """Run an agent against a session, persisting input/reply messages
55
+ and updated agent state.
56
+
57
+ Shared by the HTTP chat endpoint and the wakeup dispatcher so both
58
+ paths go through identical validation, assembly, and persistence.
59
+
60
+ Session serialisation and event fan-out are both handled by the
61
+ :class:`MessageBus`: :meth:`bus.session_run` acquires a distributed
62
+ lock (guaranteeing at most one chat run per session across all
63
+ processes), and :meth:`bus.session_publish_event` writes each event
64
+ to both a replay log (for late-joining subscribers) and a live
65
+ Pub/Sub channel.
66
+ """
67
+
68
+ def __init__(
69
+ self,
70
+ storage: StorageBase,
71
+ workspace_manager: WorkspaceManagerBase,
72
+ scheduler_manager: SchedulerManager,
73
+ background_task_manager: BackgroundTaskManager,
74
+ message_bus: MessageBus,
75
+ knowledge_base_manager: KnowledgeBaseManagerBase | None = None,
76
+ extra_agent_middlewares: AgentMiddlewareFactory | None = None,
77
+ extra_agent_tools: AgentToolFactory | None = None,
78
+ custom_subagent_templates: dict[str, SubAgentTemplate] | None = None,
79
+ custom_agent_cls: type[Agent] | None = None,
80
+ extra_projectors: list[EventProjector] | None = None,
81
+ ) -> None:
82
+ """Initialize chat service.
83
+
84
+ Args:
85
+ storage (`StorageBase`):
86
+ Application storage backend.
87
+ workspace_manager (`WorkspaceManagerBase`):
88
+ Provides per-session workspace (tools, MCPs, skills) used
89
+ during agent assembly.
90
+ scheduler_manager (`SchedulerManager`):
91
+ Application scheduler — passed through to
92
+ :func:`get_toolkit` so the agent toolkit gets the four
93
+ ``Schedule*`` tools.
94
+ background_task_manager (`BackgroundTaskManager`):
95
+ Tracks offloaded long-running tool tasks. Also provides
96
+ the :class:`ToolStop` tool through
97
+ :func:`get_toolkit`.
98
+ message_bus (`MessageBus`):
99
+ Application-wide message bus. Provides session-level
100
+ distributed locking (via :meth:`session_run`), event
101
+ replay + live fan-out (via :meth:`session_publish_event`),
102
+ and inbox delivery (via :class:`InboxMiddleware`).
103
+ knowledge_base_manager (`KnowledgeBaseManagerBase | None`, \
104
+ optional):
105
+ The application's knowledge base manager. When
106
+ provided and the session config carries a
107
+ ``knowledge_config``, a
108
+ :class:`~agentscope.middleware.RAGMiddleware`
109
+ is attached to the agent at run time. ``None``
110
+ disables knowledge-base wiring even for sessions that
111
+ have one configured.
112
+ extra_agent_middlewares (`AgentMiddlewareFactory | None`, \
113
+ optional):
114
+ Async factory invoked at every chat turn to produce
115
+ user/session-specific middlewares to attach to the agent.
116
+ extra_agent_tools (`AgentToolFactory | None`, optional):
117
+ Async factory invoked at every chat turn to produce
118
+ user/session-specific tools to register in the toolkit.
119
+ custom_subagent_templates (`dict[str, SubAgentTemplate] | None`,\
120
+ optional):
121
+ Sub-agent template registry, keyed by template type.
122
+ Passed through to :func:`get_toolkit` so that
123
+ ``AgentCreate`` can route to the appropriate template
124
+ when a ``subagent_type`` is specified.
125
+ custom_agent_cls (`type[Agent] | None`, optional):
126
+ Custom :class:`Agent` subclass for assembling agents.
127
+ Falls back to :class:`Agent` when ``None``.
128
+ extra_projectors (`list[EventProjector] | None`, optional):
129
+ Additional cross-session event projectors to run after
130
+ the built-in ones (mirrors the ``extra_agent_*``
131
+ injection style). Each is invoked once per produced
132
+ event to mirror a UI feed onto another session; see
133
+ :class:`~agentscope.app._types.EventProjector`.
134
+ """
135
+ self._storage = storage
136
+ self._workspace_manager = workspace_manager
137
+ self._scheduler_manager = scheduler_manager
138
+ self._background_task_manager = background_task_manager
139
+ self._message_bus = message_bus
140
+ self._knowledge_base_manager = knowledge_base_manager
141
+ self._extra_agent_middlewares = extra_agent_middlewares
142
+ self._extra_agent_tools = extra_agent_tools
143
+ self._sub_agent_templates = custom_subagent_templates
144
+ self._agent_cls = custom_agent_cls or Agent
145
+ self._projection = SessionProjection(message_bus)
146
+ self._projectors: list[EventProjector] = [
147
+ SubagentHitlProjector(storage),
148
+ *(extra_projectors or []),
149
+ ]
150
+
151
+ async def run(
152
+ self,
153
+ user_id: str,
154
+ session_id: str,
155
+ agent_id: str,
156
+ input_msg: Msg
157
+ | list[Msg]
158
+ | UserConfirmResultEvent
159
+ | ExternalExecutionResultEvent
160
+ | None = None,
161
+ ) -> None:
162
+ """Drive a chat run to completion.
163
+
164
+ Persists input messages (Case A) or the incoming continuation
165
+ event applied to the existing reply (Case B), runs the agent
166
+ while publishing every produced event to the message bus, and
167
+ persists the rebuilt reply ``Msg`` + updated agent state when
168
+ finished.
169
+
170
+ Session serialisation is handled by the bus's distributed lock
171
+ (:meth:`MessageBus.session_run`); events are simultaneously
172
+ persisted to the replay log and fanned out on the live channel
173
+ via :meth:`MessageBus.session_publish_event`. Exceptions are
174
+ logged and swallowed so a single failed fire does not tear
175
+ down its trigger (HTTP request task, wakeup dispatcher, …).
176
+
177
+ Args:
178
+ user_id (`str`):
179
+ Authenticated caller's user ID.
180
+ session_id (`str`):
181
+ Target session ID.
182
+ agent_id (`str`):
183
+ Agent to run.
184
+ input_msg:
185
+ One of:
186
+
187
+ - ``Msg`` / ``list[Msg]``: new user message(s) (Case A).
188
+ - ``None``: continue from current state — used by the
189
+ wakeup dispatcher when there is no fresh user input
190
+ but pending inbox content needs draining (Case A
191
+ with no input).
192
+ - ``UserConfirmResultEvent`` /
193
+ ``ExternalExecutionResultEvent``: resume an awaiting
194
+ tool call (Case B).
195
+ """
196
+ try:
197
+ await self._run_impl(user_id, session_id, agent_id, input_msg)
198
+ except Exception as e:
199
+ logger.exception(
200
+ "ChatService.run failed for user_id=%s session_id=%s "
201
+ "agent_id=%s, error=%s",
202
+ user_id,
203
+ session_id,
204
+ agent_id,
205
+ str(e),
206
+ )
207
+
208
+ async def _run_impl(
209
+ self,
210
+ user_id: str,
211
+ session_id: str,
212
+ agent_id: str,
213
+ input_msg: Msg
214
+ | list[Msg]
215
+ | UserConfirmResultEvent
216
+ | ExternalExecutionResultEvent
217
+ | None,
218
+ ) -> None:
219
+ """The actual chat-run body; wrapped by :meth:`run` for error
220
+ swallowing. Separated so the try/except doesn't bury the
221
+ per-step logic at one extra indentation level."""
222
+
223
+ # ----------------------------------------------------------------
224
+ # 1. Load records + resolve workspace ONCE here, reused below.
225
+ # Reject missing records up front with a clear error so the
226
+ # downstream assembly code can rely on non-None values.
227
+ # ----------------------------------------------------------------
228
+ agent_record = await self._storage.get_agent(user_id, agent_id)
229
+ if agent_record is None:
230
+ raise HTTPException(
231
+ status_code=404,
232
+ detail=f"Agent {agent_id!r} not found.",
233
+ )
234
+ session_record = await self._storage.get_session(
235
+ user_id,
236
+ agent_id,
237
+ session_id,
238
+ )
239
+ if session_record is None:
240
+ raise HTTPException(
241
+ status_code=404,
242
+ detail=(
243
+ f"Session {session_id!r} not found for "
244
+ f"agent {agent_id!r}."
245
+ ),
246
+ )
247
+ workspace = await self._workspace_manager.get_workspace(
248
+ user_id,
249
+ agent_id,
250
+ session_id,
251
+ session_record.config.workspace_id,
252
+ )
253
+
254
+ # Add workspace working directory to the permission context
255
+ if (
256
+ workspace.workdir
257
+ not in session_record.state.permission_context.working_directories
258
+ ):
259
+ session_record.state.permission_context.working_directories[
260
+ workspace.workdir
261
+ ] = AdditionalWorkingDirectory(
262
+ path=workspace.workdir,
263
+ source="session",
264
+ )
265
+
266
+ # ----------------------------------------------------------------
267
+ # 2. Middlewares — framework-supplied first, then caller extras.
268
+ # Background-tool completions deliver their results via
269
+ # ``message_bus.inbox_push + enqueue_wakeup``, so the dispatcher
270
+ # (any process) wakes an idle session — no in-process retrigger
271
+ # plumbing is needed here.
272
+ # ----------------------------------------------------------------
273
+ middlewares: list = [
274
+ InboxMiddleware(self._message_bus),
275
+ StateChangeMiddleware(
276
+ message_bus=self._message_bus,
277
+ session_id=session_id,
278
+ ),
279
+ ToolOffloadMiddleware(
280
+ bg_manager=self._background_task_manager,
281
+ message_bus=self._message_bus,
282
+ user_id=user_id,
283
+ agent_id=agent_id,
284
+ ),
285
+ ]
286
+ if self._extra_agent_middlewares is not None:
287
+ middlewares.extend(
288
+ await self._extra_agent_middlewares(
289
+ user_id,
290
+ agent_id,
291
+ session_id,
292
+ ),
293
+ )
294
+
295
+ # ----------------------------------------------------------------
296
+ # 2b. TTS middleware — inject when the session has a TTS config.
297
+ # ----------------------------------------------------------------
298
+ tts_cfg = session_record.config.tts_model_config
299
+ if tts_cfg is not None:
300
+ tts_model = await get_tts_model(
301
+ user_id,
302
+ tts_cfg,
303
+ self._storage,
304
+ )
305
+ middlewares.append(TTSMiddleware(tts_model))
306
+
307
+ # ----------------------------------------------------------------
308
+ # 2c. Knowledge-base middleware — inject when the session has KBs
309
+ # attached. Each KB resolves to its own :class:`KnowledgeBase` handle
310
+ # (own embedding model + vector store), so the middleware can
311
+ # retrieve across heterogeneous KBs in one fan-out.
312
+ # ----------------------------------------------------------------
313
+ kb_cfg = session_record.config.knowledge_config
314
+ if (
315
+ kb_cfg is not None
316
+ and kb_cfg.knowledge_base_ids
317
+ and self._knowledge_base_manager is not None
318
+ ):
319
+ knowledges: list[KnowledgeBase] = []
320
+ for kb_id in kb_cfg.knowledge_base_ids:
321
+ try:
322
+ knowledge = (
323
+ await self._knowledge_base_manager.get_knowledge(
324
+ user_id,
325
+ kb_id,
326
+ )
327
+ )
328
+ except Exception: # pylint: disable=broad-except
329
+ # A KB the session referenced was deleted (or its
330
+ # credential revoked) — log and skip so the chat
331
+ # turn can still run with the remaining KBs.
332
+ logger.exception(
333
+ "Skipping knowledge base %r for session %r: "
334
+ "failed to resolve runtime handle.",
335
+ kb_id,
336
+ session_id,
337
+ )
338
+ continue
339
+ knowledges.append(knowledge)
340
+ if knowledges:
341
+ middlewares.append(
342
+ RAGMiddleware(
343
+ knowledge_bases=knowledges,
344
+ parameters=RAGMiddleware.Parameters(
345
+ **(kb_cfg.parameters or {}),
346
+ ),
347
+ ),
348
+ )
349
+
350
+ # ----------------------------------------------------------------
351
+ # 3. Toolkit (workspace tools + planning + ToolStop + schedule +
352
+ # team + extras + skills + mcps).
353
+ # ----------------------------------------------------------------
354
+ toolkit = await get_toolkit(
355
+ storage=self._storage,
356
+ workspace=workspace,
357
+ scheduler_manager=self._scheduler_manager,
358
+ background_task_manager=self._background_task_manager,
359
+ message_bus=self._message_bus,
360
+ middlewares=middlewares,
361
+ user_id=user_id,
362
+ agent_record=agent_record,
363
+ session_record=session_record,
364
+ extra_factory=self._extra_agent_tools,
365
+ sub_agent_templates=self._sub_agent_templates,
366
+ )
367
+
368
+ # ----------------------------------------------------------------
369
+ # 4. Model + fallback (resolved from session's config).
370
+ # ----------------------------------------------------------------
371
+ model_cfg = session_record.config.chat_model_config
372
+ if not model_cfg:
373
+ raise HTTPException(
374
+ status_code=404,
375
+ detail=f"No model configuration found for agent {agent_id}",
376
+ )
377
+ model = await get_model(user_id, model_cfg, self._storage)
378
+
379
+ fallback_cfg = session_record.config.fallback_chat_model_config
380
+ fallback_model = (
381
+ await get_model(user_id, fallback_cfg, self._storage)
382
+ if fallback_cfg is not None
383
+ else None
384
+ )
385
+
386
+ # ----------------------------------------------------------------
387
+ # 5. Assemble the Agent.
388
+ # ----------------------------------------------------------------
389
+ agent_state = session_record.state
390
+ agent_state.session_id = session_id
391
+ agent = self._agent_cls(
392
+ name=agent_record.data.name,
393
+ system_prompt=agent_record.data.system_prompt,
394
+ model=model,
395
+ toolkit=toolkit,
396
+ model_config=ModelConfig(fallback_model=fallback_model),
397
+ context_config=agent_record.data.context_config,
398
+ react_config=agent_record.data.react_config,
399
+ state=agent_state,
400
+ middlewares=middlewares,
401
+ offloader=workspace,
402
+ )
403
+
404
+ # ----------------------------------------------------------------
405
+ # 6. Guard: skip wake-up driven runs when the agent is parked on
406
+ # an awaiting tool call.
407
+ #
408
+ # Wake-ups deliver pending inbox content (team messages, etc.) by
409
+ # poking the dispatcher to run the session with ``input_msg=None``.
410
+ # If the agent is currently parked on an ``ASKING`` or
411
+ # ``SUBMITTED`` tool call (waiting for user confirmation or
412
+ # external-execution results), kicking off another ``None`` run
413
+ # would hit :meth:`Agent._check_incoming_event`, which rightly
414
+ # rejects ``None`` when there is something to confirm — and fail
415
+ # the run noisily. The inbox content is safe to leave queued:
416
+ # whenever the user does confirm (or the external result lands),
417
+ # the resuming run's next reasoning step lets
418
+ # :class:`InboxMiddleware` drain the queue naturally.
419
+ # ----------------------------------------------------------------
420
+ if input_msg is None and agent.state.context:
421
+ last_msg = agent.state.context[-1]
422
+ if last_msg.role == "assistant" and last_msg.name == agent.name:
423
+ awaiting = [
424
+ tc
425
+ for tc in last_msg.get_content_blocks("tool_call")
426
+ if tc.state
427
+ in (ToolCallState.ASKING, ToolCallState.SUBMITTED)
428
+ ]
429
+ if awaiting:
430
+ logger.info(
431
+ "Skipping wake-up for session %s: agent is parked "
432
+ "on %d awaiting tool call(s); inbox messages will "
433
+ "be drained when the agent resumes.",
434
+ session_id,
435
+ len(awaiting),
436
+ )
437
+ return
438
+
439
+ # ----------------------------------------------------------------
440
+ # 7. Run the agent inside the distributed session lock
441
+ # ----------------------------------------------------------------
442
+ lock_key = MessageBusKeys.session_lock(session_id)
443
+ events_key = MessageBusKeys.session_events(session_id)
444
+ async with self._message_bus.acquire_lock(
445
+ lock_key,
446
+ ttl_secs=MessageBusKeys.SESSION_RUN_TTL_SECS,
447
+ ):
448
+ try:
449
+ reply_msg: Msg | None = None
450
+
451
+ if input_msg is None or isinstance(input_msg, (Msg, list)):
452
+ # Case A: new reply (user message(s), or retrigger with
453
+ # empty input)
454
+ if isinstance(input_msg, (Msg, list)):
455
+ input_msgs = (
456
+ [input_msg]
457
+ if isinstance(input_msg, Msg)
458
+ else input_msg
459
+ )
460
+ for msg in input_msgs:
461
+ await self._storage.upsert_message(
462
+ user_id,
463
+ session_id,
464
+ msg,
465
+ )
466
+
467
+ async for event in agent.reply_stream(inputs=input_msg):
468
+ await publish_session_event(
469
+ self._message_bus,
470
+ session_id,
471
+ event.model_dump(mode="json"),
472
+ )
473
+ await self._project_event(
474
+ user_id,
475
+ session_record,
476
+ agent_record,
477
+ event,
478
+ )
479
+ if isinstance(event, ReplyStartEvent):
480
+ reply_msg = AssistantMsg(
481
+ id=event.reply_id,
482
+ name=event.name,
483
+ content=[],
484
+ )
485
+ elif reply_msg is not None:
486
+ reply_msg.append_event(event)
487
+
488
+ else:
489
+ # Case B: continuation (UserConfirmResult
490
+ # / ExternalExecResult)
491
+ reply_msg = await self._storage.get_message(
492
+ user_id,
493
+ session_id,
494
+ agent.state.reply_id,
495
+ )
496
+
497
+ if reply_msg is None:
498
+ logger.warning(
499
+ "Reply message %r not found in storage for "
500
+ "session %r; tool-call state changes from the "
501
+ "incoming event will not be persisted.",
502
+ agent.state.reply_id,
503
+ session_id,
504
+ )
505
+ elif input_msg:
506
+ reply_msg.append_event(input_msg)
507
+
508
+ async for event in agent.reply_stream(inputs=input_msg):
509
+ await publish_session_event(
510
+ self._message_bus,
511
+ session_id,
512
+ event.model_dump(mode="json"),
513
+ )
514
+ await self._project_event(
515
+ user_id,
516
+ session_record,
517
+ agent_record,
518
+ event,
519
+ )
520
+ if reply_msg is not None:
521
+ reply_msg.append_event(event)
522
+
523
+ # Persist the reply Msg (upsert: overwrite if same id,
524
+ # append if new).
525
+ if reply_msg is not None:
526
+ await self._storage.upsert_message(
527
+ user_id,
528
+ session_id,
529
+ reply_msg,
530
+ )
531
+
532
+ # Persist the updated agent state. MUST happen inside
533
+ # the session lock: if we released the lock first,
534
+ # another process could acquire it and load a stale
535
+ # state from storage before this write lands.
536
+ await self._storage.update_session_state(
537
+ user_id=user_id,
538
+ agent_id=agent_id,
539
+ session_id=session_id,
540
+ state=agent.state,
541
+ )
542
+ finally:
543
+ await self._message_bus.log_trim(events_key)
544
+
545
+ async def _project_event(
546
+ self,
547
+ user_id: str,
548
+ session_record: SessionRecord,
549
+ agent_record: AgentRecord,
550
+ event: AgentEvent,
551
+ ) -> None:
552
+ """Run every registered projector against one produced event.
553
+
554
+ Each :class:`~agentscope.app._types.EventProjector` decides
555
+ whether the event is relevant to its cross-session UI feed and,
556
+ if so, mirrors it onto the owning session via the shared
557
+ :class:`SessionProjection`. Projectors are independent: one
558
+ failing must neither tear down the producing run nor block the
559
+ others, so each call is guarded individually and its error
560
+ logged. Adding a feed means adding a projector — no change here.
561
+
562
+ Args:
563
+ user_id (`str`):
564
+ The owner user id.
565
+ session_record (`SessionRecord`):
566
+ The currently-running session's record.
567
+ agent_record (`AgentRecord`):
568
+ The currently-running agent's record.
569
+ event (`AgentEvent`):
570
+ The event just published to this session's channel.
571
+ """
572
+ for projector in self._projectors:
573
+ try:
574
+ await projector.maybe_project(
575
+ user_id,
576
+ session_record,
577
+ agent_record,
578
+ event,
579
+ self._projection,
580
+ )
581
+ except Exception as e: # pylint: disable=broad-except
582
+ logger.warning(
583
+ "Projector %s failed on event %s from session %s: %s",
584
+ type(projector).__name__,
585
+ type(event).__name__,
586
+ session_record.id,
587
+ str(e),
588
+ )
src/agentscope/app/_service/_embedding.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """Embedding model service: builds an EmbeddingModelBase from stored
3
+ credential + config.
4
+
5
+ Mirrors :mod:`._model` (which does the same for chat models).
6
+ """
7
+ from fastapi import HTTPException, status
8
+
9
+ from ..storage import StorageBase, EmbeddingModelConfig
10
+ from ...credential import CredentialFactory
11
+ from ...embedding import EmbeddingModelBase
12
+
13
+
14
+ async def get_embedding_model(
15
+ user_id: str,
16
+ config: EmbeddingModelConfig,
17
+ storage: StorageBase,
18
+ ) -> EmbeddingModelBase:
19
+ """Construct an embedding model from a stored credential and config.
20
+
21
+ This is the embedding counterpart of
22
+ :func:`~agentscope.app._service._model.get_model`. It loads the
23
+ user's credential from storage, resolves the matching embedding
24
+ model class, looks up the model card for ``context_size``, and
25
+ constructs a ready-to-use instance.
26
+
27
+ Args:
28
+ user_id (`str`):
29
+ The authenticated user id (credential owner).
30
+ config (`EmbeddingModelConfig`):
31
+ The embedding model configuration containing
32
+ ``type``, ``credential_id``, ``model``, and
33
+ ``parameters``.
34
+ storage (`StorageBase`):
35
+ The storage backend for loading credentials.
36
+
37
+ Returns:
38
+ `EmbeddingModelBase`:
39
+ A configured embedding model instance.
40
+
41
+ Raises:
42
+ `HTTPException`:
43
+ 404 if the credential is not found.
44
+ 400 if the provider does not support embedding.
45
+ """
46
+ # 1. Load credential from storage.
47
+ credential_record = await storage.get_credential(
48
+ user_id,
49
+ config.credential_id,
50
+ )
51
+ if credential_record is None:
52
+ raise HTTPException(
53
+ status_code=status.HTTP_404_NOT_FOUND,
54
+ detail=f"Credential {config.credential_id!r} not found.",
55
+ )
56
+
57
+ credential = CredentialFactory.from_dict(credential_record.data)
58
+
59
+ # 2. Resolve the embedding model class from the credential type.
60
+ credential_cls = CredentialFactory.get_credential_class(config.type)
61
+ if credential_cls is None:
62
+ raise HTTPException(
63
+ status_code=status.HTTP_400_BAD_REQUEST,
64
+ detail=f"Provider {config.type!r} not found.",
65
+ )
66
+
67
+ embedding_cls = credential_cls.get_embedding_model_class()
68
+ if embedding_cls is None:
69
+ raise HTTPException(
70
+ status_code=status.HTTP_400_BAD_REQUEST,
71
+ detail=(
72
+ f"Provider {config.type!r} does not support "
73
+ f"embedding models."
74
+ ),
75
+ )
76
+
77
+ # 3. Look up the model card for context_size.
78
+ context_size: int | None = None
79
+ for card in embedding_cls.list_models():
80
+ if card.name == config.model:
81
+ context_size = card.context_size
82
+ break
83
+
84
+ # 4. Build parameters (provider-specific, no dimensions).
85
+ parameters = (
86
+ embedding_cls.Parameters(**config.parameters)
87
+ if config.parameters
88
+ else None
89
+ )
90
+
91
+ # 5. Construct the model — dimensions is first-class, not in parameters.
92
+ kwargs: dict = {
93
+ "credential": credential,
94
+ "model": config.model,
95
+ "dimensions": config.dimensions,
96
+ "parameters": parameters,
97
+ }
98
+ if context_size is not None:
99
+ kwargs["context_size"] = context_size
100
+
101
+ return embedding_cls(**kwargs)
src/agentscope/app/_service/_index_sweeper.py ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """Background sweep for stuck knowledge-document indexing jobs.
3
+
4
+ The indexing pipeline relies on two storage-level signals to keep
5
+ moving when something goes wrong:
6
+
7
+ - a *lease* per in-flight document — its ``lease_expires_at`` is the
8
+ upper bound on how long a worker may sit on the document before
9
+ another worker is allowed to take over;
10
+ - a *creation timestamp* on every ``pending`` record — used to catch
11
+ documents that were never picked up by a worker (e.g. process died
12
+ right after the upload endpoint persisted the record).
13
+
14
+ The sweeper periodically scans storage for both classes of stuck
15
+ records and re-enqueues them on the index-task channel. Re-enqueue is
16
+ safe because the worker's CAS lease acquisition rejects duplicates,
17
+ so multiple nodes running their own sweeper does not produce double
18
+ processing.
19
+ """
20
+ import asyncio
21
+ from datetime import datetime, timedelta
22
+ from typing import TYPE_CHECKING
23
+
24
+ from ..._logging import logger
25
+ from .._bus_ops import enqueue_index_task
26
+
27
+ if TYPE_CHECKING:
28
+ from ..message_bus import MessageBus
29
+ from ..storage import StorageBase
30
+
31
+
32
+ class IndexSweeper:
33
+ """Periodically re-enqueues documents stuck in indexing.
34
+
35
+ Lifecycle is wired into the app's lifespan: :meth:`start` schedules
36
+ the background task and runs an immediate sweep so that documents
37
+ left stuck by the previous process generation get picked up at
38
+ once; :meth:`stop` cancels the loop on shutdown.
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ storage: "StorageBase",
44
+ message_bus: "MessageBus",
45
+ interval: timedelta = timedelta(seconds=60),
46
+ pending_grace: timedelta = timedelta(minutes=5),
47
+ ) -> None:
48
+ """Initialize the sweeper.
49
+
50
+ Args:
51
+ storage (`StorageBase`):
52
+ Used to find stuck records and as the contract holder
53
+ for the lease semantics.
54
+ message_bus (`MessageBus`):
55
+ The same bus the upload endpoint uses. Re-enqueuing
56
+ a document re-enters the worker pipeline, where the
57
+ CAS lease acquisition decides whether to actually
58
+ process or bail.
59
+ interval (`timedelta`, defaults to ``60s``):
60
+ How often the loop wakes up. Roughly one order of
61
+ magnitude shorter than the typical lease TTL — fast
62
+ enough to recover from crashes within a few minutes,
63
+ slow enough not to thrash storage.
64
+ pending_grace (`timedelta`, defaults to ``5min``):
65
+ A record may legitimately sit in ``pending`` while the
66
+ bus push is still queued; only after this grace period
67
+ do we treat the record as orphaned.
68
+ """
69
+ self._storage = storage
70
+ self._bus = message_bus
71
+ self._interval = interval
72
+ self._pending_grace = pending_grace
73
+ self._task: asyncio.Task[None] | None = None
74
+
75
+ async def start(self) -> None:
76
+ """Start the background sweep loop and run one immediate sweep."""
77
+ if self._task is not None:
78
+ return
79
+ # Catch up from any state the previous generation left behind.
80
+ await self._sweep_once()
81
+ self._task = asyncio.create_task(
82
+ self._loop(),
83
+ name="kb-index-sweeper",
84
+ )
85
+
86
+ async def stop(self) -> None:
87
+ """Cancel the sweep loop and wait for it to exit."""
88
+ if self._task is None:
89
+ return
90
+ self._task.cancel()
91
+ try:
92
+ await self._task
93
+ except asyncio.CancelledError:
94
+ pass
95
+ self._task = None
96
+
97
+ async def _loop(self) -> None:
98
+ """Run sweeps forever until cancelled."""
99
+ interval_seconds = self._interval.total_seconds()
100
+ while True:
101
+ try:
102
+ await asyncio.sleep(interval_seconds)
103
+ except asyncio.CancelledError:
104
+ return
105
+ try:
106
+ await self._sweep_once()
107
+ except asyncio.CancelledError:
108
+ return
109
+ except Exception: # noqa: BLE001 — keep the loop alive
110
+ logger.exception("Sweep iteration failed")
111
+
112
+ async def _sweep_once(self) -> None:
113
+ """Find and re-enqueue every stuck document.
114
+
115
+ De-duplication: a document showing up in both the
116
+ expired-lease and orphan-pending queries (a record that was
117
+ never picked up and whose lease pre-dates the grace period)
118
+ is enqueued only once per sweep, by record id.
119
+ """
120
+ now = datetime.now()
121
+ pending_threshold = now - self._pending_grace
122
+
123
+ seen: set[str] = set()
124
+ stuck = (
125
+ await self._storage.list_knowledge_documents_with_expired_lease(
126
+ now=now,
127
+ )
128
+ )
129
+ orphans = await self._storage.list_knowledge_documents_pending_since(
130
+ threshold=pending_threshold,
131
+ )
132
+ for record in (*stuck, *orphans):
133
+ if record.id in seen:
134
+ continue
135
+ seen.add(record.id)
136
+ try:
137
+ await enqueue_index_task(
138
+ self._bus,
139
+ user_id=record.user_id,
140
+ knowledge_base_id=record.knowledge_base_id,
141
+ document_id=record.id,
142
+ )
143
+ except Exception: # noqa: BLE001 — keep iterating
144
+ logger.exception(
145
+ "Failed to re-enqueue document %s",
146
+ record.id,
147
+ )
148
+
149
+ if seen:
150
+ logger.info("Re-enqueued %d stuck document(s)", len(seen))
src/agentscope/app/_service/_index_task_consumer.py ADDED
@@ -0,0 +1,216 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """Single per-worker-process consumer of the shared index-task channel.
3
+
4
+ One asyncio task per worker process. Subscribes to the shared
5
+ :meth:`~agentscope.app.message_bus.MessageBusKeys.index_tasks_signal`
6
+ channel and drains the durable
7
+ :meth:`~agentscope.app.message_bus.MessageBusKeys.index_tasks_queue`
8
+ on each signal. For each queued entry it invokes
9
+ :meth:`IndexWorker.process` directly — the worker holds its own
10
+ semaphore so we can fire-and-forget multiple ``process`` calls without
11
+ overrunning resources.
12
+
13
+ Mirrors :class:`~agentscope.app._manager.WakeupDispatcher`. The two
14
+ patterns are deliberately identical: both subscribe to a signal,
15
+ drain a queue, dispatch each entry, and run forever inside an
16
+ ``async with`` block. Keeping them shaped the same makes it cheap to
17
+ reason about either one once you've read the other.
18
+
19
+ The bus exposes only transport-level primitives — there is no
20
+ ``enqueue_index_task`` or ``dequeue_index_task`` method on it. The
21
+ key constants live on :class:`~agentscope.app.message_bus.
22
+ MessageBusKeys` (next to every other application-layer key) and the
23
+ composition is inline here because the consumer is the only sink for
24
+ the channel; introducing a separate ``IndexTaskBroker`` would be
25
+ ceremony without gain.
26
+ """
27
+ import asyncio
28
+ from typing import TYPE_CHECKING, Any, Self
29
+
30
+ from ..message_bus import MessageBusKeys
31
+ from ..._logging import logger
32
+
33
+ if TYPE_CHECKING:
34
+ from ..message_bus import MessageBus
35
+ from ._index_worker import IndexWorker
36
+
37
+
38
+ class IndexTaskConsumer:
39
+ """Subscribe-then-drain consumer that feeds :class:`IndexWorker`.
40
+
41
+ Args:
42
+ message_bus (`MessageBus`):
43
+ Application message bus. The consumer only uses the two
44
+ transport-level primitives — ``subscribe`` (for the
45
+ signal channel) and ``queue_drain`` (for the durable
46
+ task queue).
47
+ worker (`IndexWorker`):
48
+ The worker that owns the parse → chunk → index pipeline.
49
+ ``process`` is invoked once per queue entry; the worker's
50
+ internal semaphore + lease CAS handle concurrency and
51
+ deduplication.
52
+ max_batch (`int`, defaults to ``32``):
53
+ Maximum entries drained per signal. Keeps a single
54
+ signal from monopolising the loop when the queue is
55
+ backed up; remaining entries are picked up on the next
56
+ signal or the next sweeper-driven eager drain.
57
+ """
58
+
59
+ def __init__(
60
+ self,
61
+ message_bus: "MessageBus",
62
+ worker: "IndexWorker",
63
+ max_batch: int = 32,
64
+ ) -> None:
65
+ self._bus = message_bus
66
+ self._worker = worker
67
+ self._max_batch = max_batch
68
+ self._task: asyncio.Task | None = None
69
+ # In-flight ``worker.process`` calls. Tracked so ``__aexit__``
70
+ # can cancel + drain them; otherwise the event-loop teardown
71
+ # would swallow exceptions raised inside the worker.
72
+ self._inflight: set[asyncio.Task[Any]] = set()
73
+
74
+ async def __aenter__(self) -> Self:
75
+ """Start the consumer loop and wait until its subscription
76
+ is live.
77
+
78
+ After the subscription is established, an initial drain runs
79
+ synchronously so tasks queued while every worker was down
80
+ get picked up immediately on startup, without waiting for
81
+ a fresh signal.
82
+ """
83
+ ready = asyncio.Event()
84
+ self._task = asyncio.create_task(
85
+ self._loop(ready),
86
+ name="index-task-consumer",
87
+ )
88
+ await ready.wait()
89
+ await self._drain_and_dispatch()
90
+ return self
91
+
92
+ async def __aexit__(
93
+ self,
94
+ exc_type: type[BaseException] | None,
95
+ exc_val: BaseException | None,
96
+ exc_tb: Any,
97
+ ) -> None:
98
+ """Cancel the consumer loop and drain any in-flight work.
99
+
100
+ Cancellation of the worker's ``process`` calls is a clean
101
+ shutdown signal — the worker holds the storage lease and
102
+ will let it expire so the sweeper re-dispatches the document
103
+ on the next loop tick.
104
+ """
105
+ if self._task is not None:
106
+ self._task.cancel()
107
+ try:
108
+ await self._task
109
+ except asyncio.CancelledError:
110
+ pass
111
+ self._task = None
112
+
113
+ for task in list(self._inflight):
114
+ task.cancel()
115
+ if self._inflight:
116
+ await asyncio.gather(*self._inflight, return_exceptions=True)
117
+ self._inflight.clear()
118
+
119
+ # ------------------------------------------------------------------
120
+ # Internals
121
+ # ------------------------------------------------------------------
122
+
123
+ async def _loop(self, ready: asyncio.Event) -> None:
124
+ """Long-lived loop: subscribe to the signal and drain on each
125
+ received signal.
126
+
127
+ Args:
128
+ ready (`asyncio.Event`):
129
+ Signalled after the underlying SUBSCRIBE completes.
130
+ :meth:`__aenter__` blocks on this so the producer
131
+ can publish a signal immediately after start-up
132
+ without racing.
133
+ """
134
+ try:
135
+ async for _signal in self._bus.subscribe(
136
+ MessageBusKeys.index_tasks_signal(),
137
+ on_ready=ready.set,
138
+ ):
139
+ await self._drain_and_dispatch()
140
+ except Exception: # pylint: disable=broad-except
141
+ logger.exception(
142
+ "IndexTaskConsumer loop crashed; subscription ended.",
143
+ )
144
+ finally:
145
+ # If ``subscribe`` raises before ``on_ready`` fires, the
146
+ # ``__aenter__`` coroutine would deadlock on ``ready.wait()``.
147
+ # Set the event unconditionally on the way out so startup
148
+ # cannot stall on a transient bus failure.
149
+ ready.set()
150
+
151
+ async def _drain_and_dispatch(self) -> None:
152
+ """Read up to a batch of task entries and dispatch each one."""
153
+ try:
154
+ entries = await self._bus.queue_drain(
155
+ MessageBusKeys.index_tasks_queue(),
156
+ max_count=self._max_batch,
157
+ )
158
+ except Exception: # pylint: disable=broad-except
159
+ logger.exception("IndexTaskConsumer: drain failed.")
160
+ return
161
+
162
+ for _entry_id, payload in entries:
163
+ try:
164
+ user_id = payload["user_id"]
165
+ knowledge_base_id = payload["knowledge_base_id"]
166
+ document_id = payload["document_id"]
167
+ except (KeyError, TypeError):
168
+ logger.warning(
169
+ "IndexTaskConsumer: skipping malformed entry %r",
170
+ payload,
171
+ )
172
+ continue
173
+
174
+ self._spawn(
175
+ user_id=user_id,
176
+ knowledge_base_id=knowledge_base_id,
177
+ document_id=document_id,
178
+ )
179
+
180
+ def _spawn(
181
+ self,
182
+ *,
183
+ user_id: str,
184
+ knowledge_base_id: str,
185
+ document_id: str,
186
+ ) -> None:
187
+ """Run :meth:`IndexWorker.process` as a tracked background task.
188
+
189
+ We do not ``await`` ``worker.process`` inline — a slow parse
190
+ would block draining the next signal. The worker holds its
191
+ own concurrency semaphore, so spawning many tasks at once is
192
+ safe; they will queue at the semaphore.
193
+ """
194
+ task = asyncio.create_task(
195
+ self._worker.process(
196
+ user_id=user_id,
197
+ knowledge_base_id=knowledge_base_id,
198
+ document_id=document_id,
199
+ ),
200
+ name=f"index-task:{knowledge_base_id}:{document_id}",
201
+ )
202
+ self._inflight.add(task)
203
+ task.add_done_callback(self._on_done)
204
+
205
+ def _on_done(self, task: asyncio.Task[Any]) -> None:
206
+ """Drop the task reference and log any uncaught exception."""
207
+ self._inflight.discard(task)
208
+ if task.cancelled():
209
+ return
210
+ exc = task.exception()
211
+ if exc is not None:
212
+ logger.exception(
213
+ "IndexTaskConsumer: worker.process(%s) raised",
214
+ task.get_name(),
215
+ exc_info=exc,
216
+ )
src/agentscope/app/_service/_index_worker.py ADDED
@@ -0,0 +1,534 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """Background indexing pipeline for one knowledge document.
3
+
4
+ The :class:`IndexWorker` owns the post-upload half of the document
5
+ lifecycle. Given a ``document_id`` it:
6
+
7
+ 1. acquires the processing lease via storage CAS (so only one worker
8
+ in the cluster handles the document at a time);
9
+ 2. reads the bytes back from the blob store (streamed);
10
+ 3. routes to a parser by IANA media type;
11
+ 4. chunks the resulting sections;
12
+ 5. embeds + writes to the vector store through
13
+ :class:`~agentscope.rag.KnowledgeBase`;
14
+ 6. transitions the status through ``parsing → chunking → indexing →
15
+ ready`` (or ``error``) on the way.
16
+
17
+ The worker is intentionally embeddable: a single instance can live
18
+ inside the API process (embedded deployment) or inside a dedicated
19
+ worker process (dedicated deployment). Coordination across workers
20
+ is done entirely through the storage lease — workers do not need to
21
+ know about each other.
22
+ """
23
+ import asyncio
24
+ import contextlib
25
+ import mimetypes
26
+ from concurrent.futures import ProcessPoolExecutor
27
+ from datetime import timedelta
28
+ from typing import TYPE_CHECKING
29
+
30
+ from ..._logging import logger
31
+
32
+ if TYPE_CHECKING:
33
+ from ..rag.blob_store import BlobStoreBase
34
+ from ..rag.knowledge_base_manager import KnowledgeBaseManagerBase
35
+ from ..storage import StorageBase
36
+ from ...rag import ChunkerBase, ParserBase, Section
37
+
38
+ # Read blob bytes in chunks bounded so the worker never holds the whole
39
+ # file in memory at once even when the parser is byte-oriented.
40
+ _READ_CHUNK = 1 << 20 # 1 MiB
41
+
42
+
43
+ def _build_parser_registry(
44
+ parsers: "list[ParserBase] | dict[str, ParserBase]",
45
+ ) -> "dict[str, ParserBase]":
46
+ """Normalise the user-supplied parser registry.
47
+
48
+ Two input shapes are accepted:
49
+
50
+ - **List** — each parser's ``supported_media_types`` is expanded;
51
+ duplicate media types resolve to the **last** parser in the list
52
+ (so callers can layer custom parsers over the defaults), with a
53
+ warning logged for each override so silent shadowing is not
54
+ possible.
55
+ - **Dict** — the caller's mapping is used verbatim. This is the
56
+ escape hatch for callers who want full control over routing (one
57
+ parser bound to multiple types, type aliases, etc.); a warning is
58
+ logged when a parser is registered against a media type it does
59
+ not declare in ``supported_media_types``, since that almost
60
+ always indicates a typo.
61
+
62
+ Args:
63
+ parsers (`list[ParserBase] | dict[str, ParserBase]`):
64
+ The user-supplied parser registry.
65
+
66
+ Returns:
67
+ `dict[str, ParserBase]`:
68
+ The resolved ``media_type → parser`` routing table.
69
+ """
70
+ if isinstance(parsers, dict):
71
+ for media_type, parser in parsers.items():
72
+ declared = getattr(parser, "supported_media_types", ())
73
+ if declared and media_type not in declared:
74
+ logger.warning(
75
+ "Parser %s registered for media type %r but it only "
76
+ "declares %s — proceeding with the caller-supplied "
77
+ "mapping.",
78
+ type(parser).__name__,
79
+ media_type,
80
+ list(declared),
81
+ )
82
+ return dict(parsers)
83
+
84
+ registry: dict[str, "ParserBase"] = {}
85
+ for parser in parsers:
86
+ for media_type in parser.supported_media_types:
87
+ previous = registry.get(media_type)
88
+ if previous is not None and previous is not parser:
89
+ logger.warning(
90
+ "Parser %s overrides %s for media type %r — later "
91
+ "entries in `parsers` win. Pass a "
92
+ "`dict[str, ParserBase]` if you want explicit "
93
+ "routing.",
94
+ type(parser).__name__,
95
+ type(previous).__name__,
96
+ media_type,
97
+ )
98
+ registry[media_type] = parser
99
+ return registry
100
+
101
+
102
+ class IndexWorker:
103
+ """Drive one document through parse → chunk → index.
104
+
105
+ Multiple invocations of :meth:`process` are run concurrently up to
106
+ a per-worker semaphore. The semaphore protects shared resources
107
+ that scale with the number of in-flight parses (memory for big
108
+ PDFs, embedding API rate budget), while the lease CAS in storage
109
+ protects against the *cross-worker* version of the same race.
110
+ """
111
+
112
+ def __init__(
113
+ self,
114
+ storage: "StorageBase",
115
+ blob_store: "BlobStoreBase",
116
+ knowledge_base_manager: "KnowledgeBaseManagerBase",
117
+ parsers: "list[ParserBase] | dict[str, ParserBase]",
118
+ chunker: "ChunkerBase",
119
+ node_id: str,
120
+ max_concurrency: int = 4,
121
+ lease_ttl: timedelta = timedelta(seconds=90),
122
+ parser_executor: ProcessPoolExecutor | None = None,
123
+ ) -> None:
124
+ """Initialize the worker.
125
+
126
+ Args:
127
+ storage (`StorageBase`):
128
+ Document records, lease, status.
129
+ blob_store (`BlobStoreBase`):
130
+ Source of the document bytes.
131
+ knowledge_base_manager (`KnowledgeBaseManagerBase`):
132
+ Resolves the :class:`KnowledgeBase` runtime for embedding
133
+ and vector store writes.
134
+ parsers (`list[ParserBase] | dict[str, ParserBase]`):
135
+ Parsers used to dispatch uploads by IANA media type.
136
+ Two input shapes are accepted:
137
+
138
+ - **List** — each parser's ``supported_media_types`` is
139
+ expanded into a routing table; later entries override
140
+ earlier ones for overlapping types, with a warning
141
+ logged at construction time.
142
+ - **Dict** — caller-supplied ``media_type → parser``
143
+ routing table used verbatim. ``supported_media_types``
144
+ is **not** consulted, but a warning is logged if a
145
+ parser is registered against a media type it does not
146
+ declare.
147
+
148
+ Same registry the upload service uses, passed in by DI.
149
+ chunker (`ChunkerBase`):
150
+ The shared chunker.
151
+ node_id (`str`):
152
+ Stable identifier for this worker process. Used as
153
+ ``processing_node`` on the lease so the sweeper can
154
+ tell whose work expired. Typically
155
+ ``f"{hostname}:{pid}:{uuid}"``.
156
+ max_concurrency (`int`, defaults to ``4``):
157
+ Maximum number of documents processed concurrently by
158
+ this worker. Higher values trade memory for
159
+ throughput; tune per embedding-API rate limits and
160
+ per-document parse cost.
161
+ lease_ttl (`timedelta`, defaults to ``90s``):
162
+ How long a single processing lease lives. The worker
163
+ renews periodically so long-running parses do not
164
+ trip the sweeper.
165
+ parser_executor (`ProcessPoolExecutor | None`, optional):
166
+ Process pool used to off-load CPU-intensive parses
167
+ (PDF, Office). ``None`` runs parses in the event-loop
168
+ thread, which is fine for plain text but unsafe for
169
+ third-party byte-oriented parsers. Injected so a
170
+ single pool can be shared across the app (built in
171
+ lifespan).
172
+ """
173
+ self._storage = storage
174
+ self._blob_store = blob_store
175
+ self._manager = knowledge_base_manager
176
+ self._parsers_by_media_type = _build_parser_registry(parsers)
177
+ self._chunker = chunker
178
+ self._node_id = node_id
179
+ self._lease_ttl = lease_ttl
180
+ self._sem = asyncio.Semaphore(max_concurrency)
181
+ self._parser_executor = parser_executor
182
+ # Renewal cadence: refresh while there is still half the lease
183
+ # left so a one-cycle missed renewal doesn't drop the lease.
184
+ self._renew_interval = max(lease_ttl / 2, timedelta(seconds=5))
185
+
186
+ async def process(
187
+ self,
188
+ user_id: str,
189
+ knowledge_base_id: str,
190
+ document_id: str,
191
+ ) -> None:
192
+ """Run the full indexing pipeline for one document.
193
+
194
+ Steps:
195
+
196
+ 1. **Lease** — CAS-acquire the processing lease; bail if some
197
+ other worker already holds it (duplicate dispatch / sweep).
198
+ 2. **Throttle** — wait on the per-worker semaphore so the
199
+ number of in-flight parses stays bounded.
200
+ 3. **Pipeline** — parse → chunk → embed + write vector store,
201
+ updating status before each phase. A background heartbeat
202
+ keeps the lease alive while parsing runs.
203
+ 4. **Finalise** — on success mark ``ready`` with the final
204
+ chunk count; on failure mark ``error`` with a sanitised
205
+ message. The lease is released regardless.
206
+
207
+ Args:
208
+ user_id (`str`):
209
+ The owner user id.
210
+ knowledge_base_id (`str`):
211
+ The parent knowledge base id.
212
+ document_id (`str`):
213
+ The document to process.
214
+ """
215
+ acquired = await self._storage.acquire_knowledge_document_lease(
216
+ user_id=user_id,
217
+ knowledge_base_id=knowledge_base_id,
218
+ document_id=document_id,
219
+ processing_node=self._node_id,
220
+ lease_ttl=self._lease_ttl,
221
+ )
222
+ if not acquired:
223
+ logger.debug(
224
+ "Skipping %s — another worker holds the lease.",
225
+ document_id,
226
+ )
227
+ return
228
+
229
+ pipeline_task = asyncio.create_task(
230
+ self._guarded_pipeline(
231
+ user_id,
232
+ knowledge_base_id,
233
+ document_id,
234
+ ),
235
+ name=f"pipeline:{document_id}",
236
+ )
237
+ heartbeat_task = asyncio.create_task(
238
+ self._heartbeat(user_id, knowledge_base_id, document_id),
239
+ name=f"lease-renew:{document_id}",
240
+ )
241
+ try:
242
+ # Race the pipeline against the heartbeat: if the heartbeat
243
+ # returns first, the lease was stolen mid-flight (e.g. the
244
+ # sweeper reaped this worker after a renewal gap) — we MUST
245
+ # stop the pipeline before it writes the vector store again,
246
+ # otherwise the worker that just took over and this one will
247
+ # both insert the same chunks.
248
+ await asyncio.wait(
249
+ {pipeline_task, heartbeat_task},
250
+ return_when=asyncio.FIRST_COMPLETED,
251
+ )
252
+
253
+ if not pipeline_task.done():
254
+ # Heartbeat reached the end first; only `_heartbeat`'s
255
+ # lost-lease branch returns, so cancel the pipeline and
256
+ # surface it as a terminal error for this document.
257
+ pipeline_task.cancel()
258
+ with contextlib.suppress(
259
+ asyncio.CancelledError,
260
+ Exception,
261
+ ):
262
+ await pipeline_task
263
+ raise RuntimeError(
264
+ f"Lost lease on {document_id} during processing; "
265
+ "another worker has taken over.",
266
+ )
267
+
268
+ # Pipeline finished first; stop the heartbeat and re-raise
269
+ # whatever the pipeline raised (if anything).
270
+ heartbeat_task.cancel()
271
+ with contextlib.suppress(asyncio.CancelledError):
272
+ await heartbeat_task
273
+ await pipeline_task
274
+ except Exception as exc: # noqa: BLE001 — terminal error sink
275
+ await self._mark_error(
276
+ user_id,
277
+ knowledge_base_id,
278
+ document_id,
279
+ exc,
280
+ )
281
+ finally:
282
+ # Release is CAS-guarded server-side on ``processing_node``
283
+ # (storage._base.release_knowledge_document_lease) — calling
284
+ # it after a stolen lease is a safe no-op.
285
+ await self._storage.release_knowledge_document_lease(
286
+ user_id=user_id,
287
+ knowledge_base_id=knowledge_base_id,
288
+ document_id=document_id,
289
+ processing_node=self._node_id,
290
+ )
291
+
292
+ async def _guarded_pipeline(
293
+ self,
294
+ user_id: str,
295
+ knowledge_base_id: str,
296
+ document_id: str,
297
+ ) -> None:
298
+ """Run the throttled pipeline inside the per-worker semaphore."""
299
+ async with self._sem:
300
+ await self._run_pipeline(
301
+ user_id,
302
+ knowledge_base_id,
303
+ document_id,
304
+ )
305
+
306
+ # ------------------------------------------------------------------
307
+ # Pipeline
308
+ # ------------------------------------------------------------------
309
+
310
+ async def _run_pipeline(
311
+ self,
312
+ user_id: str,
313
+ knowledge_base_id: str,
314
+ document_id: str,
315
+ ) -> None:
316
+ """Walk the document through parse → chunk → index."""
317
+ record = await self._storage.get_knowledge_document(
318
+ user_id,
319
+ knowledge_base_id,
320
+ document_id,
321
+ )
322
+ if record is None:
323
+ logger.warning(
324
+ "Document %s vanished before processing.",
325
+ document_id,
326
+ )
327
+ return
328
+
329
+ data = record.data
330
+ media_type = (
331
+ data.content_type or mimetypes.guess_type(data.filename)[0]
332
+ )
333
+ if not media_type:
334
+ raise ValueError(
335
+ f"Cannot determine media type for {data.filename!r}.",
336
+ )
337
+ parser = self._parsers_by_media_type.get(media_type)
338
+ if parser is None:
339
+ raise ValueError(
340
+ f"No parser registered for media type {media_type!r}.",
341
+ )
342
+
343
+ # ---- parsing ----
344
+ await self._storage.update_knowledge_document_status(
345
+ user_id,
346
+ knowledge_base_id,
347
+ document_id,
348
+ "parsing",
349
+ )
350
+ file_bytes = await self._read_blob(data.blob_uri)
351
+ sections = await self._parse(parser, file_bytes, data.filename)
352
+
353
+ # ---- chunking ----
354
+ await self._storage.update_knowledge_document_status(
355
+ user_id,
356
+ knowledge_base_id,
357
+ document_id,
358
+ "chunking",
359
+ )
360
+ chunks = await self._chunker.chunk(sections)
361
+
362
+ # ---- indexing ----
363
+ await self._storage.update_knowledge_document_status(
364
+ user_id,
365
+ knowledge_base_id,
366
+ document_id,
367
+ "indexing",
368
+ )
369
+ knowledge = await self._manager.get_knowledge(
370
+ user_id,
371
+ knowledge_base_id,
372
+ )
373
+ await knowledge.insert_document(
374
+ chunks=chunks,
375
+ document_id=document_id,
376
+ document_metadata={
377
+ "filename": data.filename,
378
+ "media_type": media_type,
379
+ "size_bytes": data.size,
380
+ },
381
+ )
382
+
383
+ # ---- ready ----
384
+ await self._storage.update_knowledge_document_status(
385
+ user_id,
386
+ knowledge_base_id,
387
+ document_id,
388
+ "ready",
389
+ chunk_count=len(chunks),
390
+ )
391
+
392
+ async def _parse(
393
+ self,
394
+ parser: "ParserBase",
395
+ file_bytes: bytes,
396
+ filename: str,
397
+ ) -> "list[Section]":
398
+ """Run the parser, optionally on the process pool."""
399
+ if self._parser_executor is None:
400
+ return await parser.parse(file_bytes, filename)
401
+ loop = asyncio.get_running_loop()
402
+ return await loop.run_in_executor(
403
+ self._parser_executor,
404
+ _run_parser_sync,
405
+ parser,
406
+ file_bytes,
407
+ filename,
408
+ )
409
+
410
+ async def _read_blob(self, blob_uri: str) -> bytes:
411
+ """Stream the blob into memory in bounded chunks.
412
+
413
+ We buffer the whole file before handing it to the parser
414
+ because today's parser API is byte-oriented (``parse(file:
415
+ bytes, filename: str)``). The read loop still avoids large
416
+ single allocations and gives us a single place to upgrade to a
417
+ true streaming parser API later — only this method needs to
418
+ change.
419
+ """
420
+ buffer = bytearray()
421
+ async with self._blob_store.open(blob_uri) as fp:
422
+ while True:
423
+ chunk = await fp.read(_READ_CHUNK)
424
+ if not chunk:
425
+ break
426
+ buffer.extend(chunk)
427
+ return bytes(buffer)
428
+
429
+ # ------------------------------------------------------------------
430
+ # Lease heartbeat
431
+ # ------------------------------------------------------------------
432
+
433
+ async def _heartbeat(
434
+ self,
435
+ user_id: str,
436
+ knowledge_base_id: str,
437
+ document_id: str,
438
+ ) -> None:
439
+ """Renew the lease in the background while processing runs.
440
+
441
+ Two exit paths:
442
+
443
+ - The surrounding pipeline finishes first and cancels this
444
+ task — silent return via :class:`asyncio.CancelledError`.
445
+ - The renewal fails (the sweeper reaped this worker and
446
+ another worker now holds the lease). The task **returns
447
+ normally** in this case; :meth:`process` is racing this task
448
+ against the pipeline and treats a normal return as the
449
+ "lost-lease" signal, cancelling the pipeline before it
450
+ double-writes the vector store.
451
+
452
+ Anything other than ``ok=False`` keeps the loop alive.
453
+ """
454
+ interval_seconds = self._renew_interval.total_seconds()
455
+ while True:
456
+ try:
457
+ await asyncio.sleep(interval_seconds)
458
+ except asyncio.CancelledError:
459
+ return
460
+ ok = await self._storage.renew_knowledge_document_lease(
461
+ user_id=user_id,
462
+ knowledge_base_id=knowledge_base_id,
463
+ document_id=document_id,
464
+ processing_node=self._node_id,
465
+ lease_ttl=self._lease_ttl,
466
+ )
467
+ if not ok:
468
+ logger.warning(
469
+ "Lost lease on %s while processing.",
470
+ document_id,
471
+ )
472
+ return
473
+
474
+ # ------------------------------------------------------------------
475
+ # Error sink
476
+ # ------------------------------------------------------------------
477
+
478
+ async def _mark_error(
479
+ self,
480
+ user_id: str,
481
+ knowledge_base_id: str,
482
+ document_id: str,
483
+ exc: BaseException,
484
+ ) -> None:
485
+ """Persist a sanitised error and mark the document failed."""
486
+ logger.exception(
487
+ "Indexing failed for %s/%s",
488
+ knowledge_base_id,
489
+ document_id,
490
+ exc_info=exc,
491
+ )
492
+ message = _sanitise_error(exc)
493
+ try:
494
+ await self._storage.update_knowledge_document_status(
495
+ user_id,
496
+ knowledge_base_id,
497
+ document_id,
498
+ "error",
499
+ error=message,
500
+ )
501
+ except Exception: # noqa: BLE001 — last-resort log
502
+ logger.exception(
503
+ "Failed to persist error status for %s",
504
+ document_id,
505
+ )
506
+
507
+
508
+ # ----------------------------------------------------------------------
509
+ # Module-level helpers (picklable for ProcessPoolExecutor)
510
+ # ----------------------------------------------------------------------
511
+
512
+
513
+ def _run_parser_sync(
514
+ parser: "ParserBase",
515
+ file_bytes: bytes,
516
+ filename: str,
517
+ ) -> "list[Section]":
518
+ """Run an async parser to completion inside a sync executor."""
519
+ return asyncio.run(parser.parse(file_bytes, filename))
520
+
521
+
522
+ def _sanitise_error(exc: BaseException) -> str:
523
+ """Reduce an exception to a single user-facing line.
524
+
525
+ Only the exception class name + first line of its message are
526
+ kept — stack traces and filesystem paths stay inside the worker
527
+ log and out of the user-visible record.
528
+ """
529
+ raw = str(exc) or exc.__class__.__name__
530
+ first_line = raw.splitlines()[0].strip()
531
+ cls = exc.__class__.__name__
532
+ if not first_line:
533
+ return cls
534
+ return f"{cls}: {first_line[:240]}"
src/agentscope/app/_service/_knowledge_base.py ADDED
@@ -0,0 +1,515 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """Knowledge base service: HTTP-side orchestration.
3
+
4
+ The router stays thin and DTO-shaped; everything HTTP-side that needs
5
+ to coordinate persistence, the blob store, the indexing pipeline,
6
+ and the vector store goes through this service.
7
+
8
+ The split with :class:`~agentscope.rag.KnowledgeBase`
9
+ is deliberate. ``KnowledgeBase`` is a **library-mode** handle that only
10
+ depends on the vector store; embedded users instantiate one and drive
11
+ the parse → chunk → embed pipeline themselves. ``KnowledgeBaseService``
12
+ is **service-mode** orchestration: it owns the document records
13
+ (status / blob / lease) and is the single source of truth for "what
14
+ documents exist in this KB" when the app is running over HTTP. The
15
+ two views are intentionally not blended — mixing library-mode inserts
16
+ with service-mode listing would leave records out of sync, and the
17
+ project's stance is that a knowledge base is managed end-to-end in one
18
+ mode.
19
+ """
20
+ import uuid
21
+ from typing import IO, TYPE_CHECKING
22
+
23
+ from fastapi import HTTPException, status
24
+
25
+ from ..rag.knowledge_base_manager import (
26
+ DimensionPolicyError,
27
+ KnowledgeBaseNotFoundError,
28
+ )
29
+ from ..storage import (
30
+ KnowledgeDocumentData,
31
+ KnowledgeDocumentRecord,
32
+ )
33
+ from ..._logging import logger
34
+ from .._bus_ops import enqueue_index_task
35
+
36
+ if TYPE_CHECKING:
37
+ from ..rag.blob_store import BlobStoreBase
38
+ from ..rag.knowledge_base_manager import KnowledgeBaseManagerBase
39
+ from ..message_bus import MessageBus
40
+ from ..storage import (
41
+ EmbeddingModelConfig,
42
+ KnowledgeBaseRecord,
43
+ StorageBase,
44
+ )
45
+ from ...rag import VectorSearchResult
46
+
47
+
48
+ class KnowledgeBaseService:
49
+ """HTTP service for knowledge bases.
50
+
51
+ Owns the document lifecycle in service mode: register on upload,
52
+ enqueue an index task, query status during indexing, and clean up
53
+ record + blob + vector store on delete. All parsing / chunking /
54
+ embedding work happens inside the
55
+ :class:`~agentscope.app._service.IndexWorker`; the service only
56
+ hands off (via the message bus) and observes.
57
+ """
58
+
59
+ def __init__(
60
+ self,
61
+ storage: "StorageBase",
62
+ knowledge_base_manager: "KnowledgeBaseManagerBase",
63
+ blob_store: "BlobStoreBase",
64
+ message_bus: "MessageBus",
65
+ ) -> None:
66
+ """Initialize the service.
67
+
68
+ Args:
69
+ storage (`StorageBase`):
70
+ The application storage backend; documents are
71
+ persisted here, not inside the vector store.
72
+ knowledge_base_manager (`KnowledgeBaseManagerBase`):
73
+ Resolves the :class:`KnowledgeBase` runtime used to clear
74
+ vector store records on document deletion.
75
+ blob_store (`BlobStoreBase`):
76
+ Owns the bytes from upload until the worker is done.
77
+ The service writes on upload and deletes on document
78
+ removal.
79
+ message_bus (`MessageBus`):
80
+ Application message bus. The service publishes one
81
+ index-task entry per uploaded document via
82
+ :func:`~agentscope.app._bus_ops.enqueue_index_task`;
83
+ a co-located or out-of-process
84
+ :class:`IndexTaskConsumer` drains and processes them.
85
+ """
86
+ self._storage = storage
87
+ self._manager = knowledge_base_manager
88
+ self._blob_store = blob_store
89
+ self._bus = message_bus
90
+
91
+ # ------------------------------------------------------------------
92
+ # Knowledge base CRUD
93
+ # ------------------------------------------------------------------
94
+
95
+ async def create_knowledge_base(
96
+ self,
97
+ user_id: str,
98
+ name: str,
99
+ description: str,
100
+ embedding_model_config: "EmbeddingModelConfig",
101
+ ) -> "KnowledgeBaseRecord":
102
+ """Delegate creation to the manager, mapping policy errors.
103
+
104
+ Args:
105
+ user_id (`str`):
106
+ The owner user id.
107
+ name (`str`):
108
+ Display name.
109
+ description (`str`):
110
+ Free-form description.
111
+ embedding_model_config (`EmbeddingModelConfig`):
112
+ Embedding model configuration; pinned to the record.
113
+
114
+ Returns:
115
+ `KnowledgeBaseRecord`:
116
+ The newly persisted record.
117
+
118
+ Raises:
119
+ `HTTPException`:
120
+ ``409`` when the requested embedding dimension
121
+ violates the manager's dimension policy.
122
+ """
123
+ try:
124
+ return await self._manager.create_knowledge_base(
125
+ user_id=user_id,
126
+ name=name,
127
+ description=description,
128
+ embedding_model_config=embedding_model_config,
129
+ )
130
+ except DimensionPolicyError as exc:
131
+ raise HTTPException(
132
+ status_code=status.HTTP_409_CONFLICT,
133
+ detail=str(exc),
134
+ ) from exc
135
+
136
+ async def list_knowledge_bases(
137
+ self,
138
+ user_id: str,
139
+ ) -> "list[KnowledgeBaseRecord]":
140
+ """List all knowledge base records owned by the given user.
141
+
142
+ Args:
143
+ user_id (`str`):
144
+ The owner user id.
145
+
146
+ Returns:
147
+ `list[KnowledgeBaseRecord]`:
148
+ All knowledge base records belonging to the user.
149
+ """
150
+ return await self._manager.list_knowledge_bases(user_id)
151
+
152
+ async def update_knowledge_base(
153
+ self,
154
+ user_id: str,
155
+ knowledge_base_id: str,
156
+ name: str | None = None,
157
+ description: str | None = None,
158
+ ) -> "KnowledgeBaseRecord":
159
+ """Update mutable fields on a knowledge base, raising 404 if absent.
160
+
161
+ Only ``name`` and ``description`` are mutable. The embedding
162
+ model configuration is pinned at creation time.
163
+ """
164
+ record = await self._manager.update_knowledge_base(
165
+ user_id=user_id,
166
+ knowledge_base_id=knowledge_base_id,
167
+ name=name,
168
+ description=description,
169
+ )
170
+ if record is None:
171
+ raise HTTPException(
172
+ status_code=status.HTTP_404_NOT_FOUND,
173
+ detail=f"Knowledge base {knowledge_base_id!r} not found.",
174
+ )
175
+ return record
176
+
177
+ async def delete_knowledge_base(
178
+ self,
179
+ user_id: str,
180
+ knowledge_base_id: str,
181
+ ) -> None:
182
+ """Delete a knowledge base, raising 404 if absent.
183
+
184
+ Documents under the KB are cascade-deleted at the storage
185
+ layer; blob files referenced by those records are released
186
+ best-effort here so disk space is reclaimed even though the
187
+ manager + storage cascade would otherwise orphan them.
188
+ """
189
+ documents = await self._storage.list_knowledge_documents(
190
+ user_id,
191
+ knowledge_base_id,
192
+ )
193
+ for document in documents:
194
+ await self._delete_blob_quietly(document.data.blob_uri)
195
+
196
+ deleted = await self._manager.delete_knowledge_base(
197
+ user_id,
198
+ knowledge_base_id,
199
+ )
200
+ if not deleted:
201
+ raise HTTPException(
202
+ status_code=status.HTTP_404_NOT_FOUND,
203
+ detail=f"Knowledge base {knowledge_base_id!r} not found.",
204
+ )
205
+
206
+ # ------------------------------------------------------------------
207
+ # Document management
208
+ # ------------------------------------------------------------------
209
+
210
+ async def register_document(
211
+ self,
212
+ user_id: str,
213
+ knowledge_base_id: str,
214
+ filename: str,
215
+ stream: IO[bytes],
216
+ size: int,
217
+ content_type: str | None = None,
218
+ ) -> KnowledgeDocumentRecord:
219
+ """Persist an uploaded document and enqueue it for indexing.
220
+
221
+ Streams ``stream`` into the blob store (so the bytes never
222
+ live fully in memory), records a ``pending`` document, and
223
+ pushes an index-task entry onto the message bus. Returns
224
+ immediately — a worker (in-process or dedicated) takes over
225
+ from here and the client tracks progress via
226
+ :meth:`get_document_status`.
227
+
228
+ Args:
229
+ user_id (`str`):
230
+ The owner user id.
231
+ knowledge_base_id (`str`):
232
+ The target knowledge base id.
233
+ filename (`str`):
234
+ The original filename.
235
+ stream (`IO[bytes]`):
236
+ A synchronous binary stream — typically
237
+ ``UploadFile.file`` from FastAPI.
238
+ size (`int`):
239
+ Byte length declared by the uploader. Persisted on
240
+ the record for the UI; not authoritative.
241
+ content_type (`str | None`, optional):
242
+ IANA media type; ``None`` lets the worker fall back
243
+ to a filename guess at processing time.
244
+
245
+ Returns:
246
+ `KnowledgeDocumentRecord`:
247
+ The persisted record (``status='pending'``) with the
248
+ final ``blob_uri`` filled in.
249
+
250
+ Raises:
251
+ `HTTPException`:
252
+ ``404`` if the knowledge base does not exist.
253
+ """
254
+ # Authorise before touching the blob store: raising after a
255
+ # write would leave the blob orphaned.
256
+ await self._authorise_kb(user_id, knowledge_base_id)
257
+
258
+ document_id = uuid.uuid4().hex
259
+ blob_uri = await self._blob_store.write_stream(
260
+ key=f"kb/{knowledge_base_id}/{document_id}",
261
+ stream=stream,
262
+ )
263
+
264
+ record = KnowledgeDocumentRecord(
265
+ id=document_id,
266
+ user_id=user_id,
267
+ knowledge_base_id=knowledge_base_id,
268
+ data=KnowledgeDocumentData(
269
+ filename=filename,
270
+ size=size,
271
+ content_type=content_type,
272
+ blob_uri=blob_uri,
273
+ ),
274
+ )
275
+ try:
276
+ stored = await self._storage.upsert_knowledge_document(
277
+ user_id,
278
+ record,
279
+ )
280
+ except Exception:
281
+ # Storage write failed — drop the blob so the orphan
282
+ # sweeper doesn't later see a referenced-by-nobody file.
283
+ await self._delete_blob_quietly(blob_uri)
284
+ raise
285
+
286
+ await enqueue_index_task(
287
+ self._bus,
288
+ user_id=user_id,
289
+ knowledge_base_id=knowledge_base_id,
290
+ document_id=document_id,
291
+ )
292
+ return stored
293
+
294
+ async def list_documents(
295
+ self,
296
+ user_id: str,
297
+ knowledge_base_id: str,
298
+ ) -> list[KnowledgeDocumentRecord]:
299
+ """List every document registered against a knowledge base.
300
+
301
+ Service-mode source of truth: reads from storage, NOT the
302
+ vector store. Documents in ``pending`` / ``parsing`` /
303
+ ``chunking`` / ``indexing`` / ``error`` show up here even
304
+ though they have no chunks in the vector store yet.
305
+
306
+ Args:
307
+ user_id (`str`):
308
+ The owner user id.
309
+ knowledge_base_id (`str`):
310
+ The target knowledge base id.
311
+
312
+ Returns:
313
+ `list[KnowledgeDocumentRecord]`:
314
+ Every document registered against the knowledge base,
315
+ in unspecified order.
316
+
317
+ Raises:
318
+ `HTTPException`:
319
+ ``404`` if the knowledge base does not exist.
320
+ """
321
+ await self._authorise_kb(user_id, knowledge_base_id)
322
+ return await self._storage.list_knowledge_documents(
323
+ user_id,
324
+ knowledge_base_id,
325
+ )
326
+
327
+ async def get_document_status(
328
+ self,
329
+ user_id: str,
330
+ knowledge_base_id: str,
331
+ document_ids: list[str],
332
+ ) -> list[KnowledgeDocumentRecord]:
333
+ """Batch-fetch documents for status polling.
334
+
335
+ The endpoint backing this method accepts a comma-separated list
336
+ of ids so the front-end can ask "what's the state of these N
337
+ in-flight uploads" in a single round-trip. Records that do
338
+ not exist or do not belong to the user are silently skipped —
339
+ the front-end may legitimately ask about a document that was
340
+ deleted between two polls.
341
+
342
+ Args:
343
+ user_id (`str`):
344
+ The owner user id.
345
+ knowledge_base_id (`str`):
346
+ The target knowledge base id.
347
+ document_ids (`list[str]`):
348
+ Document ids to look up.
349
+
350
+ Returns:
351
+ `list[KnowledgeDocumentRecord]`:
352
+ One record per matched id; missing ids omitted.
353
+
354
+ Raises:
355
+ `HTTPException`:
356
+ ``404`` if the knowledge base does not exist.
357
+ """
358
+ await self._authorise_kb(user_id, knowledge_base_id)
359
+ records: list[KnowledgeDocumentRecord] = []
360
+ for document_id in document_ids:
361
+ record = await self._storage.get_knowledge_document(
362
+ user_id,
363
+ knowledge_base_id,
364
+ document_id,
365
+ )
366
+ if record is not None:
367
+ records.append(record)
368
+ return records
369
+
370
+ async def delete_document(
371
+ self,
372
+ user_id: str,
373
+ knowledge_base_id: str,
374
+ document_id: str,
375
+ ) -> None:
376
+ """Remove a document end-to-end: vector store, record, blob.
377
+
378
+ Order is chosen so that a crash mid-way always leaves a
379
+ recoverable state:
380
+
381
+ 1. Vector store delete (idempotent — re-deleting an already
382
+ empty document_id is harmless).
383
+ 2. Storage record delete.
384
+ 3. Blob delete (idempotent).
385
+
386
+ A failure at step 1 surfaces as an exception to the caller and
387
+ the record + blob are left untouched, so a retry sees the same
388
+ state. Failures at steps 2/3 leave a small amount of orphan
389
+ data but the user-visible deletion has already succeeded from
390
+ the vector store's point of view.
391
+
392
+ Args:
393
+ user_id (`str`):
394
+ The owner user id.
395
+ knowledge_base_id (`str`):
396
+ The target knowledge base id.
397
+ document_id (`str`):
398
+ The document to delete.
399
+
400
+ Raises:
401
+ `HTTPException`:
402
+ ``404`` if the knowledge base does not exist.
403
+ """
404
+ record = await self._storage.get_knowledge_document(
405
+ user_id,
406
+ knowledge_base_id,
407
+ document_id,
408
+ )
409
+ if record is None:
410
+ # 404 if the KB does not exist, otherwise treat the
411
+ # missing document as already-deleted (idempotent).
412
+ await self._authorise_kb(user_id, knowledge_base_id)
413
+ return
414
+
415
+ knowledge = await self._resolve_knowledge(user_id, knowledge_base_id)
416
+ await knowledge.delete_document(document_id)
417
+ await self._storage.delete_knowledge_document(
418
+ user_id,
419
+ knowledge_base_id,
420
+ document_id,
421
+ )
422
+ await self._delete_blob_quietly(record.data.blob_uri)
423
+
424
+ # ------------------------------------------------------------------
425
+ # Search
426
+ # ------------------------------------------------------------------
427
+
428
+ async def search(
429
+ self,
430
+ user_id: str,
431
+ knowledge_base_id: str,
432
+ query: str,
433
+ top_k: int = 5,
434
+ ) -> "list[VectorSearchResult]":
435
+ """Search a knowledge base by text query.
436
+
437
+ Args:
438
+ user_id (`str`):
439
+ The owner user id.
440
+ knowledge_base_id (`str`):
441
+ The knowledge base to search.
442
+ query (`str`):
443
+ The natural-language query.
444
+ top_k (`int`, defaults to ``5``):
445
+ Maximum number of results.
446
+
447
+ Returns:
448
+ `list[VectorSearchResult]`:
449
+ The top hits ordered by descending similarity score.
450
+
451
+ Raises:
452
+ `HTTPException`:
453
+ ``404`` if the knowledge base does not exist.
454
+ """
455
+ knowledge = await self._resolve_knowledge(user_id, knowledge_base_id)
456
+ return await knowledge.search(queries=[query], top_k=top_k)
457
+
458
+ # ------------------------------------------------------------------
459
+ # Internal helpers
460
+ # ------------------------------------------------------------------
461
+
462
+ async def _authorise_kb(
463
+ self,
464
+ user_id: str,
465
+ knowledge_base_id: str,
466
+ ) -> "KnowledgeBaseRecord":
467
+ """Look the KB record up so we can 404 cleanly.
468
+
469
+ The check is intentionally separate from :meth:`_resolve_knowledge`
470
+ because document-level endpoints (list / delete) need to refuse
471
+ unknown KBs without paying the embedding-model construction cost
472
+ that :meth:`_resolve_knowledge` triggers.
473
+ """
474
+ record = await self._storage.get_knowledge_base(
475
+ user_id,
476
+ knowledge_base_id,
477
+ )
478
+ if record is None:
479
+ raise HTTPException(
480
+ status_code=status.HTTP_404_NOT_FOUND,
481
+ detail=f"Knowledge base {knowledge_base_id!r} not found.",
482
+ )
483
+ return record
484
+
485
+ async def _resolve_knowledge(
486
+ self,
487
+ user_id: str,
488
+ knowledge_base_id: str,
489
+ ) -> "object":
490
+ """Resolve a :class:`KnowledgeBase` and translate not-found to 404."""
491
+ try:
492
+ return await self._manager.get_knowledge(
493
+ user_id,
494
+ knowledge_base_id,
495
+ )
496
+ except KnowledgeBaseNotFoundError as exc:
497
+ raise HTTPException(
498
+ status_code=status.HTTP_404_NOT_FOUND,
499
+ detail=str(exc),
500
+ ) from exc
501
+
502
+ async def _delete_blob_quietly(self, blob_uri: str) -> None:
503
+ """Best-effort blob delete — swallow backend errors.
504
+
505
+ Treated as cleanup: if the blob store is unavailable the
506
+ record/vector-store state is still consistent and a future
507
+ sweep can reclaim the disk space. Surface only via logs.
508
+ """
509
+ try:
510
+ await self._blob_store.delete(blob_uri)
511
+ except Exception: # noqa: BLE001 — cleanup only
512
+ logger.exception(
513
+ "Failed to delete blob %s",
514
+ blob_uri,
515
+ )
src/agentscope/app/_service/_model.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """Model service: builds a ChatModelBase from stored credential + config."""
3
+ from fastapi import HTTPException, status
4
+
5
+ from ..storage import StorageBase, ChatModelConfig
6
+ from ...credential import CredentialFactory
7
+ from ...model import ChatModelBase
8
+
9
+
10
+ async def get_model(
11
+ user_id: str,
12
+ config: ChatModelConfig,
13
+ storage: StorageBase,
14
+ ) -> ChatModelBase:
15
+ """Get the model instance from the configuration and storage.
16
+
17
+ Args:
18
+ user_id (`str`):
19
+ The user id.
20
+ config (`ChatModelConfig`):
21
+ The chat model configuration.
22
+ storage (`StorageBase`):
23
+ The storage instance.
24
+
25
+ Returns:
26
+ `ChatModelBase`:
27
+ The model instance.
28
+ """
29
+ credential_record = await storage.get_credential(
30
+ user_id,
31
+ config.credential_id,
32
+ )
33
+ if credential_record is None:
34
+ raise HTTPException(
35
+ status_code=status.HTTP_404_NOT_FOUND,
36
+ detail=f"Credential {config.credential_id!r} not found.",
37
+ )
38
+
39
+ credential = CredentialFactory.from_dict(credential_record.data)
40
+ model_cls = credential.get_chat_model_class()
41
+ parameters = (
42
+ model_cls.Parameters(**config.parameters)
43
+ if config.parameters
44
+ else None
45
+ )
46
+ return model_cls(
47
+ credential=credential,
48
+ model=config.model,
49
+ parameters=parameters,
50
+ )
src/agentscope/app/_service/_projectors/__init__.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """Built-in event projectors.
3
+
4
+ Each projector mirrors one cross-session UI feed onto the owning
5
+ session via the shared
6
+ :class:`~agentscope.app._service._session_projection.SessionProjection`
7
+ primitive. See :class:`~agentscope.app._types.EventProjector`.
8
+ """
9
+ from ._subagent_hitl import SubagentHitlProjector
10
+
11
+ __all__ = [
12
+ "SubagentHitlProjector",
13
+ ]
src/agentscope/app/_service/_projectors/_subagent_hitl.py ADDED
@@ -0,0 +1,270 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """Projector that bridges team-member HITL events to leader sessions.
3
+
4
+ When a team *member* (worker) session hits a tool call that needs human
5
+ confirmation, the worker run parks on an ``ASKING`` tool call in its
6
+ **own** session — invisible to a client subscribed only to the *leader*
7
+ session's event stream. This projector mirrors each such pending
8
+ request onto the leader session so the leader UI can render and resolve
9
+ it, and clears it when the request is answered.
10
+
11
+ It is a thin strategy over the generic
12
+ :class:`~agentscope.app._service._session_projection.SessionProjection`
13
+ primitive: this file holds only the HITL-specific policy (which events
14
+ matter, how to resolve the leader, what the card payload looks like).
15
+ The durable hash, live notification, and key conventions all live in
16
+ the shared primitive.
17
+
18
+ Persistence model (see the design doc, §2.4):
19
+
20
+ - **Authoritative state** is the worker session's own ``state.context``
21
+ (the ``ASKING`` tool call). The projection is only a mirror.
22
+ - The projected hash entry carries **no TTL**: a legitimate
23
+ confirmation can stay pending indefinitely. Stale entries (worker
24
+ cancelled/crashed without clearing) are healed by reconcile-on-read
25
+ at SSE replay time.
26
+ """
27
+ from datetime import datetime
28
+ from typing import TYPE_CHECKING
29
+
30
+ from ....event import (
31
+ RequireUserConfirmEvent,
32
+ RequireExternalExecutionEvent,
33
+ UserConfirmResultEvent,
34
+ ExternalExecutionResultEvent,
35
+ ReplyEndEvent,
36
+ )
37
+
38
+ if TYPE_CHECKING:
39
+ from ...storage import AgentRecord, SessionRecord, StorageBase
40
+ from ....event import AgentEvent
41
+ from .._session_projection import SessionProjection
42
+
43
+
44
+ class SubagentHitlProjector:
45
+ """Project pending team-member HITL requests onto leader sessions.
46
+
47
+ Holds the storage handle it needs to resolve a worker's team (and
48
+ thus its leader). The :class:`SessionProjection` it writes through
49
+ is passed in per call by
50
+ :class:`~agentscope.app._service.ChatService`, which also resolves
51
+ confirm-routing and SSE replay through this projector's
52
+ :meth:`resolve` / :meth:`entry_id` helpers.
53
+ """
54
+
55
+ KIND = "subagent_hitl"
56
+ """Projection feed key (namespaces the entry within a session's
57
+ shared projection hash)."""
58
+
59
+ EVT_REQUIRE = "subagent_require_user_confirm"
60
+ """``CustomEvent.name`` used to push/replay a pending request to the
61
+ leader's event stream."""
62
+
63
+ EVT_RESULT = "subagent_user_confirm_result"
64
+ """``CustomEvent.name`` used to tell the leader UI a pending request
65
+ has been resolved and its card should be cleared."""
66
+
67
+ def __init__(self, storage: "StorageBase") -> None:
68
+ """Bind the storage backend.
69
+
70
+ Args:
71
+ storage (`StorageBase`):
72
+ Application storage, used to resolve the team (and hence
73
+ the leader session) a worker session belongs to.
74
+ """
75
+ self._storage = storage
76
+
77
+ @staticmethod
78
+ def entry_id(worker_session_id: str, reply_id: str) -> str:
79
+ """Return the projection entry id for one pending request.
80
+
81
+ Args:
82
+ worker_session_id (`str`):
83
+ The worker session that emitted the HITL request.
84
+ reply_id (`str`):
85
+ The worker-side reply id the request belongs to.
86
+
87
+ Returns:
88
+ `str`:
89
+ The entry id, ``"{worker_session_id}:{reply_id}"``.
90
+ """
91
+ return f"{worker_session_id}:{reply_id}"
92
+
93
+ async def maybe_project(
94
+ self,
95
+ user_id: str,
96
+ session_record: "SessionRecord",
97
+ agent_record: "AgentRecord",
98
+ event: "AgentEvent",
99
+ projection: "SessionProjection",
100
+ ) -> None:
101
+ """Mirror a worker HITL event onto its team's leader session.
102
+
103
+ When a *worker* session emits an HITL request, write the pending
104
+ card (durable entry + live notification) onto the leader; when
105
+ it resolves one or its reply ends, clear the card. No-op for
106
+ non-team sessions and for the leader session itself (a leader's
107
+ own HITL reaches its client directly).
108
+
109
+ Args:
110
+ user_id (`str`):
111
+ The owner of the running session.
112
+ session_record (`SessionRecord`):
113
+ The currently-running session's record.
114
+ agent_record (`AgentRecord`):
115
+ The currently-running agent's record. Only
116
+ ``source == "team"`` agents forward.
117
+ event (`AgentEvent`):
118
+ The event just published to this session's channel.
119
+ projection (`SessionProjection`):
120
+ Shared primitive used to write the durable entry and the
121
+ live notification.
122
+ """
123
+ # Fast path: only team-member sessions forward anything, and
124
+ # only for the event kinds we care about.
125
+ if agent_record.source != "team" or not session_record.team_id:
126
+ return
127
+ if not isinstance(
128
+ event,
129
+ (
130
+ RequireUserConfirmEvent,
131
+ RequireExternalExecutionEvent,
132
+ UserConfirmResultEvent,
133
+ ExternalExecutionResultEvent,
134
+ ReplyEndEvent,
135
+ ),
136
+ ):
137
+ return
138
+
139
+ team = await self._storage.get_team(
140
+ user_id,
141
+ session_record.team_id,
142
+ )
143
+ if team is None or team.session_id == session_record.id:
144
+ # No team, or this IS the leader session — nothing to mirror.
145
+ return
146
+ leader_sid = team.session_id
147
+
148
+ if isinstance(
149
+ event,
150
+ (RequireUserConfirmEvent, RequireExternalExecutionEvent),
151
+ ):
152
+ payload = {
153
+ "worker_session_id": session_record.id,
154
+ "worker_agent_id": agent_record.id,
155
+ "worker_agent_name": agent_record.data.name,
156
+ "reply_id": event.reply_id,
157
+ "event_type": (
158
+ "require_user_confirm"
159
+ if isinstance(event, RequireUserConfirmEvent)
160
+ else "require_external_execution"
161
+ ),
162
+ "event": event.model_dump(mode="json"),
163
+ "created_at": datetime.now().isoformat(),
164
+ }
165
+ await projection.upsert(
166
+ leader_sid,
167
+ self.KIND,
168
+ self.entry_id(session_record.id, event.reply_id),
169
+ payload,
170
+ )
171
+ await projection.publish(leader_sid, self.EVT_REQUIRE, payload)
172
+ else:
173
+ # Clear the pending card. ``ReplyEndEvent`` is the primary
174
+ # clear signal (the resume's continuation event is NOT
175
+ # republished through the stream); the explicit result
176
+ # events clear early when they do flow through. All are
177
+ # idempotent — deleting an already-gone entry is a no-op.
178
+ await projection.delete(
179
+ leader_sid,
180
+ self.KIND,
181
+ self.entry_id(session_record.id, event.reply_id),
182
+ )
183
+ await projection.publish(
184
+ leader_sid,
185
+ self.EVT_RESULT,
186
+ {
187
+ "worker_session_id": session_record.id,
188
+ "reply_id": event.reply_id,
189
+ },
190
+ )
191
+
192
+ @classmethod
193
+ async def resolve(
194
+ cls,
195
+ projection: "SessionProjection",
196
+ leader_sid: str,
197
+ reply_id: str,
198
+ ) -> dict | None:
199
+ """Find the pending entry for ``reply_id`` under a leader.
200
+
201
+ Used by the confirm-routing entry point (the chat router): given
202
+ a confirm result POSTed to the leader session, locate which
203
+ worker session it actually belongs to so the result can be
204
+ forwarded there.
205
+
206
+ Args:
207
+ projection (`SessionProjection`):
208
+ The shared projection store to scan.
209
+ leader_sid (`str`):
210
+ The leader session the confirm result was POSTed to.
211
+ reply_id (`str`):
212
+ The worker-side reply id carried by the confirm result.
213
+
214
+ Returns:
215
+ `dict | None`:
216
+ The stored payload (with ``worker_session_id`` /
217
+ ``worker_agent_id``), or ``None`` when no pending entry
218
+ matches — meaning the confirm is the leader's own.
219
+ """
220
+ for entry in await projection.list(leader_sid, cls.KIND):
221
+ if entry.get("reply_id") == reply_id:
222
+ return entry
223
+ return None
224
+
225
+ @classmethod
226
+ async def purge(
227
+ cls,
228
+ projection: "SessionProjection",
229
+ leader_sid: str,
230
+ ) -> None:
231
+ """Drop every pending HITL entry for a leader session.
232
+
233
+ Used when the leader session (or its team) is deleted. Scoped to
234
+ this feed so other projections on the same session survive.
235
+
236
+ Args:
237
+ projection (`SessionProjection`):
238
+ The shared projection store.
239
+ leader_sid (`str`):
240
+ The leader session to purge.
241
+ """
242
+ await projection.purge(leader_sid, cls.KIND)
243
+
244
+ @classmethod
245
+ async def drop_worker(
246
+ cls,
247
+ projection: "SessionProjection",
248
+ leader_sid: str,
249
+ worker_sid: str,
250
+ ) -> None:
251
+ """Drop every pending entry that originated from one worker.
252
+
253
+ Used when a single worker session is deleted while the leader
254
+ survives.
255
+
256
+ Args:
257
+ projection (`SessionProjection`):
258
+ The shared projection store.
259
+ leader_sid (`str`):
260
+ The leader session the entries are projected onto.
261
+ worker_sid (`str`):
262
+ The worker session whose entries should be dropped.
263
+ """
264
+ for entry in await projection.list(leader_sid, cls.KIND):
265
+ if entry.get("worker_session_id") == worker_sid:
266
+ await projection.delete(
267
+ leader_sid,
268
+ cls.KIND,
269
+ cls.entry_id(worker_sid, entry["reply_id"]),
270
+ )
src/agentscope/app/_service/_session.py ADDED
@@ -0,0 +1,473 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """Cross-resource session lifecycle service.
3
+
4
+ Owns the "stop in-flight runs + delete records + drop bus state"
5
+ cascades that ``DELETE /sessions/{sid}``, ``DELETE /agents/{aid}``,
6
+ ``DELETE /schedules/{sid}`` and the agent-facing
7
+ :class:`~agentscope.app._tools.TeamDelete` /
8
+ :class:`~agentscope.app._manager._scheduler._tools.ScheduleDelete`
9
+ tools all share.
10
+
11
+ Layering
12
+ ========
13
+
14
+ Methods deliberately delegate down the cascade so the bus-touching
15
+ logic lives in exactly one place — :meth:`delete_session`. Higher-level
16
+ methods only orchestrate which sessions to delete, then ask storage
17
+ to clean its own non-session scope (records, indexes, back-refs).
18
+
19
+ ::
20
+
21
+ delete_session ← atomic: cancel run, storage.delete_session,
22
+ bus.session_purge
23
+
24
+ delete_team → service.delete_agent per worker
25
+ → storage.delete_team (record + leader detach)
26
+
27
+ delete_agent → service.delete_session per session
28
+ → service.delete_schedule per owned schedule
29
+ → storage.delete_agent (agent record + team back-refs)
30
+
31
+ delete_schedule → service.delete_session per spawned session
32
+ → storage.delete_schedule (schedule record + indexes)
33
+
34
+ Storage's own internal cascades (e.g.
35
+ ``storage.delete_agent`` re-iterating sessions) become idempotent
36
+ no-ops because the records are already gone — they still execute, but
37
+ do no work and never touch the bus, so the storage layer stays
38
+ unaware of the message bus.
39
+
40
+ Separation of concerns
41
+ ======================
42
+
43
+ Storage and message bus are treated as distinct backends — they may
44
+ live in different databases in the future. The service is the **only**
45
+ component that touches both in the same call. Storage code never
46
+ imports the bus; bus code never imports storage.
47
+ """
48
+ import asyncio
49
+
50
+ from ..message_bus import MessageBus, MessageBusKeys
51
+ from ..storage import StorageBase
52
+ from ._session_projection import SessionProjection
53
+ from ._projectors import SubagentHitlProjector
54
+ from ..._logging import logger
55
+
56
+
57
+ class SessionService:
58
+ """Cancel in-flight chat runs and cascade-delete related records.
59
+
60
+ The cancel side broadcasts via
61
+ :meth:`MessageBus.session_publish_cancel`, then polls
62
+ :meth:`MessageBus.session_is_running` until the run-lock clears or
63
+ a timeout expires — so the implementation is multi-process and
64
+ multi-node by construction.
65
+
66
+ Args:
67
+ storage (`StorageBase`):
68
+ Persistent storage backend. Owns durable records and their
69
+ cascades among themselves.
70
+ message_bus (`MessageBus`):
71
+ Live message bus. Owns transient per-session state (events
72
+ log, inbox, run-lock, cancel channel).
73
+ """
74
+
75
+ _CANCEL_POLL_INTERVAL_SECS: float = 0.1
76
+ """Interval between :meth:`MessageBus.session_is_running` polls
77
+ while waiting for a cancelled run to release its distributed
78
+ run-lock."""
79
+
80
+ def __init__(
81
+ self,
82
+ storage: StorageBase,
83
+ message_bus: MessageBus,
84
+ ) -> None:
85
+ """Bind dependencies.
86
+
87
+ Args:
88
+ storage (`StorageBase`): Persistent storage backend.
89
+ message_bus (`MessageBus`): Live message bus.
90
+ """
91
+ self._storage = storage
92
+ self._bus = message_bus
93
+ self._projection = SessionProjection(message_bus)
94
+
95
+ # ------------------------------------------------------------------
96
+ # Cancel
97
+ # ------------------------------------------------------------------
98
+
99
+ async def cancel_session_run(
100
+ self,
101
+ session_id: str,
102
+ *,
103
+ timeout: float = 10.0,
104
+ ) -> bool:
105
+ """Broadcast a session cancel and wait for the chat-run lock to
106
+ clear.
107
+
108
+ Publishes one cancel payload on the bus's shared cancel channel,
109
+ unconditionally. Every process's
110
+ :class:`~agentscope.app._manager.CancelDispatcher` reacts to the
111
+ broadcast by cancelling whatever it locally holds for the
112
+ session — the chat-run asyncio task **and** any background
113
+ tasks owned by that session. The publisher does not need to
114
+ know which worker holds which piece.
115
+
116
+ After publishing, polls
117
+ :meth:`MessageBus.session_is_running` until the distributed
118
+ chat-run lock clears. Only the chat run holds a distributed
119
+ lock; BG tasks do not, so this poll only waits for the chat
120
+ run. Returns immediately when no chat run was active.
121
+
122
+ Idempotent: calling on an idle session just sends a no-op
123
+ broadcast and observes a clear lock.
124
+
125
+ Args:
126
+ session_id (`str`):
127
+ The session whose chat run + BG tasks should be
128
+ cancelled.
129
+ timeout (`float`, defaults to ``10.0``):
130
+ Maximum seconds to wait for the chat-run lock to
131
+ release. On timeout the method returns ``False`` so
132
+ callers can proceed (e.g. with cascade delete) instead
133
+ of hanging on a process that may have died.
134
+
135
+ Returns:
136
+ `bool`:
137
+ ``True`` if the chat-run lock was confirmed released
138
+ within ``timeout`` seconds (or was never held).
139
+ ``False`` if the lock was still held when the timeout
140
+ expired.
141
+ """
142
+ await self._bus.publish(
143
+ MessageBusKeys.session_cancel_channel(),
144
+ {"session_id": session_id},
145
+ )
146
+
147
+ deadline = asyncio.get_event_loop().time() + timeout
148
+ while True:
149
+ if not await self._bus.is_locked(
150
+ MessageBusKeys.session_lock(session_id),
151
+ ):
152
+ return True
153
+ if asyncio.get_event_loop().time() >= deadline:
154
+ logger.warning(
155
+ "Session %s did not release its run-lock within "
156
+ "%.1fs after cancel; proceeding anyway.",
157
+ session_id,
158
+ timeout,
159
+ )
160
+ return False
161
+ await asyncio.sleep(self._CANCEL_POLL_INTERVAL_SECS)
162
+
163
+ # ------------------------------------------------------------------
164
+ # Delete cascades — every higher-level method delegates to
165
+ # ``delete_session`` so the cancel + bus-purge logic exists in
166
+ # exactly one place.
167
+ # ------------------------------------------------------------------
168
+
169
+ async def delete_session(
170
+ self,
171
+ user_id: str,
172
+ agent_id: str,
173
+ session_id: str,
174
+ ) -> bool:
175
+ """Cancel, delete and bus-purge a single session.
176
+
177
+ This is the atomic primitive — every other cascade delegates
178
+ here for per-session work.
179
+
180
+ Steps:
181
+
182
+ 1. Cancel any in-flight run for ``session_id`` (cross-process
183
+ via the bus cancel channel).
184
+ 2. Delete the session record (and its storage-side cascade:
185
+ message log, schedule-session index, team dissolution when
186
+ this session leads one — recursive into worker agents).
187
+ 3. Purge transient bus state for ``session_id`` (events log,
188
+ inbox).
189
+
190
+ Worker sessions that storage cascades through are picked up
191
+ here too: when this session is a team leader,
192
+ ``storage.delete_session`` calls ``storage.delete_team`` →
193
+ ``storage.delete_agent`` → ``storage.delete_session`` for each
194
+ worker, and we mirror that on the bus side by purging worker
195
+ sessions identified up front via
196
+ :meth:`_team_worker_session_ids`.
197
+
198
+ Args:
199
+ user_id (`str`): The owner user id.
200
+ agent_id (`str`): The agent that owns the session.
201
+ session_id (`str`): The session to delete.
202
+
203
+ Returns:
204
+ `bool`:
205
+ ``True`` if the session record existed and was deleted,
206
+ ``False`` otherwise. Mirrors
207
+ :meth:`StorageBase.delete_session`.
208
+ """
209
+ # Identify all bus-purge targets before storage mutates anything.
210
+ worker_sids = await self._team_worker_session_ids(
211
+ user_id,
212
+ agent_id,
213
+ session_id,
214
+ )
215
+ all_sids = [session_id, *worker_sids]
216
+
217
+ # Clean leader-side subagent HITL projections before storage
218
+ # cascades remove the records we need to resolve roles from.
219
+ await self._purge_subagent_hitl(user_id, agent_id, session_id)
220
+
221
+ await self._cancel_runs(all_sids)
222
+ deleted = await self._storage.delete_session(
223
+ user_id,
224
+ agent_id,
225
+ session_id,
226
+ )
227
+ await self._purge_bus(all_sids)
228
+ return deleted
229
+
230
+ async def delete_team(self, user_id: str, team_id: str) -> bool:
231
+ """Cancel, delete and bus-purge a team.
232
+
233
+ Delegates worker dissolution to :meth:`delete_agent` (one call
234
+ per ``member_id``) so the per-session cancel + bus purge runs
235
+ for each worker. The leader's own session is **not** deleted —
236
+ teams dissolve, leaders survive (and have their ``team_id``
237
+ cleared by ``storage.delete_team``).
238
+
239
+ Args:
240
+ user_id (`str`): The owner user id.
241
+ team_id (`str`): The team to dissolve.
242
+
243
+ Returns:
244
+ `bool`:
245
+ ``True`` if the team record existed and was deleted.
246
+ """
247
+ team = await self._storage.get_team(user_id, team_id)
248
+ if team is None:
249
+ # Still call storage.delete_team so it can clean any index
250
+ # residue, but the return value will be False.
251
+ return await self._storage.delete_team(user_id, team_id)
252
+
253
+ for member_id in team.data.member_ids:
254
+ await self.delete_agent(user_id, member_id)
255
+
256
+ # storage.delete_team will iterate member_ids again to delete
257
+ # each worker agent — those calls are now no-ops because the
258
+ # agents are already gone, leaving only the leader-detach and
259
+ # team-record cleanup work.
260
+ return await self._storage.delete_team(user_id, team_id)
261
+
262
+ async def delete_agent(self, user_id: str, agent_id: str) -> bool:
263
+ """Cancel, delete and bus-purge every session and schedule
264
+ owned by an agent, then drop the agent record.
265
+
266
+ Delegates per-session work to :meth:`delete_session` and
267
+ per-schedule work to :meth:`delete_schedule`, then asks
268
+ storage to clean the remaining agent-scoped state (the agent
269
+ record, the agent index entry, and any team back-references).
270
+
271
+ Args:
272
+ user_id (`str`): The owner user id.
273
+ agent_id (`str`): The agent to delete.
274
+
275
+ Returns:
276
+ `bool`:
277
+ ``True`` if the agent record existed and was deleted.
278
+ """
279
+ for session in await self._storage.list_sessions(user_id, agent_id):
280
+ await self.delete_session(user_id, agent_id, session.id)
281
+
282
+ for schedule in await self._storage.list_schedules(user_id):
283
+ if schedule.agent_id == agent_id:
284
+ await self.delete_schedule(user_id, schedule.id)
285
+
286
+ # storage.delete_agent re-iterates sessions and schedules —
287
+ # those re-runs are idempotent no-ops because the records were
288
+ # already removed above. What remains is the agent record,
289
+ # the agent index entry, and team back-reference scrubbing.
290
+ return await self._storage.delete_agent(user_id, agent_id)
291
+
292
+ async def delete_schedule(
293
+ self,
294
+ user_id: str,
295
+ schedule_id: str,
296
+ ) -> bool:
297
+ """Cancel, delete and bus-purge every session spawned by a
298
+ schedule, then drop the schedule record.
299
+
300
+ Args:
301
+ user_id (`str`): The owner user id.
302
+ schedule_id (`str`): The schedule to delete.
303
+
304
+ Returns:
305
+ `bool`:
306
+ ``True`` if the schedule record existed and was deleted.
307
+ """
308
+ for session in await self._storage.list_sessions_by_schedule(
309
+ user_id,
310
+ schedule_id,
311
+ ):
312
+ await self.delete_session(
313
+ user_id,
314
+ session.agent_id,
315
+ session.id,
316
+ )
317
+
318
+ # storage.delete_schedule re-iterates the same sessions —
319
+ # idempotent no-ops; only schedule record + indexes remain.
320
+ return await self._storage.delete_schedule(user_id, schedule_id)
321
+
322
+ # ------------------------------------------------------------------
323
+ # Internals
324
+ # ------------------------------------------------------------------
325
+
326
+ async def _team_worker_session_ids(
327
+ self,
328
+ user_id: str,
329
+ agent_id: str,
330
+ session_id: str,
331
+ ) -> list[str]:
332
+ """Return the session ids of every worker in the team that
333
+ ``session_id`` leads, or ``[]`` when the session does not lead
334
+ a team.
335
+
336
+ Mirrors :meth:`StorageBase.delete_session`'s own team-leader
337
+ cascade so the bus side can purge the same sessions.
338
+
339
+ Args:
340
+ user_id (`str`): The owner user id.
341
+ agent_id (`str`):
342
+ The agent that owns ``session_id``. May be empty when
343
+ unknown; team-leader lookup does not depend on it.
344
+ session_id (`str`):
345
+ The candidate leader session.
346
+
347
+ Returns:
348
+ `list[str]`:
349
+ Worker session ids, empty when this session is not a
350
+ team leader.
351
+ """
352
+ session = await self._storage.get_session(
353
+ user_id,
354
+ agent_id,
355
+ session_id,
356
+ )
357
+ if session is None or not session.team_id:
358
+ return []
359
+ team = await self._storage.get_team(user_id, session.team_id)
360
+ if team is None or team.session_id != session_id:
361
+ return []
362
+ sids: list[str] = []
363
+ for member_id in team.data.member_ids:
364
+ worker_sessions = await self._storage.list_sessions(
365
+ user_id,
366
+ member_id,
367
+ )
368
+ sids.extend(s.id for s in worker_sessions)
369
+ return sids
370
+
371
+ async def _cancel_runs(self, session_ids: list[str]) -> None:
372
+ """Cancel every in-flight run in ``session_ids`` concurrently.
373
+
374
+ Args:
375
+ session_ids (`list[str]`):
376
+ Sessions whose runs should be cancelled.
377
+ """
378
+ if not session_ids:
379
+ return
380
+ await asyncio.gather(
381
+ *(self.cancel_session_run(sid) for sid in session_ids),
382
+ )
383
+
384
+ async def _purge_bus(self, session_ids: list[str]) -> None:
385
+ """Drop bus state (events log + inbox) for each id concurrently.
386
+
387
+ Args:
388
+ session_ids (`list[str]`):
389
+ Sessions whose bus state should be purged.
390
+ """
391
+ if not session_ids:
392
+ return
393
+ await asyncio.gather(
394
+ *(self._purge_session_bus(sid) for sid in session_ids),
395
+ )
396
+
397
+ async def _purge_session_bus(self, session_id: str) -> None:
398
+ """Drop all per-session bus state for one session."""
399
+ await self._bus.log_trim(
400
+ MessageBusKeys.session_events(session_id),
401
+ )
402
+ await self._bus.queue_delete(
403
+ MessageBusKeys.inbox(session_id),
404
+ )
405
+ await self._bus.registry_drop(
406
+ MessageBusKeys.bg_tasks(session_id),
407
+ )
408
+
409
+ async def _purge_subagent_hitl(
410
+ self,
411
+ user_id: str,
412
+ agent_id: str,
413
+ session_id: str,
414
+ ) -> None:
415
+ """Clean leader-side subagent HITL projections for a session
416
+ about to be deleted (design §3.7).
417
+
418
+ Two cases, resolved from the session's role:
419
+
420
+ - **Leader session** (it leads a team): purge the entire hash
421
+ keyed by this session — every projected member card goes.
422
+ - **Worker session** (it has a ``team_id`` but is not the
423
+ leader): drop just this worker's entries from the *leader's*
424
+ hash, leaving sibling members' cards intact.
425
+
426
+ Must run before storage cascades remove the team / session
427
+ records this resolution depends on. Failures are swallowed — a
428
+ stale projection is self-healed by reconcile-on-read and must
429
+ not block the delete cascade.
430
+
431
+ Args:
432
+ user_id (`str`):
433
+ The owner user id.
434
+ agent_id (`str`):
435
+ The agent that owns ``session_id``.
436
+ session_id (`str`):
437
+ The session being deleted.
438
+ """
439
+ try:
440
+ session = await self._storage.get_session(
441
+ user_id,
442
+ agent_id,
443
+ session_id,
444
+ )
445
+ if session is None or not session.team_id:
446
+ # Not in a team — also clear any hash that may have been
447
+ # created with this session as a (future) leader key.
448
+ await SubagentHitlProjector.purge(self._projection, session_id)
449
+ return
450
+
451
+ team = await self._storage.get_team(user_id, session.team_id)
452
+ if team is None:
453
+ await SubagentHitlProjector.purge(self._projection, session_id)
454
+ return
455
+
456
+ if team.session_id == session_id:
457
+ # Leader session — drop the whole projection store.
458
+ await SubagentHitlProjector.purge(self._projection, session_id)
459
+ else:
460
+ # Worker session — drop only its entries from the
461
+ # leader's store.
462
+ await SubagentHitlProjector.drop_worker(
463
+ self._projection,
464
+ team.session_id,
465
+ session_id,
466
+ )
467
+ except Exception as e: # pylint: disable=broad-except
468
+ logger.warning(
469
+ "Failed to purge subagent HITL projection for session "
470
+ "%s: %s",
471
+ session_id,
472
+ str(e),
473
+ )
src/agentscope/app/_service/_session_projection.py ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """Generic cross-session UI projection primitive.
3
+
4
+ A *projection* mirrors a UI card owned by one session onto another
5
+ session's event stream, so a client subscribed only to the target
6
+ session can render and resolve it. The canonical use is team HITL: a
7
+ worker (member) session parks on a tool call awaiting confirmation in
8
+ its own session — invisible to a client watching only the *leader* —
9
+ so the pending request is projected onto the leader.
10
+
11
+ This class is **pure mechanism**: it knows nothing about teams,
12
+ workers, leaders, or HITL. It is the reusable substrate every such
13
+ feature shares — a durable per-session hash plus a live notification —
14
+ so a new projection feature is a small strategy object (an
15
+ ``EventProjector``) over this primitive, not a new bus wrapper class.
16
+
17
+ Backed entirely by the message-bus generic registry primitives
18
+ (``registry_*``) and :meth:`MessageBus.session_publish_event`; no
19
+ business methods are added to ``MessageBus``. Key conventions live in
20
+ :class:`~agentscope.app.message_bus.MessageBusKeys`.
21
+
22
+ Persistence model:
23
+
24
+ - The Redis hash is the **only durable** record of a projected card —
25
+ the target session's event channel (replay log + pub/sub) is not
26
+ durable across runs. It carries **no TTL**: a legitimate card can
27
+ stay pending indefinitely. Authoritative truth for whether a card is
28
+ still live lives in the *owning* session's own state; stale hash
29
+ entries are healed by reconcile-on-read at SSE replay time, not by
30
+ expiry.
31
+ - ``kind`` partitions the hash so one target session can host several
32
+ independent feeds (HITL, progress, errors, …) without collision.
33
+ """
34
+ import json
35
+ from typing import TYPE_CHECKING
36
+
37
+ from ..message_bus import MessageBusKeys
38
+ from .._bus_ops import publish_session_event
39
+ from ...event import CustomEvent
40
+
41
+ if TYPE_CHECKING:
42
+ from ..message_bus import MessageBus
43
+
44
+
45
+ class SessionProjection:
46
+ """Durable per-session store of UI cards projected from elsewhere.
47
+
48
+ A thin stateless wrapper around the message bus — construct one
49
+ wherever needed (it holds only a bus reference). Entries are grouped
50
+ per ``(target_session_id, kind)``; within a feed each entry is keyed
51
+ by a caller-chosen ``entry_id``.
52
+
53
+ Live notification piggybacks on the target session's existing event
54
+ channel via :meth:`publish`, so front-ends receive updates over the
55
+ same ``GET /sessions/{sid}/stream`` SSE connection they already use.
56
+ """
57
+
58
+ def __init__(self, message_bus: "MessageBus") -> None:
59
+ """Bind the message bus.
60
+
61
+ Args:
62
+ message_bus (`MessageBus`):
63
+ Application message bus; only its generic ``registry_*``
64
+ primitives and :meth:`session_publish_event` are used.
65
+ """
66
+ self._bus = message_bus
67
+
68
+ async def upsert(
69
+ self,
70
+ target_sid: str,
71
+ kind: str,
72
+ entry_id: str,
73
+ payload: dict,
74
+ ) -> None:
75
+ """Persist (or overwrite) one projected entry.
76
+
77
+ Args:
78
+ target_sid (`str`):
79
+ The session the entry is projected onto.
80
+ kind (`str`):
81
+ The projection feed (e.g. ``"subagent_hitl"``).
82
+ entry_id (`str`):
83
+ Identity of the entry within the feed.
84
+ payload (`dict`):
85
+ The entry to store (JSON-serializable).
86
+ """
87
+ await self._bus.registry_set(
88
+ MessageBusKeys.projection_namespace(target_sid),
89
+ MessageBusKeys.projection_field(kind, entry_id),
90
+ json.dumps(payload),
91
+ )
92
+
93
+ async def delete(
94
+ self,
95
+ target_sid: str,
96
+ kind: str,
97
+ entry_id: str,
98
+ ) -> None:
99
+ """Remove one projected entry.
100
+
101
+ Idempotent: a no-op when the entry is already gone.
102
+
103
+ Args:
104
+ target_sid (`str`):
105
+ The session the entry was projected onto.
106
+ kind (`str`):
107
+ The projection feed.
108
+ entry_id (`str`):
109
+ Identity of the entry within the feed.
110
+ """
111
+ await self._bus.registry_del(
112
+ MessageBusKeys.projection_namespace(target_sid),
113
+ MessageBusKeys.projection_field(kind, entry_id),
114
+ )
115
+
116
+ async def list(self, target_sid: str, kind: str) -> list[dict]:
117
+ """Return every entry in one feed for a target session.
118
+
119
+ Args:
120
+ target_sid (`str`):
121
+ The session whose projections to read.
122
+ kind (`str`):
123
+ The projection feed to filter by.
124
+
125
+ Returns:
126
+ `list[dict]`:
127
+ All stored payloads in the feed; empty when none.
128
+ """
129
+ raw = await self._bus.registry_getall(
130
+ MessageBusKeys.projection_namespace(target_sid),
131
+ )
132
+ prefix = MessageBusKeys.projection_field_prefix(kind)
133
+ return [
134
+ json.loads(value)
135
+ for field, value in raw.items()
136
+ if field.startswith(prefix)
137
+ ]
138
+
139
+ async def purge(self, target_sid: str, kind: str | None = None) -> None:
140
+ """Drop projected entries for a target session.
141
+
142
+ Args:
143
+ target_sid (`str`):
144
+ The session to purge.
145
+ kind (`str | None`, optional):
146
+ When given, drop only that feed's entries (preserving
147
+ other feeds on the same session). When ``None``, drop
148
+ the session's entire projection store in one shot.
149
+ """
150
+ if kind is None:
151
+ await self._bus.registry_drop(
152
+ MessageBusKeys.projection_namespace(target_sid),
153
+ )
154
+ return
155
+ ns = MessageBusKeys.projection_namespace(target_sid)
156
+ prefix = MessageBusKeys.projection_field_prefix(kind)
157
+ raw = await self._bus.registry_getall(ns)
158
+ for field in raw:
159
+ if field.startswith(prefix):
160
+ await self._bus.registry_del(ns, field)
161
+
162
+ async def publish(
163
+ self,
164
+ target_sid: str,
165
+ event_name: str,
166
+ value: dict,
167
+ ) -> None:
168
+ """Send a live ``CustomEvent`` to a target session's channel.
169
+
170
+ Notifies front-ends subscribed to the target session that a
171
+ projected card should be rendered or cleared.
172
+
173
+ Args:
174
+ target_sid (`str`):
175
+ The session to notify.
176
+ event_name (`str`):
177
+ The ``CustomEvent.name`` carried to the front-end.
178
+ value (`dict`):
179
+ The event payload.
180
+ """
181
+ custom = CustomEvent(name=event_name, value=value)
182
+ await publish_session_event(
183
+ self._bus,
184
+ target_sid,
185
+ custom.model_dump(mode="json"),
186
+ )