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

Предварительное уточнение

Прежде чем браться за работу, нужно выяснить требования. Уточнитель — это роль, которая только задаёт вопросы и ничего не делает; она спрашивает до тех пор, пока всё не станет ясно, и в конце выдаёт бриф ровно из четырёх разделов, замораживая его на диске. Каждый последующий шаг стартует с чтения этого документа и больше не угадывает требования заново — а сам диалог вопросов и ответов никогда не попадал в контекст нижестоящих шагов.

Какую задачу это решает

Все механизмы очистки контекста во flower убирают рабочие следы: истёкшую по времени информацию, отклонённые вызовы, сообщения об ошибках, крупные результаты, сброшенные на диск. Потерять следы не страшно — повторный прогон их вернёт.

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

Long-horizon доводит это до худшего варианта: ошибочная предпосылка сначала работает несколько часов, порождает десяток subagent, кладёт на диск кучу артефактов — и только потом вскрывается. К этому моменту дорого не в токенах, дорого в том, что каждый артефакт построен под неправильные требования. Счёт HT001 показывает пропорцию: шаг уточнения требований — $0.3704 / 5 раундов / 0.06h, следующий за ним рабочий шаг — $171.2476 / 31 раунд / 10.44h.

Поэтому нужен канал, по которому можно «остановиться и спросить», и он обязан быть до начала работы.

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

Без кода: командная строка

Заходите в каталог проекта и просто запускаете:

cd /path/to/your/project
flower
要做什么? 一句话就够,回车开始(Ctrl-C 退出)
> 帮我做一个 X

В терминале появятся вопросы такого вида:

  ? 这个工具是给命令行用,还是要有 Web 界面?
     1) 纯命令行
     2) Web 界面
     3) 两个都要
你的回答 (回车=跳过,让它自己判断) > 1
  • Вводите номер, чтобы выбрать вариант, или просто пишите ответ текстом
  • Enter = пропустить вопрос: модель решит сама и запишет допущение в раздел «Неизвестное и допущения»
  • Дальше пропускают только полный бриф из четырёх разделов; он замораживается в .flower/notes/需求.md
  • Повторный запуск не устраивает допрос заново — чтобы уточнить требования снова, удалите этот файл или добавьте --new

Хотите только посмотреть, о чём она спросит, без последующей работы: flower --clarify-only. Жёсткая квота на вопросы: --asks 12 (только при заданной квоте под вариантами появляется строка (还能问 N 次); по умолчанию число вопросов не ограничено и этой строки нет). Никого нет у экрана: --timeout 0. Реализация этого пути — в flower/workflow/starter.py.

Ответы идут через стандартный ввод, запускать нужно в настоящем терминале

В пайпе, под nohup, в CI отвечать некому: как только stdin отдаёт EOF, висящий в этот момент вопрос считается «ввод закрыт» и пропускается, а каждый следующий вопрос будет впустую ждать весь --timeout. В таких условиях сразу ставьте --timeout 0 — все вопросы мгновенно уходят вхолостую, модель решает сама и пишет допущения в раздел «Неизвестное и допущения».

Своя обвязка

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

wb = Workbench(Path.cwd()).ensure()
ch = HumanChannel(log_path=wb.notes / "问答记录.md")   # по умолчанию число вопросов не ограничено, ждём человека 30 минут
wf = Workflow(channel=ch, workbench=wb, steps=[
    clarify_step(ch, brief_path=wb.notes / "需求.md", prompt="帮我做一个 X"),
    Step("干活", spec=协调者, prompt=lambda ctx: f"照这份需求做:\n\n{ctx['确认需求']}"),
])

В prompt пишите только своё исходное пожелание — одной фразы достаточно. О чём спрашивать, уточнитель решает сам: какие вопросы уместны в вашей предметной области, фреймворк не знает и знать не должен. 协调者 выше — это ваш собственный AgentSpec, собранный через coordinator(), см. Проектирование workflow.

Параметры clarify_step():

Параметр По умолчанию Описание
channel HumanChannel. Тот же самый экземпляр нужно повесить и на Workflow(channel=...)
brief_path Куда ложится бриф. Обязан находиться внутри того верстака, который вы подключили, см. ниже
prompt Ваше исходное пожелание. str или Callable[[Ctx], str]
name "确认需求" Имя шага, оно же ключ в ctx
spec None Свой AgentSpec; если задан, clarify() не используется
instructions "" Доменные инструкции, дописываемые после CLARIFIER_RULES
always_ask False True = уточнять заново каждый раз (когда меняются требования)
on_fail "stop" Что делать при неполных четырёх разделах, как у Step.on_fail
retries 0 Сколько раз повторить при неполных четырёх разделах
**spec_kw Пробрасывается в clarify(): can_read / model / effort / max_turns / max_budget_usd

После выполнения в ctx лежат три вещи:

ctx["确认需求"]     # str, компактная версия четырёх разделов (prompt_block), вставляется прямо в нижестоящий prompt; ключ = имя шага
ctx[BRIEF_KEY]     # "_brief" —— объект Brief, если нужен доступ по разделам
ctx[MISSING_KEY]   # "_brief_missing" —— есть только при неполных разделах: каких не хватает, для показа в UI

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

Симптом Что крутить
Спрашивает слишком много и слишком мелко Задайте жёсткую квоту max_asks; в instructions опишите, что в вашей области очевидно
Спрашивает слишком мало и берётся за работу В instructions прямо назовите, в чём она обязана разобраться (число вопросов и так не ограничено, менять квоту бесполезно)
Четыре раздела заполнены формально Дайте в instructions образец из вашей предметной области
Никого нет у экрана, а оно висит timeout_s=0
Хочется уточнять заново каждый раз always_ask=True либо удаляйте файл брифа

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

Момент срабатывания: три точки подключения, ни одного нового поля

clarify_step() создаёт обычный Step, у которого просто заполнены три колбэка:

Куда подключено Когда выполняется Что делает
Step.when Перед входом в шаг Если бриф уже есть и все четыре раздела на месте — пропустить шаг и залить бриф в ctx
Step.gate После выполнения шага, до передачи результата дальше Если четыре раздела заполнены не полностью — не пускать дальше; если полностью — write() и заморозить
Step.reduce После прохождения Передаёт дальше разобранные четыре раздела, а не сырой текст модели

Пропуск тоже заливает ctx. Это легко упустить: когда when возвращает False, Workflow не выполняет шаг и, естественно, не пишет ctx[step.name] — поэтому clarify_step заливает уже имеющийся бриф прямо внутри when. Иначе при повторном запуске нижестоящий шаг получит KeyError.

reduce передаёт Brief.prompt_block(), а не сырой текст модели, потому что в сыром тексте может оказаться лишнее (на практике она вставляет в ответ целиком весь код).

При продолжении этот шаг открывается другой фразой — CLARIFY_RESUME: «продолжаем то незавершённое уточнение требований — это не начало заново…». Без этой фразы продолжение переотправит исходное пожелание как новую задачу, и уточнитель может заново задать уже заданные вопросы.

Граница: диалог не попадает в контекст нижестоящих шагов

确认需求        独立会话  ────→  磁盘上一份冻结的四段确认书
干活(下一步)   新会话(resume_from=None)◄─┘   只拿到那四段

У clarify_step параметр resume_from остаётся по умолчанию None, поэтому следующий шаг — новая сессия, которой достаётся только бриф. Диалог вопросов и ответов никогда не попадал в контекст координатора — это не «попал, а потом был вырезан». Разница существенная: вырезанное всё ещё лежит в sessions.db и может вернуться при resume; то, что не попадало вовсе, такой проблемы не создаёт.

Сам диалог дописывается в log_path. Эта запись не занимает контекст, не страдает от compact и переживает переезд на другую машину — ровно та же идея, что и с верстаком.

Каждый из четырёх разделов закрывает свой класс отказов

Раздел Что писать Что будет, если не написать
Цель (目标) Одна фраза: что делаем и для кого Сделают не то
Критерии приёмки (验收标准) Проверяемые условия, по одному в строке. «Сделано хорошо» не годится, «запуск x выводит y» годится Никто не сможет вынести вердикт «готово»
Границы (边界) Явно: чего не делаем Расползание объёма. Этот раздел держит каждый последующий subagent
Неизвестное и допущения (未知与假设) Всё, что не спросили, ушло вхолостую по таймауту или додумано самостоятельно, по одному в строке Ошибочная предпосылка тихо закапывается

Четвёртый раздел — предохранитель long-horizon-запуска. Если ошибка в любом из первых трёх разделов, но допущение явно записано в четвёртом, у читателя дальше по цепочке есть шанс её перехватить; если оно закопано — узнаете только через несколько часов, когда все артефакты окажутся мусором. Ошибочных предпосылок полностью не избежать, но их можно сделать явными.

Дальше пропускают только полные четыре раздела; чего именно не хватает, сообщает Brief.missing() — он возвращает названия разделов по-китайски, их можно показывать как есть.

Разбор очень терпим к оформлению: распознаются ## 目标 / **目标** / 目标: / 3. 边界, распознаётся текст прямо за заголовком (目标: 做一个 X), распознаются частые синонимы (验收条件→«Критерии приёмки», 不做什么→«Границы», 未知项与假设→«Неизвестное и допущения»); если раздел встречается несколько раз, берётся первый непустой. Два исключения надо знать:

  • Brief.parse() сначала срезает блоки кода в ограждениях, и при незакрытом ограждении выбрасывает всё, начиная с него и до конца. Если вывод модели обрезан, все последующие разделы не разберутся → четыре раздела неполны → gate отправляет на повтор.
  • Brief.load() считает "(未填)" пустотой. Если при ручном редактировании брифа вы скопировали текст-заглушку из to_markdown(), раздел по-прежнему считается отсутствующим.

Граница: к чему уточнитель имеет доступ

Прогоняли неограниченного уточнителя (/tmp/probe_ask.py, $0.8908 / 230 секунд): он задал два вопроса и сразу начал писать код; когда его остановил слой разрешений, он вставил весь код прямо в текст ответа. Фраза «не пиши код» в промпте это не останавливает — в его системном промпте тогда была похожая формулировка. Поэтому механизмов два:

Первое: hook, отсекающий пишущие инструменты. Список автодопуска у clarify() — это mcp__human__ask плюс (при can_read=True) Read / Glob / Grep / WebFetch / WebSearch; никаких Write / Edit / Bash / Agent. Реально это исполняет whitelist_guard, который Runtime ставит автоматически: он по списку автодопуска выводит, какие из Bash / Write / Edit / NotebookEdit надо блокировать, и при совпадении отдаёт deny. Дело не в том, что его «попросили не работать», — он не может работать.

Давать ему чтение выгодно: один взгляд в репозиторий экономит несколько вопросов, а эта сессия одноразовая, испачкать её контекст не жалко (при can_read=False можно не давать и чтение).

Это обязан быть hook, одного allowed_tools мало. Последний — список автодопуска, а не исчерпывающий белый список: модель прекрасно вызывает инструменты, которых там нет. Два подтверждающих факта из практики:

  • В HT002 арбитр шага «постановка цели» фактически выполнил 11 вызовов Bash, хотя у judge() по умолчанию can_run=False и Bash в списке нет вовсе (в том запуске этого hook ещё не было — сегодня тот же вызов получил бы deny от whitelist_guard, что и показывает: держит его hook, а не список).
  • Зонд за $0.1: агенту с allowed_tools=["Read"] дали задание записать файл — Write отклонён слоем разрешений ("requested permissions to write ... but you haven't granted it yet"), Bash отклонён защитой путей ("Output redirection was blocked. For security, Claude Code may only write to files in the allowed working directories"). Вызовы были отправлены, их остановили другие слои.

clarify() не задаёт permission_mode явно и наследует значение AgentSpec по умолчанию — "default". У coordinator() по умолчанию "acceptEdits" — кто прокинет это значение уточнителю, тот снимет с него защиту.

Второе: фреймворк разбирает только эти четыре раздела, всё остальное отбрасывается. Brief.parse() сначала вырезает блоки кода в ограждениях, потом ищет заголовки — вставленное дальше не пройдёт. Это последний шлюз против «он загрязнил нижестоящий контекст».

Граница: канал вопросов

Инструмент вопросов на стороне модели называется mcp__human__ask (параметр question, опционально options). HumanChannel — внутрипроцессный MCP-сервер, и регистрирует он два инструмента: mcp__human__ask и mcp__human__inbox; в списке автодопуска уточнителя есть только первый (входящие — для координатора).

HumanChannel(
    on_event=None,        # колбэк для push-UI. Если повешен на Workflow, подключается автоматически в Workflow.run
    max_asks=None,        # по умолчанию без ограничения по числу вопросов
    timeout_s=1800.0,     # 30 минут. None = ждать вечно; <= 0 = полностью автоматически
    log_path=None,        # диалог дописывается в этот файл, контекст не занимает
    amend_path=None,      # то, что человек говорит по ходу запуска, дописывается в этот файл (обычно это и есть бриф)
    over_budget_text=..., timeout_text=..., declined_text=...,   # формулировки для трёх видов холостого ответа
)

Нормальное состояние long-horizon-агента — за ним никто не следит, поэтому «остановиться и подождать человека» обязано уметь отказывать аккуратно:

Настройка Поведение
timeout_s=1800.0 (по умолчанию) Ждать полчаса; по истечении вернуть пояснительную фразу, а не ошибку
timeout_s=None Ждать вечно. Только когда точно есть дежурный (из CLI такое значение не задать, --timeout — float)
timeout_s=0 (и отрицательные) Полностью автоматически: все вопросы уходят вхолостую сразу, без имитации ожидания
max_asks=None (по умолчанию) Без ограничения — сколько спрашивать, решает сам уточнитель
max_asks=N Жёсткая квота. Вопросы сверх неё инструмент сразу отклоняет, без блокировки и без ошибки
max_asks=0 Спрашивать нельзя (CI / без дежурного)

При max_asks=None remaining возвращает -1 (не 0 и не бесконечность), и терминал по этому признаку не показывает «还能问 N 次».

Дословный ответ при таймауте:

Никто не ответил. Продолжай по собственному суждению и запиши этот вопрос вместе с принятым допущением в раздел «Неизвестное и допущения». Не повторяй вопрос и не останавливайся здесь.

Все три вида холостого ответа (таймаут / исчерпана квота / человек сам пропустил) ведут к одному и тому же действию: записать допущение в четвёртый раздел. Именно поэтому четвёртый раздел непуст даже без дежурного, и именно поэтому long-horizon-запуск может продолжаться. Квота, записанная в промпте, — это пожелание; гарантия — счётчик в канале.

Проверенный на практике факт о механике: в обработчике внутрипроцессного MCP-инструмента await на внешний future не приводит к взаимной блокировке — пока обработчик висит, цикл событий продолжает крутиться, и ответ может подставить как другая задача, так и другой поток. Поэтому answer() / decline() можно вызывать прямо из веб-бэкенда или из потока ввода TUI (внутри используется loop.call_soon_threadsafe), и это норма, а не краевой случай. Исключения из UI-колбэков собираются в ui_errors и не прерывают запуск — упавший фронтенд не должен уносить с собой три часа работы. Полный список членов — в Python API.

Если max_turns задан маленьким, «спрашивать до полной ясности» превращается в пустые слова

У clarify() max_turns по умолчанию None (без ограничения). Каждый заданный вопрос — это раунд: значение 16 равносильно «не больше десятка вопросов», и действует оно молча: на стороне канала по-прежнему написано max_asks=None, «без ограничения», и понять, кто именно придушил вопросы, невозможно. Чтобы вопросы были свободны, оба значения по умолчанию — HumanChannel.max_asks и clarify(max_turns=...) — должны остаться None.

Куда ложится бриф: только в подключённый верстак

Индекс верстака подмешивается в system prompt, поэтому координатор с самого старта знает, где лежит файл требований; раздавая работу, достаточно передать путь — содержимое переписывать в задание не нужно.

Индекс доходит только до координатора

У subagent свой system prompt, и сессионную часть он не наследует (проверено, $0.2461, tests/prelude_live.py). То есть работает «координатор пересказывает путь», а не «каждый subagent автоматически знает».

Ключевой вопрос — какой именно верстак. Правильный способ ровно один: создать его самому, повесить на Workflow и дать драйверу передать тот же самый объект в Runtime.

wb = Workbench(Path.cwd()).ensure()
wf = Workflow(channel=ch, workbench=wb, steps=[            # ← вешаем сюда
    clarify_step(ch, brief_path=wb.notes / "需求.md", prompt="…"),
    ...,
])

Есть два неправильных варианта, и оба не дают ошибки, поэтому осторожность нужна особая:

# ✗ Собрать путь вручную: он относителен cwd процесса, а <run_dir>/workbench, который создаёт
#   Runtime(workbench=True), — это другой каталог. Бриф пишется в A, подмешанный индекс сканирует B —
#   обещание выше молча перестаёт работать.
clarify_step(ch, brief_path=Path(".flower/notes/需求.md"), prompt="…")

# ✗ Попытаться достать верстак из Runtime: через cli.py это невозможно. Он сначала вызывает main()
#   и собирает Workflow, и только потом создаёт Runtime — к тому моменту brief_path давно зафиксирован.
rt = Runtime(workspace="repo", workbench=True); wb = rt.workbench

Когда пишете свой драйвер (в обход cli.py), сначала создайте Workbench, а затем передайте один и тот же объект и в Workflow(workbench=wb), и в Runtime(workbench=wb). Пункт 5 в tests/trial_offline.py прямо утверждает, что «бриф присутствует в prompt_block()», а пункт 11 подтверждает, что это утверждение ловит регрессию.

Что проверено, а что нет

Офлайн всё зелёное (tests/clarify.py, 52 пункта, без затрат): пять сценариев канала вопросов (блокирующее ожидание ответа / исчерпание квоты / холостой таймаут / пропуск / ответ из другого потока), разбор четырёх разделов (включая образец «сюда вставили код»), отсутствие пишущих инструментов у роли clarify(), три точки подключения clarify_step.

Путь CLI офлайн проходит целиком: подкладываем готовый полный бриф → первый шаг пропускается → канал автоматически подключается к потоку стандартного ввода → бриф заливается в ctx → чистый выход.

Реальный API не гонялся. Зонд на $0.8908 был настоящим запросом, но проверял он «что сделает неограниченный уточнитель», а не текущий путь.

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

Требования уже заморожены. Требования лежат в файле, заданы вышестоящей системой или это повторный прогон того же самого — спрашивать нечего. Скармливайте текст требований сразу рабочему шагу либо оставьте clarify_step, и when его пропустит (бриф на месте — вопросов и не будет).

Спрашивать некого, а гадать вы ей не хотите позволять. При timeout_s=0 все вопросы мгновенно уходят вхолостую, и четвёртый раздел наполнится её собственными допущениями — так задумано, но достоверность такого брифа равна достоверности этих допущений. В CI чище поступить иначе: max_asks=0 (спрашивать явно запрещено), а требования подать снаружи целиком.

Разовая мелочь. Сам шаг уточнения стоит денег: в HT002 на задачу «склонировать репозиторий, поставить и запустить на macOS» уточнение требований обошлось в $0.5306 / 9 раундов / 0.10h. Чем мельче работа, тем хуже выглядит доля этого шага. Одноагентный путь flower once его не содержит.

При изменении требований не надо заводить диалог заново. Бриф — замороженный артефакт, и с момента записи на диск источником истины является файл, поэтому правильное действие — править этот файл. --clarify-only в уже уточнённом каталоге — пустая операция (в том workflow только один шаг, и он пропускается); чтобы уточнить заново, нужен --new, либо в своей обвязке always_ask=True.

Она не выносит вердикт «сделано или нет». Это другой слой, см. Страж цели. Предварительное уточнение защищает от «сделали не то, что хотели», но не от «сказали, что сделали, а на самом деле нет».