Spaces:
Paused
Paused
Upload folder using huggingface_hub (part 2)
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- src/agentscope/agent/_config.py +172 -0
- src/agentscope/agent/_utils.py +17 -0
- src/agentscope/app/__init__.py +12 -0
- src/agentscope/app/_app.py +263 -0
- src/agentscope/app/_bus_ops.py +167 -0
- src/agentscope/app/_lifespan.py +180 -0
- src/agentscope/app/_manager/__init__.py +17 -0
- src/agentscope/app/_manager/_background_task_manager.py +469 -0
- src/agentscope/app/_manager/_cancel_dispatcher.py +186 -0
- src/agentscope/app/_manager/_chat_run_registry.py +134 -0
- src/agentscope/app/_manager/_scheduler/__init__.py +8 -0
- src/agentscope/app/_manager/_scheduler/_scheduler_manager.py +432 -0
- src/agentscope/app/_manager/_scheduler/_tools/__init__.py +14 -0
- src/agentscope/app/_manager/_scheduler/_tools/_schedule_create.py +253 -0
- src/agentscope/app/_manager/_scheduler/_tools/_schedule_delete.py +154 -0
- src/agentscope/app/_manager/_scheduler/_tools/_schedule_list.py +115 -0
- src/agentscope/app/_manager/_scheduler/_tools/_schedule_view.py +138 -0
- src/agentscope/app/_manager/_wakeup_dispatcher.py +371 -0
- src/agentscope/app/_router/__init__.py +23 -0
- src/agentscope/app/_router/_agent.py +214 -0
- src/agentscope/app/_router/_chat.py +160 -0
- src/agentscope/app/_router/_credential.py +164 -0
- src/agentscope/app/_router/_knowledge_base.py +574 -0
- src/agentscope/app/_router/_model.py +40 -0
- src/agentscope/app/_router/_schedule.py +239 -0
- src/agentscope/app/_router/_schema/__init__.py +110 -0
- src/agentscope/app/_router/_schema/_agent.py +82 -0
- src/agentscope/app/_router/_schema/_chat.py +45 -0
- src/agentscope/app/_router/_schema/_credential.py +42 -0
- src/agentscope/app/_router/_schema/_knowledge_base.py +292 -0
- src/agentscope/app/_router/_schema/_mcp.py +127 -0
- src/agentscope/app/_router/_schema/_model.py +21 -0
- src/agentscope/app/_router/_schema/_schedule.py +118 -0
- src/agentscope/app/_router/_schema/_session.py +188 -0
- src/agentscope/app/_router/_schema/_tts_model.py +23 -0
- src/agentscope/app/_router/_session.py +680 -0
- src/agentscope/app/_router/_tts_model.py +40 -0
- src/agentscope/app/_router/_workspace.py +220 -0
- src/agentscope/app/_service/__init__.py +29 -0
- src/agentscope/app/_service/_chat.py +588 -0
- src/agentscope/app/_service/_embedding.py +101 -0
- src/agentscope/app/_service/_index_sweeper.py +150 -0
- src/agentscope/app/_service/_index_task_consumer.py +216 -0
- src/agentscope/app/_service/_index_worker.py +534 -0
- src/agentscope/app/_service/_knowledge_base.py +515 -0
- src/agentscope/app/_service/_model.py +50 -0
- src/agentscope/app/_service/_projectors/__init__.py +13 -0
- src/agentscope/app/_service/_projectors/_subagent_hitl.py +270 -0
- src/agentscope/app/_service/_session.py +473 -0
- 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 |
+
)
|