feat: 会话内 LLM 自判 goal / 自动化标记工具,不靠关键词

agent 在回合输出里写 goal-convert 块,worker 在轮次边界
排成 /goal 自后续消息、走已有 followup 转换路径把本会话
转成 goal;写 automation-create 块,worker 在轮次边界
创建自动化定义(权限 create_task、配额整轮门、创建人跟
父任务走、name 缺失自动生成、首次时刻已过去单块拒绝、
bind heartbeat 可绑回本会话)。

automations 新增 first_run_at / run_once:首次时刻只管
首次、之后按间隔算;只跑一次跑过后 is_due / due / claim
三处都不再到期(SQL 到期条件与 Python 口径共用同一
_DUE_SQL);API 创建与更新同步接受这两个字段。

刻意不向 LLM 自动注入工具指南:避免 prompt 臃肿,格式
只写进 docs/marker-tools.md 与 README,需要时由用户在
会话里手动告诉 agent;服务端不按关键词再猜意图。

单测 cloud/tests/test_session_goal_automation.py 12 项,
相关回归 218 项全过。
This commit is contained in:
host committed 2026-10-30 07:15:05 +08:00
1 parent f1aab5415c
commit e81ba8d2bf
8 files changed
+892 -51

No files matched your search

+225 -1
View File
@@ -1,6 +1,18 @@
"""任务内标记工具(marker tools):task-create / task-poll / task-read /
wait-threads / task-cancel / task-retry / todo-list / todo-create /
todo-update / task-list / task-cost / quota-status / task-files。
todo-update / task-list / task-cost / quota-status / task-files /
goal-convert / automation-create。
新增的两个工具是“LLM 自己判断意图、不用用户说关键词”
那条路:用户只说“我要一直收 xxx”“明天 9 点看 yyy”这种
自然语言,agent 在回合里写 goal-convert / automation-create
块,worker 在轮次边界执行——不是服务端再用正则猜用户
是不是在说 goal / 自动化。关键词解析(cloud/goal_command)
仍保留作显式命令兜底,两条路不互斥。
注意:本批刻意不向 LLM 自动注入这两个工具的指南(避免
prompt 臃肿),agent 知道它们的唯一途径是用户在会话里
手动告诉它,或调用方在任务 prompt 里自己写明;格式见
docs/marker-tools.md 新增的两节。
设计(与 peer_messages、task_spawn 同款原则,不另起一套):
@@ -68,6 +80,17 @@ todo-update / task-list / task-cost / quota-status / task-files。
二进制文件拒绝);绝对路径、.. 逃逸、符号链接逃逸一律
拒绝;cloud 任务与 worktree 在别机的任务拒绝(文件不在
本机);目标没有本地 worktree 时按 404 报告,不编内容。
- goal-convert:把本会话在轮次边界转成新语义 goal 任务
(goal_mode=1、goal 即块里给的目标、无限轮直到模型自决
“目标已达成”,与 /goal 转换同款 CAS)。每轮最多 1 块;
实际执行时各块照常执行、报告先排、/goal 消息后排,转成
goal 后本任务不再解析标记块。
- automation-create:新建一个自动化定义(见
extract_automation_creates 的字段说明)。每轮最多 5 块;
创建人跟父任务走,须有项目 create_task 权限,配额超限
时整轮跳过;name 缺失时 worker 自动生成,避免重名撞号。
创建只是登记定义,到期执行仍走 automations 的 claim 门
与外部触发器,见 automations 模块。
上限口径(本模块最终定的值,文档与代码一致):
@@ -111,8 +134,20 @@ import os
import re
import time
import uuid
from datetime import datetime, timedelta, timezone
try:
from zoneinfo import ZoneInfo
except Exception: # pragma: no cover - 极老 Python 无 zoneinfo
ZoneInfo = None
MAX_CREATES_PER_TURN = 5
MAX_GOAL_CONVERTS_PER_TURN = 1
MAX_AUTOMATION_CREATES_PER_TURN = 5
MIN_AUTOMATION_INTERVAL_SECONDS = 60
MAX_AUTOMATION_INTERVAL_SECONDS = 30 * 86400
MAX_AUTOMATION_REPO_URL_CHARS = 1000
DEFAULT_AUTOMATION_INTERVAL_SECONDS = 86400
MAX_POLLS_PER_TURN = 5
MAX_WAITS_PER_TURN = 2
MAX_WAIT_TARGETS_PER_BLOCK = 8
@@ -176,6 +211,64 @@ _QUOTA_RE = re.compile(
r"```(quota-status|quota_status)\s*\n(.*?)\n```", re.DOTALL)
_TASK_FILES_RE = re.compile(
r"```(task-files|task_files)\s*\n(.*?)\n```", re.DOTALL)
_GOAL_CONVERT_RE = re.compile(
r"```(goal-convert|goal_convert)\s*\n(.*?)\n```", re.DOTALL)
_AUTOMATION_CREATE_RE = re.compile(
r"```(automation-create|automation_create)\s*\n(.*?)\n```",
re.DOTALL)
def _shanghai_tz():
"""无时区时间默认按 Asia/Shanghai 理解(用户时区)。
有 zoneinfo 时用它;没有时退回固定 UTC+8,避免把“明天 9 点”
按 UTC 算成北京时间 17 点。"""
if ZoneInfo is not None:
try:
return ZoneInfo("Asia/Shanghai")
except Exception:
pass
return timezone(timedelta(hours=8))
def parse_first_run_at(value):
"""解析 first_run_at,返回 epoch 秒(float)或 None(无效)。
接受:epoch 秒(int/float,毫秒级数字自动除 1000)、纯数字
字符串、ISO8601 字符串(带偏移量按偏移量算;无时区按
Asia/Shanghai 算;“YYYY-MM-DD HH:MM”这种 fromisoformat
能读的也算)。不判断“是否过去”——过去判断在 worker 层,
那里才能把“已过去”写进报告让 agent 下一轮纠正;纯解析层
只负责“是不是一个合法时刻”,否则 LLM 的一次笔误会连
报告都拿不到。
本函数只依赖标准库,server 解析 API 的 first_run_at 时
也复用它,避免两端对同一字段的解析不一致。"""
if value is None or isinstance(value, bool):
return None
if isinstance(value, (int, float)):
epoch = float(value)
elif isinstance(value, str):
s = value.strip()
if not s:
return None
try:
epoch = float(s)
except ValueError:
try:
dt = datetime.fromisoformat(
s.replace("Z", "+00:00").replace("z", "+00:00"))
except ValueError:
return None
if dt.tzinfo is None:
dt = dt.replace(tzinfo=_shanghai_tz())
return dt.timestamp()
else:
return None
if epoch >= 1e11: # 毫秒级 epoch,秒级不可能这么大
epoch = epoch / 1000.0
if epoch <= 0 or epoch > 4102444800: # 2100-01-01 之后不认
return None
return epoch
def row_get(row, name):
@@ -786,6 +879,137 @@ def extract_task_retries(text):
return _extract_controls(text, _RETRY_RE)
def extract_goal_converts(text):
"""提取 goal-convert / goal_convert 块:把本会话转成 goal。
块格式::
```goal-convert
{"goal": "收 xxx 到 yyy 为止"}
```
goal 必填、1-20000 字;每轮最多 1 块(转一次就够了,
多余的忽略)。不接受 verify_command / goal_max_rounds:
LLM 定的 goal 一律是新语义——无限轮直到模型自己输出
“目标已达成”,与服务端 /goal 转换口径一致,绝不让 LLM
顺手把旧验收语义混进来。"""
if not text:
return []
out = []
for match in _GOAL_CONVERT_RE.finditer(str(text)):
if len(out) >= MAX_GOAL_CONVERTS_PER_TURN:
break
try:
payload = json.loads(match.group(2))
except (TypeError, ValueError):
continue
if not isinstance(payload, dict):
continue
goal = payload.get("goal") or payload.get("target")
if not isinstance(goal, str) or not goal.strip():
continue
goal = goal.strip()
if len(goal) > MAX_PROMPT_CHARS:
continue
out.append({"goal": goal})
return out
def extract_automation_creates(text):
"""提取 automation-create / automation_create 块。
块格式::
```automation-create
{"name": "看 xxx 每日", "prompt": "查看 xxx 最新并汇总",
"interval_seconds": 86400,
"first_run_at": "2026-10-30T09:00:00+08:00",
"once": false, "bind": null, "repo_url": null}
```
- prompt 必填、1-20000 字;name 可选、1-100 字,缺失时
worker 自动生成(LLM 不必为起名费劲,也不会因重名
撞上同项目已有的 active 自动化)。
- interval_seconds 可选、缺省 86400,必须是整数且在
60-30*86400 之间;非法时整块跳过,不静默钳制——钳制
会把“每小时”悄悄改成“每分钟下限”或把“每月”改
短,调度错误比拒绝更贵。
- first_run_at 可选,见 parse_first_run_at;不给表示
建完即到期(沿用自动化原有语义),给了表示首次在该
时刻跑——“每天 9 点”要写成 first_run_at=下一个 9 点
+ interval_seconds=86400,而不是只写间隔(只写间隔
会从创建时间起算,每天跑在创建时间那个钟点)。
- once 可选、缺省 false;true 表示只跑一次(跑过后
is_due / due / claim 三处都不再到期,见 automations)。
- bind 可选、缺省 null(到期独立跑新任务);"heartbeat"
表示绑到本会话,到期唤醒本线程;"cron" 与 null 同款,
只是显式写明独立跑。其它值整块跳过。
- repo_url 可选、最多 1000 字。
- 每轮最多 5 块,多余的忽略;非法 JSON、prompt 缺失/
空串/超长时整块跳过。"""
if not text:
return []
out = []
for match in _AUTOMATION_CREATE_RE.finditer(str(text)):
if len(out) >= MAX_AUTOMATION_CREATES_PER_TURN:
break
try:
payload = json.loads(match.group(2))
except (TypeError, ValueError):
continue
if not isinstance(payload, dict):
continue
prompt = payload.get("prompt")
if not isinstance(prompt, str) or not prompt.strip():
continue
prompt = prompt.strip()
if len(prompt) > MAX_PROMPT_CHARS:
continue
name = payload.get("name")
if name is not None:
if not isinstance(name, str) or not name.strip():
continue
name = name.strip()
if len(name) > MAX_NAME_CHARS:
continue
interval = payload.get("interval_seconds")
if interval is None:
interval = DEFAULT_AUTOMATION_INTERVAL_SECONDS
elif isinstance(interval, bool):
continue
elif isinstance(interval, float) and interval.is_integer():
interval = int(interval)
elif not isinstance(interval, int):
continue
if interval < MIN_AUTOMATION_INTERVAL_SECONDS or \
interval > MAX_AUTOMATION_INTERVAL_SECONDS:
continue
first_run_at = None
if payload.get("first_run_at") is not None:
first_run_at = parse_first_run_at(payload.get("first_run_at"))
if first_run_at is None:
continue
once = payload.get("once", False)
if not isinstance(once, bool):
continue
bind = payload.get("bind", payload.get("bind_mode"))
if bind is not None and bind not in ("heartbeat", "cron"):
continue
repo_url = payload.get("repo_url")
if repo_url is not None:
if not isinstance(repo_url, str):
continue
repo_url = repo_url.strip() or None
if repo_url is not None and \
len(repo_url) > MAX_AUTOMATION_REPO_URL_CHARS:
continue
out.append({"name": name, "prompt": prompt,
"interval_seconds": interval,
"first_run_at": first_run_at, "once": once,
"bind": bind, "repo_url": repo_url})
return out
def parent_spawn_generation(conn, parent_id):
"""父任务自身的派生代数:普通任务 0,第一代派生任务 1,依此类推。