feat: 统一自动化任务调度配置并增强聊天流恢复

This commit is contained in:
qzl
2026-03-24 18:19:33 +08:00
parent 23359c2d01
commit 389f5248fc
30 changed files with 1144 additions and 888 deletions
@@ -319,7 +319,7 @@ class AgentScopeRunner:
content = user_blocks
user_msg = Msg(name="user", role="user", content=content)
return [user_msg, *context_messages]
return [*context_messages, user_msg]
async def _run_worker_stage(
self,
@@ -1,11 +1,23 @@
input_template: |
你正在执行自动化记忆提取任务。必须只使用 memory_forget 与 memory_write,不要执行任何 calendar 或 user_lookup 工具
步骤1:基于最近两天聊天上下文,抽取“有证据支持”的用户长期偏好变化,禁止编造。
步骤2:对已失效或被用户明确否定的信息,调用 memory_forget 执行遗忘。
步骤3:对新增或变化的信息,调用 memory_write 执行写入
步骤4:两类工具都必须使用批量参数 operations(对象数组),并保证参数是结构化 JSON,不要把数组或对象写成字符串
步骤5:只写入被证据覆盖的最小字段集;无证据字段不要写
输出要求:仅基于工具结果给出一句执行摘要(包含 success/failed 计数)。
你正在执行一次“自动化记忆回顾与整理”任务
任务目标:
1) 回顾最近两天的聊天与上下文,识别用户长期偏好、习惯和关键事实的变化
2) 对已经失效、被否定或明显过期的信息执行遗忘
3) 对新增且有证据支持的信息执行写入
4) 严禁编造;没有证据就不要写入。
5) 只更新最小必要字段,避免过度覆盖。
输出要求:
- 必须使用以下固定格式输出;每一行都要有:
【记忆回顾】<一句人性化总结,说明今天主要发生了什么>
【新增记忆】<按“X条:要点1;要点2”描述;没有则写“0条”>
【遗忘记忆】<按“X条:要点1;要点2”描述;没有则写“0条”>
【未来展望】<基于本次记忆变化,给出1-2条温和、可执行的后续建议;若暂无建议则说明“可继续观察”>
表达风格:
- 语言自然、温和、可读,像助理在做每日回顾。
- 结论先行,避免空话,不要输出与任务无关的闲聊内容。
enabled_tools:
- memory.write
- memory.forget
@@ -18,3 +30,4 @@ schedule:
run_at:
hour: 8
minute: 0
weekdays: null
-8
View File
@@ -45,14 +45,6 @@ class AutomationJob(TimestampMixin, SoftDeleteMixin, Base):
nullable=False,
default=dict,
)
schedule_type: Mapped[ScheduleType] = mapped_column(
String(20),
nullable=False,
)
run_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
)
next_run_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
+16 -5
View File
@@ -5,7 +5,7 @@ from enum import Enum
from uuid import UUID
from core.agentscope.tools.tool_config import AgentTool
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, model_validator
from models.automation_jobs import AutomationJob as OrmAutomationJob
from models.automation_jobs import AutomationJobStatus, ScheduleType
@@ -40,6 +40,21 @@ class ScheduleConfig(BaseModel):
type: ScheduleType
run_at: ScheduleRunAt
weekdays: list[int] | None = None
@model_validator(mode="after")
def validate_weekdays(self) -> "ScheduleConfig":
if self.type == ScheduleType.WEEKLY:
if not self.weekdays:
raise ValueError("weekdays is required when schedule type is weekly")
invalid = [day for day in self.weekdays if day < 1 or day > 7]
if invalid:
raise ValueError("weekdays must be within 1-7")
deduped = sorted(set(self.weekdays))
self.weekdays = deduped
else:
self.weekdays = None
return self
class RuntimeConfig(BaseModel):
@@ -66,8 +81,6 @@ class AutomationJob(BaseModel):
bootstrap_key: str | None = Field(default=None, min_length=1, max_length=64)
title: str = Field(..., min_length=1, max_length=255)
config: AutomationJobConfig
schedule_type: ScheduleType
run_at: datetime
next_run_at: datetime
timezone: str = Field(default="UTC", min_length=1, max_length=50)
last_run_at: datetime | None = None
@@ -84,8 +97,6 @@ class AutomationJob(BaseModel):
bootstrap_key=obj.bootstrap_key,
title=obj.title,
config=AutomationJobConfig.model_validate(obj.config or {}),
schedule_type=obj.schedule_type,
run_at=obj.run_at,
next_run_at=obj.next_run_at,
timezone=obj.timezone,
last_run_at=obj.last_run_at,
@@ -7,12 +7,7 @@ from typing import Any
import yaml
from core.agentscope.tools.tool_config import AgentTool
from models.automation_jobs import ScheduleType
from schemas.automation import (
AutomationJobConfig,
MessageContextConfig,
)
from schemas.automation import AutomationJobConfig
_CONFIG_NAME_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
@@ -37,18 +32,4 @@ def load_static_automation_job_config(*, config_name: str) -> AutomationJobConfi
loaded: Any = yaml.safe_load(file) or {}
if not isinstance(loaded, dict):
raise ValueError(f"invalid automation config format: {path}")
config = AutomationJobConfig.model_validate(loaded)
if config_name == "memory_extraction":
if config.enabled_tools != [AgentTool.MEMORY_WRITE, AgentTool.MEMORY_FORGET]:
raise ValueError(
"memory_extraction enabled_tools must be [memory.write, memory.forget]"
)
if config.context != MessageContextConfig(window_count=2):
raise ValueError(
"memory_extraction context must be latest_chat/day with window_count=2"
)
if config.schedule is None:
raise ValueError("memory_extraction schedule must be configured")
if config.schedule.type != ScheduleType.DAILY:
raise ValueError("memory_extraction schedule type must be daily")
return config
return AutomationJobConfig.model_validate(loaded)
+37 -34
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from datetime import UTC, datetime, time, timedelta
from typing import Protocol
from uuid import UUID, uuid4
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
@@ -13,7 +13,7 @@ from core.logging import get_logger
from models.automation_jobs import AutomationJob, AutomationJobStatus, ScheduleType
from models.memories import MemoryType
from models.profile import Profile
from schemas.automation import AutomationJobConfig
from schemas.automation import AutomationJobConfig, ScheduleConfig
from schemas.memories.memory_content import UserMemoryContent, WorkProfileContent
from schemas.user.context import parse_profile_settings
from v1.auth.automation_static_config import load_static_automation_job_config
@@ -44,9 +44,7 @@ class RegistrationBootstrapRepository:
title: str,
config: AutomationJobConfig,
timezone_name: str,
run_at: datetime,
next_run_at: datetime,
schedule_type: ScheduleType,
) -> bool:
stmt = (
insert(AutomationJob)
@@ -56,8 +54,6 @@ class RegistrationBootstrapRepository:
bootstrap_key=bootstrap_key,
title=title,
config=config.model_dump(mode="json"),
schedule_type=schedule_type,
run_at=run_at,
next_run_at=next_run_at,
timezone=timezone_name,
status=AutomationJobStatus.ACTIVE,
@@ -103,9 +99,7 @@ class RegistrationBootstrapRepositoryLike(Protocol):
title: str,
config: AutomationJobConfig,
timezone_name: str,
run_at: datetime,
next_run_at: datetime,
schedule_type: ScheduleType,
) -> bool: ...
async def upsert_initial_memory(
@@ -123,35 +117,48 @@ class SessionLike(Protocol):
async def rollback(self) -> None: ...
def compute_next_local_time_utc(
def compute_first_run_at_utc(
*,
now_utc: datetime,
timezone_name: str,
local_hour: int,
local_minute: int,
schedule_type: ScheduleType,
) -> tuple[datetime, datetime]:
schedule: ScheduleConfig,
) -> datetime:
try:
timezone_obj = ZoneInfo(timezone_name)
except ZoneInfoNotFoundError:
timezone_obj = ZoneInfo("UTC")
local_now = now_utc.astimezone(timezone_obj)
today_run_local = local_now.replace(
hour=local_hour,
minute=local_minute,
second=0,
microsecond=0,
run_clock = time(
hour=schedule.run_at.hour,
minute=schedule.run_at.minute,
tzinfo=timezone_obj,
)
run_local = (
today_run_local
if local_now <= today_run_local
else today_run_local + timedelta(days=1)
)
if schedule_type == ScheduleType.WEEKLY:
next_local = run_local + timedelta(weeks=1)
else:
next_local = run_local + timedelta(days=1)
return run_local.astimezone(UTC), next_local.astimezone(UTC)
if schedule.type == ScheduleType.DAILY:
candidate_local = datetime.combine(local_now.date(), run_clock)
if candidate_local <= local_now:
candidate_local = candidate_local + timedelta(days=1)
return candidate_local.astimezone(UTC)
weekdays = schedule.weekdays or []
if not weekdays:
raise ValueError("weekly schedule requires weekdays")
normalized_weekdays = sorted(set(weekdays))
for day_offset in range(0, 8):
candidate_day = local_now.date() + timedelta(days=day_offset)
if candidate_day.isoweekday() not in normalized_weekdays:
continue
candidate_local = datetime.combine(candidate_day, run_clock)
if candidate_local > local_now:
return candidate_local.astimezone(UTC)
fallback_day = local_now.date() + timedelta(days=7)
while fallback_day.isoweekday() not in normalized_weekdays:
fallback_day = fallback_day + timedelta(days=1)
fallback_local = datetime.combine(fallback_day, run_clock)
return fallback_local.astimezone(UTC)
class RegistrationAutomationBootstrapService:
@@ -203,12 +210,10 @@ class RegistrationAutomationBootstrapService:
raise ValueError(
f"bootstrap job {bootstrap_key} has no schedule configured"
)
run_at, next_run_at = compute_next_local_time_utc(
next_run_at = compute_first_run_at_utc(
now_utc=datetime.now(UTC),
timezone_name=timezone_name,
local_hour=schedule.run_at.hour,
local_minute=schedule.run_at.minute,
schedule_type=schedule.type,
schedule=schedule,
)
inserted = (
await self._repository.insert_bootstrap_automation_job_if_absent(
@@ -217,9 +222,7 @@ class RegistrationAutomationBootstrapService:
title=str(definition["title"]),
config=job_config,
timezone_name=timezone_name,
run_at=run_at,
next_run_at=next_run_at,
schedule_type=schedule.type,
)
)
inserted_any = inserted_any or inserted
+64 -73
View File
@@ -11,6 +11,7 @@ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from core.db.base_repository import BaseRepository
from models.agent_chat_session import AgentChatSession, SessionType
from models.automation_jobs import AutomationJob, AutomationJobStatus, ScheduleType
from schemas.automation import AutomationJobConfig, ScheduleConfig
if TYPE_CHECKING:
from v1.automation_jobs.schemas import (
@@ -107,26 +108,45 @@ class AutomationJobsRepository(BaseRepository[AutomationJob]):
except ZoneInfoNotFoundError:
return ZoneInfo("UTC")
def _compute_initial_next_run_at(
def _compute_next_run_at(
self,
*,
run_at: time,
schedule: ScheduleConfig,
timezone_str: str,
now_utc: datetime,
schedule_type: ScheduleType,
) -> datetime:
tz = self._resolve_timezone(timezone_str)
local_now = now_utc.astimezone(tz)
run_at_local = datetime.combine(local_now.date(), run_at, tz)
if run_at_local.tzinfo is None:
run_at_local = run_at_local.replace(tzinfo=tz)
next_run_at = run_at_local
if next_run_at <= local_now:
if schedule_type == ScheduleType.DAILY:
next_run_at = next_run_at + timedelta(days=1)
else:
next_run_at = next_run_at + timedelta(weeks=1)
return next_run_at.astimezone(timezone.utc)
run_clock = time(
hour=schedule.run_at.hour,
minute=schedule.run_at.minute,
tzinfo=tz,
)
if schedule.type == ScheduleType.DAILY:
candidate_local = datetime.combine(local_now.date(), run_clock)
if candidate_local <= local_now:
candidate_local = candidate_local + timedelta(days=1)
return candidate_local.astimezone(timezone.utc)
weekdays = schedule.weekdays or []
if not weekdays:
raise ValueError("weekly schedule requires weekdays")
normalized_weekdays = sorted(set(weekdays))
for day_offset in range(0, 8):
candidate_day = local_now.date() + timedelta(days=day_offset)
if candidate_day.isoweekday() not in normalized_weekdays:
continue
candidate_local = datetime.combine(candidate_day, run_clock)
if candidate_local > local_now:
return candidate_local.astimezone(timezone.utc)
fallback_day = local_now.date() + timedelta(days=7)
while fallback_day.isoweekday() not in normalized_weekdays:
fallback_day = fallback_day + timedelta(days=1)
fallback_local = datetime.combine(fallback_day, run_clock)
return fallback_local.astimezone(timezone.utc)
async def create(
self,
@@ -134,16 +154,14 @@ class AutomationJobsRepository(BaseRepository[AutomationJob]):
data: AutomationJobCreateRequest,
) -> AutomationJob:
now_utc = datetime.now(tz=timezone.utc)
timezone_obj = self._resolve_timezone(data.timezone)
local_now = now_utc.astimezone(timezone_obj)
date_ref = local_now.date()
local_dt = datetime.combine(date_ref, data.run_at, timezone_obj)
run_at_datetime = local_dt.astimezone(timezone.utc)
next_run_at = self._compute_initial_next_run_at(
run_at=data.run_at,
schedule = data.config.schedule
if schedule is None:
raise ValueError("config.schedule is required")
next_run_at = self._compute_next_run_at(
schedule=schedule,
timezone_str=data.timezone,
now_utc=now_utc,
schedule_type=data.schedule_type,
)
new_job = AutomationJob(
@@ -151,8 +169,6 @@ class AutomationJobsRepository(BaseRepository[AutomationJob]):
created_by=owner_id,
bootstrap_key=None,
title=data.title,
schedule_type=data.schedule_type,
run_at=run_at_datetime,
timezone=data.timezone,
status=data.status,
config=data.config.model_dump(mode="json"),
@@ -168,69 +184,44 @@ class AutomationJobsRepository(BaseRepository[AutomationJob]):
data: AutomationJobUpdateRequest,
) -> AutomationJob | None:
update_values: dict[str, object] = {}
existing_job: AutomationJob | None = None
existing_job = await self.get_by_id(job_id)
if existing_job is None:
return None
if data.title is not None:
update_values["title"] = data.title
if data.schedule_type is not None:
update_values["schedule_type"] = data.schedule_type
should_recompute_schedule = (
data.run_at is not None
or data.schedule_type is not None
or data.timezone is not None
)
if should_recompute_schedule:
now_utc = datetime.now(tz=timezone.utc)
if existing_job is None:
existing_job = await self.get_by_id(job_id)
if existing_job is None:
return None
effective_timezone = data.timezone or existing_job.timezone
effective_timezone_obj = self._resolve_timezone(effective_timezone)
effective_schedule_type = data.schedule_type or existing_job.schedule_type
if data.run_at is not None:
effective_run_at = data.run_at
else:
existing_timezone_obj = self._resolve_timezone(existing_job.timezone)
effective_run_at = (
existing_job.run_at.astimezone(existing_timezone_obj)
.time()
.replace(microsecond=0)
)
local_now = now_utc.astimezone(effective_timezone_obj)
local_dt = datetime.combine(
local_now.date(),
effective_run_at,
effective_timezone_obj,
)
update_values["run_at"] = local_dt.astimezone(timezone.utc)
update_values["next_run_at"] = self._compute_initial_next_run_at(
run_at=effective_run_at,
timezone_str=effective_timezone,
now_utc=now_utc,
schedule_type=effective_schedule_type,
)
if data.timezone is not None:
update_values["timezone"] = data.timezone
if data.status is not None:
update_values["status"] = data.status
merged_config_raw: dict[str, object] = dict(existing_job.config or {})
if data.config is not None:
if existing_job is None:
existing_job = await self.get_by_id(job_id)
if existing_job is None:
return None
merged_config = {
**existing_job.config,
merged_config_raw = {
**merged_config_raw,
**data.config.model_dump(mode="json", exclude_unset=True),
}
update_values["config"] = merged_config
normalized_config = AutomationJobConfig.model_validate(merged_config_raw)
update_values["config"] = normalized_config.model_dump(mode="json")
else:
normalized_config = AutomationJobConfig.model_validate(merged_config_raw)
schedule_changed = data.config is not None and (
"schedule" in data.config.model_dump(mode="json", exclude_unset=True)
)
if data.timezone is not None or schedule_changed:
if normalized_config.schedule is None:
raise ValueError("config.schedule is required")
effective_timezone = data.timezone or existing_job.timezone
update_values["next_run_at"] = self._compute_next_run_at(
schedule=normalized_config.schedule,
timezone_str=effective_timezone,
now_utc=datetime.now(tz=timezone.utc),
)
if not update_values:
return await self.get_by_id(job_id)
return existing_job
return await self.update_by_id(job_id, update_values)
+9 -11
View File
@@ -1,14 +1,14 @@
from __future__ import annotations
from datetime import datetime, time
from datetime import datetime
from typing import Self
from uuid import UUID
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from pydantic import BaseModel, ConfigDict, Field, field_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from models.automation_jobs import AutomationJob as OrmAutomationJob
from models.automation_jobs import AutomationJobStatus, ScheduleType
from models.automation_jobs import AutomationJobStatus
from schemas.automation import AutomationJobConfig
@@ -19,8 +19,6 @@ class AutomationJobResponse(BaseModel):
owner_id: UUID
bootstrap_key: str | None = None
title: str
schedule_type: ScheduleType
run_at: time
timezone: str
status: AutomationJobStatus
is_system: bool
@@ -37,8 +35,6 @@ class AutomationJobResponse(BaseModel):
owner_id=obj.owner_id,
bootstrap_key=obj.bootstrap_key,
title=obj.title,
schedule_type=obj.schedule_type,
run_at=obj.run_at.time(),
timezone=obj.timezone,
status=obj.status,
is_system=obj.bootstrap_key is not None,
@@ -54,12 +50,16 @@ class AutomationJobCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
title: str = Field(..., min_length=1, max_length=255)
schedule_type: ScheduleType
run_at: time = Field(..., description="Local time in HH:MM:SS format")
timezone: str = Field(..., min_length=1, max_length=50)
status: AutomationJobStatus = Field(default=AutomationJobStatus.ACTIVE)
config: AutomationJobConfig
@model_validator(mode="after")
def validate_schedule_required(self) -> "AutomationJobCreateRequest":
if self.config.schedule is None:
raise ValueError("config.schedule is required")
return self
@field_validator("timezone")
@classmethod
def validate_timezone(cls, value: str) -> str:
@@ -74,8 +74,6 @@ class AutomationJobUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
title: str | None = Field(None, min_length=1, max_length=255)
schedule_type: ScheduleType | None = None
run_at: time | None = None
timezone: str | None = Field(None, min_length=1, max_length=50)
status: AutomationJobStatus | None = None
config: AutomationJobConfig | None = None
+50 -10
View File
@@ -1,9 +1,10 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta
from datetime import datetime, time, timedelta, timezone
from typing import TYPE_CHECKING, Protocol
from uuid import UUID
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from fastapi import HTTPException, status
from models.automation_jobs import ScheduleType
@@ -11,6 +12,7 @@ from schemas.automation import (
AutomationJob as AutomationJobSchema,
MessageContextConfig,
RuntimeConfig,
ScheduleConfig,
)
from sqlalchemy import text
from sqlalchemy.exc import SQLAlchemyError
@@ -68,15 +70,53 @@ class DispatchFn(Protocol):
def _compute_next_run_at(
*,
current_next_run_at: datetime,
schedule: ScheduleConfig,
timezone_str: str,
now_utc: datetime,
schedule_type: ScheduleType,
) -> datetime:
delta = timedelta(days=1 if schedule_type == ScheduleType.DAILY else 7)
next_run_at = current_next_run_at
while next_run_at <= now_utc:
next_run_at = next_run_at + delta
return next_run_at
try:
tz = ZoneInfo(timezone_str)
except ZoneInfoNotFoundError:
tz = ZoneInfo("UTC")
local_now = now_utc.astimezone(tz)
run_clock = time(
hour=schedule.run_at.hour,
minute=schedule.run_at.minute,
tzinfo=tz,
)
if schedule.type == ScheduleType.DAILY:
candidate_local = datetime.combine(local_now.date(), run_clock)
if candidate_local <= local_now:
candidate_local = candidate_local + timedelta(days=1)
return candidate_local.astimezone(timezone.utc)
weekdays = schedule.weekdays or []
if not weekdays:
raise ValueError("weekly schedule requires weekdays")
normalized_weekdays = sorted(set(weekdays))
for day_offset in range(0, 8):
candidate_day = local_now.date() + timedelta(days=day_offset)
if candidate_day.isoweekday() not in normalized_weekdays:
continue
candidate_local = datetime.combine(candidate_day, run_clock)
if candidate_local > local_now:
return candidate_local.astimezone(timezone.utc)
fallback_day = local_now.date() + timedelta(days=7)
while fallback_day.isoweekday() not in normalized_weekdays:
fallback_day = fallback_day + timedelta(days=1)
fallback_local = datetime.combine(fallback_day, run_clock)
return fallback_local.astimezone(timezone.utc)
def _ensure_schedule(job: AutomationJobSchema) -> ScheduleConfig:
schedule = job.config.schedule
if schedule is None:
raise ValueError(f"job {job.id} config.schedule is missing")
return schedule
@dataclass(slots=True)
@@ -129,9 +169,9 @@ class AutomationJobsService:
await self._repository.update_job_schedule(
job_id=job.id,
next_run_at=_compute_next_run_at(
current_next_run_at=job.next_run_at,
schedule=_ensure_schedule(job),
timezone_str=job.timezone,
now_utc=now_utc,
schedule_type=job.schedule_type,
),
last_run_at=now_utc,
)