feat: bounded subagent delegation (v5)

Coordinator parts gain three enforced bounds, validated in
validate_parts and carried through the coordinator_parts JSON
(no schema change): timeout_seconds (1..86400, per-attempt
wall clock from coordinator start; the coordinator poll loop
cancels only that child with the same cancel mechanism as
the global timeout, other parts keep waiting, audit
coordinator_child_bound_exceeded), on_failure fail|tolerate
(tolerated failures do not fail the coordinator, are named
in the report head, and their failed children are not
requeued on coordinator re-run while interrupted children
still are), and result_chars (0..2000, default 500, enforced
in build_report; the 4000-char report writeback cap still
applies). evaluate_outcome is the single outcome rule used
by both build_report and the worker error text, so tolerated
failures never leak into the coordinator failure reason.
Unknown part names fail closed.

Deliberately NOT added, after reading the execution layer:
per-part token/cost budget (cost is recorded only on
completion, so a running child's spend is unobservable and
creator quota is monthly, not per part), per-part round cap
(normal tasks have no per-task round cap in the worker),
and write-scope isolation (sandbox is per-project worktree,
a scope string in a prompt would be an instruction, not a
guarantee). Tests: test_delegation_bounds.py (8 tests).
This commit is contained in:
host committed 2026-11-04 18:01:44 +00:00
1 parent 7f60f12639
commit c15c224570
4 files changed
+443 -29

No files matched your search

+130 -12
View File
@@ -26,6 +26,37 @@
(父仍在跑时绝不动子任务)。本模块只提供纯函数
nonterminal_children 供 worker 判断,DB 写回在 worker 侧。
- 有界委派(v5,Foundry“有界 subagent 委派”的对应物):
每个分工除 name/prompt/model/effort/executor 外,还可带
三个显式边界,全部在 validate_parts 校验、经 DB 的
coordinator_parts JSON 原样流转、由 worker 的协调员循环
真正执行,不是只写进 prompt 的愿望:
① timeout_seconds:该分工单次委派的墙钟上限,从协调员
本轮开始计时,超时由协调员循环直接 cancel 该子任务
(与全局超时同款 cancel 机制),其他分工继续等,不因
一个慢分工拖住整批到全局超时;② on_failure:fail(默认,
旧行为,任一非容忍分工未完成则协调员失败)或 tolerate
(该分工失败不拖垮协调员,汇总里点名已容忍,协调员
重跑时其 failed 子任务不再重排、interrupted 的仍重排,
因为后者可能是父任务取消所致、未获公平一轮);③
result_chars:回传给父任务的单项输出上限,默认 500,
汇总报告本身仍受 worker 写回的 4000 字上限约束。
三个边界的执行点都在协调员层:超时在循环里 cancel,
容忍在合流评价里算,截断在汇总报告里切。
- v5 明确不宣称的边界(查代码后的诚实口径,勿以字段
冒充执行):① 每分工 token/费用预算:成本记录只在任务
完成时写,运行中的子任务花费在循环里观测不到,创建人
配额是按月不是按分工,所以不存在可执行的分工费用门,
v5 不加 max_tokens/max_cost 字段;② 每分工轮数上限:
普通任务在 worker 侧没有按任务的轮数上限(只有 goal
任务的 goal_max_rounds 与 worker 进程级的 max_turns),
所以 max_rounds 字段在子任务执行层无法执行,v5 不加;
③ 写范围隔离:沙箱按项目 worktree 隔离、不按分工
量子目录隔离,prompt 里写“只写 docs/”是指令不是保证,
所以 v5 不加 scope 字段冒充隔离。这三项要做需先改
执行层(成本流、轮数帽、沙箱子目录),不在本轨范围。
本模块只放纯逻辑(校验、解析、汇总、建表),不碰 DB 之外的
任何东西,单测不需要 worker/server。
"""
@@ -36,15 +67,25 @@ MAX_PARTS = 20
DEFAULT_TIMEOUT_SECONDS = 3600
DEFAULT_POLL_INTERVAL = 5.0
REPORT_RESULT_CHARS = 500
MAX_RESULT_CHARS = 2000
MAX_TIMEOUT_SECONDS = 86400
ON_FAILURES = ("fail", "tolerate")
EFFORTS = {"low", "medium", "high", "xhigh", "max"}
EXECUTORS = {"local", "docker", "cloud"}
def _is_int(value):
return isinstance(value, int) and not isinstance(value, bool)
def validate_parts(parts):
"""校验 coordinator_parts,返回 (normalized, error)。
normalized 每项:{name, prompt, model, effort, executor},
未指定的三项为 None(子任务跟随协调员任务或 worker 默认)。
normalized 每项:{name, prompt, model, effort, executor,
timeout_seconds, on_failure, result_chars},未指定的模型/
强度/执行器为 None(子任务跟随协调员任务或 worker 默认),
未指定的 timeout_seconds 为 None(只受全局超时约束),
on_failure 默认 fail,result_chars 默认 500。
"""
if not isinstance(parts, list):
return None, "coordinator_parts must be a list"
@@ -80,12 +121,34 @@ def validate_parts(parts):
if executor is not None and executor not in EXECUTORS:
return None, (
"part %s: executor must be local, docker or cloud" % name)
timeout = part.get("timeout_seconds")
if timeout is not None:
if not _is_int(timeout) or not 1 <= timeout <= \
MAX_TIMEOUT_SECONDS:
return None, (
"part %s: timeout_seconds must be an integer in"
" 1..%d" % (name, MAX_TIMEOUT_SECONDS))
on_failure = part.get("on_failure") or "fail"
if on_failure not in ON_FAILURES:
return None, (
"part %s: on_failure must be fail or tolerate" % name)
result_chars = part.get("result_chars")
if result_chars is None:
result_chars = REPORT_RESULT_CHARS
if not _is_int(result_chars) or not 0 <= result_chars <= \
MAX_RESULT_CHARS:
return None, (
"part %s: result_chars must be an integer in"
" 0..%d" % (name, MAX_RESULT_CHARS))
out.append({
"name": name,
"prompt": prompt.strip(),
"model": model.strip() if model else None,
"effort": effort,
"executor": executor,
"timeout_seconds": timeout,
"on_failure": on_failure,
"result_chars": result_chars,
})
return out, None
@@ -163,25 +226,71 @@ def nonterminal_children(children):
return out
def build_report(children):
def parts_by_name(parts):
"""分工列表 → {name: part};parts 为 None 时返回 {}。"""
out = {}
for part in parts or []:
if isinstance(part, dict) and part.get("name"):
out[part["name"]] = part
return out
def evaluate_outcome(children, parts=None):
"""合流评价(纯函数):返回 (overall, failed_names,
tolerated_names)。
completed 计完成;未完成的项中,on_failure 为 tolerate
的分工计容忍、不拖垮整体,其余计失败。未知分工名按
fail 处理(fail-closed,绝不把查不到的分工当容忍)。
空 children 时 overall 为 failed(与旧 build_report 一致,
失败名为空——调用方按“无子任务”理解,不按名理解)。
"""
by_name = parts_by_name(parts)
has_parts = parts is not None
done = 0
failed = []
tolerated = []
for ch in children or []:
name = ch.get("name")
if ch.get("status") == "completed":
done += 1
continue
part = by_name.get(name) if has_parts else None
if has_parts and part is not None and \
part.get("on_failure") == "tolerate":
tolerated.append(name)
else:
failed.append(name)
overall = "completed" if children and not failed else "failed"
return overall, failed, tolerated
def build_report(children, parts=None):
"""合流汇总:children 为 [{name, id, status, result_text,
error_text}],返回 (report_text, overall_status)。
overall:全部 completed 才 completed,否则 failed(取消与超时
路径由调用方直接定 interrupted/failed,不走这里)。单项输出
截断到 REPORT_RESULT_CHARS,汇总报告本身仍受 worker 写回的
4000 字上限约束(超长时尾部项会被截断,头部计数不受影响)。
不传 parts 时为旧行为:全部 completed 才 completed,
单项输出截断到 REPORT_RESULT_CHARS。传 parts 时按
evaluate_outcome 算总体(容忍分工的失败不拖垮整体,
头部点名已容忍项),单项截断按该分工的 result_chars。
取消与超时路径由调用方直接定 interrupted/failed,不走
这里。汇总报告本身仍受 worker 写回的 4000 字上限约束
(超长时尾部项会被截断,头部计数不受影响)。
"""
by_name = parts_by_name(parts)
lines = []
done = 0
for ch in children:
st = ch.get("status") or "?"
if st == "completed":
done += 1
part = by_name.get(ch.get("name"))
chars = part.get("result_chars") if part is not None else \
REPORT_RESULT_CHARS
lines.append("## %s (%s)" % (ch.get("name"), st))
lines.append("子任务:%s" % ch.get("id"))
res = (ch.get("result_text") or "")[:REPORT_RESULT_CHARS]
err = (ch.get("error_text") or "")[:REPORT_RESULT_CHARS]
res = (ch.get("result_text") or "")[:chars]
err = (ch.get("error_text") or "")[:chars]
if res:
lines.append(res)
if err:
@@ -189,7 +298,16 @@ def build_report(children):
if not res and not err:
lines.append("(无输出)")
lines.append("")
overall = "completed" if children and done == len(children) else "failed"
head = "协调员汇总:%d/%d 个子任务完成,总体 %s" % (
done, len(children), overall)
if parts is None:
overall = "completed" if children and \
done == len(children) else "failed"
head = "协调员汇总:%d/%d 个子任务完成,总体 %s" % (
done, len(children), overall)
else:
overall, _failed, tolerated = evaluate_outcome(children, parts)
head = "协调员汇总:%d/%d 个子任务完成,总体 %s" % (
done, len(children), overall)
if tolerated:
head += "(%d 项失败已容忍:%s)" % (
len(tolerated), "、".join(tolerated))
return head + "\n\n" + "\n".join(lines), overall