From e81ba8d2bfc6bd86b1ada6ccf1f04c4ff5dd77ce Mon Sep 17 00:00:00 2001 From: host Date: Thu, 29 Oct 2026 16:02:26 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=9A=E8=AF=9D=E5=86=85=20LLM=20?= =?UTF-8?q?=E8=87=AA=E5=88=A4=20goal=20/=20=E8=87=AA=E5=8A=A8=E5=8C=96?= =?UTF-8?q?=E6=A0=87=E8=AE=B0=E5=B7=A5=E5=85=B7=EF=BC=8C=E4=B8=8D=E9=9D=A0?= =?UTF-8?q?=E5=85=B3=E9=94=AE=E8=AF=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 项全过。 --- cloud/README.md | 9 + cloud/automations.py | 129 +++++--- cloud/marker_tools.py | 226 ++++++++++++- cloud/sandbox.py | 18 ++ cloud/server.py | 26 +- cloud/tests/test_session_goal_automation.py | 334 ++++++++++++++++++++ cloud/worker.py | 134 +++++++- docs/marker-tools.md | 67 +++- 8 files changed, 892 insertions(+), 51 deletions(-) create mode 100644 cloud/tests/test_session_goal_automation.py diff --git a/cloud/README.md b/cloud/README.md index fbd6c31..820f0e9 100644 --- a/cloud/README.md +++ b/cloud/README.md @@ -559,6 +559,15 @@ merged 409、空目标 400。goal 语义与新建 goal 模式一致: 是永不结束的常驻采集——需要继续收时再发一次 /goal 续开 新循环。 +不靠关键词的另一条路(LLM 自己判断):会话里的 agent +也可以在回合输出里写 `goal-convert` 块把本会话转成 goal, +或写 `automation-create` 块新建自动化定义(首次运行时刻 +`first_run_at`、只跑一次 `once`、绑回本会话 `bind: +"heartbeat"`),worker 在轮次边界执行,格式与口径见 +`docs/marker-tools.md` 新增的两节。本批刻意不向 LLM 自动 +注入这两个工具的指南(避免 prompt 臃肿),需要时由用户在 +会话里手动告诉 agent;服务端不按关键词再猜意图。 + ## 任务间互发消息(peer messages,Muse 式通用互发) 任意两个同项目任务可以互发消息、对方做完可以回消息,不 diff --git a/cloud/automations.py b/cloud/automations.py index 31248df..3320da2 100644 --- a/cloud/automations.py +++ b/cloud/automations.py @@ -72,7 +72,8 @@ def row_get(row, name): AUTOMATION_COLUMNS = ( "id", "project_id", "name", "prompt", "repo_url", "interval_seconds", "status", "last_run_at", "last_claimed_at", - "created_by", "created_at", "cancelled_at") + "created_by", "created_at", "cancelled_at", + "first_run_at", "run_once") RUN_COLUMNS = ( "id", "automation_id", "project_id", "task_id", "status", "detail", "created_at") @@ -122,7 +123,9 @@ def ensure_automations_tables(conn): "last_claimed_at DOUBLE PRECISION, " "created_by TEXT, " "created_at DOUBLE PRECISION, " - "cancelled_at DOUBLE PRECISION)" + "cancelled_at DOUBLE PRECISION, " + "first_run_at DOUBLE PRECISION, " + "run_once INTEGER)" ) conn.execute( "CREATE TABLE IF NOT EXISTS automation_runs(" @@ -138,7 +141,9 @@ def ensure_automations_tables(conn): for col, ddl in ( ("repo_url", "TEXT"), ("last_claimed_at", "DOUBLE PRECISION"), - ("cancelled_at", "DOUBLE PRECISION")): + ("cancelled_at", "DOUBLE PRECISION"), + ("first_run_at", "DOUBLE PRECISION"), + ("run_once", "INTEGER")): if col not in existing: try: conn.execute( @@ -169,23 +174,40 @@ def ensure_automations_tables(conn): def next_run_at(auto, now=None): - """下次到期时刻:从未跑过时就是创建时间(建完即到期, - 执行器下一轮即可认领);跑过时为上次执行 + 间隔。""" + """下次到期时刻: + - 只跑一次且已跑过:None(不再到期,不是“下次”); + - 从未跑过且给了 first_run_at:就是该时刻(“明天 9 点” + 的首次运行就靠它,而不是建完即到期); + - 从未跑过且没给 first_run_at:创建时间(建完即到期, + 执行器下一轮即可认领,沿用旧语义); + - 跑过:上次执行 + 间隔(first_run_at 只管首次,之后 + 不再看它)。""" last = row_get(auto, "last_run_at") - interval = row_get(auto, "interval_seconds") or 0 + if row_get(auto, "run_once") and last is not None: + return None if last is None: + first = row_get(auto, "first_run_at") + if first is not None: + return first return row_get(auto, "created_at") + interval = row_get(auto, "interval_seconds") or 0 return last + interval def is_due(auto, now=None): """是否到期:仅 active 的定义可到期;now 缺省取当前时间。 到期边界用 >=,与 host run-due 的 now - last_run >= - interval 同款(含边界那一秒)。""" + interval 同款(含边界那一秒)。只跑一次且已跑过时永远 + 不到期(先于 next_run_at 判断);next_run_at 为 None + 的其它情形(行缺创建时间等脏数据)仍按旧代码认到期, + 不静默改口径。""" if row_get(auto, "status") != ACTIVE: return False if now is None: now = time.time() + if row_get(auto, "run_once") and row_get(auto, "last_run_at") \ + is not None: + return False nxt = next_run_at(auto) if nxt is None: return True @@ -208,12 +230,15 @@ def automation_to_dict(row, now=None): "created_by": row["created_by"], "created_at": row["created_at"], "cancelled_at": row_get(row, "cancelled_at"), + "first_run_at": row_get(row, "first_run_at"), + "run_once": bool(row_get(row, "run_once")), "next_run_at": nxt, "due": is_due(row, now), } -def _validate_fields(name, prompt, interval_seconds, repo_url): +def _validate_fields(name, prompt, interval_seconds, repo_url, + first_run_at=None, run_once=False): if not name or not isinstance(name, str) or not name.strip(): raise ValueError("name required") name = name.strip() @@ -238,16 +263,31 @@ def _validate_fields(name, prompt, interval_seconds, repo_url): repo_url = repo_url.strip() or None if repo_url is not None and len(repo_url) > MAX_REPO_URL_LEN: raise ValueError("repo_url too long") - return name, prompt, interval_seconds, repo_url + if first_run_at is not None: + if isinstance(first_run_at, bool) or \ + not isinstance(first_run_at, (int, float)): + raise ValueError("first_run_at must be a timestamp") + first_run_at = float(first_run_at) + if first_run_at <= 0: + raise ValueError("first_run_at must be positive") + if not isinstance(run_once, bool): + raise ValueError("run_once must be a boolean") + return name, prompt, interval_seconds, repo_url, \ + first_run_at, run_once def create_automation(conn, project_id, name, prompt, created_by, - interval_seconds=86400, repo_url=None, now=None): + interval_seconds=86400, repo_url=None, now=None, + first_run_at=None, run_once=False): """创建自动化定义:同项目内 active 的名字唯一(取消过的 名字可重用,与环境名唯一规则同款语义)。成功时 commit - 并返回 dict;校验失败抛 ValueError(调用方转 400)。""" - name, prompt, interval_seconds, repo_url = _validate_fields( - name, prompt, interval_seconds, repo_url) + 并返回 dict;校验失败抛 ValueError(调用方转 400)。 + first_run_at 为首次运行时刻(epoch 秒,过去则建完即 + 到期);run_once 为 True 时跑过一次后不再到期。""" + (name, prompt, interval_seconds, repo_url, + first_run_at, run_once) = _validate_fields( + name, prompt, interval_seconds, repo_url, + first_run_at, run_once) if now is None: now = time.time() ensure_automations_tables(conn) @@ -261,10 +301,12 @@ def create_automation(conn, project_id, name, prompt, created_by, conn.execute( "INSERT INTO automations(id, project_id, name, prompt," " repo_url, interval_seconds, status, last_run_at," - " last_claimed_at, created_by, created_at, cancelled_at)" - " VALUES(?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, NULL)", + " last_claimed_at, created_by, created_at, cancelled_at," + " first_run_at, run_once)" + " VALUES(?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, NULL, ?, ?)", (auto_id, project_id, name, prompt, repo_url, - interval_seconds, ACTIVE, created_by, now)) + interval_seconds, ACTIVE, created_by, now, + first_run_at, 1 if run_once else 0)) conn.commit() row = conn.execute( "SELECT * FROM automations WHERE id = ?", (auto_id,)).fetchone() @@ -327,11 +369,12 @@ def cancel_automation(conn, automation_id, now=None): def update_automation(conn, automation_id, fields, now=None): """更新自动化定义(automation_update):fields 只认 - name / prompt / interval_seconds / repo_url 四个键, - 其它键一律忽略(不报错、不存入,避免调用方拼错字段 - 名后以为已改);四个键全没给时抛 ValueError(空更新 - 不是成功)。校验与创建同款(_validate_fields,名字 - 仍按“同项目 active 名字唯一”查重,改名撞名时 400)。 + name / prompt / interval_seconds / repo_url / first_run_at / + run_once 六个键,其它键一律忽略(不报错、不存入,避免 + 调用方拼错字段名后以为已改);六个键全没给时抛 + ValueError(空更新不是成功)。校验与创建同款 + (_validate_fields,名字仍按“同项目 active 名字唯一” + 查重,改名撞名时 400)。 返回 (ok, auto):行不存在时 (False, None);已取消时 (False, 旧 dict)——取消是终态,改已取消的定义必须 @@ -359,11 +402,16 @@ def update_automation(conn, automation_id, fields, now=None): interval = fields.get("interval_seconds", current["interval_seconds"]) repo_url = fields.get("repo_url", current["repo_url"]) + first_run_at = fields.get("first_run_at", + current["first_run_at"]) + run_once = fields.get("run_once", current["run_once"]) if not any(k in fields for k in ( - "name", "prompt", "interval_seconds", "repo_url")): + "name", "prompt", "interval_seconds", "repo_url", + "first_run_at", "run_once")): raise ValueError("no updatable field given") - name, prompt, interval, repo_url = _validate_fields( - name, prompt, interval, repo_url) + (name, prompt, interval, repo_url, + first_run_at, run_once) = _validate_fields( + name, prompt, interval, repo_url, first_run_at, run_once) if name != current["name"]: clash = conn.execute( "SELECT id FROM automations WHERE project_id = ?" @@ -375,8 +423,10 @@ def update_automation(conn, automation_id, fields, now=None): "automation name already exists in this project") conn.execute( "UPDATE automations SET name = ?, prompt = ?," - " interval_seconds = ?, repo_url = ? WHERE id = ?", - (name, prompt, interval, repo_url, automation_id)) + " interval_seconds = ?, repo_url = ?, first_run_at = ?," + " run_once = ? WHERE id = ?", + (name, prompt, interval, repo_url, first_run_at, + 1 if run_once else 0, automation_id)) conn.commit() row = conn.execute( "SELECT * FROM automations WHERE id = ?", (automation_id,) @@ -384,20 +434,29 @@ def update_automation(conn, automation_id, fields, now=None): return True, automation_to_dict(row, now) +# 到期 SQL(due 与 claim 共用,绝不能只改一处): +# - 从未跑过:没给 first_run_at 时即到期;给了则只到该时刻才到期; +# - 跑过:上次 + 间隔 <= now 才到期; +# - 只跑一次且已跑过:永远不到期(run_once=1 且 last_run_at 非空)。 +_DUE_SQL = ( + " AND (run_once IS NULL OR run_once = 0 OR last_run_at IS NULL)" + " AND ((last_run_at IS NULL AND (first_run_at IS NULL" + " OR first_run_at <= ?))" + " OR (last_run_at IS NOT NULL AND" + " last_run_at + interval_seconds <= ?))") + + def due_automations(conn, project_id, now=None): """列出某项目当前到期的 active 定义(纯读,不抢单)。 - SQL 层算到期:从未跑过(last_run_at 为空)或上次执行距 - 今已满间隔;与 is_due 的 Python 算法同款。""" + SQL 层算到期:与 is_due 的 Python 算法同款,见 _DUE_SQL。""" if now is None: now = time.time() ensure_automations_tables(conn) rows = conn.execute( "SELECT * FROM automations WHERE project_id = ?" - " AND status = ?" - " AND (last_run_at IS NULL" - " OR last_run_at + interval_seconds <= ?)" + " AND status = ?" + _DUE_SQL + " ORDER BY created_at, id", - (project_id, ACTIVE, now)).fetchall() + (project_id, ACTIVE, now, now)).fetchall() return [automation_to_dict(r, now) for r in rows] @@ -420,10 +479,8 @@ def claim_due_automation(conn, automation_id, now=None): cur = conn.execute( "UPDATE automations SET last_run_at = ?," " last_claimed_at = ?" - " WHERE id = ? AND status = ?" - " AND (last_run_at IS NULL" - " OR last_run_at + interval_seconds <= ?)", - (now, now, automation_id, ACTIVE, now)) + " WHERE id = ? AND status = ?" + _DUE_SQL, + (now, now, automation_id, ACTIVE, now, now)) conn.commit() return cur.rowcount == 1 diff --git a/cloud/marker_tools.py b/cloud/marker_tools.py index 5abe246..ecb87fd 100644 --- a/cloud/marker_tools.py +++ b/cloud/marker_tools.py @@ -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,依此类推。 diff --git a/cloud/sandbox.py b/cloud/sandbox.py index 3c96dc8..5d26e94 100644 --- a/cloud/sandbox.py +++ b/cloud/sandbox.py @@ -23,6 +23,10 @@ import sys import time from cloud.marker_tools import extract_quota_status as _extract_quota_status +from cloud.marker_tools import extract_automation_creates as \ + _extract_automation_creates +from cloud.marker_tools import extract_goal_converts as \ + _extract_goal_converts from cloud.marker_tools import extract_task_cancels as _extract_task_cancels from cloud.marker_tools import extract_task_costs as _extract_task_costs from cloud.marker_tools import extract_task_creates as _extract_task_creates @@ -418,6 +422,8 @@ class LocalExecutor(Executor): todo_lists = [] todo_creates = [] todo_updates = [] + goal_converts = [] + automation_creates = [] turn_id = None root_turn_id = None parsed = None @@ -465,6 +471,9 @@ class LocalExecutor(Executor): todo_lists = _extract_todo_lists(parsed.get("text")) todo_creates = _extract_todo_creates(parsed.get("text")) todo_updates = _extract_todo_updates(parsed.get("text")) + goal_converts = _extract_goal_converts(parsed.get("text")) + automation_creates = _extract_automation_creates( + parsed.get("text")) else: host_status = None else: @@ -496,6 +505,8 @@ class LocalExecutor(Executor): "todo_lists": todo_lists, "todo_creates": todo_creates, "todo_updates": todo_updates, + "goal_converts": goal_converts, + "automation_creates": automation_creates, "turn_id": turn_id, "root_turn_id": root_turn_id, } @@ -1944,6 +1955,8 @@ def _parse_host_result(task_id, fake): todo_lists = [] todo_creates = [] todo_updates = [] + goal_converts = [] + automation_creates = [] turn_id = None root_turn_id = None parsed = None @@ -1978,6 +1991,9 @@ def _parse_host_result(task_id, fake): todo_lists = _extract_todo_lists(parsed.get("text")) todo_creates = _extract_todo_creates(parsed.get("text")) todo_updates = _extract_todo_updates(parsed.get("text")) + goal_converts = _extract_goal_converts(parsed.get("text")) + automation_creates = _extract_automation_creates( + parsed.get("text")) # 任务列表显示 turn 用:与 Local 版同款语义,取不到 # 时保持 None,worker 写回时不覆盖旧值。 turn_id = parsed.get("turn_id") @@ -2006,6 +2022,8 @@ def _parse_host_result(task_id, fake): "todo_lists": todo_lists, "todo_creates": todo_creates, "todo_updates": todo_updates, + "goal_converts": goal_converts, + "automation_creates": automation_creates, "turn_id": turn_id, "root_turn_id": root_turn_id, } diff --git a/cloud/server.py b/cloud/server.py index 15f6986..6ddf2eb 100644 --- a/cloud/server.py +++ b/cloud/server.py @@ -68,6 +68,7 @@ from cloud.db import init_postgres_schema as _init_postgres_schema from cloud.db import PostgresIntegrityError as _PgIntegrityError from cloud.db import PostgresOperationalError as _PgOperationalError from cloud.goal_command import parse_goal_command as _parse_goal_command +from cloud.marker_tools import parse_first_run_at as _parse_first_run_at from cloud.peer_messages import deliver_peer_message as _deliver_peer_message from cloud.peer_messages import ensure_followups_table as _peer_ensure_followups from cloud.peer_messages import peer_message_prefix as _peer_prefix @@ -9472,7 +9473,10 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler): """创建自动化定义:只登记定义与到期状态,不跑模型、 不另起调度器(执行器后续走 claim 门,见 automations 模块文档)。body:name、prompt 必填;interval_seconds - 可选(默认 86400,最小 60);repo_url 可选。 + 可选(默认 86400,最小 60);repo_url 可选; + first_run_at 可选(epoch 秒或 ISO8601 字符串,与 + marker 的 parse_first_run_at 同款解析,过去则建完 + 即到期);run_once 可选(true 时跑过一次后不再到期)。 P1 配额门:创建时先过与建任务同一道配额门(美元配额 与无价 token cap,超限 402),避免“定义免费建、到期 @@ -9490,13 +9494,24 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler): interval = body.get("interval_seconds", 86400) if interval is None: interval = 86400 + first_run_at = body.get("first_run_at") + if first_run_at is not None: + first_run_at = _parse_first_run_at(first_run_at) + if first_run_at is None: + self._send_json(400, {"error": "invalid first_run_at"}) + return + run_once = body.get("run_once", False) + if not isinstance(run_once, bool): + self._send_json(400, {"error": "run_once must be boolean"}) + return conn = self._db() try: try: auto = _auto_create( conn, project_id, body.get("name"), body.get("prompt"), user_id, interval, - body.get("repo_url")) + body.get("repo_url"), + first_run_at=first_run_at, run_once=run_once) except ValueError as exc: self._send_json(400, {"error": str(exc)}) return @@ -9845,6 +9860,13 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler): if jerr: self._send_json(400, {"error": jerr}) return + if body.get("first_run_at") is not None and \ + isinstance(body.get("first_run_at"), str): + parsed = _parse_first_run_at(body["first_run_at"]) + if parsed is None: + self._send_json(400, {"error": "invalid first_run_at"}) + return + body = dict(body, first_run_at=parsed) conn = self._db() try: try: diff --git a/cloud/tests/test_session_goal_automation.py b/cloud/tests/test_session_goal_automation.py new file mode 100644 index 0000000..0c1d1ef --- /dev/null +++ b/cloud/tests/test_session_goal_automation.py @@ -0,0 +1,334 @@ +"""会话内 goal / 自动化标记工具单测:LLM 自己判断意图那条路。 + +不跑真实模型、不联网: +- 解析层是纯函数(goal-convert、automation-create、 + first_run_at 的 ISO / 无时区 / 毫秒解析与非法跳过); +- automations 层用内存 SQLite,测 first_run_at 到期与 + run_once 跑过后不再到期(Python 口径与 SQL due/claim + 口径一致); +- worker 投递用 init_db 建的临时库 + 最小任务行,直接调 + _deliver_marker_tools,不走 run_one 全量流程。 + +注意:本文件不测“LLM 真的会写块”——那是模型行为, +且本批刻意不向 LLM 自动注入工具指南(避免 prompt +臃肿);这里测的是块写出来后系统一定按约定执行,且 +非法块一定不静默变调度。工具格式见 docs/marker-tools.md, +需要时由用户在会话里手动告诉 agent。 +""" + +import os +import sqlite3 +import sys +import tempfile +import time +import unittest +from pathlib import Path + +try: + from cloud.automations import ( + claim_due_automation, + create_automation, + due_automations, + get_automation, + update_automation, + ) + from cloud.marker_tools import ( + extract_automation_creates, + extract_goal_converts, + parse_first_run_at, + ) + from cloud.server import init_db + from cloud.worker import Worker +except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path + sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + from cloud.automations import ( + claim_due_automation, + create_automation, + due_automations, + get_automation, + update_automation, + ) + from cloud.marker_tools import ( + extract_automation_creates, + extract_goal_converts, + parse_first_run_at, + ) + from cloud.server import init_db + from cloud.worker import Worker + +FAKE_ENV = {"XCM_API_KEY": "test-placeholder"} + + +class ParseTestCase(unittest.TestCase): + def test_goal_convert_basic_and_alias(self): + text = ( + "```goal-convert\n{\"goal\": \"收 xxx\"}\n```\n" + "```goal_convert\n{\"goal\": \"收 yyy\"}\n```\n") + converts = extract_goal_converts(text) + # 每轮最多 1 块,第二个被忽略 + self.assertEqual([c["goal"] for c in converts], ["收 xxx"]) + + def test_goal_convert_invalid_skipped(self): + text = ( + "```goal-convert\nnot json\n```\n" + "```goal-convert\n{\"goal\": \" \"}\n```\n" + "```goal-convert\n{\"goal\": \"ok\"}\n```\n") + self.assertEqual( + [c["goal"] for c in extract_goal_converts(text)], ["ok"]) + + def test_first_run_at_formats(self): + iso = parse_first_run_at("2026-10-30T09:00:00+08:00") + naive = parse_first_run_at("2026-10-30 09:00") + self.assertEqual(iso, naive) + self.assertEqual(parse_first_run_at(iso), iso) + self.assertEqual(parse_first_run_at(int(iso * 1000)), iso) + self.assertIsNone(parse_first_run_at("not a time")) + self.assertIsNone(parse_first_run_at("2026-13-40 09:00")) + self.assertIsNone(parse_first_run_at(True)) + self.assertIsNone(parse_first_run_at(-1)) + + def test_automation_create_basic(self): + text = ( + "```automation-create\n" + "{\"prompt\": \"看 yyy\", \"interval_seconds\": 86400," + " \"first_run_at\": \"2026-10-30T09:00:00+08:00\"," + " \"once\": true, \"bind\": \"heartbeat\"}\n```\n") + blocks = extract_automation_creates(text) + self.assertEqual(len(blocks), 1) + block = blocks[0] + self.assertEqual(block["prompt"], "看 yyy") + self.assertEqual(block["interval_seconds"], 86400) + self.assertEqual( + block["first_run_at"], + parse_first_run_at("2026-10-30T09:00:00+08:00")) + self.assertTrue(block["once"]) + self.assertEqual(block["bind"], "heartbeat") + self.assertIsNone(block["name"]) + + def test_automation_create_invalid_interval_and_bind_skipped(self): + text = ( + "```automation-create\n{\"prompt\": \"x\"," + " \"interval_seconds\": 30}\n```\n" + "```automation-create\n{\"prompt\": \"y\"," + " \"bind\": \"weird\"}\n```\n" + "```automation-create\n{\"prompt\": \"good\"}\n```\n") + blocks = extract_automation_creates(text) + self.assertEqual([b["prompt"] for b in blocks], ["good"]) + self.assertEqual(blocks[0]["interval_seconds"], 86400) + + +class AutomationsTestCase(unittest.TestCase): + def setUp(self): + self.conn = sqlite3.connect(":memory:") + self.conn.row_factory = sqlite3.Row + + def tearDown(self): + self.conn.close() + + def test_first_run_at_not_due_early_then_due(self): + now = 1700000000.0 + auto = create_automation( + self.conn, "p", "future", "看 x", "u", now=now, + first_run_at=now + 3600) + self.assertFalse(auto["due"]) + self.assertEqual(auto["next_run_at"], now + 3600) + self.assertEqual(due_automations(self.conn, "p", now=now), []) + self.assertFalse( + claim_due_automation(self.conn, auto["id"], now=now)) + self.assertEqual( + [a["name"] for a in due_automations( + self.conn, "p", now=now + 3600)], + ["future"]) + self.assertTrue( + claim_due_automation(self.conn, auto["id"], now=now + 3600)) + + def test_run_once_never_due_after_first_run(self): + now = 1700000000.0 + auto = create_automation( + self.conn, "p", "once", "看 y", "u", now=now, + first_run_at=now + 10, run_once=True) + self.assertTrue( + claim_due_automation(self.conn, auto["id"], now=now + 20)) + after = get_automation(self.conn, auto["id"]) + self.assertFalse(after["due"]) + self.assertIsNone(after["next_run_at"]) + self.assertEqual( + due_automations(self.conn, "p", now=now + 90000), []) + + def test_update_first_run_at_and_run_once(self): + now = 1700000000.0 + auto = create_automation( + self.conn, "p", "upd", "看 z", "u", now=now) + ok, updated = update_automation( + self.conn, auto["id"], + {"first_run_at": now + 7200, "run_once": True}, now=now) + self.assertTrue(ok) + self.assertEqual(updated["first_run_at"], now + 7200) + self.assertTrue(updated["run_once"]) + self.assertFalse(updated["due"]) + + +class WorkerDeliverTestCase(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.db_path = os.path.join(self.tmp.name, "control.db") + init_db(self.db_path) + self._insert("PM", status="running", created_by="tester") + self._member("tester", "maintainer") + + def tearDown(self): + self.tmp.cleanup() + + def _insert(self, task_id, status="queued", created_by="tester", + project_id="p1"): + now = time.time() + conn = sqlite3.connect(self.db_path) + try: + conn.execute( + "INSERT INTO tasks(id, project_id, prompt, status," + " idempotency_key, depends_on, priority, created_by," + " created_at, updated_at, last_heartbeat)" + " VALUES(?, ?, ?, ?, NULL, '[]', 0, ?, ?, ?, NULL)", + (task_id, project_id, "do work", status, created_by, + now, now)) + conn.commit() + finally: + conn.close() + + def _member(self, user_id, role): + conn = sqlite3.connect(self.db_path) + try: + conn.execute( + "CREATE TABLE IF NOT EXISTS users(id TEXT PRIMARY KEY," + " username TEXT UNIQUE, api_key_hash TEXT," + " created_at REAL)") + conn.execute( + "CREATE TABLE IF NOT EXISTS project_members(" + "project_id TEXT, user_id TEXT, role TEXT," + " PRIMARY KEY(project_id, user_id))") + conn.execute( + "INSERT OR REPLACE INTO project_members" + "(project_id, user_id, role) VALUES('p1', ?, ?)", + (user_id, role)) + conn.execute( + "INSERT OR IGNORE INTO users(id, username, api_key_hash," + " created_at) VALUES(?, ?, '', 0)", (user_id, user_id)) + conn.commit() + finally: + conn.close() + + def _worker(self): + return Worker(self.db_path, "/fake/host.py", env=FAKE_ENV) + + def _followup_bodies(self): + conn = sqlite3.connect(self.db_path) + try: + rows = conn.execute( + "SELECT body FROM task_followups" + " WHERE task_id = 'PM' ORDER BY created_at").fetchall() + return [row[0] for row in rows] + finally: + conn.close() + + def _automations(self): + conn = sqlite3.connect(self.db_path) + try: + try: + rows = conn.execute( + "SELECT name, prompt, interval_seconds, run_once" + " FROM automations").fetchall() + except sqlite3.OperationalError: + # 一个自动化都没建过时表还不存在,不是失败 + return [] + return [tuple(row) for row in rows] + finally: + conn.close() + + def test_deliver_automation_create_and_bind(self): + worker = self._worker() + sender = worker.get_task("PM") + future = time.time() + 3600 + worker._deliver_marker_tools(sender, { + "status": "completed", + "task_creates": [], "task_polls": [], "wait_threads": [], + "task_cancels": [], "task_retries": [], + "goal_converts": [], + "automation_creates": [{ + "name": None, "prompt": "看 yyy", + "interval_seconds": 86400, "first_run_at": future, + "once": False, "bind": "heartbeat", + "repo_url": None}], + }) + autos = self._automations() + self.assertEqual(len(autos), 1) + self.assertEqual(autos[0][1], "看 yyy") + conn = sqlite3.connect(self.db_path) + try: + binding = conn.execute( + "SELECT task_id, mode FROM automation_bindings" + ).fetchone() + bodies = self._followup_bodies() + finally: + conn.close() + self.assertEqual(tuple(binding), ("PM", "heartbeat")) + self.assertTrue(any("已创建自动化" in b for b in bodies)) + + def test_deliver_automation_past_first_run_rejected(self): + worker = self._worker() + sender = worker.get_task("PM") + worker._deliver_marker_tools(sender, { + "status": "completed", + "task_creates": [], "task_polls": [], "wait_threads": [], + "task_cancels": [], "task_retries": [], + "goal_converts": [], + "automation_creates": [{ + "name": "old", "prompt": "看旧", + "interval_seconds": 86400, + "first_run_at": time.time() - 3600, + "once": False, "bind": None, "repo_url": None}], + }) + self.assertEqual(self._automations(), []) + bodies = self._followup_bodies() + self.assertTrue(any("已过去" in b for b in bodies)) + + def test_deliver_goal_convert_queues_after_report(self): + worker = self._worker() + sender = worker.get_task("PM") + worker._deliver_marker_tools(sender, { + "status": "completed", + "task_creates": [], "task_polls": [], "wait_threads": [], + "task_cancels": [], "task_retries": [], + "goal_converts": [{"goal": "收 xxx"}], + "automation_creates": [{ + "name": "daily", "prompt": "看 yyy", + "interval_seconds": 86400, "first_run_at": None, + "once": False, "bind": None, "repo_url": None}], + }) + bodies = self._followup_bodies() + # 报告先、/goal 后:排空先跑报告轮、再转 goal + self.assertTrue(any("已创建自动化" in b for b in bodies)) + self.assertEqual(bodies[-1], "/goal 收 xxx") + self.assertEqual(len(self._automations()), 1) + + def test_deliver_goal_task_ignored(self): + conn = sqlite3.connect(self.db_path) + try: + conn.execute( + "UPDATE tasks SET goal_mode = 1 WHERE id = 'PM'") + conn.commit() + finally: + conn.close() + worker = self._worker() + sender = worker.get_task("PM") + worker._deliver_marker_tools(sender, { + "status": "completed", + "task_creates": [], "task_polls": [], "wait_threads": [], + "task_cancels": [], "task_retries": [], + "goal_converts": [{"goal": "收 xxx"}], + "automation_creates": [], + }) + self.assertEqual(self._followup_bodies(), []) + + +if __name__ == "__main__": + unittest.main() diff --git a/cloud/worker.py b/cloud/worker.py index 4086b14..9d4978b 100644 --- a/cloud/worker.py +++ b/cloud/worker.py @@ -150,6 +150,8 @@ from cloud.models import ModelRouter, is_fallbackable, record_fallback from cloud.auto_memory import AutoMemoryStore from cloud.auto_memory import build_injection_prefix as _auto_prefix from cloud.auto_memory import process_due_tasks as _auto_process_due +from cloud.automations import create_automation as _auto_create +from cloud.lifecycle import bind_automation as _life_bind from cloud.marker_tools import ancestry_relation as _marker_ancestry from cloud.marker_tools import check_control_gate as _marker_gate from cloud.marker_tools import count_direct_spawn_children as _marker_count_direct @@ -3492,14 +3494,21 @@ class Worker: 只执行 completed 轮次的块;goal 任务与协调员任务不解析 (只收不发,与 peer 同款);父任务被取消时不执行。 - 执行顺序:创建 → 取消 → 重试 → 待办创建 → 待办更新 → - 待办列表 → 任务列表 → 任务成本 → 配额 → 任务文件 → - 轮询 → 等待——创建在前让同一轮刚创建的任务立刻能被 - 轮询,控制操作在快照之前让轮询/等待读到取消或重试后 - 的新状态;待办列表放在待办写操作之后,让同一轮刚建/ - 刚改的待办立刻能在列表里看到。全部报告拼成一条系统 - 后续消息排给父任务自己,由排空循环在轮次边界跑下 - 一轮;拒绝只记报告与审计,不炸 run_one。""" + 执行顺序:创建 → 自动化创建 → 取消 → 重试 → 待办创建 → + 待办更新 → 待办列表 → 任务列表 → 任务成本 → 配额 → + 任务文件 → 轮询 → 等待——创建在前让同一轮刚创建的 + 任务立刻能被轮询,控制操作在快照之前让轮询/等待读到 + 取消或重试后的新状态;待办列表放在待办写操作之后, + 让同一轮刚建/刚改的待办立刻能在列表里看到。全部报告 + 拼成一条系统后续消息排给父任务自己,由排空循环在轮次 + 边界跑下一轮;拒绝只记报告与审计,不炸 run_one。 + goal-convert 不在这里直接转:它排成一条“/goal <目标>” + 的自后续消息,放在报告消息之后——排空先跑报告那轮 + (agent 看到自动化等工具的确认),再跑 /goal 那条, + 走已有的 followup /goal 转换路径(plan 冲突、CAS、 + 终局 queued 审计都在那条路里,不在这里另起一套)。 + 这样转 goal 后报告不会丢在 goal 循环外,也不会出现 + 两套转换逻辑打架。""" if not result or result.get("status") != "completed": return if self._was_cancelled(task["id"]): @@ -3520,14 +3529,17 @@ class Worker: task_costs = result.get("task_costs") or [] quota_status = result.get("quota_status") or [] task_files = result.get("task_files") or [] + goal_converts = result.get("goal_converts") or [] + automation_creates = result.get("automation_creates") or [] if not (creates or polls or waits or cancels or retries or todo_lists or todo_creates or todo_updates or task_lists or task_costs or quota_status - or task_files): + or task_files or goal_converts or automation_creates): return reports = [] created = 0 rejected = 0 + auto_created = 0 if creates: quota_error = None try: @@ -3616,6 +3628,93 @@ class Worker: except Exception as exc: reports.append("创建失败:%s" % exc) rejected += len(creates) - created + if automation_creates: + # 自动化创建:权限与配额是整轮门(与 task-create 同款, + # 配额超限时整轮跳过,不逐块半建);单块的首次时刻 + # 已过去、重名等只拒该块。创建人跟父任务走,绝不 + # 用 worker 自己的身份建——否则自动化到期产出的任务 + # 会算到错误的人头上,配额门也绕过去了。 + auth_ok, _auth_reason = self._marker_auth( + task, "create_task") + quota_error = None + try: + quota_error = self._quota_exceeded(task, None) + except Exception as exc: + quota_error = "quota_check_failed: %s" % exc + if auth_ok is not True: + reports.append( + "自动化创建跳过:发送方创建人无 create_task " + "权限或权限查不到,本轮 %d 个自动化块全部跳过。" + % len(automation_creates)) + rejected += len(automation_creates) + elif quota_error is not None: + reports.append( + "自动化创建跳过:父任务配额检查未通过(%s)," + "本轮 %d 个自动化块全部跳过。" + % (quota_error, len(automation_creates))) + rejected += len(automation_creates) + else: + now = time.time() + for idx, block in enumerate(automation_creates): + first = block.get("first_run_at") + if first is not None and first < now - 60: + reports.append( + "自动化创建跳过:首次运行时刻已过去" + "(%s),请给一个未来时刻再写块。" + % block.get("prompt", "")[:40]) + rejected += 1 + continue + name = block.get("name") or ( + "auto-%s-%d-%d" + % (task["id"][:8], int(now), idx)) + try: + conn = _db_connect(self.db_path) + try: + auto = _auto_create( + conn, task["project_id"], name, + block["prompt"], task.get("created_by"), + block["interval_seconds"], + block.get("repo_url"), now=now, + first_run_at=first, + run_once=block.get("once", False)) + bind = block.get("bind") + if bind: + try: + _life_bind( + conn, auto["id"], + task["project_id"], task["id"], + bind, now=now) + except Exception as exc: + reports.append( + "已创建自动化 %s(%s),但绑定" + "到本会话失败(%s),定义本身" + "仍有效,到期独立跑。" + % (auto["id"], name, exc)) + auto_created += 1 + continue + finally: + conn.close() + except Exception as exc: + reports.append( + "自动化创建失败(%s):%s。" + % (block.get("prompt", "")[:40], exc)) + rejected += 1 + continue + auto_created += 1 + nxt = auto.get("next_run_at") + nxt_text = ( + time.strftime( + "%Y-%m-%d %H:%M", time.localtime(nxt)) + if nxt else "不再到期") + reports.append( + "已创建自动化 %s(%s),间隔 %d 秒," + "下次 %s%s,到期后由执行器经 claim 抢单" + "建任务%s。" + % (auto["id"], name, + block["interval_seconds"], nxt_text, + "(只跑一次)" if block.get("once") else "", + ",已绑到本会话唤醒" + if block.get("bind") == "heartbeat" else "")) for kind, blocks, action in (("取消", cancels, "create_task"), ("重试", retries, "approve_merge")): for block in blocks: @@ -4071,16 +4170,26 @@ class Worker: if reports: body = _marker_header() + "\n" + "\n".join(reports) self._queue_self_followup(task, body) + if goal_converts: + # 顺序关键:报告消息先排、/goal 消息后排,排空先跑 + # 报告那轮、再转 goal;反过来排会先转 goal,报告 + # 就送不到 goal 循环里了。plan 冲突等拒绝由排空的 + # /goal 路径处理(标 failed 停排空),这里不预判。 + goal_target = goal_converts[0]["goal"] + self._queue_self_followup( + task, "/goal %s" % goal_target) detail = ("%s: spawned=%d rejected=%d polls=%d waits=%d " "cancels=%d retries=%d todo_lists=%d " "todo_creates=%d todo_updates=%d task_lists=%d " - "task_costs=%d quota=%d task_files=%d" + "task_costs=%d quota=%d task_files=%d " + "automations=%d goal_converts=%d" % (task["id"], created, rejected, len(polls), len(waits), len(cancels), len(retries), len(todo_lists), len(todo_creates), len(todo_updates), len(task_lists), len(task_costs), len(quota_status), - len(task_files))) + len(task_files), auto_created, + len(goal_converts))) self._audit(task["project_id"], "worker_marker_tools", detail) def _deliver_peer_messages(self, task, result): @@ -5769,6 +5878,9 @@ class Worker: if fu.get("sender_task_id"): fu_prompt = _peer_prefix(fu["sender_task_id"]) + fu_prompt fu_task["prompt"] = fu_prompt + # 后续消息轮不重复加指南:首轮指南已在同一 + # thread 历史里,后续轮靠历史想起工具;每轮 + # 都加会让指南在历史里越积越多。 fu_result, fu_error = self._run_attempt( fu_task, quota_check=_quota_hit) usage_total = sum_usage( diff --git a/docs/marker-tools.md b/docs/marker-tools.md index 52f2ae7..55f5f56 100644 --- a/docs/marker-tools.md +++ b/docs/marker-tools.md @@ -3,9 +3,19 @@ 适用基线:main d129f7b 上的 feat-marker-tools 分支(本地提交,未推、 未建 PR、未部署)。本批只做四类标记工具,不要把本文档读成 “所有 Codex app 线程工具都已实现”:handoff、worktree 创建状态、 -PR 检查诊断、automation、clock-sleep 都不在本批;MCP 真工具路 +PR 检查诊断、clock-sleep 都不在本批;MCP 真工具路 (同轮函数返回)也不在本批。 +新增(feat-mainline-goal 分支,本地提交,未推、未部署): +goal-convert 与 automation-create 两个标记工具已实现,见下面 +新增的两节。注意:本批刻意**不**向 LLM 自动注入工具指南—— +不靠服务端再猜关键词、也不让每个任务的 prompt 都变长; +agent 知道这两个工具的唯一途径,是用户在会话里手动告诉它 +(例如把下面两节的格式贴给它),或由调用方在任务 prompt 里 +自己写明。LLM 是否用它们、判成 goal 还是自动化,全由 LLM +按用户话里的意图决定,服务端不按“收 / 每天 / 明天”这类词 +再做一次关键词判断。 + ## 一句话原理 agent 不用联网、不用凭证。agent 在回合输出里写标记块,本地与 @@ -32,6 +42,61 @@ Docker 执行器都经 sandbox 的结果解析提取标记块(与 task-message - 容易混淆的一点:协调员的子任务本身是普通任务(不是协调员 任务),它写标记块会被解析;它的派生代数从 0 计(见“派生护栏”)。 +## 新增的两个工具:goal-convert 与 automation-create + +### goal-convert:把本会话转成 goal + +```text +```goal-convert +{"goal": "收 xxx 到 yyy 为止"} +``` +``` + +- goal 必填、1-20000 字;每轮最多 1 块。转成的是新语义 goal + (goal_mode=1、无限轮直到模型自己输出“目标已达成”), + 与服务端 /goal 转换同款,不接受 verify_command / + goal_max_rounds。 +- 执行方式是排队,不是立即转:worker 把它排成一条 + “/goal <目标>”的自后续消息,放在本轮其它工具的报告消息 + 之后——排空先跑报告那轮,再跑 /goal 那条走已有的 followup + /goal 转换路径(plan 冲突、CAS、终局 queued 都在那条路里)。 + 所以同轮其它标记块的报告不会丢在 goal 循环外。 +- 只在普通任务里有效:goal 任务与协调员任务不解析标记块。 + +### automation-create:新建自动化定义 + +```text +```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 自动生成(`auto-<任务短 id>-<时间>-<序号>`),不会因 + 重名撞上同项目已有的 active 自动化。 +- interval_seconds 可选、缺省 86400,必须是整数且在 60 到 + 30*86400 之间;非法时整块跳过,不静默钳制。 +- first_run_at 可选:epoch 秒(毫秒级自动除 1000)、ISO8601 + 字符串(带偏移量按偏移量算,无时区按 Asia/Shanghai 算)。 + 不给表示建完即到期(沿用自动化原有语义);给了表示首次 + 在该时刻跑——“每天 9 点”要写成 first_run_at=下一个 9 点 + + interval_seconds=86400,而不是只写间隔(只写间隔会从创建 + 时间起算,每天跑在创建时间那个钟点)。首次时刻已过去的块 + 在 worker 层拒绝并写进报告,agent 下一轮可纠正。 +- once 可选、缺省 false;true 表示只跑一次,跑过后 is_due / + due / claim 三处都不再到期(automations 模块的 + first_run_at / run_once 字段与 SQL 到期条件已同步改过, + API 的创建与更新也接受这两个字段)。 +- bind 可选、缺省 null(到期独立跑新任务);"heartbeat" + 表示绑到本会话,到期唤醒本线程;"cron" 与 null 同款。 +- 每轮最多 5 块;创建人跟父任务走,须有项目 create_task + 权限,配额超限时整轮跳过。创建只是登记定义,到期执行 + 仍走 automations 的 claim 门与外部触发器,不是建完就跑。 +- 只在普通任务里有效:goal 任务与协调员任务不解析标记块。 + ## 四个工具的标记格式 ### task-create:新建平级主线任务