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

+334
View File
@@ -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()