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

+123 -11
View File
@@ -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(