Merge pull request #121 from openqj/feat-child-model

feat: 子任务默认模型/强度(child_model/child_effort)
This commit is contained in:
openqj authored and GitHub committed 2026-10-29 05:35:51 +08:00
commit ab0aaf4a5e
7 files changed
+588 -25

No files matched your search

+35
View File
@@ -236,6 +236,21 @@
<option value="full">完全访问</option>
</select>
</div>
<div class="bar">
<select id="childModelSelect">
<option value="">子任务模型:跟随本任务模型</option>
</select>
<input id="childModelCustom" type="text" placeholder="子任务模型手动输入(中转列表里没有时填,如 gpt-6-luna;留空=跟随本任务模型)">
<select id="childEffortSelect">
<option value="">子任务强度:跟随本任务强度</option>
<option value="low">low</option>
<option value="medium">medium</option>
<option value="high">high</option>
<option value="xhigh">xhigh</option>
<option value="max">max</option>
</select>
</div>
<div class="muted">子任务模型/强度是派生默认:本任务用 marker task-create 或协调员分工派生的子任务,未显式写模型/强度时用它;块/分工里显式写了则覆盖它。也可以不在这里填、直接在 prompt 里写“子任务用 gpt-6-luna max”,建任务时服务端会解析成同一对字段(任务详情里可核对)。</div>
<div id="goalHint" style="display:none" class="muted">goal 模式:输入框内容即目标,不需要专用 prompt 与验收命令;不限轮次,模型确认达成后在回复末尾输出“目标已达成”即结束,未达成会一直运行,可用取消停止。</div>
<div class="bar">
<span class="muted">附件(放进任务工作区,图片与普通文件不区分):</span>
@@ -315,6 +330,15 @@
<option value="auto">帮我批准</option>
<option value="full">完全访问</option>
</select>
<input id="continueChildModel" type="text" placeholder="子任务模型:沿用任务原值(填了则改派生默认)">
<select id="continueChildEffort">
<option value="">子任务强度:沿用任务原值</option>
<option value="low">low</option>
<option value="medium">medium</option>
<option value="high">high</option>
<option value="xhigh">xhigh</option>
<option value="max">max</option>
</select>
<label><input type="checkbox" id="continuePlan"> 计划模式(只读)</label>
<label><input type="checkbox" id="continueGoal"> goal 模式(不限轮次,模型宣告完成)</label>
</div>
@@ -481,6 +505,8 @@ async function loadRelayModels() {
sel.innerHTML = "<option value=''>模型:默认(跟随配置)</option>" + opts;
var csel = $("#continueModel");
if (csel) csel.innerHTML = "<option value=''>模型:沿用任务原值</option>" + opts;
var chsel = $("#childModelSelect");
if (chsel) chsel.innerHTML = "<option value=''>子任务模型:跟随本任务模型</option>" + opts;
$("#modelCustom").style.display = "none";
} catch (e) {
$("#modelCustom").style.display = "inline";
@@ -995,6 +1021,7 @@ async function showDetail(taskId) {
esc(STATUS_EXPLAIN[t.status] || "未知状态") + ") | " +
"executor " + esc(t.executor || "") + " | 主机 " + esc(t.worktree_host || "") +
" | 模型 " + esc(t.model || "默认") + " | 强度 " + esc(t.effort || "默认") +
((t.child_model || t.child_effort) ? " | 子任务默认 " + esc(t.child_model || "跟随本任务") + " / " + esc(t.child_effort || "跟随本任务") : "") +
" | 权限 " + esc(t.approval || "默认") +
(t.plan ? " | <b>计划模式(只读)</b>" : "") +
(t.forked_from ? " | 分支自 " + esc(shortId(t.forked_from)) : "") +
@@ -1432,6 +1459,10 @@ $("#createTask").onclick = async function () {
if (model) body.model = model;
var eff = $("#effortSelect").value;
if (eff) body.effort = eff;
var cmodel = $("#childModelCustom").value.trim() || $("#childModelSelect").value;
if (cmodel) body.child_model = cmodel;
var ceff = $("#childEffortSelect").value;
if (ceff) body.child_effort = ceff;
var ap = $("#approvalSelect").value;
if (ap) {
if (ap === "full" && !confirm("完全访问 = 自动批准 + 不受工作区限制,agent 可读写工作区外路径。确认提交吗?")) return;
@@ -1507,6 +1538,10 @@ $("#btnContinueConfirm").onclick = async function () {
if (model) body.model = model;
var eff = $("#continueEffort").value;
if (eff) body.effort = eff;
var cmodel = $("#continueChildModel").value.trim();
if (cmodel) body.child_model = cmodel;
var ceff = $("#continueChildEffort").value;
if (ceff) body.child_effort = ceff;
var ap = $("#continueApproval").value;
if (ap) {
if (ap === "full" && !confirm("完全访问 = 自动批准 + 不受工作区限制,agent 可读写工作区外路径。确认继续吗?")) return;
+5 -1
View File
@@ -128,7 +128,11 @@ def ensure_columns(conn):
for name, coltype in {
"coordinator_parts": "TEXT",
"parent_task_id": "TEXT",
"coordinator_part": "TEXT"}.items():
"coordinator_part": "TEXT",
# 派生默认列:协调员子任务 INSERT 也写这两列,
# 老库缺列时这里补上,否则 INSERT 会炸。
"child_model": "TEXT",
"child_effort": "TEXT"}.items():
if name not in cols:
try:
conn.execute(
+4
View File
@@ -841,6 +841,8 @@ POSTGRES_SCHEMA = (
"goal_mode INTEGER, "
"model TEXT, "
"effort TEXT, "
"child_model TEXT, "
"child_effort TEXT, "
"approval TEXT, "
"plan INTEGER, "
"forked_from TEXT, "
@@ -1033,6 +1035,8 @@ def init_postgres_schema(conn):
("worktree_host", "TEXT"),
("goal_mode", "INTEGER"),
("environment_id", "TEXT"),
("child_model", "TEXT"),
("child_effort", "TEXT"),
("resume_message", "TEXT")):
if _name not in tcols:
conn.execute(
+118 -1
View File
@@ -25,7 +25,12 @@ wait-threads / task-cancel / task-retry。
fork)。新任务 prompt 由标记块给,不继承父任务的历史;
环境基线继承父任务的 environment_id,但 worker 会给它独立
的 tenant/worktree,不动父任务的 cwd。父任务未提交的改动
不会自动带过去。
不会自动带过去。模型/强度继承优先级:块里显式写 >
父任务的 child_model / child_effort(派生默认)> 父任务
自身的 model / effort > worker 默认(NULL 时由认领方补)。
子任务行上同时记下父任务的 child_model / child_effort,
这样隔代派生时派生默认仍然沿用,不会因为中间一层没写
就丢回 worker 默认。
- task-poll / task-read:按任务 id 轮询阅读。task-poll 按
interval 查库直到终态或超时;task-read 是同款别名,
不传 wait_seconds 时立即读一次快照,传了则等这么久再读
@@ -185,6 +190,118 @@ def extract_task_creates(text):
return out
_SPAWN_SUBJECT_ZH = ("新建的任务", "派生任务", "新建任务",
"子任务", "子代理", "新任务", "新会话")
_SPAWN_SUBJECT_EN = ("child tasks", "spawned tasks", "new tasks",
"new sessions", "subtasks", "subtask",
"child task", "new task", "new session")
def _effort_after(text, start):
"""在 model 后的同一子句里找强度:先找“强度/effort + 值”,
再找紧跟 model 的裸强度词(如“gpt-6-luna max”)。子句以
句号/换行/分号截断,跨子句的强度词不算,避免把下一句的
词误当成这一句的强度。"""
clause = str(text)[start:start + 60]
for stop in ("。", "\n", ";", ";"):
cut = clause.find(stop)
if cut >= 0:
clause = clause[:cut]
labelled = re.search(
r"(?:强度|effort)\s*[::=\s]\s*(low|medium|high|xhigh|max)",
clause, re.IGNORECASE)
if labelled:
return labelled.group(1).lower()
trailing = re.search(
r"^\s*[,,、\s]*(low|medium|high|xhigh|max)\b",
clause, re.IGNORECASE)
if trailing:
return trailing.group(1).lower()
return None
def _model_from_match(text, match, token_group):
"""从一次正则命中里取 (model, effort):model 词本身若是
强度词(如“子任务用 max”),只当强度、不当模型;模型词
尾部的句点是句子标点、不是模型名的一部分,要去掉。"""
token = match.group(token_group).rstrip(".")
if not token:
return None, None
if token.lower() in EFFORTS:
return None, token.lower()
return token, _effort_after(text, match.end(token_group))
def parse_spawn_defaults(text):
"""从主线 prompt 里解析派生默认模型/强度,只认显式指令。
认的句式(主语必须是派生对象,裸说“主线用 X”不算,
那是本任务自己的模型、应走建任务的 model 字段):
- 中文:子任务/子代理/派生任务/新建任务/新任务/新会话 +
用/使用/采用/模型:/模型是 + 模型词,可跟强度词或
“强度/effort + 强度词”,如“子代理用 gpt-6-luna max”、
“子任务模型:gpt-6-luna,强度:max”。
- 英文:subtask/child task/spawned task/new task/session +
use/using/with model/model: + 模型词,如
“subtasks use gpt-6-luna with effort max”。
- 中文倒装:用 + 模型词 + … + 子任务/子代理,如
“用 gpt-6-luna max 跑子任务”。
只取第一条命中;模型词只认 ASCII 词(中转模型 id 都是
ASCII),“用默认模型”这类中文词不会被误当成模型。
显式传了 child_model / child_effort 的任务不走本函数,
本函数只在建任务时两者缺省才作兜底,且结果落在任务行
的 child_model / child_effort 上、调用方可直接检查。
返回 dict,只含解析到的键(都没解析到时为空 dict)。"""
if not text:
return {}
text = str(text)
zh_subject = "|".join(_SPAWN_SUBJECT_ZH)
zh_pattern = re.compile(
r"(?:%s)[^。\n;;]{0,50}?(?:用\s*|使用\s*|采用\s*"
r"|模型\s*[::]\s*|模型\s*是\s*)"
r"([A-Za-z][A-Za-z0-9._-]{1,63})" % zh_subject)
match = zh_pattern.search(text)
if match:
model, effort = _model_from_match(text, match, 1)
out = {}
if model:
out["model"] = model
if effort:
out["effort"] = effort
if out:
return out
en_subject = "|".join(_SPAWN_SUBJECT_EN)
en_pattern = re.compile(
r"(?:%s)\b[^.\n]{0,50}?(?:use\s+|using\s+|with\s+model\s+"
r"|model\s*[:=]\s*)"
r"([A-Za-z][A-Za-z0-9._-]{1,63})" % en_subject,
re.IGNORECASE)
match = en_pattern.search(text)
if match:
model, effort = _model_from_match(text, match, 1)
out = {}
if model:
out["model"] = model
if effort:
out["effort"] = effort
if out:
return out
rev_pattern = re.compile(
r"(?:用|使用|采用)\s*([A-Za-z][A-Za-z0-9._-]{1,63})"
r"[^。\n;;]{0,30}?(?:%s)" % zh_subject)
match = rev_pattern.search(text)
if match:
model, effort = _model_from_match(text, match, 1)
out = {}
if model:
out["model"] = model
if effort:
out["effort"] = effort
if out:
return out
return {}
def extract_task_polls(text):
"""提取 task-poll / task-read(含 read-thread 别名)块。
+88 -11
View File
@@ -71,6 +71,7 @@ from cloud.peer_messages import deliver_peer_message as _deliver_peer_message
from cloud.peer_messages import ensure_followups_table as _peer_ensure_followups
from cloud.peer_messages import peer_message_prefix as _peer_prefix
from cloud.peer_messages import row_get as _row_get
from cloud.marker_tools import parse_spawn_defaults as _parse_spawn_defaults
from cloud.automations import cancel_automation as _auto_cancel
from cloud.automations import create_automation as _auto_create
from cloud.automations import get_automation as _auto_get
@@ -1076,6 +1077,13 @@ def migrate_tasks_table(conn):
"model": "TEXT",
"effort": "TEXT",
"approval": "TEXT",
# 派生默认(子任务模型/强度):NULL = 没有单独声明,
# 派生时先取它、再取本任务的 model / effort;显式
# 传 child_model / child_effort,或建任务 prompt 里
# 写明“子任务用 X max”这类显式指令时由服务端解析
# 填入(见 marker_tools.parse_spawn_defaults)。
"child_model": "TEXT",
"child_effort": "TEXT",
# 计划模式(只读)与分支来源:plan=1 时 worker 用
# read-only 沙箱;forked_from 记源任务 id(非分支为 NULL)。
"plan": "INTEGER",
@@ -1520,6 +1528,8 @@ def _task_to_dict(row, attachments=None):
"worktree_host": _row_get(row, "worktree_host"),
"model": _row_get(row, "model"),
"effort": _row_get(row, "effort"),
"child_model": _row_get(row, "child_model"),
"child_effort": _row_get(row, "child_effort"),
"approval": _row_get(row, "approval"),
"plan": _row_get(row, "plan") or 0,
"forked_from": _row_get(row, "forked_from"),
@@ -3837,6 +3847,34 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
self._send_json(400, {
"error": "approval must be ask, auto or full"})
return
# 派生默认:本任务派生的子任务(marker task-create、
# 协调员分工)未显式写模型/强度时用的模型/强度。
# 显式字段优先;缺省时若 prompt 里有“子任务用 X max”
# 这类显式指令,由 parse_spawn_defaults 解析填入——
# 只认带派生主语的显式指令,裸说“主线用 X”不算,
# 那是本任务自己的 model,不应被误当成派生默认。
# 两者都缺省(NULL)时派生走本任务 model / effort,
# 再缺省才走 worker 默认,行为与过去一致。
child_model = body.get("child_model")
if child_model is not None:
if not isinstance(child_model, str) or not child_model.strip():
self._send_json(400, {
"error": "child_model must be a non-empty string"})
return
child_model = child_model.strip()
child_effort = body.get("child_effort")
if child_effort is not None and child_effort not in (
"low", "medium", "high", "xhigh", "max"):
self._send_json(400, {
"error": "child_effort must be low, medium, high,"
" xhigh or max"})
return
if child_model is None or child_effort is None:
parsed = _parse_spawn_defaults(prompt)
if child_model is None:
child_model = parsed.get("model")
if child_effort is None:
child_effort = parsed.get("effort")
# 计划模式:只读沙箱,不改任何文件;与“完全访问”
# (danger-full-access)语义直接冲突,拒绝建任务。
plan = 1 if body.get("plan") in (True, 1) else 0
@@ -4067,16 +4105,17 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
" goal, verify_command, goal_max_rounds,"
" model, effort, approval, plan, goal_mode,"
" environment_id, coordinator_parts,"
" parent_task_id, coordinator_part)"
" parent_task_id, coordinator_part,"
" child_model, child_effort)"
" 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, goal_mode,
environment_id,
json.dumps(coord_parts) if coord_parts else None,
None, None),
None, None, child_model, child_effort),
)
for rel_path, size in bundle_files:
conn.execute(
@@ -6089,11 +6128,12 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
def _fork_task(self, user_id, task_id):
"""会话分支:从已结束任务的 thread 分叉出新任务。
新任务继承源任务的 executor/模型/强度/权限/计划模式,
不继承 goal(分支是普通任务,首轮 prompt 就是这里传的
message)。worker 认领后先调 host.py fork(分叉 thread
历史 + 拷 worktree 文件),再以 message 跑首轮。
以及派生默认 child_model / child_effort(分支会话自己
再派生时仍用同一派生默认),不继承 goal(分支是普通
任务,首轮 prompt 就是这里传的 message)。worker 认领
后先调 host.py fork(分叉 thread 历史 + 拷 worktree
文件),再以 message 跑首轮。
只能从终态任务分支(进行中的 thread 正在变,分叉快照
不可信);message 必填——分支没有首条消息就没有方向。
"""
@@ -6137,15 +6177,16 @@ 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, forked_from)"
" model, effort, approval, plan, forked_from,"
" child_model, child_effort)"
" VALUES(?, ?, ?, 'queued', NULL, '[]', ?, ?, ?, ?,"
" NULL, ?, NULL, NULL, ?, ?, ?, ?, ?, ?)",
" NULL, ?, NULL, NULL, ?, ?, ?, ?, ?, ?, ?, ?)",
(new_id, task["project_id"], message, task["priority"],
user_id, now, now,
task["executor"] or _default_executor(),
task["goal_max_rounds"] or 3, task["model"],
task["effort"], task["approval"], task["plan"] or 0,
task_id),
task_id, task["child_model"], task["child_effort"]),
)
# 分支带走附件清单(worktree 本身已含这些文件,分支
# 不再 seed;清单只作展示,bundle 删了也不影响分支)。
@@ -6412,7 +6453,8 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
必须是终端且非 merged(queued/running 请用后续消息,
merged 的 worktree 已删、请分支,各 409)。选项与新建
任务同款校验:model(空串 = 跟随配置)、effort、approval、
plan、goal_mode;不传的选项沿用任务原值。冲突与新建
plan、goal_mode,以及派生默认 child_model / child_effort
(空串 = 清空);不传的选项沿用任务原值。冲突与新建
一致:plan + approval=full、plan + goal_mode 各 400。
goal_mode=1 时同时把任务转成新 goal 语义(goal=新消息、
verify_command 清空、轮次 0),worker 走 goal-resume
@@ -6483,6 +6525,35 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
self._send_json(400, {
"error": "effort must be low, medium, high, xhigh or max"})
return
# 派生默认同款:child_model 空串 = 清空(跟随本任务
# model / effort),child_effort 空串 = 清空;不传沿用原值。
child_model = None
child_model_present = "child_model" in body
if child_model_present:
if body.get("child_model") is not None \
and not isinstance(body.get("child_model"), str):
self._send_json(400, {
"error": "child_model must be a string"})
return
raw_child_model = body.get("child_model")
child_model = raw_child_model.strip() \
if isinstance(raw_child_model, str) else None
if child_model == "":
child_model = None
child_effort = None
child_effort_present = "child_effort" in body
if child_effort_present:
raw_child_effort = body.get("child_effort")
if raw_child_effort is None or raw_child_effort == "":
child_effort = None
elif raw_child_effort not in (
"low", "medium", "high", "xhigh", "max"):
self._send_json(400, {
"error": "child_effort must be low, medium, high,"
" xhigh or max"})
return
else:
child_effort = raw_child_effort
approval = body.get("approval")
if approval is not None and approval not in (
"ask", "auto", "full"):
@@ -6518,6 +6589,12 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
if effort is not None:
sets.append("effort = ?")
vals.append(effort)
if child_model_present:
sets.append("child_model = ?")
vals.append(child_model)
if child_effort_present:
sets.append("child_effort = ?")
vals.append(child_effort)
if approval is not None:
sets.append("approval = ?")
vals.append(approval)
+292
View File
@@ -0,0 +1,292 @@
"""子任务默认模型/强度(child_model / child_effort)单测。
不跑真实模型、不联网:解析是纯函数;服务端用本地临时端口
+ 临时 SQLite(与 test_server_endpoints 同款骨架);worker
派生用临时 SQLite + _deliver_marker_tools(与
test_marker_tools 同款骨架,不走 run_one 全量流程)。
"""
import http.client
import json
import os
import sqlite3
import sys
import tempfile
import threading
import time
import unittest
from pathlib import Path
try:
from cloud.auth import AuthStore
from cloud.marker_tools import parse_spawn_defaults
from cloud.server import create_server, init_db
from cloud.worker import Worker
except ImportError:
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.auth import AuthStore
from cloud.marker_tools import parse_spawn_defaults
from cloud.server import create_server, init_db
from cloud.worker import Worker
FAKE_ENV = {"XCM_API_KEY": "test-placeholder"}
class ParseSpawnDefaultsTestCase(unittest.TestCase):
def test_user_example(self):
self.assertEqual(
parse_spawn_defaults("子代理用 gpt-6-luna max"),
{"model": "gpt-6-luna", "effort": "max"})
def test_labelled_forms(self):
self.assertEqual(
parse_spawn_defaults("子任务模型:gpt-6-luna,强度:max"),
{"model": "gpt-6-luna", "effort": "max"})
self.assertEqual(
parse_spawn_defaults("子任务用 gpt-6.1-sol,effort high"),
{"model": "gpt-6.1-sol", "effort": "high"})
def test_english_and_reversed(self):
self.assertEqual(
parse_spawn_defaults("subtasks use gpt-6-luna with effort max"),
{"model": "gpt-6-luna", "effort": "max"})
self.assertEqual(
parse_spawn_defaults("用 gpt-6-luna max 跑子任务"),
{"model": "gpt-6-luna", "effort": "max"})
def test_no_false_positives(self):
self.assertEqual(
parse_spawn_defaults("子任务要写报告,不要用默认模型"), {})
self.assertEqual(
parse_spawn_defaults("模型选择很重要,子任务用默认配置"), {})
# 裸说主线用 X 是本任务自己的模型,不算派生默认。
self.assertEqual(parse_spawn_defaults("主线用 gpt-6-luna max"), {})
# 跨子句的强度词不算。
self.assertEqual(
parse_spawn_defaults("子任务用 gpt-6-luna。下一句强度是 high。"),
{"model": "gpt-6-luna"})
class ServerChildModelTestCase(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 body is not None:
payload = json.dumps(body).encode("utf-8")
hdrs["Content-Type"] = "application/json"
if key is not None:
hdrs["X-API-Key"] = key
try:
conn.request(method, path, body=payload, headers=hdrs)
resp = conn.getresponse()
data = resp.read()
finally:
conn.close()
try:
parsed = json.loads(data.decode("utf-8")) if data else None
except ValueError:
parsed = None
return resp.status, parsed
def _create(self, prompt, **extra):
body = {"project_id": self.project["id"], "prompt": prompt}
body.update(extra)
status, task = self._req("POST", "/tasks", body, self.owner_key)
self.assertEqual(status, 201, task)
return task
def _set_terminal(self, task_id):
conn = sqlite3.connect(self.db_path)
try:
conn.execute(
"UPDATE tasks SET status = 'completed' WHERE id = ?",
(task_id,))
conn.commit()
finally:
conn.close()
def test_create_explicit_child_fields(self):
task = self._create(
"做事", model="gpt-5.6-terra", effort="low",
child_model="gpt-6-luna", child_effort="max")
self.assertEqual(task["model"], "gpt-5.6-terra")
self.assertEqual(task["child_model"], "gpt-6-luna")
self.assertEqual(task["child_effort"], "max")
def test_create_prompt_directive_fills_child_fields(self):
task = self._create("子代理用 gpt-6-luna max,做三件事")
self.assertEqual(task["child_model"], "gpt-6-luna")
self.assertEqual(task["child_effort"], "max")
# prompt 指令只填派生默认,不改本任务自己的模型。
self.assertIsNone(task["model"])
def test_create_explicit_wins_over_prompt(self):
task = self._create(
"子代理用 gpt-6-luna max", child_model="gpt-6-sol",
child_effort="high")
self.assertEqual(task["child_model"], "gpt-6-sol")
self.assertEqual(task["child_effort"], "high")
def test_create_invalid_child_effort_400(self):
status, body = self._req("POST", "/tasks", {
"project_id": self.project["id"], "prompt": "做事",
"child_effort": "huge"}, self.owner_key)
self.assertEqual(status, 400)
def test_fork_copies_child_fields(self):
parent = self._create(
"做事", model="gpt-5.6-terra", child_model="gpt-6-luna",
child_effort="max")
self._set_terminal(parent["id"])
status, forked = self._req(
"POST", "/tasks/%s/fork" % parent["id"],
{"message": "继续"}, self.owner_key)
self.assertEqual(status, 201, forked)
self.assertEqual(forked["model"], "gpt-5.6-terra")
self.assertEqual(forked["child_model"], "gpt-6-luna")
self.assertEqual(forked["child_effort"], "max")
def test_continue_updates_child_fields(self):
task = self._create("做事")
self._set_terminal(task["id"])
status, continued = self._req(
"POST", "/tasks/%s/continue" % task["id"],
{"message": "再跑一轮", "child_model": "gpt-6-luna",
"child_effort": "max"}, self.owner_key)
self.assertEqual(status, 200, continued)
self.assertEqual(continued["child_model"], "gpt-6-luna")
self.assertEqual(continued["child_effort"], "max")
class WorkerChildModelTestCase(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.auth = AuthStore(self.db_path)
self.user_id, _key = self.auth.create_user("tester")
self._insert("PM", status="running", model="gpt-5.6-terra",
effort="low", child_model="gpt-6-luna",
child_effort="max")
def tearDown(self):
try:
self.auth.conn.close()
except Exception:
pass
self.tmp.cleanup()
def _insert(self, task_id, status="queued", **cols):
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(?, 'p1', 'do work', ?, NULL, '[]', 0, 'tester',"
" ?, ?, NULL)",
(task_id, status, now, now))
for key, value in cols.items():
conn.execute(
"UPDATE tasks SET %s = ? WHERE id = ?" % key,
(value, task_id))
conn.commit()
finally:
conn.close()
def _child_row(self):
conn = sqlite3.connect(self.db_path)
try:
return conn.execute(
"SELECT model, effort, child_model, child_effort"
" FROM tasks WHERE parent_task_id = 'PM'").fetchone()
finally:
conn.close()
def _deliver(self, creates):
worker = Worker(self.db_path, "/fake/host.py", env=FAKE_ENV)
worker._deliver_marker_tools(worker.get_task("PM"), {
"status": "completed", "task_creates": creates,
"task_polls": [], "wait_threads": [], "task_cancels": [],
"task_retries": []})
def test_marker_inherits_child_defaults_when_block_blank(self):
self._deliver([{"name": "E1", "prompt": "do e1",
"model": None, "effort": None, "executor": None}])
row = self._child_row()
self.assertIsNotNone(row)
self.assertEqual(row[0], "gpt-6-luna")
self.assertEqual(row[1], "max")
# 派生默认本身也传给子任务,隔代派生不丢。
self.assertEqual(row[2], "gpt-6-luna")
self.assertEqual(row[3], "max")
def test_marker_explicit_block_wins(self):
self._deliver([{"name": "E1", "prompt": "do e1",
"model": "gpt-6-sol", "effort": "high",
"executor": None}])
row = self._child_row()
self.assertEqual(row[0], "gpt-6-sol")
self.assertEqual(row[1], "high")
def test_marker_falls_back_to_parent_model_without_child_defaults(self):
conn = sqlite3.connect(self.db_path)
try:
conn.execute(
"UPDATE tasks SET child_model = NULL,"
" child_effort = NULL WHERE id = 'PM'")
conn.commit()
finally:
conn.close()
self._deliver([{"name": "E1", "prompt": "do e1",
"model": None, "effort": None, "executor": None}])
row = self._child_row()
self.assertEqual(row[0], "gpt-5.6-terra")
self.assertEqual(row[1], "low")
def test_coordinator_inherits_child_defaults(self):
worker = Worker(self.db_path, "/fake/host.py", env=FAKE_ENV)
parent = worker.get_task("PM")
children = worker._coord_sync_children(parent, [
{"name": "后端", "prompt": "做后端", "model": None,
"effort": None, "executor": None}])
self.assertEqual(len(children), 1)
conn = sqlite3.connect(self.db_path)
try:
row = conn.execute(
"SELECT model, effort FROM tasks WHERE id = ?",
(children[0]["id"],)).fetchone()
finally:
conn.close()
self.assertEqual(row[0], "gpt-6-luna")
self.assertEqual(row[1], "max")
if __name__ == "__main__":
unittest.main()
+46 -12
View File
@@ -347,6 +347,10 @@ def _task_from_row(row):
# 任务级选项:老行缺列时 None = 跟随配置(见 server 侧)。
"model": _col("model"),
"effort": _col("effort"),
# 派生默认:老行缺列时 None = 没有单独声明,派生时
# 先取它、再取本任务的 model / effort。
"child_model": _col("child_model"),
"child_effort": _col("child_effort"),
"approval": _col("approval"),
"plan": _col("plan"),
"forked_from": _col("forked_from"),
@@ -832,6 +836,8 @@ class Worker:
"worktree_host": "TEXT",
"model": "TEXT",
"effort": "TEXT",
"child_model": "TEXT",
"child_effort": "TEXT",
"approval": "TEXT",
"plan": "INTEGER",
"forked_from": "TEXT",
@@ -2610,33 +2616,54 @@ class Worker:
rejected += 1
continue
child_id = _marker_new_id()
# 派生模型/强度优先级:块里显式写 >
# 父任务的派生默认 child_model /
# child_effort > 父任务自身的
# model / effort > NULL(认领时走
# worker 默认)。子任务行上同时记下
# 父任务的派生默认,隔代派生不丢。
child_model = (create["model"]
or task.get("child_model")
or task.get("model"))
child_effort = (create["effort"]
or task.get("child_effort")
or task.get("effort"))
conn.execute(
"INSERT INTO tasks(id, project_id, prompt,"
" status, idempotency_key, depends_on,"
" priority, created_by, created_at,"
" updated_at, last_heartbeat, executor,"
" model, effort, environment_id,"
" parent_task_id, coordinator_part)"
" parent_task_id, coordinator_part,"
" child_model, child_effort)"
" VALUES(?, ?, ?, 'queued', NULL, '[]',"
" ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, NULL)",
" ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, NULL,"
" ?, ?)",
(child_id, task["project_id"],
create["prompt"],
task.get("priority", 0) or 0,
task.get("created_by"), now, now,
create["executor"]
or task.get("executor"),
create["model"] or task.get("model"),
create["effort"] or task.get("effort"),
child_model,
child_effort,
task.get("environment_id"),
task["id"]),
task["id"],
task.get("child_model"),
task.get("child_effort")),
)
direct += 1
created += 1
label = create["name"] or "未命名"
model_note = ""
if child_model or child_effort:
model_note = ",模型 %s,强度 %s" % (
child_model or "默认",
child_effort or "默认")
reports.append(
"已创建任务 %s(%s),状态 queued,"
"已创建任务 %s(%s),状态 queued%s,"
"可用 task-poll 按该 id 轮询。"
% (child_id, label))
% (child_id, label, model_note))
conn.commit()
finally:
conn.close()
@@ -3314,9 +3341,9 @@ class Worker:
" idempotency_key, depends_on, priority, created_by,"
" created_at, updated_at, last_heartbeat, executor,"
" model, effort, environment_id, parent_task_id,"
" coordinator_part)"
" coordinator_part, child_model, child_effort)"
" VALUES(?, ?, ?, 'queued', NULL, '[]', ?, ?, ?, ?,"
" NULL, ?, ?, ?, ?, ?, ?)",
" NULL, ?, ?, ?, ?, ?, ?, ?, ?)",
(child_id, task["project_id"], part["prompt"],
task.get("priority", 0) or 0, task["created_by"],
now, now,
@@ -3325,9 +3352,16 @@ class Worker:
# 直接写 part 的 None,子任务会落到 worker
# 默认模型/强度,与父任务的显式选择脱节,
# 既改成本也改行为,且排查时看不出继承关系)。
part.get("model") or task.get("model"),
part.get("effort") or task.get("effort"),
task.get("environment_id"), task["id"], name))
# 派生默认优先于父任务自身:分工显式写 >
# 协调员的 child_model / child_effort >
# 协调员自身的 model / effort。
part.get("model") or task.get("child_model")
or task.get("model"),
part.get("effort") or task.get("child_effort")
or task.get("effort"),
task.get("environment_id"), task["id"], name,
task.get("child_model"),
task.get("child_effort")))
self._audit(
task["project_id"], "coordinator_child_created",
"%s -> %s" % (name, child_id))