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:
1 parent
f1aab5415c
commit
e81ba8d2bf
8 files changed
+892
-51
No files matched your search
+225
-1
@@ -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,依此类推。
|
||||
|
||||
|
||||
Reference in new issue
Block a user