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:
1 parent
4179fb1a0a
commit
c86b450851
10 files changed
+519
-108
No files matched your search
@@ -138,7 +138,7 @@ turn 可以真正并发(不是轮流排队)。
|
||||
| 线程续跑 / fork / 归档 | `run`(resume + 新回合)、`fork`、`archive`、`list` | 已实测 |
|
||||
| 宿主级子代理委派 | `delegate`:子 thread(默认 read-only)跑完,结果可 `--report-to-parent` 汇总回主 thread | 已实测 |
|
||||
| 模型内子代理 | prompt 明确要求派生,见 `examples/subagent-prompt.txt`;与宿主委派是两层,别混 | 已实测(模板 + 协议) |
|
||||
| 长任务 / 目标循环 | `goal`:同一 thread 多回合,直到 `--verify` 验收命令通过或到 `--max-rounds`;每轮落盘检查点,中断后 `goal-resume` 从下一轮续跑(goal-met / goal-unmet 终局后不许续跑,须新建);`--turn-timeout 0` 不限时,`--verify-timeout` 限验测;回合超时下一轮提示写明是回合中断不是验收失败 | 已实测:端到端、断点续跑、正常多轮反馈修复 |
|
||||
| 长任务 / 目标循环 | `goal`:同一 thread 多回合,每轮落盘检查点,中断后 `goal-resume` 从下一轮续跑(goal-met / goal-unmet 终局后不许续跑,须新建);`--turn-timeout 0` 不限时;回合超时下一轮提示写明是回合中断不是验收失败。两种验收形态:旧形态 `--verify` 验收命令通过即达标,到 `--max-rounds` 则 unmet;新形态(console 打勾 goal 模式,`goal_mode=1`)不设验收命令、不设轮次上限(`--max-rounds 0`),prompt 本身就是目标,模型在回合末尾单独一行输出“目标已达成”即宣告达标,否则一直运行,停靠取消或预算(`max_daily_turns` 耗尽时记 interrupted、可续跑) | 旧形态已实测:端到端、断点续跑、正常多轮反馈修复;新形态单测已过,真模型宣告达标尚未实弹 |
|
||||
| 自动化 / 评审队列 | `automations --add` + `run-due`:到期的任务自动派发,结果进 host-state.json 评审队列 | 已实测 |
|
||||
| 沙箱边界 | read-only thread 的写文件请求被拦下,文件确实没建出来 | 已实测 |
|
||||
| 审批控制 | untrusted + deny 时 agent 明确收到审批拒绝,没拿到命令输出 | 已实测 |
|
||||
|
||||
+7
-10
@@ -151,11 +151,7 @@
|
||||
<option value="full">完全访问</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="goalFields" style="display:none">
|
||||
<input id="goal" type="text" placeholder="goal(缺省用 prompt)" style="width:100%; box-sizing:border-box; margin:6px 0;">
|
||||
<input id="verifyCommand" type="text" placeholder="verify_command 验收命令(goal 模式必填项按服务端规则)" style="width:100%; box-sizing:border-box; margin:6px 0;">
|
||||
<label>轮次 <input id="goalRounds" type="number" value="3" min="1" style="width:60px;"></label>
|
||||
</div>
|
||||
<div id="goalHint" style="display:none" class="muted">goal 模式:输入框内容即目标,不需要专用 prompt 与验收命令;不限轮次,模型确认达成后在回复末尾输出“目标已达成”即结束,未达成会一直运行,可用取消停止。</div>
|
||||
<div class="bar">
|
||||
<button id="createTask" class="primary">提交任务</button>
|
||||
<span id="taskMsg" class="muted"></span>
|
||||
@@ -316,7 +312,7 @@ async function loadTasks() {
|
||||
var pr = t.pr_url ? "<a href='" + esc(t.pr_url) + "' target='_blank' rel='noopener'>#" + esc(t.pr_number) + "</a>" : "";
|
||||
return "<tr data-id='" + esc(t.id) + "' class='" + (currentTask && currentTask.id === t.id ? "selected" : "") + "'>" +
|
||||
"<td>" + esc(shortId(t.id)) + "</td>" +
|
||||
"<td>" + (t.plan ? "[计划] " : "") + (t.forked_from ? "[分支] " : "") + esc(String(t.prompt || "").slice(0, 60)) + "</td>" +
|
||||
"<td>" + (t.plan ? "[计划] " : "") + (t.forked_from ? "[分支] " : "") + ((t.goal_mode || t.verify_command) ? "[目标] " : "") + esc(String(t.prompt || "").slice(0, 60)) + "</td>" +
|
||||
"<td class='st-" + esc(t.status) + "'>" + esc(t.status) + "</td>" +
|
||||
"<td>" + esc(t.executor || "") + "</td>" +
|
||||
"<td>" + esc(t.worktree_host || "") + "</td>" +
|
||||
@@ -442,6 +438,7 @@ async function showDetail(taskId) {
|
||||
" | 权限 " + esc(t.approval || "默认") +
|
||||
(t.plan ? " | <b>计划模式(只读)</b>" : "") +
|
||||
(t.forked_from ? " | 分支自 " + esc(shortId(t.forked_from)) : "") +
|
||||
(t.goal_mode ? " | <b>goal 模式(不限轮次,模型宣告完成)</b>" : (t.verify_command ? " | goal 模式(旧语义,验收命令)" : "")) +
|
||||
(t.pr_url ? " | <a href='" + esc(t.pr_url) + "' target='_blank' rel='noopener'>PR #" + esc(t.pr_number) + "</a>" : "") +
|
||||
"<br>prompt:<br>" + esc(t.prompt || "");
|
||||
var body = "";
|
||||
@@ -519,7 +516,7 @@ $("#refreshProjects").onclick = loadProjects;
|
||||
$("#refreshWorkers").onclick = loadWorkers;
|
||||
$("#refreshTasks").onclick = loadTasks;
|
||||
$("#projectSelect").onchange = loadTasks;
|
||||
$("#goalMode").onchange = function () { $("#goalFields").style.display = this.checked ? "block" : "none"; };
|
||||
$("#goalMode").onchange = function () { $("#goalHint").style.display = this.checked ? "block" : "none"; };
|
||||
$("#createProject").onclick = async function () {
|
||||
var name = $("#newProjectName").value.trim();
|
||||
if (!name) { $("#projectMsg").textContent = "项目名必填"; return; }
|
||||
@@ -556,9 +553,9 @@ $("#createTask").onclick = async function () {
|
||||
body.plan = 1;
|
||||
}
|
||||
if ($("#goalMode").checked) {
|
||||
body.goal = $("#goal").value.trim() || prompt;
|
||||
body.verify_command = $("#verifyCommand").value.trim();
|
||||
body.goal_max_rounds = parseInt($("#goalRounds").value, 10) || 3;
|
||||
// 新 goal 语义:prompt 本身就是目标;不发专用 goal 文本、
|
||||
// 验收命令与轮次(服务端 goal_mode=1 时一律不限轮次)。
|
||||
body.goal_mode = 1;
|
||||
}
|
||||
try {
|
||||
var t = await api("/tasks", "POST", body);
|
||||
|
||||
+3
-1
@@ -831,6 +831,7 @@ POSTGRES_SCHEMA = (
|
||||
"goal TEXT, "
|
||||
"verify_command TEXT, "
|
||||
"goal_max_rounds INTEGER, "
|
||||
"goal_mode INTEGER, "
|
||||
"model TEXT, "
|
||||
"effort TEXT, "
|
||||
"approval TEXT, "
|
||||
@@ -929,7 +930,8 @@ def init_postgres_schema(conn):
|
||||
"PRAGMA table_info(tasks)").fetchall()}
|
||||
for _name, _type in (("executor", "TEXT"), ("pr_url", "TEXT"),
|
||||
("pr_number", "INTEGER"),
|
||||
("worktree_host", "TEXT")):
|
||||
("worktree_host", "TEXT"),
|
||||
("goal_mode", "INTEGER")):
|
||||
if _name not in tcols:
|
||||
conn.execute(
|
||||
"ALTER TABLE tasks ADD COLUMN %s %s" % (_name, _type))
|
||||
|
||||
+24
-14
@@ -346,28 +346,33 @@ class LocalExecutor(Executor):
|
||||
"""调 host.py goal:目标循环(turn→验测→喂回重试)。
|
||||
|
||||
命令:host.py [--config CFG] [--model M] goal --name <host 名>
|
||||
--task <goal 或 prompt> --verify <验收命令>
|
||||
--task <goal 或 prompt> [--verify <验收命令>]
|
||||
[--max-rounds N] [--repo <repo>]。goal 文本缺省用 prompt;
|
||||
轮次缺省 3,与 host.py 默认一致;repo 取任务的 repo 字段,
|
||||
没有则取构造时传的 repo。
|
||||
新形态(goal_mode 任务)没有验收命令:--verify 不传,
|
||||
--max-rounds 传 0(不限轮次,模型宣告完成才结束),
|
||||
0 是合法值不能用 or 兜底成 3;旧形态仍按原样传验收命令
|
||||
与轮次。轮次缺省 3(任务行没写时),与 host.py 默认一致;
|
||||
repo 取任务的 repo 字段,没有则取构造时传的 repo。
|
||||
结论解析见 _goal_result_from_runner:host.py goal 两种结局
|
||||
都 exit 0,不能沿用 _result_from_runner。
|
||||
"""
|
||||
if not isinstance(task, dict) or task.get("id") is None:
|
||||
raise ValueError("任务缺少 id")
|
||||
verify = task.get("verify_command")
|
||||
if not verify:
|
||||
raise ValueError("goal 任务缺少 verify_command")
|
||||
goal_text = task.get("goal") or task.get("prompt")
|
||||
if not goal_text:
|
||||
raise ValueError("任务缺少 prompt")
|
||||
rounds = task.get("goal_max_rounds") or 3
|
||||
rounds = task.get("goal_max_rounds")
|
||||
if rounds is None:
|
||||
rounds = 3
|
||||
cmd = self._base_command(model=task.get("model"),
|
||||
effort=task.get("effort"),
|
||||
approval=task.get("approval"))
|
||||
cmd += ["goal", "--name", self._host_name(task),
|
||||
"--task", goal_text, "--verify", verify,
|
||||
"--max-rounds", str(rounds)]
|
||||
"--task", goal_text]
|
||||
if verify:
|
||||
cmd += ["--verify", verify]
|
||||
cmd += ["--max-rounds", str(rounds)]
|
||||
repo = task.get("repo") or self.repo
|
||||
if repo:
|
||||
cmd += ["--repo", repo]
|
||||
@@ -920,16 +925,21 @@ class DockerExecutor(Executor):
|
||||
if not isinstance(task, dict) or task.get("id") is None:
|
||||
raise ValueError("任务缺少 id")
|
||||
verify = task.get("verify_command")
|
||||
if not verify:
|
||||
raise ValueError("goal 任务缺少 verify_command")
|
||||
goal_text = task.get("goal") or task.get("prompt")
|
||||
if not goal_text:
|
||||
raise ValueError("任务缺少 prompt")
|
||||
rounds = task.get("goal_max_rounds") or 3
|
||||
# 新形态(goal_mode 任务)没有验收命令:--verify 不传,
|
||||
# --max-rounds 传 0(不限轮次);0 是合法值不能用 or 兜底。
|
||||
rounds = task.get("goal_max_rounds")
|
||||
if rounds is None:
|
||||
rounds = 3
|
||||
inner_args = ["goal", "--name", self._host_name(task),
|
||||
"--task", goal_text]
|
||||
if verify:
|
||||
inner_args += ["--verify", verify]
|
||||
inner_args += ["--max-rounds", str(rounds)]
|
||||
inner = self._inner_host_command(
|
||||
"goal", "--name", self._host_name(task),
|
||||
"--task", goal_text, "--verify", verify,
|
||||
"--max-rounds", str(rounds), model=task.get("model"),
|
||||
*inner_args, model=task.get("model"),
|
||||
effort=task.get("effort"), approval=task.get("approval"))
|
||||
repo = task.get("repo")
|
||||
if repo:
|
||||
|
||||
+55
-33
@@ -416,7 +416,9 @@ def migrate_tasks_table(conn):
|
||||
|
||||
新列:result_text、error_text、actual_model、started_at、
|
||||
finished_at、child_pid、executor、pr_url、pr_number,以及
|
||||
goal、verify_command、goal_max_rounds(goal 模式用)。
|
||||
goal、verify_command、goal_max_rounds(goal 模式用)、
|
||||
goal_mode(新 goal 语义:1 时 prompt 即目标,无验收命令、
|
||||
不限轮次;老行 NULL 按 0 计)。
|
||||
PRAGMA table_info 查现有列,缺哪个补哪个;已迁移的库再跑
|
||||
一次是 no-op。worker 写执行结果、server 读任务时都依赖
|
||||
这些列存在。
|
||||
@@ -436,6 +438,7 @@ def migrate_tasks_table(conn):
|
||||
"goal": "TEXT",
|
||||
"verify_command": "TEXT",
|
||||
"goal_max_rounds": "INTEGER",
|
||||
"goal_mode": "INTEGER",
|
||||
# HA 阶段 1:认领任务的 worker 所在主机名;/pr 据此判断
|
||||
# worktree 在本机(同步跑)还是别机(排 pr 任务给该主机)。
|
||||
"worktree_host": "TEXT",
|
||||
@@ -620,6 +623,7 @@ def _task_to_dict(row):
|
||||
"goal": _row_get(row, "goal"),
|
||||
"verify_command": _row_get(row, "verify_command"),
|
||||
"goal_max_rounds": _row_get(row, "goal_max_rounds"),
|
||||
"goal_mode": _row_get(row, "goal_mode") or 0,
|
||||
"worktree_host": _row_get(row, "worktree_host"),
|
||||
"model": _row_get(row, "model"),
|
||||
"effort": _row_get(row, "effort"),
|
||||
@@ -1540,35 +1544,53 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
self._send_json(400, {
|
||||
"error": "plan 模式与 approval=full 冲突(只读 vs 完全访问)"})
|
||||
return
|
||||
# goal 字段:verify_command 非空即 goal 模式(worker 走
|
||||
# host.py goal 循环);goal 文本缺省用 prompt;轮次缺省 3
|
||||
# (与 host.py 默认一致),非法值 400 且不建任务。
|
||||
goal = body.get("goal")
|
||||
if goal is not None and not isinstance(goal, str):
|
||||
self._send_json(400, {"error": "goal must be a string"})
|
||||
return
|
||||
verify_command = body.get("verify_command")
|
||||
if verify_command is not None and not isinstance(
|
||||
verify_command, str):
|
||||
self._send_json(400, {
|
||||
"error": "verify_command must be a string"})
|
||||
return
|
||||
# 计划模式与 goal 模式也冲突:goal 循环就是要改文件
|
||||
# 直到验测通过,只读沙箱下它永远做不成。
|
||||
if plan and verify_command:
|
||||
self._send_json(400, {
|
||||
"error": "plan 模式与 goal 模式冲突(只读 vs 改文件)"})
|
||||
return
|
||||
try:
|
||||
goal_max_rounds = int(body.get("goal_max_rounds", 3))
|
||||
except (TypeError, ValueError):
|
||||
self._send_json(400, {
|
||||
"error": "goal_max_rounds must be an integer"})
|
||||
return
|
||||
if goal_max_rounds < 1:
|
||||
self._send_json(400, {
|
||||
"error": "goal_max_rounds must be >= 1"})
|
||||
return
|
||||
# goal 模式分新旧两种语义(2026-10-27 起 console 只发新语义):
|
||||
# 新语义 goal_mode=1:prompt 本身就是目标,不需要专用 goal
|
||||
# 文本、不需要验收命令、不设轮次上限(goal_max_rounds 存 0,
|
||||
# worker 走 host.py goal 循环的模型自决分支);传了
|
||||
# verify_command 是语义冲突,400。旧语义(verify_command
|
||||
# 非空)保留:goal 文本缺省用 prompt,轮次缺省 3,非法值
|
||||
# 400 且不建任务;老客户端与已建的旧 goal 任务继续可用。
|
||||
goal_mode = 1 if body.get("goal_mode") in (True, 1) else 0
|
||||
if goal_mode:
|
||||
if plan:
|
||||
self._send_json(400, {
|
||||
"error": "plan 模式与 goal 模式冲突(只读 vs 改文件)"})
|
||||
return
|
||||
if body.get("verify_command"):
|
||||
self._send_json(400, {
|
||||
"error": "goal 模式不需要验收命令(达标由模型宣告)"})
|
||||
return
|
||||
goal = prompt
|
||||
verify_command = None
|
||||
goal_max_rounds = 0
|
||||
else:
|
||||
goal = body.get("goal")
|
||||
if goal is not None and not isinstance(goal, str):
|
||||
self._send_json(400, {"error": "goal must be a string"})
|
||||
return
|
||||
verify_command = body.get("verify_command")
|
||||
if verify_command is not None and not isinstance(
|
||||
verify_command, str):
|
||||
self._send_json(400, {
|
||||
"error": "verify_command must be a string"})
|
||||
return
|
||||
# 计划模式与 goal 模式也冲突:goal 循环就是要改文件
|
||||
# 直到验测通过,只读沙箱下它永远做不成。
|
||||
if plan and verify_command:
|
||||
self._send_json(400, {
|
||||
"error": "plan 模式与 goal 模式冲突(只读 vs 改文件)"})
|
||||
return
|
||||
try:
|
||||
goal_max_rounds = int(body.get("goal_max_rounds", 3))
|
||||
except (TypeError, ValueError):
|
||||
self._send_json(400, {
|
||||
"error": "goal_max_rounds must be an integer"})
|
||||
return
|
||||
if goal_max_rounds < 1:
|
||||
self._send_json(400, {
|
||||
"error": "goal_max_rounds must be >= 1"})
|
||||
return
|
||||
idempotency_key = body.get("idempotency_key")
|
||||
if idempotency_key is not None and not isinstance(idempotency_key, str):
|
||||
self._send_json(400, {"error": "idempotency_key must be a string"})
|
||||
@@ -1596,13 +1618,13 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
" idempotency_key, depends_on, priority, created_by,"
|
||||
" created_at, updated_at, last_heartbeat, executor,"
|
||||
" goal, verify_command, goal_max_rounds,"
|
||||
" model, effort, approval, plan)"
|
||||
" model, effort, approval, plan, goal_mode)"
|
||||
" VALUES(?, ?, ?, 'queued', ?, ?, ?, ?, ?, ?, NULL, ?,"
|
||||
" ?, ?, ?, ?, ?, ?, ?)",
|
||||
" ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(task_id, project_id, prompt, idempotency_key,
|
||||
json.dumps(depends_on), priority, user_id, now, now,
|
||||
executor, goal, verify_command, goal_max_rounds,
|
||||
model, effort, approval, plan),
|
||||
model, effort, approval, plan, goal_mode),
|
||||
)
|
||||
conn.commit()
|
||||
except (sqlite3.IntegrityError, _PgIntegrityError):
|
||||
|
||||
@@ -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()
|
||||
@@ -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):
|
||||
|
||||
+16
-8
@@ -234,6 +234,9 @@ def _task_from_row(row):
|
||||
"goal": _col("goal"),
|
||||
"verify_command": _col("verify_command"),
|
||||
"goal_max_rounds": _col("goal_max_rounds"),
|
||||
# 新 goal 语义:goal_mode=1 时 prompt 即目标,无验收命令、
|
||||
# 不限轮次;老行缺列时 None = 按 verify_command 判定。
|
||||
"goal_mode": _col("goal_mode"),
|
||||
# 任务级选项:老行缺列时 None = 跟随配置(见 server 侧)。
|
||||
"model": _col("model"),
|
||||
"effort": _col("effort"),
|
||||
@@ -467,7 +470,7 @@ class Worker:
|
||||
"SELECT id, project_id, prompt, status, idempotency_key,"
|
||||
" depends_on, priority, created_by, created_at, updated_at,"
|
||||
" last_heartbeat, executor, goal, verify_command,"
|
||||
" goal_max_rounds FROM tasks"
|
||||
" goal_max_rounds, goal_mode FROM tasks"
|
||||
" WHERE status IN ('queued', 'completed', 'merged')"
|
||||
" ORDER BY created_at, id").fetchall()
|
||||
finally:
|
||||
@@ -562,6 +565,7 @@ class Worker:
|
||||
"pr_number": "INTEGER", "goal": "TEXT",
|
||||
"verify_command": "TEXT",
|
||||
"goal_max_rounds": "INTEGER",
|
||||
"goal_mode": "INTEGER",
|
||||
"worktree_host": "TEXT",
|
||||
"model": "TEXT",
|
||||
"effort": "TEXT",
|
||||
@@ -1326,10 +1330,12 @@ class Worker:
|
||||
task_id = task["id"]
|
||||
result = None
|
||||
error = None
|
||||
# goal 模式:任务带 verify_command 时走 host.py goal 循环,
|
||||
# 不走普通 dispatch。goal 循环内部已多轮重试,worker 级
|
||||
# goal 模式:goal_mode=1(新语义,prompt 即目标)或带
|
||||
# verify_command(旧语义)时走 host.py goal 循环,不走
|
||||
# 普通 dispatch。goal 循环内部已多轮重试,worker 级
|
||||
# 的已存在续跑(run)对它没有意义(goal 没有 run 语义)。
|
||||
goal_mode = bool(task.get("verify_command"))
|
||||
goal_mode = bool(task.get("goal_mode")
|
||||
or task.get("verify_command"))
|
||||
# Docker 同步路径:dispatch 阻塞在 docker run 里时,取消
|
||||
# 线程靠容器名杀容器(见 cancel())。容器名按任务派生,
|
||||
# 与执行器本身无关,所以这里只记名不碰执行器状态。
|
||||
@@ -1632,10 +1638,11 @@ class Worker:
|
||||
if self._was_cancelled(task_id) or \
|
||||
result.get("status") == "cancelled":
|
||||
break
|
||||
if task.get("verify_command"):
|
||||
if task.get("verify_command") or task.get("goal_mode"):
|
||||
# goal 模式只跑一次:循环内部已多轮重试(turn→验测→
|
||||
# 喂回),worker 级重试/fallback 不再叠加——同任务重跑
|
||||
# 会撞 host 任务名已存在,第二次尝试必失败且污染错误文本。
|
||||
# 喂回,或新形态的模型自决多轮),worker 级重试/fallback
|
||||
# 不再叠加——同任务重跑会撞 host 任务名已存在,第二
|
||||
# 次尝试必失败且污染错误文本。
|
||||
break
|
||||
if result.get("status") == "completed":
|
||||
break
|
||||
@@ -1698,7 +1705,8 @@ class Worker:
|
||||
# 剩余 pending 留给重试入口(重试走已存在续跑,跑完继续
|
||||
# 排空剩余消息);取消时该条回 pending,任务走中断收尾。
|
||||
if (result is not None and result.get("status") == "completed"
|
||||
and not task.get("verify_command")):
|
||||
and not task.get("verify_command")
|
||||
and not task.get("goal_mode")):
|
||||
while True:
|
||||
fu = self._claim_next_followup(task_id)
|
||||
if fu is None:
|
||||
|
||||
@@ -1672,18 +1672,40 @@ def run_task_turn_with_retry(server, state, task, prompt, model, effort,
|
||||
return result
|
||||
|
||||
|
||||
GOAL_DONE_MARKER = "目标已达成"
|
||||
|
||||
|
||||
def _llm_declared_done(text):
|
||||
"""模型是否宣告目标达成:回合文本里单独一行恰好是标记行。
|
||||
|
||||
只有完整跑完的回合才查标记(调用方保证):超时/中断回合的
|
||||
部分文本即使含标记也不算,否则没做完也会提前结束。"""
|
||||
if not text:
|
||||
return False
|
||||
return any(line.strip() == GOAL_DONE_MARKER
|
||||
for line in text.splitlines())
|
||||
|
||||
|
||||
def run_goal_loop(server, state, state_path, task, goal_text, verify,
|
||||
max_rounds, model, effort, sandbox, cfg=None,
|
||||
timeout=None, verify_timeout=600, start_round=1,
|
||||
last_verify="", last_turn_status=None, last_turn_text=""):
|
||||
"""目标循环(长任务版,对齐 Codex App 的可续跑语义)。
|
||||
|
||||
每轮:turn → 验测 → 把真实结果喂回下一轮;每轮结束立即落盘
|
||||
(检查点),进程被杀 / VM 回收后可用 goal-resume 从
|
||||
goal_rounds_done 的下一轮续跑,而不是从头重来。
|
||||
两种验收形态(2026-10-27 起,console goal 模式走新形态):
|
||||
旧形态 verify 非空:每轮 turn → 跑验收命令 → v_rc==0 达标,
|
||||
max_rounds 穷尽则 unmet。
|
||||
新形态 verify 为空:不跑验收命令,达标与否由模型自己在回合
|
||||
末尾单独一行输出“目标已达成”宣告;max_rounds=0 表示不限轮次,
|
||||
一直跑到模型宣告、预算耗尽或用户取消为止(与 turn_timeout=0
|
||||
不限时同约定)。轮次提示里会把标记约定写明,模型才知道怎么
|
||||
宣告;同一 thread 保留历史,续轮提示可以很短。
|
||||
每轮结束立即落盘(检查点),进程被杀 / VM 回收后可用
|
||||
goal-resume 从 goal_rounds_done 的下一轮续跑,而不是从头重来。
|
||||
与云端 worker 的解析契约(cloud/sandbox.py):
|
||||
结论行必须是 "goal met after rounds" / "goal unmet after rounds",
|
||||
每轮行必须是 "round N: turn=... verify_rc=... verify_out=...",
|
||||
(无验收时 verify_rc 记 -,verify_out 为空),
|
||||
用量行必须是 "usage input=... cached=... ..." 格式,改动时
|
||||
不能动这三行;两种结局都 exit 0(main 不抛异常即 0)。
|
||||
预算 stop 是第三种结局:打印 "goal stopped after rounds: ..."
|
||||
@@ -1704,7 +1726,13 @@ def run_goal_loop(server, state, state_path, task, goal_text, verify,
|
||||
ok = False
|
||||
budget_stopped = False
|
||||
goal_usage = None
|
||||
for round_no in range(int(start_round), int(max_rounds) + 1):
|
||||
# max_rounds=0 是不限轮次:while 循环只在模型宣告、预算耗尽
|
||||
# 或取消时结束;非 0 时按旧语义穷尽轮次即 unmet。
|
||||
round_no = int(start_round) - 1
|
||||
while True:
|
||||
round_no += 1
|
||||
if int(max_rounds) != 0 and round_no > int(max_rounds):
|
||||
break
|
||||
if cfg is not None and budget_exceeded(state, cfg):
|
||||
budget_stopped = True
|
||||
print(f"goal stopped: 预算已用完(max_daily_turns),"
|
||||
@@ -1712,15 +1740,34 @@ def run_goal_loop(server, state, state_path, task, goal_text, verify,
|
||||
break
|
||||
if round_no == 1 and not last_verify and not last_turn_status:
|
||||
prompt = goal_text
|
||||
if verify is None:
|
||||
# 新形态首轮必须把标记约定写进提示,否则模型不知
|
||||
# 道怎么宣告完成,循环永远等不到结束信号。
|
||||
prompt = (f"{goal_text}\n\n"
|
||||
f"完成方式:这是目标循环,你可以多轮工作;"
|
||||
f"当你确认目标已完全达成时,在回复末尾单独一行输出:"
|
||||
f"{GOAL_DONE_MARKER}。未达成时不要输出这一行。")
|
||||
elif last_turn_status in ("timeout", "interrupted"):
|
||||
# 上轮回合根本没跑完:说清是回合中断,不是验收失败,
|
||||
# 否则 agent 会对着验收输出修一个不存在的问题。
|
||||
prompt = (f"上一轮回合未完成(状态:{last_turn_status}),"
|
||||
f"请继续完成目标,不要重复已完成的部分。\n"
|
||||
f"目标:{goal_text}\n")
|
||||
if verify is not None:
|
||||
prompt += (f"验收命令:{verify}\n"
|
||||
f"上一轮验收输出:\n{last_verify}\n")
|
||||
else:
|
||||
prompt += (f"确认目标已完全达成时,在回复末尾单独一行输出:"
|
||||
f"{GOAL_DONE_MARKER}。\n")
|
||||
prompt += f"上一轮部分文本:\n{last_turn_text[:1000]}"
|
||||
elif verify is None:
|
||||
# 新形态续轮:同一 thread 已有历史,只需说明“还没
|
||||
# 宣告达成、继续做”,并重申标记约定。
|
||||
prompt = (f"上一轮未宣告目标达成,请继续完成目标,"
|
||||
f"不要重复已完成的部分。\n"
|
||||
f"目标:{goal_text}\n"
|
||||
f"验收命令:{verify}\n"
|
||||
f"上一轮验收输出:\n{last_verify}\n"
|
||||
f"上一轮部分文本:\n{last_turn_text[:1000]}")
|
||||
f"确认目标已完全达成时,在回复末尾单独一行输出:"
|
||||
f"{GOAL_DONE_MARKER}。")
|
||||
else:
|
||||
# 生产教训:只说“验收未通过” agent 不知道改什么,
|
||||
# 2026-10-25 实测 prod-retry 3 轮都原地不动。
|
||||
@@ -1737,28 +1784,33 @@ def run_goal_loop(server, state, state_path, task, goal_text, verify,
|
||||
goal_usage = _add_usage(goal_usage, result.get("usage"))
|
||||
last_turn_status = result["status"]
|
||||
last_turn_text = result.get("text", "") or ""
|
||||
try:
|
||||
v_rc, v_out = None, None
|
||||
if verify_timeout and verify_timeout > 0:
|
||||
v = subprocess.run(verify, shell=True, cwd=task["cwd"],
|
||||
capture_output=True, text=True,
|
||||
timeout=verify_timeout)
|
||||
else:
|
||||
v = subprocess.run(verify, shell=True, cwd=task["cwd"],
|
||||
capture_output=True, text=True)
|
||||
v_rc = v.returncode
|
||||
v_out = (v.stdout + v.stderr).strip()[:1000] or \
|
||||
f"exit code {v.returncode}"
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
# 验测本身也可能很长(测试套件):超时是验测失败,
|
||||
# 不是进程挂死,喂回下一轮让 agent 缩小验测范围。
|
||||
v_rc = 124
|
||||
out = (exc.stdout or "") if isinstance(exc.stdout, str) \
|
||||
else ""
|
||||
err = (exc.stderr or "") if isinstance(exc.stderr, str) \
|
||||
else ""
|
||||
v_out = (f"verify timeout after {verify_timeout}s: "
|
||||
f"{(out + err).strip()[:900]}")
|
||||
if verify is None:
|
||||
# 新形态没有验收命令可跑,v_rc 记 -(轮次行格式不变,
|
||||
# 云端解析只读 verify_out 做 unmet 详情,不受影响)。
|
||||
v_rc, v_out = None, ""
|
||||
else:
|
||||
try:
|
||||
v_rc, v_out = None, None
|
||||
if verify_timeout and verify_timeout > 0:
|
||||
v = subprocess.run(verify, shell=True, cwd=task["cwd"],
|
||||
capture_output=True, text=True,
|
||||
timeout=verify_timeout)
|
||||
else:
|
||||
v = subprocess.run(verify, shell=True, cwd=task["cwd"],
|
||||
capture_output=True, text=True)
|
||||
v_rc = v.returncode
|
||||
v_out = (v.stdout + v.stderr).strip()[:1000] or \
|
||||
f"exit code {v.returncode}"
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
# 验测本身也可能很长(测试套件):超时是验测失败,
|
||||
# 不是进程挂死,喂回下一轮让 agent 缩小验测范围。
|
||||
v_rc = 124
|
||||
out = (exc.stdout or "") if isinstance(exc.stdout, str) \
|
||||
else ""
|
||||
err = (exc.stderr or "") if isinstance(exc.stderr, str) \
|
||||
else ""
|
||||
v_out = (f"verify timeout after {verify_timeout}s: "
|
||||
f"{(out + err).strip()[:900]}")
|
||||
last_verify = v_out
|
||||
task["goal_rounds_done"] = round_no
|
||||
task["goal_last_verify"] = last_verify
|
||||
@@ -1768,8 +1820,15 @@ def run_goal_loop(server, state, state_path, task, goal_text, verify,
|
||||
task["status"] = "goal-running"
|
||||
save_state(state_path, state)
|
||||
print(f"round {round_no}: turn={result['status']} "
|
||||
f"verify_rc={v_rc} verify_out={last_verify[:300]}")
|
||||
if v_rc == 0:
|
||||
f"verify_rc={v_rc if v_rc is not None else '-'} "
|
||||
f"verify_out={last_verify[:300]}")
|
||||
if verify is None:
|
||||
# 模型自决:只有完整跑完的回合里的标记行才算数。
|
||||
if (result["status"] == "completed"
|
||||
and _llm_declared_done(last_turn_text)):
|
||||
ok = True
|
||||
break
|
||||
elif v_rc == 0:
|
||||
ok = True
|
||||
break
|
||||
if budget_stopped:
|
||||
@@ -1888,8 +1947,13 @@ def main():
|
||||
p.add_argument("--name", required=True); p.add_argument("--report-to-parent", action="store_true")
|
||||
p = sub.add_parser("goal")
|
||||
p.add_argument("--name", required=True); p.add_argument("--task", required=True)
|
||||
p.add_argument("--repo", default=None); p.add_argument("--verify", required=True)
|
||||
p.add_argument("--max-rounds", type=int, default=3)
|
||||
p.add_argument("--repo", default=None)
|
||||
p.add_argument("--verify", default=None,
|
||||
help="验收命令;不传时走模型自决:模型在回合末尾"
|
||||
"单独一行输出“目标已达成”即达标")
|
||||
p.add_argument("--max-rounds", type=int, default=3,
|
||||
help="轮次上限;0 表示不限轮次(模型宣告完成才结束),"
|
||||
"默认 3")
|
||||
p.add_argument("--verify-timeout", type=int, default=600,
|
||||
help="验测命令上限(秒);0 表示不限时,默认 600")
|
||||
p = sub.add_parser("goal-resume")
|
||||
@@ -2489,10 +2553,17 @@ def main():
|
||||
f"{t.get('status')},已终局或已达标;"
|
||||
f"goal-unmet / goal-met 后请新建任务,不要同任务续跑")
|
||||
return
|
||||
if not t.get("goal") or not t.get("verify_command"):
|
||||
print(f"goal-resume 拒绝:{args.task} 没有 goal/verify_command 记录")
|
||||
if not t.get("goal"):
|
||||
print(f"goal-resume 拒绝:{args.task} 没有 goal 记录")
|
||||
return
|
||||
max_rounds = args.max_rounds or t.get("goal_max_rounds") or 3
|
||||
# 新形态 verify_command 为空、goal_max_rounds 为 0(不限轮次):
|
||||
# 0 是合法值不能用 or 兜底,否则续跑会被悄悄改成 3 轮上限。
|
||||
if args.max_rounds is not None:
|
||||
max_rounds = args.max_rounds
|
||||
elif t.get("goal_max_rounds") is not None:
|
||||
max_rounds = t["goal_max_rounds"]
|
||||
else:
|
||||
max_rounds = 3
|
||||
srv.resume_thread(t["thread_id"])
|
||||
last_turn = (t.get("turns") or [{}])[-1]
|
||||
last_result = last_turn.get("result") or {}
|
||||
|
||||
@@ -889,6 +889,97 @@ class GoalLoopLongTaskTest(unittest.TestCase):
|
||||
self.assertEqual(task["status"], "goal-unmet")
|
||||
self.assertIn("verify timeout", task["goal_last_verify"])
|
||||
|
||||
def test_goal_llm_decided_met_on_marker_unlimited(self):
|
||||
# 新形态:verify=None、max_rounds=0(不限轮次);首轮提示
|
||||
# 含标记约定;第 2 轮模型输出标记行即达标。
|
||||
class FakeServer:
|
||||
def __init__(self):
|
||||
self.prompts = []
|
||||
|
||||
def run_turn(self, thread_id, prompt, model=None, effort=None,
|
||||
sandbox=None, timeout=None):
|
||||
self.prompts.append(prompt)
|
||||
if len(self.prompts) == 1:
|
||||
return {"status": "completed", "text": "working",
|
||||
"turn_id": "u1"}
|
||||
return {"status": "completed",
|
||||
"text": "done\n目标已达成",
|
||||
"turn_id": "u2"}
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
state_path = os.path.join(tmp, "host-state.json")
|
||||
state = {"tasks": {}, "automations": []}
|
||||
task = self._task(tmp)
|
||||
state["tasks"]["g"] = task
|
||||
srv = FakeServer()
|
||||
result = host.run_goal_loop(srv, state, state_path,
|
||||
task, "make it", None, 0, "m",
|
||||
"low", None, cfg={}, timeout=600)
|
||||
self.assertTrue(result["ok"])
|
||||
self.assertEqual(task["status"], "goal-met")
|
||||
self.assertEqual(task["goal_rounds_done"], 2)
|
||||
self.assertEqual(task["goal_max_rounds"], 0)
|
||||
self.assertIn("目标已达成", srv.prompts[0])
|
||||
self.assertIn("目标已达成", srv.prompts[1])
|
||||
|
||||
def test_goal_llm_decided_marker_ignored_on_timeout_turn(self):
|
||||
# 超时回合的部分文本即使含标记行也不算达标,必须继续跑。
|
||||
class FakeServer:
|
||||
def __init__(self):
|
||||
self.calls = 0
|
||||
|
||||
def run_turn(self, thread_id, prompt, model=None, effort=None,
|
||||
sandbox=None, timeout=None):
|
||||
self.calls += 1
|
||||
if self.calls == 1:
|
||||
return {"status": "timeout",
|
||||
"text": "partial\n目标已达成",
|
||||
"turn_id": "u1"}
|
||||
return {"status": "completed",
|
||||
"text": "done\n目标已达成",
|
||||
"turn_id": "u2"}
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
state_path = os.path.join(tmp, "host-state.json")
|
||||
state = {"tasks": {}, "automations": []}
|
||||
task = self._task(tmp)
|
||||
state["tasks"]["g"] = task
|
||||
result = host.run_goal_loop(FakeServer(), state, state_path,
|
||||
task, "make it", None, 0, "m",
|
||||
"low", None, cfg={}, timeout=600)
|
||||
self.assertTrue(result["ok"])
|
||||
self.assertEqual(task["status"], "goal-met")
|
||||
self.assertEqual(task["goal_rounds_done"], 2)
|
||||
|
||||
def test_goal_llm_decided_unlimited_past_three_rounds(self):
|
||||
# 0 = 不限轮次:第 4 轮才宣告也照常结束,不会被旧的
|
||||
# 默认 3 轮上限截断。
|
||||
class FakeServer:
|
||||
def __init__(self):
|
||||
self.calls = 0
|
||||
|
||||
def run_turn(self, thread_id, prompt, model=None, effort=None,
|
||||
sandbox=None, timeout=None):
|
||||
self.calls += 1
|
||||
if self.calls < 4:
|
||||
return {"status": "completed", "text": "working",
|
||||
"turn_id": "u%d" % self.calls}
|
||||
return {"status": "completed",
|
||||
"text": "done\n目标已达成",
|
||||
"turn_id": "u4"}
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
state_path = os.path.join(tmp, "host-state.json")
|
||||
state = {"tasks": {}, "automations": []}
|
||||
task = self._task(tmp)
|
||||
state["tasks"]["g"] = task
|
||||
result = host.run_goal_loop(FakeServer(), state, state_path,
|
||||
task, "make it", None, 0, "m",
|
||||
"low", None, cfg={}, timeout=600)
|
||||
self.assertTrue(result["ok"])
|
||||
self.assertEqual(task["status"], "goal-met")
|
||||
self.assertEqual(task["goal_rounds_done"], 4)
|
||||
|
||||
|
||||
|
||||
class ProgressInterruptTest(unittest.TestCase):
|
||||
|
||||
Reference in new issue
Block a user