Merge pull request #121 from openqj/feat-child-model
feat: 子任务默认模型/强度(child_model/child_effort)
This commit is contained in:
7 files changed
+588
-25
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
Reference in new issue
Block a user