待办:todo-list/create/update 标记工具 + 首轮未完成待办注入

This commit is contained in:
host committed 2026-10-30 05:11:06 +08:00
1 parent b3b3b35042
commit 73288f08db
5 files changed
+841 -12

No files matched your search

+219 -2
View File
@@ -1,5 +1,6 @@
"""任务内标记工具(marker tools):task-create / task-poll / task-read /
wait-threads / task-cancel / task-retry。
wait-threads / task-cancel / task-retry / todo-list / todo-create /
todo-update。
设计(与 peer_messages、task_spawn 同款原则,不另起一套):
@@ -44,10 +45,18 @@ wait-threads / task-cancel / task-retry。
(仅 failed / interrupted / merge-failed 可重试,重试时
清空上一轮 result_text / error_text / actual_model、状态
回 queued、prompt 与模型等原样保留)。
- todo-list / todo-create / todo-update:项目待办的读与写,
直接复用 todos 模块的校验与状态机(completed 记
completed_at、离开 completed 清掉)。todo-list 不传 status
时只返回未完成项(pending / in_progress / blocked),
查已完成项须显式传 status;写操作只在本项目内生效,
跨项目待办一律按“不存在”拒绝;删除不走标记块(硬删除
只在控制台 / REST DELETE 里做,避免模型误删整条)。
上限口径(本模块最终定的值,文档与代码一致):
- 每轮创建、轮询、取消、重试各最多 5 块;wait 每轮最多
- 每轮创建、轮询、取消、重试各最多 5 块;todo-list、
todo-create、todo-update 各最多 5 块;wait 每轮最多
2 块,每块最多 8 个目标。8 这个数是刻意与服务端
POST /tasks/{id}/wait 的“一次最多 8 个线程”对齐的;
spawn 分支原先的每块 10 个在此收紧到 8 个,不要再用 10。
@@ -104,6 +113,15 @@ TERMINAL_STATUSES = ("completed", "failed", "interrupted", "merged",
RETRYABLE_STATUSES = ("failed", "interrupted", "merge-failed")
EFFORTS = ("low", "medium", "high", "xhigh", "max")
EXECUTORS = ("local", "docker", "cloud")
TODO_STATUSES = ("pending", "in_progress", "blocked", "completed",
"cancelled")
TODO_PRIORITIES = ("low", "medium", "high")
MAX_TODO_LISTS_PER_TURN = 5
MAX_TODO_CREATES_PER_TURN = 5
MAX_TODO_UPDATES_PER_TURN = 5
MAX_TODO_TITLE_CHARS = 500
MAX_TODO_DESCRIPTION_CHARS = 20000
MAX_TODO_QUERY_CHARS = 500
_CREATE_RE = re.compile(r"```task-create\s*\n(.*?)\n```", re.DOTALL)
_POLL_RE = re.compile(
@@ -115,6 +133,12 @@ _CANCEL_RE = re.compile(r"```(task-cancel|task_cancel)\s*\n(.*?)\n```",
re.DOTALL)
_RETRY_RE = re.compile(r"```(task-retry|task_retry)\s*\n(.*?)\n```",
re.DOTALL)
_TODO_LIST_RE = re.compile(
r"```(todo-list|todo_list)\s*\n(.*?)\n```", re.DOTALL)
_TODO_CREATE_RE = re.compile(
r"```(todo-create|todo_create)\s*\n(.*?)\n```", re.DOTALL)
_TODO_UPDATE_RE = re.compile(
r"```(todo-update|todo_update)\s*\n(.*?)\n```", re.DOTALL)
def row_get(row, name):
@@ -190,6 +214,176 @@ def extract_task_creates(text):
return out
def _todo_due_at(value):
"""标记块 due_at 校验:与 todos._validate_due_at 同款语义
(epoch 秒,>=1e11 按毫秒除 1000);非法返回 (False, None),
不在本层抛异常——非法块整块跳过。"""
if value is None or value == "":
return True, None
if isinstance(value, bool) or not isinstance(value, (int, float)):
return False, None
value = float(value)
if value >= 1e11:
value = value / 1000.0
return True, value
def extract_todo_lists(text):
"""提取 todo-list 块;不传 status 时查未完成项(pending /
in_progress / blocked),不返回 completed / cancelled——
报告要短,查已完成项须显式传 status。status 非法、q 非
字符串或超长时整块跳过;每轮最多 5 块。"""
if not text:
return []
out = []
for match in _TODO_LIST_RE.finditer(str(text)):
if len(out) >= MAX_TODO_LISTS_PER_TURN:
break
try:
payload = json.loads(match.group(2))
except (TypeError, ValueError):
continue
if not isinstance(payload, dict):
continue
status = payload.get("status")
if status is not None and status not in TODO_STATUSES:
continue
q = payload.get("q")
if q is not None:
if not isinstance(q, str) or not q.strip():
continue
q = q.strip()
if len(q) > MAX_TODO_QUERY_CHARS:
continue
out.append({"status": status, "q": q})
return out
def extract_todo_creates(text):
"""提取 todo-create 块;title 必填 1-500 字,description
可选 <=20000 字,status / priority 只能是枚举值,task_id
只能是字符串,due_at 按 _todo_due_at 校验;任一项非法时
整块跳过;每轮最多 5 块。"""
if not text:
return []
out = []
for match in _TODO_CREATE_RE.finditer(str(text)):
if len(out) >= MAX_TODO_CREATES_PER_TURN:
break
try:
payload = json.loads(match.group(2))
except (TypeError, ValueError):
continue
if not isinstance(payload, dict):
continue
title = payload.get("title")
if not isinstance(title, str) or not title.strip():
continue
title = title.strip()
if len(title) > MAX_TODO_TITLE_CHARS:
continue
description = payload.get("description")
if description is not None:
if not isinstance(description, str):
continue
if len(description) > MAX_TODO_DESCRIPTION_CHARS:
continue
status = payload.get("status")
if status is not None and status not in TODO_STATUSES:
continue
priority = payload.get("priority")
if priority is not None and priority not in TODO_PRIORITIES:
continue
task_id = payload.get("task_id")
if task_id is not None:
if not isinstance(task_id, str):
continue
task_id = task_id.strip() or None
ok, due_at = _todo_due_at(payload.get("due_at"))
if not ok:
continue
sort_order = payload.get("sort_order")
if sort_order is not None and (
isinstance(sort_order, bool)
or not isinstance(sort_order, int)):
continue
out.append({"title": title, "description": description,
"status": status, "priority": priority,
"task_id": task_id, "due_at": due_at,
"sort_order": sort_order})
return out
def extract_todo_updates(text):
"""提取 todo-update 块;id 必填,至少改一个字段;各字段
校验与 todo-create 同款(status / priority 枚举、title
1-500 字、description <=20000 字、due_at 毫秒容忍、
sort_order 整数);任一项非法或无有效字段时整块跳过;
每轮最多 5 块。未知字段忽略,不因此跳过。"""
if not text:
return []
out = []
for match in _TODO_UPDATE_RE.finditer(str(text)):
if len(out) >= MAX_TODO_UPDATES_PER_TURN:
break
try:
payload = json.loads(match.group(2))
except (TypeError, ValueError):
continue
if not isinstance(payload, dict):
continue
todo_id = payload.get("id")
if not isinstance(todo_id, str) or not todo_id.strip():
continue
fields = {}
if "title" in payload:
title = payload["title"]
if not isinstance(title, str) or not title.strip():
continue
title = title.strip()
if len(title) > MAX_TODO_TITLE_CHARS:
continue
fields["title"] = title
if "description" in payload:
description = payload["description"]
if not isinstance(description, str):
continue
if len(description) > MAX_TODO_DESCRIPTION_CHARS:
continue
fields["description"] = description
if "status" in payload:
if payload["status"] not in TODO_STATUSES:
continue
fields["status"] = payload["status"]
if "priority" in payload:
if payload["priority"] not in TODO_PRIORITIES:
continue
fields["priority"] = payload["priority"]
if "task_id" in payload:
task_id = payload["task_id"]
if task_id is not None and not isinstance(task_id, str):
continue
fields["task_id"] = (task_id.strip() or None
if isinstance(task_id, str) else None)
if "due_at" in payload:
ok, due_at = _todo_due_at(payload["due_at"])
if not ok:
continue
fields["due_at"] = due_at
if "sort_order" in payload:
sort_order = payload["sort_order"]
if sort_order is not None and (
isinstance(sort_order, bool)
or not isinstance(sort_order, int)):
continue
if sort_order is not None:
fields["sort_order"] = sort_order
if not fields:
continue
out.append({"id": todo_id.strip(), "fields": fields})
return out
_SPAWN_SUBJECT_ZH = ("新建的任务", "派生任务", "新建任务",
"子任务", "子代理", "新任务", "新会话")
_SPAWN_SUBJECT_EN = ("child tasks", "spawned tasks", "new tasks",
@@ -649,3 +843,26 @@ def new_child_id():
def marker_report_header():
return "[任务工具报告]"
def format_todo(todo, max_title=100):
"""待办单行(报告与首轮注入共用):状态 + 标题(截断)+ id +
优先级,due_at 有值时附 UTC 时间;标题里的换行压成空格,
避免一行待办撑开多行报告。"""
title = str(todo.get("title") or "").replace("\n", " ").strip()
if len(title) > max_title:
title = title[:max_title] + "…"
parts = ["• [%s] %s (id=%s, priority=%s)"
% (todo.get("status"), title, todo.get("id"),
todo.get("priority"))]
due_at = todo.get("due_at")
if due_at:
try:
parts.append("due=%s" % time.strftime(
"%Y-%m-%dT%H:%M:%SZ", time.gmtime(float(due_at))))
except (TypeError, ValueError, OverflowError):
pass
task_id = todo.get("task_id")
if task_id:
parts.append("task_id=%s" % task_id)
return " ".join(parts)