refactor: 梳理规则体系并统一记忆与部署流程

This commit is contained in:
qzl
2026-03-23 17:57:24 +08:00
parent 2a14ad1d8e
commit f4b7eb7e09
39 changed files with 2091 additions and 1454 deletions
@@ -52,6 +52,50 @@ class CalendarShareInvitee(BaseModel):
)
class CalendarWriteOperation(BaseModel):
model_config = ConfigDict(extra="forbid")
action: Literal["create", "update", "delete"] = Field(
description="Action type for this operation item."
)
event_id: str | None = Field(
default=None,
description="Event id required for update/delete.",
)
title: str | None = Field(default=None, description="Event title.")
description: str | None = Field(default=None, description="Event description.")
start_at: str | None = Field(
default=None,
description="Start time in ISO 8601 with timezone offset.",
)
end_at: str | None = Field(
default=None,
description="End time in ISO 8601 with timezone offset.",
)
event_timezone: str | None = Field(
default=None,
description="IANA timezone for the event.",
)
location: str | None = Field(default=None, description="Event location.")
color: str | None = Field(default=None, description="Event color.")
reminder_minutes: int | None = Field(
default=None,
ge=0,
le=10080,
description="Reminder minutes before event start.",
)
status: Literal["active", "completed", "canceled", "archived"] | None = Field(
default=None,
description="Optional status for update action.",
)
class CalendarWriteBatchArgs(BaseModel):
model_config = ConfigDict(extra="forbid")
operations: list[CalendarWriteOperation] = Field(min_length=1, max_length=20)
def _validate_runtime_context(
*,
tool_name: str,
@@ -178,125 +222,48 @@ async def calendar_read(
async def calendar_write(
operations: Annotated[
list[Literal["create", "update", "delete"]],
list[CalendarWriteOperation],
Field(
description=(
"Batch operations list. Each item must be create, update, or delete."
"Batch operation objects. Each item includes action and its fields. "
"Use create/update/delete in a single call."
),
min_length=1,
max_length=20,
),
],
event_ids: Annotated[
list[str | None] | None,
Field(
description=(
"Optional event id list aligned with operations. "
"Required for update/delete item."
)
),
] = None,
titles: Annotated[
list[str | None] | None,
Field(description="Optional title list aligned with operations."),
] = None,
descriptions: Annotated[
list[str | None] | None,
Field(description="Optional description list aligned with operations."),
] = None,
start_ats: Annotated[
list[str | None] | None,
Field(
description=(
"Optional start time list aligned with operations, ISO 8601 with timezone."
)
),
] = None,
end_ats: Annotated[
list[str | None] | None,
Field(
description=(
"Optional end time list aligned with operations, ISO 8601 with timezone."
)
),
] = None,
event_timezones: Annotated[
list[str | None] | None,
Field(
description=(
"Optional event timezone list aligned with operations, IANA timezone."
)
),
] = None,
locations: Annotated[
list[str | None] | None,
Field(description="Optional location list aligned with operations."),
] = None,
colors: Annotated[
list[str | None] | None,
Field(description="Optional color list aligned with operations."),
] = None,
reminder_minutes_list: Annotated[
list[int | None] | None,
Field(
description=(
"Optional reminder minutes list aligned with operations, value range 0-10080."
)
),
] = None,
statuses: Annotated[
list[Literal["active", "completed", "canceled", "archived"] | None] | None,
Field(description="Optional status list aligned with operations."),
] = None,
session: Any = None,
owner_id: Any = None,
) -> ToolResponse:
"""Batch create/update/delete calendar events using aligned list parameters.
"""Batch create/update/delete calendar events using operation objects.
Args:
operations: Operation list. Length defines batch size.
event_ids: Optional event id list aligned with operations.
titles: Optional title list aligned with operations.
descriptions: Optional description list aligned with operations.
start_ats: Optional start time list aligned with operations.
end_ats: Optional end time list aligned with operations.
event_timezones: Optional event timezone list aligned with operations.
locations: Optional location list aligned with operations.
colors: Optional color list aligned with operations.
reminder_minutes_list: Optional reminder minute list aligned with operations.
statuses: Optional status list aligned with operations.
Constraints:
- All provided list parameters must have the same length as operations.
- create item requires start_ats[i] and event_timezones[i].
- update/delete item requires event_ids[i].
- start/end datetime must include timezone offset.
operations: Batch operation objects.
- create requires start_at and event_timezone.
- update/delete requires event_id.
- datetime fields must include timezone offset.
Returns:
ToolResponse with serialized ToolAgentOutput payload.
"""
tool_name = "calendar_write"
try:
parsed_batch = CalendarWriteBatchArgs.model_validate({"operations": operations})
except Exception as exc: # noqa: BLE001
code, message, retryable = map_calendar_exception(exc)
return calendar_error_output(
tool_name=tool_name,
tool_call_args={"operations": operations},
code=code,
message=message,
retryable=retryable,
)
def _align_list(name: str, values: list[Any] | None, size: int) -> list[Any | None]:
if values is None:
return [None] * size
if len(values) != size:
raise ValueError(f"{name} 长度必须与 operations 一致")
return list(values)
batch_size = len(operations)
tool_call_args = {
"operations": operations,
"event_ids": event_ids,
"titles": titles,
"descriptions": descriptions,
"start_ats": start_ats,
"end_ats": end_ats,
"event_timezones": event_timezones,
"locations": locations,
"colors": colors,
"reminder_minutes_list": reminder_minutes_list,
"statuses": statuses,
"operations": [
operation.model_dump(mode="json", exclude_none=True)
for operation in parsed_batch.operations
]
}
runtime_error = _validate_runtime_context(
tool_name=tool_name,
@@ -311,40 +278,26 @@ async def calendar_write(
service = create_schedule_service(
cast(AsyncSession, session), cast(UUID, owner_id)
)
aligned_event_ids = _align_list("event_ids", event_ids, batch_size)
aligned_titles = _align_list("titles", titles, batch_size)
aligned_descriptions = _align_list("descriptions", descriptions, batch_size)
aligned_start_ats = _align_list("start_ats", start_ats, batch_size)
aligned_end_ats = _align_list("end_ats", end_ats, batch_size)
aligned_event_timezones = _align_list(
"event_timezones", event_timezones, batch_size
)
aligned_locations = _align_list("locations", locations, batch_size)
aligned_colors = _align_list("colors", colors, batch_size)
aligned_reminders = _align_list(
"reminder_minutes_list", reminder_minutes_list, batch_size
)
aligned_statuses = _align_list("statuses", statuses, batch_size)
success_count = 0
failed_count = 0
success_event_ids: list[str] = []
result_items: list[dict[str, Any]] = []
for idx, operation in enumerate(operations):
event_id = aligned_event_ids[idx]
title = aligned_titles[idx]
description = aligned_descriptions[idx]
start_at = aligned_start_ats[idx]
end_at = aligned_end_ats[idx]
event_timezone = aligned_event_timezones[idx]
location = aligned_locations[idx]
color = aligned_colors[idx]
reminder_minutes = aligned_reminders[idx]
status = aligned_statuses[idx]
for operation in parsed_batch.operations:
event_id = operation.event_id
title = operation.title
description = operation.description
start_at = operation.start_at
end_at = operation.end_at
event_timezone = operation.event_timezone
location = operation.location
color = operation.color
reminder_minutes = operation.reminder_minutes
status = operation.status
try:
if operation == "create":
if operation.action == "create":
if start_at is None or not start_at.strip():
raise ValueError(
"创建日程需要提供 start_at,且必须包含时区偏移"
@@ -385,7 +338,7 @@ async def calendar_write(
success_event_ids.append(str(created.id))
continue
if operation == "update":
if operation.action == "update":
if event_id is None or not event_id.strip():
raise ValueError("更新日程需要提供 event_id")
parsed_event_id = UUID(event_id)
@@ -429,7 +382,7 @@ async def calendar_write(
success_event_ids.append(str(updated.id))
continue
if operation == "delete":
if operation.action == "delete":
if event_id is None or not event_id.strip():
raise ValueError("删除日程需要提供 event_id")
await service.delete(UUID(event_id))
+274 -100
View File
@@ -16,7 +16,7 @@ from core.agentscope.tools.utils.tool_response_builder import (
build_tool_response,
)
from models.memories import MemoryType
from schemas.agent.runtime_models import ToolAgentOutput, ToolStatus
from schemas.agent.runtime_models import ErrorInfo, ToolAgentOutput, ToolStatus
from schemas.memories.memory_content import UserMemoryContent, WorkProfileContent
@@ -38,6 +38,12 @@ class MemoryWriteArgs(BaseModel):
return self
class MemoryWriteBatchArgs(BaseModel):
model_config = ConfigDict(extra="forbid")
operations: list[MemoryWriteArgs] = Field(min_length=1, max_length=20)
class MemoryForgetArgs(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -70,6 +76,12 @@ class MemoryForgetArgs(BaseModel):
return self
class MemoryForgetBatchArgs(BaseModel):
model_config = ConfigDict(extra="forbid")
operations: list[MemoryForgetArgs] = Field(min_length=1, max_length=20)
def _memory_error_output(
*,
tool_name: str,
@@ -149,28 +161,45 @@ def _delete_nested_path(payload: dict[str, Any], keys: list[str]) -> bool:
return False
def _compact_result_items(items: list[dict[str, object]]) -> str:
return ",".join(
"{" + ",".join(f"{key}={value}" for key, value in item.items()) + "}"
for item in items
)
async def memory_write(
memory_type: Annotated[
str,
Field(description="Memory type: user or work."),
] = "user",
user_content: Annotated[
UserMemoryContent | None,
Field(description="Patch payload for user memory content."),
] = None,
work_content: Annotated[
WorkProfileContent | None,
Field(description="Patch payload for work memory content."),
] = None,
operations: Annotated[
list[MemoryWriteArgs],
Field(
description=(
"Batch memory write operations. Each item must include memory_type and "
"the matching content object (user_content or work_content)."
),
min_length=1,
max_length=20,
),
],
session: Any = None,
owner_id: Any = None,
) -> ToolResponse:
"""Merge structured facts into user/work memory.
Args:
memory_type: Target memory domain, either ``user`` or ``work``.
user_content: Partial user-memory payload when ``memory_type='user'``.
work_content: Partial work-memory payload when ``memory_type='work'``.
Runtime:
``session`` and ``owner_id`` are injected by toolkit preset kwargs.
Returns:
ToolResponse wrapping ToolAgentOutput.
- success: ``result`` contains a compact status summary.
- failure: ``error`` contains structured code/message/retryable metadata.
"""
tool_name = "memory_write"
tool_call_args: dict[str, Any] = {
"memory_type": memory_type,
"user_content": user_content,
"work_content": work_content,
}
tool_call_args: dict[str, Any] = {"operations": operations}
runtime_error = _validate_runtime_context(
tool_name=tool_name,
tool_call_args=tool_call_args,
@@ -181,52 +210,117 @@ async def memory_write(
return runtime_error
try:
parsed_args = MemoryWriteArgs.model_validate(tool_call_args)
parsed_batch = MemoryWriteBatchArgs.model_validate(tool_call_args)
service = create_memories_service(
session=cast(AsyncSession, session),
owner_id=cast(UUID, owner_id),
)
existing = await service.get_memory_model(memory_type=parsed_args.memory_type)
success_count = 0
failed_count = 0
updated_types: list[str] = []
failed_operations: list[dict[str, object]] = []
result_items: list[dict[str, object]] = []
for idx, op in enumerate(parsed_batch.operations):
try:
existing = await service.get_memory_model(memory_type=op.memory_type)
if op.memory_type == MemoryType.USER:
base_model = (
UserMemoryContent.model_validate(existing.content)
if existing is not None
else UserMemoryContent()
)
patch_model = cast(UserMemoryContent, op.user_content)
merged = _deep_merge_dict(
base_model.model_dump(),
patch_model.model_dump(exclude_unset=True),
)
validated = UserMemoryContent.model_validate(merged)
updated = await service.update_user_memory(content=validated)
else:
base_model = (
WorkProfileContent.model_validate(existing.content)
if existing is not None
else WorkProfileContent()
)
patch_model = cast(WorkProfileContent, op.work_content)
merged = _deep_merge_dict(
base_model.model_dump(),
patch_model.model_dump(exclude_unset=True),
)
validated = WorkProfileContent.model_validate(merged)
updated = await service.update_work_memory(content=validated)
if parsed_args.memory_type == MemoryType.USER:
base_model = (
UserMemoryContent.model_validate(existing.content)
if existing is not None
else UserMemoryContent()
)
patch_model = cast(UserMemoryContent, parsed_args.user_content)
merged = _deep_merge_dict(
base_model.model_dump(),
patch_model.model_dump(exclude_unset=True),
)
validated = UserMemoryContent.model_validate(merged)
await service.update_user_memory(
content=validated,
)
else:
base_model = (
WorkProfileContent.model_validate(existing.content)
if existing is not None
else WorkProfileContent()
)
patch_model = cast(WorkProfileContent, parsed_args.work_content)
merged = _deep_merge_dict(
base_model.model_dump(),
patch_model.model_dump(exclude_unset=True),
)
validated = WorkProfileContent.model_validate(merged)
await service.update_work_memory(
content=validated,
)
success_count += 1
updated_types.append(op.memory_type.value)
memory_id = str(
getattr(updated, "id", None)
or (getattr(existing, "id", None) if existing is not None else "")
or ""
)
result_items.append(
{
"idx": idx,
"memoryType": op.memory_type.value,
"status": "success",
"memoryId": memory_id,
}
)
except Exception as exc: # noqa: BLE001
failed_count += 1
code, message, retryable = map_memory_exception(exc)
failed_operations.append(
{
"memory_type": op.memory_type.value,
"code": code,
"message": message,
"retryable": retryable,
}
)
result_items.append(
{
"idx": idx,
"memoryType": op.memory_type.value,
"status": "failure",
"code": code,
}
)
summary = f"status=success memory_type={parsed_args.memory_type.value}"
status = (
ToolStatus.SUCCESS
if failed_count == 0
else (ToolStatus.FAILURE if success_count == 0 else ToolStatus.PARTIAL)
)
status_text = (
"success"
if status == ToolStatus.SUCCESS
else ("failure" if status == ToolStatus.FAILURE else "partial")
)
summary = (
f"status={status_text} "
f"success={success_count} failed={failed_count} "
f"updated_types=[{','.join(updated_types)}]"
)
compact_items = _compact_result_items(result_items)
if compact_items:
summary = f"{summary} items=[{compact_items}]"
error_info: ErrorInfo | None = None
if failed_operations:
first = failed_operations[0]
error_info = ErrorInfo(
code=str(first.get("code") or "MEMORY_BATCH_FAILED"),
message=str(first.get("message") or "memory batch write failed"),
retryable=bool(first.get("retryable") is True),
details={"failed_operations": failed_operations},
)
return build_tool_response(
ToolAgentOutput(
tool_name=tool_name,
tool_call_id=get_current_tool_call_id(tool_name=tool_name),
tool_call_args=tool_call_args,
status=ToolStatus.SUCCESS,
status=status,
result=summary,
error=error_info,
)
)
except Exception as exc: # noqa: BLE001
@@ -241,22 +335,38 @@ async def memory_write(
async def memory_forget(
memory_type: Annotated[
str,
Field(description="Memory type: user or work."),
] = "user",
forget_paths: Annotated[
list[str] | None,
Field(description="Dot paths to remove from content."),
] = None,
operations: Annotated[
list[MemoryForgetArgs],
Field(
description=(
"Batch memory forget operations. Each item must include memory_type and "
"forget_paths."
),
min_length=1,
max_length=20,
),
],
session: Any = None,
owner_id: Any = None,
) -> ToolResponse:
"""Forget selected paths from user/work memory content.
Args:
memory_type: Target memory domain, either ``user`` or ``work``.
forget_paths: Dot-path list to remove from memory content.
Notes:
- Path root must belong to the target memory schema.
- The tool is idempotent; missing paths are skipped safely.
Runtime:
``session`` and ``owner_id`` are injected by toolkit preset kwargs.
Returns:
ToolResponse wrapping ToolAgentOutput with compact execution summary.
"""
tool_name = "memory_forget"
tool_call_args: dict[str, Any] = {
"memory_type": memory_type,
"forget_paths": forget_paths or [],
}
tool_call_args: dict[str, Any] = {"operations": operations}
runtime_error = _validate_runtime_context(
tool_name=tool_name,
tool_call_args=tool_call_args,
@@ -267,56 +377,120 @@ async def memory_forget(
return runtime_error
try:
parsed_args = MemoryForgetArgs.model_validate(tool_call_args)
parsed_batch = MemoryForgetBatchArgs.model_validate(tool_call_args)
service = create_memories_service(
session=cast(AsyncSession, session),
owner_id=cast(UUID, owner_id),
)
existing = await service.get_memory_model(memory_type=parsed_args.memory_type)
if existing is None:
summary = f"status=success memory_type={parsed_args.memory_type.value} forgotten=0"
return build_tool_response(
ToolAgentOutput(
tool_name=tool_name,
tool_call_id=get_current_tool_call_id(tool_name=tool_name),
tool_call_args=tool_call_args,
status=ToolStatus.SUCCESS,
result=summary,
)
)
success_count = 0
failed_count = 0
forgotten_total = 0
processed_types: list[str] = []
failed_operations: list[dict[str, object]] = []
result_items: list[dict[str, object]] = []
for idx, op in enumerate(parsed_batch.operations):
try:
existing = await service.get_memory_model(memory_type=op.memory_type)
if existing is None:
success_count += 1
processed_types.append(op.memory_type.value)
result_items.append(
{
"idx": idx,
"memoryType": op.memory_type.value,
"status": "success",
"forgotten": 0,
"memoryId": "",
}
)
continue
if parsed_args.memory_type == MemoryType.USER:
base_model = UserMemoryContent.model_validate(existing.content)
updated_dict, removed_paths = _remove_content_paths(
base_model.model_dump(),
parsed_args.forget_paths,
)
validated = UserMemoryContent.model_validate(updated_dict)
await service.update_user_memory(
content=validated,
)
else:
base_model = WorkProfileContent.model_validate(existing.content)
updated_dict, removed_paths = _remove_content_paths(
base_model.model_dump(),
parsed_args.forget_paths,
)
validated = WorkProfileContent.model_validate(updated_dict)
await service.update_work_memory(
content=validated,
)
if op.memory_type == MemoryType.USER:
base_model = UserMemoryContent.model_validate(existing.content)
updated_dict, removed_paths = _remove_content_paths(
base_model.model_dump(),
op.forget_paths,
)
validated = UserMemoryContent.model_validate(updated_dict)
await service.update_user_memory(content=validated)
else:
base_model = WorkProfileContent.model_validate(existing.content)
updated_dict, removed_paths = _remove_content_paths(
base_model.model_dump(),
op.forget_paths,
)
validated = WorkProfileContent.model_validate(updated_dict)
await service.update_work_memory(content=validated)
forgotten_total += len(removed_paths)
success_count += 1
processed_types.append(op.memory_type.value)
result_items.append(
{
"idx": idx,
"memoryType": op.memory_type.value,
"status": "success",
"forgotten": len(removed_paths),
"memoryId": str(getattr(existing, "id", "") or ""),
}
)
except Exception as exc: # noqa: BLE001
failed_count += 1
code, message, retryable = map_memory_exception(exc)
failed_operations.append(
{
"memory_type": op.memory_type.value,
"code": code,
"message": message,
"retryable": retryable,
}
)
result_items.append(
{
"idx": idx,
"memoryType": op.memory_type.value,
"status": "failure",
"code": code,
}
)
status = (
ToolStatus.SUCCESS
if failed_count == 0
else (ToolStatus.FAILURE if success_count == 0 else ToolStatus.PARTIAL)
)
status_text = (
"success"
if status == ToolStatus.SUCCESS
else ("failure" if status == ToolStatus.FAILURE else "partial")
)
summary = (
f"status=success memory_type={parsed_args.memory_type.value} forgotten={len(removed_paths)} "
f"skipped=0"
f"status={status_text} "
f"success={success_count} failed={failed_count} "
f"forgotten={forgotten_total} "
f"processed_types=[{','.join(processed_types)}]"
)
compact_items = _compact_result_items(result_items)
if compact_items:
summary = f"{summary} items=[{compact_items}]"
error_info: ErrorInfo | None = None
if failed_operations:
first = failed_operations[0]
error_info = ErrorInfo(
code=str(first.get("code") or "MEMORY_BATCH_FAILED"),
message=str(first.get("message") or "memory batch forget failed"),
retryable=bool(first.get("retryable") is True),
details={"failed_operations": failed_operations},
)
return build_tool_response(
ToolAgentOutput(
tool_name=tool_name,
tool_call_id=get_current_tool_call_id(tool_name=tool_name),
tool_call_args=tool_call_args,
status=ToolStatus.SUCCESS,
status=status,
result=summary,
error=error_info,
)
)
except Exception as exc: # noqa: BLE001