Перейти к содержанию

Страж цели

«Готово или нет» решает не исполнитель. Судья — это роль, которая только ставит цель, только выносит вердикт и сама ничего не делает: перед стартом превращает бриф в проверяемый список, а затем после каждого круга работы выносит один независимый вердиктвердикт. Достигнуто — идём дальше; не достигнуто — возвращаем с описанием «чего не хватает» и продолжаем; признано невыполнимым — останавливаемся и спрашиваем человека.

Какую проблему это решает

Предварительное уточнение отсекает «сделано не то, что нужно». Этот слой отсекает другое: «на самом деле не доделано, но оно само говорит, что готово». Эти две вещи надо разделять, потому что отказывают они по-разному:

Как выглядит отказ Когда вскрывается
Неверные требования Всё сделанное построено по неверным требованиям Через несколько часов, весь результат в мусор
Неверная оценка готовности Тесты прогнаны наполовину, исправлено одно место из четырёх, «вроде должно работать» Когда сам начнёшь этим пользоваться

Почему второе нельзя доверить самому исполнителю: у него системный оптимистический сдвиг. Дело не в нечестности — он не видит своих слепых зон. Он знает, что сделал, и не знает, что пропустил.

Поэтому вердикт отдан роли, которая не участвовала в работе и живёт в собственной сессии. Она видит только цель и фактическое состояние, не знает, сколько попыток сделал исполнитель и как тяжело ему было, — а значит, не станет искать за него оправдания. Это ровно та же причина, по которой уточнитель работает в отдельной сессии.

Как пользоваться (минимум кода)

Ноль кода: командная строка

flower                      # страж цели включён по умолчанию
flower --no-goal            # выключить: работа прогналась — значит, готово
flower --rounds 5           # максимум пять кругов работы (по умолчанию 3)
flower --judge-can-run      # разрешить судье запускать команды (вердикт жёстче)

Собрать вручную

Две функции отвечают каждая за свою половину, их нельзя путать: goal_step() ставит цель (отдельный шаг), а with_goal() — это уже цикл вердикта (оборачивает рабочий шаг).

from pathlib import Path
from flower import HumanChannel, Step, Workbench, Workflow, clarify_step, goal_step, with_goal

wb = Workbench(Path.cwd()).ensure()
ch = HumanChannel(log_path=wb.notes / "问答记录.md")   # по умолчанию число вопросов не ограничено
goal_path = wb.notes / "目标.md"

work = Step("干活", spec=协调者, prompt=lambda ctx: f"照这个做:\n{ctx['确认需求']}")

wf = Workflow(channel=ch, workbench=wb, steps=[
    clarify_step(ch, brief_path=wb.notes / "需求.md", prompt="帮我做一个 X"),
    goal_step(ch, goal_path=goal_path),
    with_goal(work, ch, goal_path=goal_path, rounds=3),
])

goal_step(channel, *, goal_path, ...):

Параметр По умолчанию Описание
goal_path Куда ложится цель. Кладите в notes/ верстака — по той же причине, что и бриф
brief_key "确认需求" Из какого ключа ctx читать бриф. Если не прочитается, останется только "(没有确认书)"
name "设定目标" Имя шага, оно же ключ в ctx
spec / instructions None / "" Свой AgentSpec либо доменные инструкции, добавляемые судье
always_set False True = переустанавливать каждый раз
on_fail / retries "stop" / 0 Как у Step
**spec_kw Пробрасывается в judge(): can_run / model / effort / max_turns / max_budget_usd

with_goal() оборачивает рабочий шаг в цикл с вердиктом:

with_goal(step, channel, *, goal_path, spec=None, rounds=3,
          instructions="", can_run=False, name=None, **spec_kw)

rounds — это общее число кругов, а не число дополнительных — оно превращается в retries = max(0, rounds - 1), поэтому rounds=3 — максимум три круга работы, а rounds=1 означает «один круг, один вердикт, не прошёл — провал». Полная сигнатура и семантика полей — в Python API.

В ctx появляются три дополнительных ключа:

ctx[GOAL_KEY]     # "_goal" —— Goal 对象;ctx["设定目标"] 是它的 markdown
ctx[VERDICT_KEY]  # "_verdict" —— 最近一次 Verdict,给 UI 用
ctx[ROUND_KEY]    # "_goal_rounds" —— 跑了几轮

Судья отправляется в работу через ctx["_runtime"]Workflow.run кладёт в ctx и рантайм, и выход событий, поэтому gate может сам поднять агента, а ход вынесения вердикта всё так же попадает в ваш UI (иначе те десяток секунд экран был бы пустым и выглядело бы как зависание).

Когда что-то идёт не так, крутите сначала эти ручки:

Симптом Что крутить
Вердикт слишком мягкий: говорит «достигнуто», а на деле нет --judge-can-run, чтобы он реально прогнал; либо добавьте доменные критерии в instructions
Вердикт слишком строгий, всё время возвращает Посмотрите, не написан ли проверочный список в 目标.md строже самих требований. Правьте этот файл
Круги крутятся вхолостую Судья должен был дать «недостижимо», а дал «ещё не достигнуто». Добавьте ему инструкцию, что считается невыполнимым
Слишком дорого --rounds 1 или полностью выключить через --no-goal
Не хочу, чтобы меня прерывали --timeout 0: при недостижимости человека не спрашивают, просто останавливаются (причина остаётся на диске)

Что он делает на самом деле

Как выглядит цель

goal_step читает бриф, выдаёт два раздела и замораживает их в .flower/notes/目标.md:

# 目标
让 conv.py 能把 md 转成 html。

# 判定清单
- `python conv.py a.md` 产出 a.html
- 输出里含 `<h1>`
- 列表被转成 `<ul><li>`

Проверочный список — это вся ценность данного слоя. «Реализовано полностью» проверить нельзя, «что запустить и что увидеть» — можно. Список берётся из раздела «критерии приёмки» брифа, но переписывается так, чтобы каждый пункт можно было проверить прямо сейчас; расплывчатые пункты судья дополняет сам. Полным считается только вариант, где непусты оба раздела (statement содержит текст, checks непуст), иначе шаг не пропускает дальше.

Длину списка определяет число способов провалиться

А не педантичность судьи. Для задачи вида git clone && make && ./app хватает трёх-пяти пунктов: сборка прошла, запускается, работает.

Проверено на своей шкуре (HT002): задача «поднять репозиторий и запустить» получила список из 15 пунктов, из которых только 5 проверяли, «работает ли оно», 6 проверяли «соблюдение процесса» (включая mtime у ~/.zshrc и проверку, не менялся ли каталог .flower/ — а это собственный каталог фреймворка), и 4 были непроверяемы в принципе.

Границы — не пункты вердикта

Это и была главная причина того случая:

Что ограничивает Как соблюдается
Границы Как ты работаешь («ставить только внутри каталога проекта», «бизнес-код не трогать») За счёт того, что ты их не переходишь, а не за счёт отчёта постфактум
Пункты вердикта То, что сдано («запустилось или нет», «результат верен или нет») За счёт проверки на месте

Записать «brew install не запускался» как пункт вердикта — значит на каждую новую границу заводить ещё одну проверку, а границы как раз и предлагается расписывать подробно на этапе уточнения. Если действительно нужно отчитаться — одной фразой, а не шестью пунктами.

О непроверяемых пунктах предупреждают уже при постановке цели

Для пунктов, помеченных [此环境无法验证:原因], goal_step выдаёт предупреждение в момент заморозки цели:

  # 目标里有 4/15 条在这个环境里验不了 —— 判定时它们必然过不去,会停下来问你。
    现在改 .flower/notes/目标.md 还来得及:
      · 界面截图并实际看图 [此环境无法验证:屏幕录制未授权]
      · ...

Почему заранее: судьба этих пунктов решена уже в момент постановки цели — на вердикте они гарантированно не пройдут. В HT002 сначала было потрачено $35.90 на работу + $1.40 на вердикт, и только потом это обнаружилось. Перенос обнаружения на шаг постановки цели снижает стоимость той же информации с $37 до $0.

Только предупреждение, без блокировки: человек может решить бежать как есть (в HT002 в итоге выбрали «принять этот результат»). Goal.unverifiable — это и есть такой список, а в payload события лежат структурированные данные для UI.

Три исхода, а не два

干活 ──> 判定 ──达成────> 往下走
              ├─未达成──> 打回,带上“差在哪”,续跑同一个会话接着做
              └─无法达成─> 停下来问人:接受 / 改目标 / 你判断错了

Третий исход — ключевой. Если есть только «достигнуто/не достигнуто», то на самом деле невыполнимая цель заставит координатора крутиться круг за кругом, пока не кончится лимит, — вот это и есть настоящее сжигание денег. Поэтому от судьи явно требуют: «недостижимо» — это когда ещё один круг не поможет (нет необходимых внешних условий, требования противоречат сами себе, пункт вердикта невозможно проверить в принципе); а «просто ещё не доделано» — это «не достигнуто».

При недостижимости фреймворк останавливается и спрашивает человека:

  ? 目标被判为**无法达成**:缺少 X 依赖,判定项 2 无法验证
    怎么办?
     1) 接受这个结果,就这样往下走
     2) 修改目标
     3) 你判断错了,继续做
  • Принять → шаг считается пройденным, причина остаётся в записи
  • Изменить цель → у вас спросят новую цель и допишут её в конец исходной (видно, что именно изменилось), затем ещё один круг
  • Ты ошибся с вердиктом (а также любой ваш свободный ответ) → задача возвращается вместе с вашей формулировкой, ещё один круг

Если ответить некому — останавливаемся, а не крутимся дальше вхолостую; это сделано намеренно. Вердикт «невыполнимо» плюс отсутствие человека — продолжать значит жечь деньги круг за кругом, а именно этого и надо избежать. При остановке бросается StepAbort, причина пишется в ctx["_aborted"], файл цели и runs/manifest.json на месте — человек вернётся и примет решение.

"Не сделано" и "здесь нельзя проверить" — это два разных вердикта

У Verdict три значения: ACHIEVED / NOT_YET / UNREACHABLE. UNREACHABLE категорически нельзя засчитывать как проход — он идёт по ветке «остановиться и спросить человека», а не «ещё один круг». Всё, что судья пишет как «无法验证 / 没法验证 / 验证不了 / 无法判定 / unverifiable», целиком сводится к UNREACHABLE. Считать «здесь не проверить» за «достигнуто» — значит закрыть работу фразой «вроде должно работать»; считать за «не достигнуто» — значит круг за кругом переделывать то, что изначально невозможно проверить.

Расплывчатый вердикт = не достигнуто

Порядок распознавания в Verdict.parse: сначала берётся раздел «结论 / 判定» по заголовку; если разделов с заголовками нет, то текст целиком 1 / true считается достигнутым, 0 / false — не достигнутым (когда судью просят «отдать только 0/1», он вполне может действительно ответить одной цифрой); дальше в тексте вывода ищутся ключевые слова (длинные раньше коротких); в последнюю очередь ищется одиночная 1 / 0.

Если не сработало ничего, state остаётся пустым, ok равно False, и фреймворк считает это «не достигнуто». Это сделано намеренно: «не удалось вынести вердикт» и «доделано» — разные вещи, любая неоднозначность трактуется как «не достигнуто», плюс добавляется причина по умолчанию («судья не дал однозначного вывода, считаем не достигнутым»).

Судят результат, а не исходники

Вердикт, вынесенный по одному чтению исходников, ничего не говорит о поставляемом артефакте

В HT001 критерий приёмки дословно звучал так: «собрать отдельный исполняемый файл, запускающийся прямо в терминале macOS», а вердикт был вынесен положительным лишь на том основании, что в Makefile:25-38 действительно есть ветка Darwin, — при том что сданный артефакт был ELF 64-bit LSB pie executable, ARM aarch64, GNU/Linux.

Ошибся тогда не страж цели: в том запуске этого механизма ещё не было, этот пункт судил независимый аудитор, которого координатор отправил сам, на ходу. Но и страж цели пропустил бы то же самое: у судьи по умолчанию can_run=False, в руках только Read / Glob / Grep, он не может запустить file, значит, точно так же полез бы читать Makefile и точно так же засчитал бы ветку Darwin как достигнутую цель. Суть того провала не в том, «кто судит», а в том, «по каким доказательствам судят».

Этот урок записан в JUDGE_RULES: судят артефакт, выводы вида «в исходниках есть ветка под macOS, значит, должно запускаться» не принимаются.

HT002 — это тот случай, когда judge_can_run был включён и судья действительно запускал file / lsof, поэтому он в эту яму не попал: его первая фраза была «я не буду делать выводы по этому ответу. Иду смотреть на месте.» А дальше:

file cppide        → Mach-O 64-bit executable arm64
lsof -p 96040      → 起于 16:10,16:15 仍活着

Ровно об этом и фраза в prompt судьи: иди и посмотри сам, пройди проверочный список по пунктам, пункт, доказательств по которому не видно, считается непройденным.

«Вернуть» — значит продолжить, а не начать заново

Возврат работает через Step.on_reject: на следующем круге делается resume той самой сессии, которую только что забраковали, а prompt подменяется обратной связью вердикта (Verdict.feedback() сообщает только «чего не хватает», решения не даёт). Поэтому уже проделанная работа, прочитанные файлы и пройденные тупики остаются в контексте — ему нужно лишь закрыть разрыв.

Различие видно прямо в имени шага, в runs/manifest.json оно читается с одного взгляда:

干活            第一轮
干活#round2     被打回后接着做      ← on_reject 生效,resume 上一轮
干活#retry1     普通重试(重头跑)    ← 没有 on_reject 时的老行为

Сам судья всегда работает в новой сессии: gate внутри with_goal напрямую вызывает Runtime.run и не передаёт resume; имя шага содержит номер круга (干活·判定#1), а имена с суффиксом не попадают в межпроцессную родословную. Если в gate недоступен ctx["_runtime"], бросается StepAbortпроход не имитируется.

Пропуск и переустановка

Если файл цели уже существует и полон, шаг пропускается (как и с брифом): когда запуск с длинным горизонтом упал и перезапускается, незачем заново пересчитывать уже сделанные выводы. Чтобы переустановить цель, удалите этот файл или задайте always_set=True.

Исключение: при пробуждении вы сказали ещё одну фразу. Эта фраза дописывается в бриф, поэтому шаг выводит цель заново (always_set=True). Без пересчёта судья читал бы всё тот же замороженный старый список, и сделано ли то, что вы только что добавили, вообще не попало бы в вердикт — он бы вынес «достигнуто» по старому списку. Замеренная цена пересчёта — $0.41 / 3 минуты. См. преемственность.

Может ли судья запускать команды

По умолчанию нет. В списке безоговорочно разрешённых инструментов judge() — инструменты вопросов плюс Read / Glob / Grep; Bash добавляется только при can_run=True. Компромисс:

  • Дать Bash (в CLI — --judge-can-run) → можно реально прогнать команды приёмки, вердикт жёстче
  • Но тогда он может менять рабочую область → он способен «попутно чуть-чуть починить» и затем засчитать проход, и тогда весь вердикт бессмыслен

Как и у уточнителя, никаких Write / Edit / Agent. Это обеспечивает hook whitelist_guard, а не allowed_tools — последний это список без запроса подтверждения, а не исключающий белый список, модель по-прежнему может вызвать инструмент, которого в нём нет. Два замеренных подтверждения, что это до сих пор так: в HT002 судья шага «设定目标» вызвал Bash 11 раз, хотя Bash в его списке без подтверждения тогда не было вовсе; а в зонде за $0.1 агент с allowed_tools=["Read"] всё равно выдал вызовы Write и Bash — их остановили слой прав и защита путей ("requested permissions to write ... but you haven't granted it yet" / "Output redirection was blocked..."). Сегодня оба таких вызова hook отклоняет (deny) на месте — останавливает их hook, а не список.

Судье, который ставит цель, Bash по умолчанию не достаётся

У goal_step() нет формального параметра can_run, только через **spec_kw: goal_step(ch, goal_path=…, can_run=True). Если не задать явно, Bash у него не будет, и пункт JUDGE_RULES «сначала uname -a, разберись, где ты находишься» выполнить нельзя — а значит, он может написать вам список, который на этой машине проверить в принципе невозможно. С with_goal() иначе: там есть отдельный формальный параметр can_run (по умолчанию False).

Почему у кругов есть лимит, а у числа вопросов — нет

Вопрос почти ничего не стоит, круг работы — живые деньги. Поэтому:

  • Число вопросов не ограничено (max_asks=None) — спрашивать, пока не станет ясно, решает сам уточнитель
  • У кругов есть лимит (rounds=3) — но настоящая страховка не в этом числе, а в третьем исходе «недостижимо»: как только он появляется, работа останавливается и спрашивается человек, не дожидаясь исчерпания кругов

Когда его использовать не стоит

Задача настолько мала, что вердикт многословнее самой работы. Этот слой усложняет простые вещи, и это измерено: в HT002 для задачи «склонировать репозиторий, поставить и запустить на macOS» проверочный список раздулся до 15 пунктов, из которых 6 проверяли соблюдение процесса, а 4 были непроверяемы в принципе; сам тот круг вердикта стоил $1.4037 / 37 кругов / 0.09h, весь запуск — $38.2409 / 0.97h. Если у задачи изначально два-три способа провалиться, --no-goal выгоднее.

Цель не сводится к проверяемому списку. У исследовательской работы («посмотри, что вообще происходит в этом репозитории») нет критерия «готово»; насильно поставленная цель даст красивый, но нерабочий для вердикта список. Такое делайте через flower once или с --no-goal.

Ключевой пункт вердикта нельзя проверить в этой среде. У судьи по умолчанию can_run=False, инструменты — только Read / Glob / Grep; file он запустить не может и вынужден читать исходники. Критерий «запускается прямо в терминале macOS» из HT001 существовал при том, что весь запуск шёл в Linux-контейнере: никакой судья не способен проверить macOS-бинарник внутри контейнера, будь он самопроверкой или независимым. --judge-can-run спасает часть случаев (по крайней мере file запустится); ту часть, которую он не спасает, надо помечать при постановке цели как [此环境无法验证:…] — чтобы она пошла по ветке «остановиться и спросить человека», а не в надежде, что судья вдруг поумнеет.

Работа без присмотра, где прерывания недопустимы. Если вынесено «недостижимо», а ответить некому, шаг останавливается, и весь workflow на этом заканчивается. Нужно «сначала добежать, потом разберёмся» — тогда --no-goal; нужно «остановиться, но не ждать» — тогда --timeout 0: вопрос сразу уходит в пустоту, причина пишется на диск.

Он не отвечает за корректность требований. Проверочный список выведен из брифа; если бриф неверен, вердикт лишь аккуратно подтвердит неверную работу. Это зона ответственности слоя предварительного уточнения.