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
@@ -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 {}
|
||||
|
||||
Reference in new issue
Block a user