feat: goal 模式新语义——prompt 即目标,无验收命令,不限轮次,模型宣告完成 (#67)

console 打勾 goal 模式时不再有专用 goal 文本、验收命令、轮次
三个输入(已移除,换成说明行),只发 goal_mode=1;server 侧
goal_mode=1 时 goal 存 prompt、验收命令空、轮次存 0,带验收
命令或与计划模式同用时 400,旧的带验收命令任务照原样可用。

host.py goal 循环:verify 为空时不跑验测,轮次提示里写明
标记约定;模型在完整跑完的回合末尾单独一行输出“目标已
达成”即达标(超时回合的部分文本含标记也不算);--max-rounds
0 为不限轮次,goal-resume 续跑按 0 处理(不用 or 兜底成 3)。
刹车只有取消与预算耗尽(后者记 interrupted、可续跑)。

迁移与 worker:tasks 加 goal_mode 列(server 迁移、worker 列
补齐、PG schema 三处同步);worker 按 goal_mode 或
verify_command 判定 goal 路径,重试与后续消息排空对新语义
同样只跑一次/不排空;Local/Docker goal_task 的 --verify 可选、
--max-rounds 0 原样传。

Co-authored-by: host <host@test.local>
This commit is contained in:
openqjandhost authored and GitHub committed 2026-10-27 03:54:48 +08:00
1 parent 4179fb1a0a
commit c86b450851
10 files changed
+519 -108

No files matched your search

+197
View File
@@ -0,0 +1,197 @@
"""新 goal 语义单测:prompt 即目标,无验收命令,不限轮次,
模型在回合末尾单独一行输出“目标已达成”即达标。
不跑真实模型回合、不联网:server 侧走本地临时端口 + 临时
SQLite;worker 侧注入假 runner;旧语义(verify_command)任务
仍按原样建任务、走 goal 循环。
"""
import http.client
import json
import os
import sqlite3
import sys
import tempfile
import threading
import time
import unittest
from pathlib import Path
from types import SimpleNamespace
try:
from cloud.auth import AuthStore
from cloud.sandbox import LocalExecutor
from cloud.server import create_server, 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.auth import AuthStore
from cloud.sandbox import LocalExecutor
from cloud.server import create_server, init_db
from cloud.worker import Worker
def _goal_met_runner(cmd):
return SimpleNamespace(
returncode=0,
stdout="round 1: turn=completed verify_rc=- verify_out=\n"
"goal met after rounds\n",
stderr="")
class ServerGoalModeTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
self.auth = AuthStore(self.db_path)
self.owner_id, self.owner_key = self.auth.create_user("owner")
self.server = create_server(self.db_path, "127.0.0.1", 0)
self.port = self.server.server_address[1]
self.thread = threading.Thread(
target=self.server.serve_forever, daemon=True)
self.thread.start()
status, self.project = self._req(
"POST", "/projects", {"name": "demo"}, self.owner_key)
self.assertEqual(status, 201)
def tearDown(self):
self.server.shutdown()
self.server.server_close()
self.thread.join(timeout=5)
try:
self.auth.conn.close()
except Exception:
pass
self.tmp.cleanup()
def _req(self, method, path, body=None, key=None):
conn = http.client.HTTPConnection(
"127.0.0.1", self.port, timeout=10)
hdrs = {}
payload = None
if key:
hdrs["X-API-Key"] = key
if body is not None:
payload = json.dumps(body)
hdrs["Content-Type"] = "application/json"
conn.request(method, path, body=payload, headers=hdrs)
resp = conn.getresponse()
raw = resp.read()
conn.close()
try:
return resp.status, json.loads(raw.decode("utf-8"))
except ValueError:
return resp.status, raw.decode("utf-8", errors="replace")
def _create(self, **extra):
body = {"project_id": self.project["id"], "prompt": "build it"}
body.update(extra)
return self._req("POST", "/tasks", body, self.owner_key)
def test_create_goal_mode_task(self):
status, task = self._create(goal_mode=1)
self.assertEqual(status, 201)
self.assertEqual(task["goal_mode"], 1)
# 新语义:prompt 本身就是目标,不存验收命令,轮次存 0。
self.assertEqual(task["goal"], "build it")
self.assertIsNone(task["verify_command"])
self.assertEqual(task["goal_max_rounds"], 0)
def test_goal_mode_with_verify_command_400(self):
status, body = self._create(goal_mode=1, verify_command="true")
self.assertEqual(status, 400)
def test_goal_mode_with_plan_400(self):
status, body = self._create(goal_mode=1, plan=1)
self.assertEqual(status, 400)
def test_old_style_verify_task_still_works(self):
status, task = self._create(verify_command="true",
goal_max_rounds=2)
self.assertEqual(status, 201)
self.assertEqual(task["goal_mode"], 0)
self.assertEqual(task["verify_command"], "true")
self.assertEqual(task["goal_max_rounds"], 2)
def test_console_goal_hint_and_no_old_inputs(self):
# console 壳是静态页:无 key 也能取,_req 对非 JSON 直接回文本。
_status, text = self._req("GET", "/console")
self.assertIn('id="goalHint"', text)
self.assertIn("body.goal_mode = 1", text)
# 旧的专用 prompt / 验收命令 / 轮次输入已移除。
self.assertNotIn('id="verifyCommand"', text)
self.assertNotIn('id="goalRounds"', text)
self.assertNotIn('id="goalFields"', text)
class ExecutorGoalModeTestCase(unittest.TestCase):
def test_local_goal_task_no_verify_zero_rounds(self):
seen = []
def fake(cmd):
seen.append(list(cmd))
return _goal_met_runner(cmd)
result = LocalExecutor("/fake/host.py", runner=fake).goal_task(
{"id": "t1", "prompt": "build it", "goal": "build it",
"goal_mode": 1, "goal_max_rounds": 0})
self.assertEqual(result["status"], "completed")
self.assertTrue(result["goal_met"])
# 新形态:不传 --verify,--max-rounds 原样传 0(不能被
# or 兜底成 3,否则不限轮次悄悄变回 3 轮上限)。
self.assertNotIn("--verify", seen[0])
self.assertEqual(seen[0][seen[0].index("--max-rounds") + 1], "0")
class WorkerGoalModeTestCase(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.env = {"XCM_API_KEY": "test-placeholder"}
def tearDown(self):
self.tmp.cleanup()
def _insert(self, task_id, **extra):
now = time.time()
cols = ["id", "project_id", "prompt", "status",
"idempotency_key", "depends_on", "priority",
"created_by", "created_at", "updated_at",
"last_heartbeat"]
vals = [task_id, "p1", "do work", "queued", None, "[]", 0,
"tester", now, now, None]
for k, v in extra.items():
cols.append(k)
vals.append(v)
conn = sqlite3.connect(self.db_path)
try:
conn.execute(
"INSERT INTO tasks(%s) VALUES(%s)"
% (", ".join(cols), ", ".join("?" * len(cols))), vals)
conn.commit()
finally:
conn.close()
def test_goal_mode_task_routes_to_goal_not_dispatch(self):
self._insert("T", goal_mode=1, goal="build it",
goal_max_rounds=0)
seen = []
def fake(cmd):
seen.append(list(cmd))
if "goal" in cmd:
return _goal_met_runner(cmd)
raise AssertionError("goal 任务不应走 dispatch: %s" % cmd)
worker = Worker(self.db_path, "/fake/host.py", runner=fake,
env=self.env)
result = worker.run_one()
self.assertEqual(result["status"], "completed")
self.assertTrue(any("goal" in cmd for cmd in seen))
self.assertNotIn("--verify", seen[0])
self.assertEqual(seen[0][seen[0].index("--max-rounds") + 1], "0")
if __name__ == "__main__":
unittest.main()
+18 -5
View File
@@ -333,11 +333,24 @@ class TestGoalExecutors(unittest.TestCase):
self.assertEqual(result["status"], "failed")
self.assertIn("请新建任务", result["goal_error"])
def test_local_goal_task_requires_verify(self):
with self.assertRaises(ValueError):
LocalExecutor("/fake/host.py",
runner=lambda cmd: None).goal_task(
{"id": "t1", "prompt": "do it"})
def test_local_goal_task_without_verify_is_llm_decided(self):
# 新语义:没有验收命令不再抛错——走模型自决分支:
# 命令里没有 --verify,轮次缺省仍为 3(任务行没写时)。
seen = []
def fake(cmd):
seen.append(list(cmd))
return SimpleNamespace(
returncode=0,
stdout="goal met after rounds\n",
stderr="")
result = LocalExecutor("/fake/host.py",
runner=fake).goal_task(
{"id": "t1", "prompt": "do it"})
self.assertEqual(result["status"], "completed")
self.assertNotIn("--verify", seen[0])
self.assertEqual(seen[0][seen[0].index("--max-rounds") + 1], "3")
def test_base_goal_task_not_implemented(self):
with self.assertRaises(NotImplementedError):