添加 RuntimeContext 类用于捕获模型运行时的日期时间信息, 包括UTC时间、本地时间和时区信息,并在系统提示中显示这些信息。 同时增加最大上下文消息数和工具迭代次数的配置选项, 将验证服务从引擎加载器中移除,并更新相关的数据结构和接口。 BREAKING CHANGE: 移除了验证服务,相关字段被替换为证据状态和接受状态。 - 添加 RuntimeContext 类和相关渲染方法 - 增加 max_context_messages 和 max_tool_iterations 配置 - 移除 ValidationService 相关代码 - 更新消息记录中的验证状态字段 - 添加原始工具调用检测和回退处理
271 lines
9.3 KiB
Python
271 lines
9.3 KiB
Python
"""Internal service for automatic Task mode."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from uuid import uuid4
|
|
|
|
from .models import TaskEvent, TaskRecord
|
|
from .store import TaskStore
|
|
|
|
|
|
class TaskService:
|
|
def __init__(self, root: str | Path) -> None:
|
|
self.store = TaskStore(root)
|
|
|
|
def create_task(
|
|
self,
|
|
*,
|
|
session_id: str,
|
|
description: str,
|
|
creator: str = "main-agent",
|
|
metadata: dict[str, Any] | None = None,
|
|
) -> TaskRecord:
|
|
now = self._now()
|
|
task_metadata = dict(metadata or {})
|
|
task_metadata.setdefault("short_title", short_task_title(description))
|
|
task = TaskRecord(
|
|
task_id=uuid4().hex,
|
|
session_id=session_id,
|
|
description=description,
|
|
goal=description,
|
|
constraints=[],
|
|
priority=0,
|
|
status="open",
|
|
creator=creator,
|
|
created_at=now,
|
|
updated_at=now,
|
|
metadata=task_metadata,
|
|
)
|
|
self.store.upsert_task(task)
|
|
self._event(task, "created", payload={"description": description})
|
|
return task
|
|
|
|
def get_task(self, task_id: str) -> TaskRecord | None:
|
|
return self.store.get_task(task_id)
|
|
|
|
def list_tasks(self) -> list[TaskRecord]:
|
|
return sorted(self.store.list_tasks(), key=lambda item: item.updated_at, reverse=True)
|
|
|
|
def list_events(self, task_id: str) -> list[TaskEvent]:
|
|
return self.store.list_events(task_id=task_id)
|
|
|
|
def get_task_by_run_id(self, run_id: str) -> TaskRecord | None:
|
|
return self.store.get_task_by_run_id(run_id)
|
|
|
|
def get_latest_open_task(self, session_id: str, *, include_unengaged_scheduled: bool = False) -> TaskRecord | None:
|
|
tasks = [
|
|
task
|
|
for task in self.store.list_tasks()
|
|
if task.session_id == session_id and task.is_open
|
|
]
|
|
if not include_unengaged_scheduled:
|
|
tasks = [task for task in tasks if self._is_user_visible_active_task(task)]
|
|
if not tasks:
|
|
return None
|
|
return sorted(tasks, key=lambda item: item.updated_at)[-1]
|
|
|
|
def active_task_view(self, session_id: str) -> dict[str, Any] | None:
|
|
task = self.get_latest_open_task(session_id)
|
|
if task is None:
|
|
return None
|
|
return self.to_api_dict(task)
|
|
|
|
def to_api_dict(self, task: TaskRecord) -> dict[str, Any]:
|
|
payload = task.to_dict()
|
|
payload["short_title"] = self.ensure_short_title(task).metadata.get("short_title")
|
|
payload["is_open"] = task.is_open
|
|
payload["is_execution_active"] = task.is_execution_active
|
|
payload["requires_user_action"] = task.requires_user_action
|
|
return payload
|
|
|
|
def ensure_short_title(self, task: TaskRecord) -> TaskRecord:
|
|
if task.metadata.get("short_title"):
|
|
return task
|
|
task.metadata["short_title"] = short_task_title(task.description or task.goal or task.task_id)
|
|
self.store.upsert_task(task)
|
|
return task
|
|
|
|
def start_run(self, task_id: str, *, user_message: str, attempt_index: int) -> TaskRecord:
|
|
task = self._require(task_id)
|
|
task.status = "running"
|
|
task.updated_at = self._now()
|
|
task.metadata["latest_user_message"] = user_message
|
|
task.metadata["latest_attempt_index"] = attempt_index
|
|
self.store.upsert_task(task)
|
|
self._event(task, "run_started", payload={"user_message": user_message, "attempt_index": attempt_index})
|
|
return task
|
|
|
|
def append_run(self, task_id: str, run_id: str, *, skill_names: list[str] | None = None) -> TaskRecord:
|
|
task = self._require(task_id)
|
|
if run_id not in task.run_ids:
|
|
task.run_ids.append(run_id)
|
|
for name in skill_names or []:
|
|
if name not in task.skill_names:
|
|
task.skill_names.append(name)
|
|
task.status = "awaiting_acceptance"
|
|
task.updated_at = self._now()
|
|
self.store.upsert_task(task)
|
|
self._event(task, "run_completed", run_id=run_id, payload={"skill_names": skill_names or []})
|
|
self._event(task, "evidence_recorded", run_id=run_id, payload={"skill_names": skill_names or []})
|
|
return task
|
|
|
|
def add_acceptance(
|
|
self,
|
|
task_id: str,
|
|
*,
|
|
acceptance_type: str,
|
|
comment: str | None = None,
|
|
run_id: str | None = None,
|
|
) -> TaskRecord:
|
|
task = self._require(task_id)
|
|
now = self._now()
|
|
normalized = normalize_acceptance_type(acceptance_type)
|
|
matching_acceptance = any(
|
|
item.get("run_id") == run_id and item.get("acceptance_type") == normalized
|
|
for item in task.feedback
|
|
)
|
|
conflicting_acceptance = next(
|
|
(
|
|
item
|
|
for item in task.feedback
|
|
if item.get("run_id") == run_id and item.get("acceptance_type") != normalized
|
|
),
|
|
None,
|
|
)
|
|
if conflicting_acceptance is not None:
|
|
raise ValueError(
|
|
f"Acceptance for run_id={run_id!r} was already recorded as "
|
|
f"{conflicting_acceptance.get('acceptance_type')!r}"
|
|
)
|
|
if task.status in {"closed", "abandoned"} and not matching_acceptance:
|
|
raise ValueError(f"Task {task.task_id} is already finalized as {task.status!r}")
|
|
if matching_acceptance:
|
|
return task
|
|
|
|
entry = {
|
|
"acceptance_type": normalized,
|
|
"feedback_type": "satisfied" if normalized == "accept" else normalized,
|
|
"comment": comment or "",
|
|
"run_id": run_id,
|
|
"created_at": now,
|
|
}
|
|
task.feedback.append(entry)
|
|
if normalized == "revise":
|
|
task.status = "needs_revision"
|
|
elif normalized == "abandon":
|
|
task.status = "abandoned"
|
|
task.closed_at = now
|
|
task.close_reason = comment or "abandoned"
|
|
elif normalized == "accept":
|
|
task.status = "closed"
|
|
task.closed_at = now
|
|
task.close_reason = "accepted"
|
|
task.satisfaction = 1.0
|
|
if run_id:
|
|
task.metadata["final_accepted_run_id"] = run_id
|
|
task.updated_at = now
|
|
self.store.upsert_task(task)
|
|
self._event(task, f"acceptance_{normalized}", run_id=run_id, payload=entry)
|
|
return task
|
|
|
|
def add_feedback(
|
|
self,
|
|
task_id: str,
|
|
*,
|
|
feedback_type: str,
|
|
comment: str | None = None,
|
|
run_id: str | None = None,
|
|
) -> TaskRecord:
|
|
return self.add_acceptance(
|
|
task_id,
|
|
acceptance_type=feedback_type,
|
|
comment=comment,
|
|
run_id=run_id,
|
|
)
|
|
|
|
def close_task(self, task_id: str, *, reason: str = "closed") -> TaskRecord:
|
|
task = self._require(task_id)
|
|
now = self._now()
|
|
task.status = "closed"
|
|
task.closed_at = now
|
|
task.close_reason = reason
|
|
task.updated_at = now
|
|
self.store.upsert_task(task)
|
|
self._event(task, "closed", payload={"reason": reason})
|
|
return task
|
|
|
|
def abandon_task(self, task_id: str, *, reason: str = "abandoned") -> TaskRecord:
|
|
task = self._require(task_id)
|
|
now = self._now()
|
|
task.status = "abandoned"
|
|
task.closed_at = now
|
|
task.close_reason = reason
|
|
task.updated_at = now
|
|
self.store.upsert_task(task)
|
|
self._event(task, "abandoned", payload={"reason": reason})
|
|
return task
|
|
|
|
def delete_task(self, task_id: str) -> bool:
|
|
return self.store.delete_task(task_id)
|
|
|
|
@staticmethod
|
|
def _is_user_visible_active_task(task: TaskRecord) -> bool:
|
|
if task.creator != "cron":
|
|
return True
|
|
metadata = task.metadata or {}
|
|
return bool(metadata.get("user_engaged") or metadata.get("requires_followup"))
|
|
|
|
def _require(self, task_id: str) -> TaskRecord:
|
|
task = self.store.get_task(task_id)
|
|
if task is None:
|
|
raise ValueError(f"Unknown task_id: {task_id}")
|
|
return task
|
|
|
|
def _event(
|
|
self,
|
|
task: TaskRecord,
|
|
event_type: str,
|
|
*,
|
|
run_id: str | None = None,
|
|
payload: dict[str, Any] | None = None,
|
|
) -> None:
|
|
self.store.append_event(
|
|
TaskEvent(
|
|
event_id=uuid4().hex,
|
|
task_id=task.task_id,
|
|
session_id=task.session_id,
|
|
run_id=run_id,
|
|
event_type=event_type,
|
|
created_at=self._now(),
|
|
payload=dict(payload or {}),
|
|
)
|
|
)
|
|
|
|
@staticmethod
|
|
def _now() -> str:
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
def short_task_title(text: str) -> str:
|
|
cleaned = " ".join((text or "").strip().split())
|
|
if not cleaned:
|
|
return "当前任务"
|
|
if any("\u4e00" <= char <= "\u9fff" for char in cleaned):
|
|
return cleaned[:15]
|
|
words = cleaned.split()
|
|
if len(words) <= 4:
|
|
return cleaned[:40]
|
|
return " ".join(words[:4])[:40]
|
|
|
|
|
|
def normalize_acceptance_type(value: str) -> str:
|
|
normalized = (value or "").strip().lower()
|
|
if normalized == "satisfied":
|
|
return "accept"
|
|
if normalized not in {"accept", "revise", "abandon"}:
|
|
raise ValueError("acceptance_type must be one of: accept, revise, abandon")
|
|
return normalized
|