feat(engine): 优化智能体循环中的助手消息处理逻辑 - 在没有工具调用时才添加助手消息到上下文 - 确保工具调用响应正确添加到消息上下文中 - 修复了消息构建的条件逻辑 fix(cron): 改进定时任务调度的时间解析功能 - 添加正则表达式导入用于时间显示解析 - 实现从显示文本中提取毫秒间隔的功能 - 增强整数转换的安全性,避免类型错误 - 优化定时任务配置的解析逻辑 feat(outlook): 增强Outlook集成的功能和稳定性 - 将默认超时时间从10秒增加到180秒 - 为状态检查函数添加可选的验证参数 - 串行执行邮件概览获取操作而非并行 - 改进连接状态验证逻辑 feat(channel): 添加设备名称作为会话标识的选项 - 为终端WebSocket适配器添加新的配置选项 - 实现基于设备名称生成会话对等ID的功能 - 记录原始对等ID和设备名称的元数据 - 支持从设备名称创建会话对等ID feat(skills): 完善技能学习评估系统和进度跟踪 - 在应用启动时自动调度待评估的技能草稿 - 为技能评估工作创建独立的循环工厂 - 实现异步技能评估任务的取消和清理机制 - 添加技能评估进度报告和状态跟踪功能 - 扩展会话列表API以包含更多详细信息 - 防止对不存在的会话进行操作 - 优化技能草稿提交和评估的业务逻辑 perf(skills): 提升技能评估的并发性能 - 实现并行技能案例评估以提高效率 - 添加最大并行案例数的环境变量控制 - 实现实时评估进度更新和回调机制 - 优化评估过程中的资源管理和同步 refactor(services): 创建隔离的智能体循环实例 - 添加创建独立智能体循环的工厂方法 - 确保新循环继承运行时服务配置 - 支持技能评估等需要隔离环境的场景 ```
279 lines
10 KiB
Python
279 lines
10 KiB
Python
"""Scheduled task models for Beaver cron.
|
|
|
|
Every trigger targets Beaver Task mode so scheduled work remains visible as a
|
|
normal Task instead of a detached agent turn.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, Literal
|
|
from uuid import uuid4
|
|
|
|
|
|
CronScheduleKind = Literal["at", "every", "cron"]
|
|
CronPayloadKind = Literal["agent_turn", "system_event"]
|
|
CronPayloadMode = Literal["notification", "task"]
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class CronSchedule:
|
|
kind: CronScheduleKind
|
|
at_ms: int | None = None
|
|
every_ms: int | None = None
|
|
expr: str | None = None
|
|
tz: str | None = None
|
|
display: str | None = None
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return {
|
|
"kind": self.kind,
|
|
"at_ms": self.at_ms,
|
|
"every_ms": self.every_ms,
|
|
"expr": self.expr,
|
|
"tz": self.tz,
|
|
"display": self.display,
|
|
}
|
|
|
|
@classmethod
|
|
def from_dict(cls, payload: dict[str, Any]) -> "CronSchedule":
|
|
kind = str(payload.get("kind") or "every")
|
|
display = _optional_str(payload.get("display"))
|
|
every_ms = _optional_int(payload.get("every_ms") or payload.get("everyMs"))
|
|
if kind == "every" and every_ms is None:
|
|
every_ms = _every_ms_from_display(display)
|
|
return cls(
|
|
kind=kind, # type: ignore[arg-type]
|
|
at_ms=_optional_int(payload.get("at_ms") or payload.get("atMs")),
|
|
every_ms=every_ms,
|
|
expr=_optional_str(payload.get("expr")),
|
|
tz=_optional_str(payload.get("tz")),
|
|
display=display,
|
|
)
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class CronPayload:
|
|
kind: CronPayloadKind = "agent_turn"
|
|
mode: CronPayloadMode = "notification"
|
|
message: str = ""
|
|
session_key: str | None = None
|
|
requires_followup: bool = False
|
|
deliver: bool = False
|
|
channel: str | None = None
|
|
to: str | None = None
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return {
|
|
"kind": self.kind,
|
|
"mode": self.mode,
|
|
"message": self.message,
|
|
"session_key": self.session_key,
|
|
"requires_followup": self.requires_followup,
|
|
"deliver": self.deliver,
|
|
"channel": self.channel,
|
|
"to": self.to,
|
|
}
|
|
|
|
@classmethod
|
|
def from_dict(cls, payload: dict[str, Any]) -> "CronPayload":
|
|
return cls(
|
|
kind=str(payload.get("kind") or "agent_turn"), # type: ignore[arg-type]
|
|
mode=_payload_mode(payload.get("mode"), default="task"),
|
|
message=str(payload.get("message") or ""),
|
|
session_key=_optional_str(payload.get("session_key") or payload.get("sessionKey")),
|
|
requires_followup=bool(payload.get("requires_followup") or payload.get("requiresFollowup") or False),
|
|
deliver=bool(payload.get("deliver", False)),
|
|
channel=_optional_str(payload.get("channel")),
|
|
to=_optional_str(payload.get("to")),
|
|
)
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class CronRunRecord:
|
|
started_at_ms: int
|
|
scheduled_run_id: str = field(default_factory=lambda: uuid4().hex)
|
|
finished_at_ms: int | None = None
|
|
status: Literal["running", "ok", "error", "skipped"] = "running"
|
|
mode: CronPayloadMode = "notification"
|
|
notification_session_id: str | None = None
|
|
output: str | None = None
|
|
task_id: str | None = None
|
|
run_id: str | None = None
|
|
error: str | None = None
|
|
engaged: bool = False
|
|
engaged_at_ms: int | None = None
|
|
engage_intent: str | None = None
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return {
|
|
"scheduled_run_id": self.scheduled_run_id,
|
|
"started_at_ms": self.started_at_ms,
|
|
"finished_at_ms": self.finished_at_ms,
|
|
"status": self.status,
|
|
"mode": self.mode,
|
|
"notification_session_id": self.notification_session_id,
|
|
"output": self.output,
|
|
"task_id": self.task_id,
|
|
"run_id": self.run_id,
|
|
"error": self.error,
|
|
"engaged": self.engaged,
|
|
"engaged_at_ms": self.engaged_at_ms,
|
|
"engage_intent": self.engage_intent,
|
|
}
|
|
|
|
@classmethod
|
|
def from_dict(cls, payload: dict[str, Any]) -> "CronRunRecord":
|
|
return cls(
|
|
scheduled_run_id=str(payload.get("scheduled_run_id") or payload.get("scheduledRunId") or uuid4().hex),
|
|
started_at_ms=int(payload.get("started_at_ms") or payload.get("startedAtMs") or 0),
|
|
finished_at_ms=_optional_int(payload.get("finished_at_ms") or payload.get("finishedAtMs")),
|
|
status=str(payload.get("status") or "running"), # type: ignore[arg-type]
|
|
mode=_payload_mode(payload.get("mode"), default="notification"),
|
|
notification_session_id=_optional_str(payload.get("notification_session_id") or payload.get("notificationSessionId")),
|
|
output=_optional_str(payload.get("output")),
|
|
task_id=_optional_str(payload.get("task_id") or payload.get("taskId")),
|
|
run_id=_optional_str(payload.get("run_id") or payload.get("runId")),
|
|
error=_optional_str(payload.get("error")),
|
|
engaged=bool(payload.get("engaged", False)),
|
|
engaged_at_ms=_optional_int(payload.get("engaged_at_ms") or payload.get("engagedAtMs")),
|
|
engage_intent=_optional_str(payload.get("engage_intent") or payload.get("engageIntent")),
|
|
)
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class CronJob:
|
|
id: str
|
|
name: str
|
|
enabled: bool
|
|
schedule: CronSchedule
|
|
payload: CronPayload
|
|
created_at_ms: int
|
|
updated_at_ms: int
|
|
next_run_at_ms: int | None = None
|
|
last_run_at_ms: int | None = None
|
|
last_status: Literal["ok", "error", "skipped"] | None = None
|
|
last_error: str | None = None
|
|
delete_after_run: bool = False
|
|
history: list[CronRunRecord] = field(default_factory=list)
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return {
|
|
"id": self.id,
|
|
"name": self.name,
|
|
"enabled": self.enabled,
|
|
"schedule": self.schedule.to_dict(),
|
|
"payload": self.payload.to_dict(),
|
|
"created_at_ms": self.created_at_ms,
|
|
"updated_at_ms": self.updated_at_ms,
|
|
"next_run_at_ms": self.next_run_at_ms,
|
|
"last_run_at_ms": self.last_run_at_ms,
|
|
"last_status": self.last_status,
|
|
"last_error": self.last_error,
|
|
"delete_after_run": self.delete_after_run,
|
|
"history": [item.to_dict() for item in self.history],
|
|
}
|
|
|
|
def to_api_dict(self) -> dict[str, Any]:
|
|
latest = self.history[-1] if self.history else None
|
|
return {
|
|
"id": self.id,
|
|
"name": self.name,
|
|
"enabled": self.enabled,
|
|
"schedule_kind": self.schedule.kind,
|
|
"schedule_display": self.schedule.display or _schedule_display(self.schedule),
|
|
"schedule_expr": self.schedule.expr,
|
|
"schedule_every_ms": self.schedule.every_ms,
|
|
"message": self.payload.message,
|
|
"mode": self.payload.mode,
|
|
"requires_followup": self.payload.requires_followup,
|
|
"deliver": self.payload.deliver,
|
|
"channel": self.payload.channel,
|
|
"to": self.payload.to,
|
|
"session_key": self.payload.session_key,
|
|
"next_run_at_ms": self.next_run_at_ms,
|
|
"last_run_at_ms": self.last_run_at_ms,
|
|
"last_status": self.last_status,
|
|
"last_error": self.last_error,
|
|
"last_scheduled_run_id": latest.scheduled_run_id if latest else None,
|
|
"last_task_id": latest.task_id if latest else None,
|
|
"last_run_id": latest.run_id if latest else None,
|
|
"history": [item.to_dict() for item in self.history],
|
|
"created_at_ms": self.created_at_ms,
|
|
"updated_at_ms": self.updated_at_ms,
|
|
}
|
|
|
|
@classmethod
|
|
def from_dict(cls, payload: dict[str, Any]) -> "CronJob":
|
|
schedule_payload = payload.get("schedule") if isinstance(payload.get("schedule"), dict) else {}
|
|
payload_payload = payload.get("payload") if isinstance(payload.get("payload"), dict) else {}
|
|
return cls(
|
|
id=str(payload["id"]),
|
|
name=str(payload.get("name") or payload["id"]),
|
|
enabled=bool(payload.get("enabled", True)),
|
|
schedule=CronSchedule.from_dict(schedule_payload),
|
|
payload=CronPayload.from_dict(payload_payload),
|
|
created_at_ms=int(payload.get("created_at_ms") or payload.get("createdAtMs") or 0),
|
|
updated_at_ms=int(payload.get("updated_at_ms") or payload.get("updatedAtMs") or 0),
|
|
next_run_at_ms=_optional_int(payload.get("next_run_at_ms") or payload.get("nextRunAtMs")),
|
|
last_run_at_ms=_optional_int(payload.get("last_run_at_ms") or payload.get("lastRunAtMs")),
|
|
last_status=_optional_str(payload.get("last_status") or payload.get("lastStatus")), # type: ignore[arg-type]
|
|
last_error=_optional_str(payload.get("last_error") or payload.get("lastError")),
|
|
delete_after_run=bool(payload.get("delete_after_run") or payload.get("deleteAfterRun") or False),
|
|
history=[
|
|
CronRunRecord.from_dict(item)
|
|
for item in payload.get("history") or []
|
|
if isinstance(item, dict)
|
|
],
|
|
)
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class CronExecutionResult:
|
|
response: str | None = None
|
|
task_id: str | None = None
|
|
run_id: str | None = None
|
|
notification_session_id: str | None = None
|
|
mode: CronPayloadMode = "notification"
|
|
|
|
|
|
def _schedule_display(schedule: CronSchedule) -> str:
|
|
if schedule.kind == "every":
|
|
seconds = int((schedule.every_ms or 0) / 1000)
|
|
return f"every {seconds}s"
|
|
if schedule.kind == "cron":
|
|
return schedule.expr or "cron"
|
|
return "one-time"
|
|
|
|
|
|
def _optional_str(value: Any) -> str | None:
|
|
if value in (None, ""):
|
|
return None
|
|
return str(value)
|
|
|
|
|
|
def _optional_int(value: Any) -> int | None:
|
|
if value in (None, ""):
|
|
return None
|
|
try:
|
|
return int(value)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def _every_ms_from_display(display: str | None) -> int | None:
|
|
match = re.fullmatch(r"every\s+(\d+)s", (display or "").strip(), re.IGNORECASE)
|
|
if match is None:
|
|
return None
|
|
return int(match.group(1)) * 1000
|
|
|
|
|
|
def _payload_mode(value: Any, *, default: CronPayloadMode = "notification") -> CronPayloadMode:
|
|
if value in (None, ""):
|
|
return default
|
|
cleaned = str(value or "").strip().lower()
|
|
if cleaned == "task":
|
|
return "task"
|
|
return "notification"
|