feat(agentscope): add memory system and automation job support

- Add consumer_registry and pipeline_registry for runtime orchestration
- Add Visibility schema for message filtering
- Add PipelineSpec for agent pipeline configuration
- Add automation job models and configuration
- Remove memory_prompt.py (consolidated into memory system)
- Update runtime components: context_loader, context_service, orchestrator, runner, tasks
- Update toolkit: tool_config, tool_middleware, custom tools (calendar, user_lookup)
- Add auth_helpers and calendar_domain utilities
- Add system_agents.yaml configuration
This commit is contained in:
qzl
2026-03-19 18:42:35 +08:00
parent 0661016827
commit 0abf51e837
55 changed files with 2172 additions and 1233 deletions
@@ -11,7 +11,7 @@ from core.agentscope.tools.utils.calendar_domain import (
map_calendar_exception,
merge_schedule_metadata_for_update,
parse_iso_datetime,
resolve_share_target_email_map,
resolve_share_target_phone_map,
schedule_event_to_dict,
)
from core.agentscope.tools.utils.calendar_ui import (
@@ -580,11 +580,11 @@ async def calendar_share(
)
target_uuid = UUID(event_id)
email_map = resolve_share_target_email_map(
phone_map = resolve_share_target_phone_map(
[invitee.user_id for invitee in invitees]
)
if not email_map:
if not phone_map:
return calendar_error_output(
tool_name=tool_name,
tool_call_args=tool_call_args,
@@ -599,8 +599,8 @@ async def calendar_share(
normalized_user_id = str(UUID(invitee.user_id.strip()))
except ValueError:
continue
email = email_map.get(normalized_user_id)
if email is None:
phone = phone_map.get(normalized_user_id)
if phone is None:
continue
permission = {
"permission_view": invitee.permission_view,
@@ -608,15 +608,15 @@ async def calendar_share(
"permission_invite": invitee.permission_invite,
}
await service.share(
target_uuid, ScheduleItemShareRequest(email=email, **permission)
target_uuid, ScheduleItemShareRequest(phone=phone, **permission)
)
invited.append(email)
invited.append(phone)
if not invited:
return calendar_error_output(
tool_name=tool_name,
tool_call_args=tool_call_args,
code="NOT_FOUND",
message="邀请目标均无有效邮箱",
message="邀请目标均无有效手机号",
retryable=False,
)
@@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from agentscope.tool import ToolResponse
from core.agentscope.tools.tool_call_context import get_current_tool_call_id
from core.agentscope.tools.utils import (
find_auth_email_by_user_id,
find_auth_phone_by_user_id,
list_auth_users,
)
from core.agentscope.tools.utils.tool_response_builder import (
@@ -46,22 +46,22 @@ def _lookup_error_output(
async def _resolve_identity(
*,
session: AsyncSession,
user_email: str | None,
user_phone: str | None,
user_name: str | None,
) -> dict[str, Any]:
"""Resolve user identity by email or username."""
email = user_email.strip().lower() if isinstance(user_email, str) else ""
"""Resolve user identity by phone or username."""
phone = user_phone.strip() if isinstance(user_phone, str) else ""
name = user_name.strip() if isinstance(user_name, str) else ""
if bool(email) == bool(name):
if bool(phone) == bool(name):
raise HTTPException(
status_code=400,
detail="请提供 email 或 username 其中之一",
detail="请提供 phone 或 username 其中之一",
)
if email:
if phone:
auth_gateway = SupabaseAuthGateway()
user = await auth_gateway.get_user_by_email(email)
user = await auth_gateway.get_user_by_phone(phone)
user_id = UUID(user.id)
stmt = (
@@ -73,9 +73,9 @@ async def _resolve_identity(
return {
"userId": str(user_id),
"email": user.email,
"phone": user.phone,
"username": username,
"matchedBy": "email",
"matchedBy": "phone",
}
stmt = (
@@ -90,20 +90,20 @@ async def _resolve_identity(
raise HTTPException(status_code=404, detail="用户不存在")
users = list_auth_users()
email_value = find_auth_email_by_user_id(users=users, user_id=profile.id)
phone_value = find_auth_phone_by_user_id(users=users, user_id=profile.id)
return {
"userId": str(profile.id),
"email": email_value,
"phone": phone_value,
"username": profile.username,
"matchedBy": "username",
}
async def user_lookup(
user_email: Annotated[
user_phone: Annotated[
str | None,
Field(description="User email address to look up."),
Field(description="User phone to look up."),
] = None,
user_name: Annotated[
str | None,
@@ -112,16 +112,16 @@ async def user_lookup(
session: Any = None,
owner_id: Any = None,
) -> ToolResponse:
"""Look up user identity by email or username.
"""Look up user identity by phone or username.
Args:
user_email: User email address for lookup.
user_phone: User phone for lookup.
user_name: Username for lookup.
Returns:
ToolResponse with serialized ToolAgentOutput payload.
"""
tool_call_args = {"user_email": user_email, "user_name": user_name}
tool_call_args = {"user_phone": user_phone, "user_name": user_name}
if session is None or owner_id is None:
return _lookup_error_output(
@@ -134,17 +134,17 @@ async def user_lookup(
try:
resolved = await _resolve_identity(
session=cast(AsyncSession, session),
user_email=user_email,
user_phone=user_phone,
user_name=user_name,
)
username = str(resolved.get("username") or "")
email = str(resolved.get("email") or "")
phone = str(resolved.get("phone") or "")
user_id = str(resolved.get("userId") or "")
matched_by = str(resolved.get("matchedBy") or "")
summary = (
f"status=success matched_by={matched_by} user_id={user_id} "
f"username={username} has_email={str(bool(email)).lower()}"
f"username={username} has_phone={str(bool(phone)).lower()}"
)
return _dump_tool_output(
ToolAgentOutput(
@@ -6,7 +6,15 @@ from enum import Enum
class ToolGroup(str, Enum):
READ = "read"
WRITE = "write"
EXECUTE = "execute"
MEMORY = "memory"
class AgentTool(str, Enum):
CALENDAR_READ = "calendar.read"
CALENDAR_WRITE = "calendar.write"
CALENDAR_SHARE = "calendar.share"
USER_LOOKUP = "user.lookup"
@dataclass(frozen=True)
@@ -29,30 +37,51 @@ TOOL_CONFIGS: dict[str, ToolConfig] = {
),
"user_lookup": ToolConfig(
name="user_lookup",
group=ToolGroup.READ,
group=ToolGroup.MEMORY,
approval=ToolApprovalConfig(required=False),
),
"calendar_write": ToolConfig(
name="calendar_write",
group=ToolGroup.WRITE,
group=ToolGroup.EXECUTE,
approval=ToolApprovalConfig(required=False),
),
"calendar_share": ToolConfig(
name="calendar_share",
group=ToolGroup.WRITE,
group=ToolGroup.EXECUTE,
approval=ToolApprovalConfig(required=False),
),
}
AGENT_TOOL_TO_FUNCTION_NAME: dict[AgentTool, str] = {
AgentTool.CALENDAR_READ: "calendar_read",
AgentTool.CALENDAR_WRITE: "calendar_write",
AgentTool.CALENDAR_SHARE: "calendar_share",
AgentTool.USER_LOOKUP: "user_lookup",
}
def get_tool_config(tool_name: str) -> ToolConfig:
config = TOOL_CONFIGS.get(tool_name)
if config is None:
raise ValueError(f"unknown tool: {tool_name}")
return config
TOOL_NAME_ALIASES: dict[str, AgentTool] = {
AgentTool.CALENDAR_READ.value: AgentTool.CALENDAR_READ,
"calendar_read": AgentTool.CALENDAR_READ,
AgentTool.CALENDAR_WRITE.value: AgentTool.CALENDAR_WRITE,
"calendar_write": AgentTool.CALENDAR_WRITE,
AgentTool.CALENDAR_SHARE.value: AgentTool.CALENDAR_SHARE,
"calendar_share": AgentTool.CALENDAR_SHARE,
AgentTool.USER_LOOKUP.value: AgentTool.USER_LOOKUP,
"user_lookup": AgentTool.USER_LOOKUP,
}
def resolve_tool_names_by_groups(groups: set[ToolGroup]) -> set[str]:
if not groups:
return set()
return {name for name, config in TOOL_CONFIGS.items() if config.group in groups}
def parse_agent_tool(value: object) -> AgentTool:
if isinstance(value, AgentTool):
return value
raw_value = str(value or "").strip().lower()
if not raw_value:
raise ValueError("enabled tool value cannot be empty")
tool = TOOL_NAME_ALIASES.get(raw_value)
if tool is None:
raise ValueError(f"unknown enabled tool: {raw_value}")
return tool
def resolve_tool_function_names(tools: set[AgentTool]) -> set[str]:
return {AGENT_TOOL_TO_FUNCTION_NAME[tool] for tool in tools}
@@ -7,10 +7,15 @@ from core.agentscope.tools.tool_call_context import (
reset_current_tool_call_id,
set_current_tool_call_id,
)
from core.agentscope.tools.tool_config import (
AGENT_TOOL_TO_FUNCTION_NAME,
TOOL_CONFIGS,
ToolConfig,
parse_agent_tool,
)
from core.agentscope.tools.utils.tool_response_builder import (
build_error_response,
)
from core.agentscope.tools.tool_config import ToolConfig, TOOL_CONFIGS
def register_tool_middlewares(
@@ -59,6 +64,18 @@ def create_approval_middleware(
approval_resolver: Callable[[str, dict[str, Any], ToolConfig], str | None]
| None = None,
) -> Callable[..., AsyncGenerator[Any, None]]:
def _resolve_tool_config(*, tool_name: str) -> ToolConfig | None:
config = config_by_name.get(tool_name)
if config is not None:
return config
try:
normalized_tool_name = AGENT_TOOL_TO_FUNCTION_NAME[
parse_agent_tool(tool_name)
]
except ValueError:
return None
return config_by_name.get(normalized_tool_name)
def _resolve_tool_call_id(tool_call: dict[str, Any]) -> str:
raw_tool_call_id = tool_call.get("id")
if isinstance(raw_tool_call_id, str) and raw_tool_call_id.strip():
@@ -81,7 +98,7 @@ def create_approval_middleware(
yield response
return
config = config_by_name.get(tool_name)
config = _resolve_tool_config(tool_name=tool_name)
if config is None or not config.approval.required:
async for response in await next_handler(**kwargs):
yield response
@@ -134,15 +151,3 @@ def create_approval_middleware(
yield pending_response
return approval_middleware
def create_hitl_middleware(
*,
meta_by_name: dict[str, ToolConfig],
approval_resolver: Callable[[str, dict[str, Any], ToolConfig], str | None]
| None = None,
) -> Callable[..., AsyncGenerator[Any, None]]:
return create_approval_middleware(
config_by_name=meta_by_name,
approval_resolver=approval_resolver,
)
+20 -34
View File
@@ -13,8 +13,6 @@ from core.agentscope.tools.custom.calendar import (
from core.agentscope.tools.custom.user_lookup import user_lookup
from core.agentscope.tools.tool_config import (
TOOL_CONFIGS,
ToolGroup,
resolve_tool_names_by_groups,
)
from core.agentscope.tools.tool_middleware import register_tool_middlewares
from sqlalchemy.ext.asyncio import AsyncSession
@@ -28,46 +26,36 @@ TOOL_FUNCTIONS: dict[str, Any] = {
}
AGENT_TYPE_TO_GROUPS: dict[AgentType, set[ToolGroup]] = {
AgentType.WORKER: {ToolGroup.READ, ToolGroup.WRITE},
AgentType.MEMORY: {ToolGroup.READ, ToolGroup.WRITE},
AGENT_TYPE_TO_DEFAULT_TOOLS: dict[AgentType, set[str]] = {
AgentType.WORKER: {
"calendar_read",
"calendar_write",
"calendar_share",
"user_lookup",
},
AgentType.MEMORY: {"calendar_read", "user_lookup"},
}
def _resolve_enabled_tools(
*,
groups: set[ToolGroup] | None,
enabled_tool_names: set[str] | None,
) -> set[str]:
if enabled_tool_names is not None:
unknown = enabled_tool_names - set(TOOL_FUNCTIONS)
if unknown:
raise ValueError(f"unknown tools in enabled_tool_names: {sorted(unknown)}")
return set(enabled_tool_names)
if groups is None:
return set(TOOL_FUNCTIONS)
resolved = resolve_tool_names_by_groups(groups)
unknown = resolved - set(TOOL_FUNCTIONS)
def _validate_enabled_tool_names(enabled_tool_names: set[str]) -> set[str]:
unknown = enabled_tool_names - set(TOOL_FUNCTIONS)
if unknown:
raise ValueError(f"tool config contains unknown tools: {sorted(unknown)}")
return resolved
raise ValueError(f"unknown tools in enabled_tool_names: {sorted(unknown)}")
return enabled_tool_names
def build_toolkit(
*,
session: AsyncSession,
owner_id: UUID,
groups: set[ToolGroup] | None = None,
enabled_tool_names: set[str] | None = None,
enable_hitl: bool | None = None,
):
toolkit = Toolkit()
enabled_names = _resolve_enabled_tools(
groups=groups,
enabled_tool_names=enabled_tool_names,
)
if enabled_tool_names is None:
enabled_names = set(TOOL_FUNCTIONS)
else:
enabled_names = _validate_enabled_tool_names(set(enabled_tool_names))
preset_kwargs = cast(
dict[str, JSONSerializableObject],
@@ -100,15 +88,13 @@ def build_stage_toolkit(
enabled_tool_names: set[str] | None = None,
enable_hitl: bool | None = None,
):
groups = AGENT_TYPE_TO_GROUPS.get(agent_type)
if groups is None:
default_tools = AGENT_TYPE_TO_DEFAULT_TOOLS.get(agent_type)
if default_tools is None:
raise ValueError(f"unknown agent_type: {agent_type}")
stage_enabled_names = resolve_tool_names_by_groups(set(groups))
selected_names = (
stage_enabled_names
set(default_tools)
if enabled_tool_names is None
else stage_enabled_names | set(enabled_tool_names)
else _validate_enabled_tool_names(set(enabled_tool_names))
)
return build_toolkit(
@@ -1,9 +1,9 @@
from core.agentscope.tools.utils.auth_helpers import (
find_auth_email_by_user_id,
find_auth_phone_by_user_id,
list_auth_users,
)
__all__ = [
"list_auth_users",
"find_auth_email_by_user_id",
"find_auth_phone_by_user_id",
]
@@ -25,12 +25,12 @@ def list_auth_users() -> list[Any]:
return users
def find_auth_email_by_user_id(*, users: list[Any], user_id: UUID) -> str | None:
"""Find auth email by user id from fetched user list."""
def find_auth_phone_by_user_id(*, users: list[Any], user_id: UUID) -> str | None:
"""Find auth phone by user id from fetched user list."""
target = str(user_id)
for user in users:
if str(getattr(user, "id", "")) == target:
email = getattr(user, "email", None)
if isinstance(email, str) and email.strip():
return email.strip()
phone = getattr(user, "phone", None)
if isinstance(phone, str) and phone.strip():
return phone.strip()
return None
@@ -9,7 +9,7 @@ from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from core.agentscope.tools.utils.auth_helpers import (
find_auth_email_by_user_id,
find_auth_phone_by_user_id,
list_auth_users,
)
from core.auth.models import CurrentUser
@@ -125,7 +125,7 @@ def parse_iso_datetime(value: str | None) -> datetime | None:
return parsed.astimezone(timezone.utc)
def resolve_share_target_email_map(invitee_user_ids: list[str]) -> dict[str, str]:
def resolve_share_target_phone_map(invitee_user_ids: list[str]) -> dict[str, str]:
users = list_auth_users()
resolved: dict[str, str] = {}
for raw_user_id in invitee_user_ids:
@@ -138,7 +138,7 @@ def resolve_share_target_email_map(invitee_user_ids: list[str]) -> dict[str, str
user_uuid = UUID(normalized_user_id)
except ValueError:
continue
email = find_auth_email_by_user_id(users=users, user_id=user_uuid)
if email:
resolved[str(user_uuid)] = email.lower()
phone = find_auth_phone_by_user_id(users=users, user_id=user_uuid)
if phone:
resolved[str(user_uuid)] = phone
return resolved