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

Замена слоя взаимодействия

Ядро flower не знает, что UI существует. Каждое событие внутри run — модель говорит, вызывает инструмент, контекст почти заполнен, надо спросить человека — сплющивается в одну и ту же структуру данных Event. Слой взаимодействия знает только Event и не импортирует ни одного типа SDK. Это и есть та граница, которая позволяет менять UI, не трогая ядро: терминал, Web, HTTP-сервис, полностью автоматический режим без человека — меняется потребитель Event, всё остальное не меняется ни на строку.

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

Поток сообщений SDK — это внутренние типы: AssistantMessage, ToolUseBlock, ToolResultBlock, ResultMessage, SystemMessage… Если UI потребляет их напрямую, получаются два следствия: SDK обновился — фронтенд обязан меняться следом; и у каждого типа сообщения своя форма, поэтому в каждом UI приходится заново писать разбор «это основной текст или вызов инструмента».

normalize(message) превращает одно сообщение SDK в от 0 до N Event (core/events.py). Цена — одно преобразование, выигрыш — отсутствие типовой зависимости между слоем взаимодействия и SDK.

Заодно эта граница решает ещё четыре не столь очевидные вещи, и все четыре — внутри normalize():

  1. Реплики subagent помечаются (payload["subagent"]). Иначе задание на раздачу работы и промежуточные реплики subagent смешаются с основным текстом главного потока, а дальше по workflow загрязнят prompt следующего шага.
  2. Синтетические сообщения об ошибке при обрыве связи отводятся в kind="error". При обрыве SDK записывает API Error: … в transcript как assistant-сообщение, и выглядит оно как речь модели (model равен "<synthetic>"). Если не перехватить его здесь, оно попадёт в StepResult.text и уедет в следующий шаг.
  3. Границы compact сообщаются явно (kind="reset"). После границы модель «помнит» только резюме, и кэш prompt тоже рвётся именно здесь — long-horizon run обязан это видеть.
  4. Уровень заполнения контекста идёт с каждым сообщением (payload["context"] = input_tokens + cache_read_input_tokens + cache_creation_input_tokens). Это единственный источник для критерия handoff.

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

Слою взаимодействия нужно подключить три вещи: выход событий (куда рендерить), канал вопросов (кто отвечает) и прерывание (как остановить). Ниже подключено всё, код запускается как есть:

import asyncio

from flower import Event, HumanChannel, Runtime, starter_flow


def sink(ev: Event) -> None:
    """Отрисовать Event в вашем UI — это единственное, что нужно менять."""
    if ev.kind == "step":
        print(f"\n=== {ev.text} ({ev.payload['index']}/{ev.payload['total']}) ===")
    elif ev.kind == "text" and not ev.payload.get("subagent"):
        print(ev.text)
    elif ev.kind == "tool_call":
        print(f"  [{ev.tool}] {ev.text}")
    elif ev.kind == "handoff":
        print(f"  ~ 换代/{ev.payload.get('phase')}: {ev.text}")
    elif ev.kind == "retry":
        print(f"  ~ 重试: {ev.text}")
    elif ev.kind == "ask" and ev.payload.get("kind") == "mail":
        print(f"  ~ 人主动说:{ev.text}")
    # kind == "ask", который не mail, обрабатывает answerer ниже (pull-модель)


async def answerer(ch: HumanChannel) -> None:
    """Pull-режим получения вопросов. При переходе на Web / HTTP менять надо и эту корутину."""
    while True:
        ask = await ch.next_ask()           # без timeout ждёт бесконечно
        if ask is None:
            continue
        print(f"\n?? {ask.question} 选项={ask.options}")
        ch.answer(ask.id, "按你的判断来")     # или ch.decline(ask.id, "先跳过")


async def main() -> None:
    wf = starter_flow("帮我做一个 X", workspace=".", run_dir="runs", timeout_s=60)
    # Runtime использует workbench, созданный самим workflow — не собирайте второй
    rt = Runtime(workspace=".", run_dir="runs", workbench=wf.workbench)
    task = asyncio.create_task(answerer(wf.channel))
    try:
        ctx = await wf.run(rt, on_event=sink)
    finally:
        task.cancel()
        rt.close()                          # закрыть соединение SQLite
    print(rt.total_cost(), ctx.get("_failed_at"))


asyncio.run(main())

Две вещи в завершении, которые легко упустить: rt.close() обязательно в finally; если в ctx["_failed_at"] есть значение — run остановился на полпути (on_fail="stop"), не считайте это успехом.

Workbench только один, не собирайте второй

Workbench, созданный через Runtime(workbench=True), лежит в <run_dir>/workbench, а Workbench(ws) по умолчанию — в <ws>/.flower; это разные каталоги. Если драйвер сам собирает путь и ищет 需求.md, получится «brief записан в каталог A, а инжектируемый индекс сканирует каталог B» — и никакой ошибки при этом не будет. Либо отдайте Runtime тот workbench, который создал workflow (как выше), либо спросите путь через read-only пробу wake_state().

Три выхода событий

await rt.run(spec, "…", on_event=sink)                  # 1. отдельный agent
await wf.run(rt, on_event=sink, on_step=progress)       # 2. весь workflow, передаётся каждому шагу
wf = Workflow(steps=[...], channel=ch)                  # 3. канал вопросов, на тот же выход

Третий вариант подключается внутри Workflow.run: автоподключение происходит, только если on_event не None и channel.on_event всё ещё None. Если вы подключили выход сами, он не будет перезаписан:

ch = HumanChannel(on_event=my_own_sink)     # подключено вами, Workflow это не трогает

on_step(step, result) — второй callback, вызывается один раз после каждого шага (включая упавшие) и получает полный StepResult. Прогресс-бар, запись на диск, алерты вешайте сюда, не собирайте это из потока Event — основной текст будет разорван handoff и повторами на несколько кусков.

Терминал: тот, что по умолчанию

Он есть и без вашего кода. flower "帮我做一个 X" идёт через flower/cli.py — это референсная реализация слоя взаимодействия, а не часть фреймворка, её можно заменить целиком; ключи см. в справочнике CLI. Скажем честно о масштабе. Весь cli.py — это 1264 строки, 57KB, — но менять надо не весь файл. Настоящая точка замены — класс Render внутри него (cli.py:489-687, 197 строк), в его docstring прямо написано: «Event → терминал. Смена UI — это смена одного этого класса.» Остальная тысяча с лишним строк — прерывание, oracle, квитанции inbox, спасение по сигналам, то есть специфичная для терминала обвязка, которую при переходе на Web или HTTP переносить и не нужно.

Так что формулировка «около 200 строк заменяются целиком» верна — при условии, что речь о Render, а не о cli.py.

Если пишете терминальный UI сами, ключевой момент — поток, читающий стандартный ввод:

import select
import sys
import threading


def start_input(ch: HumanChannel) -> threading.Event:
    """Постоянно читать stdin: есть висящий вопрос — это ответ, иначе в inbox. Возвращает флаг остановки."""
    stop = threading.Event()

    def loop() -> None:
        while not stop.is_set():
            if not select.select([sys.stdin], [], [], 0.2)[0]:
                continue                        # опрос, чтобы реагировать на флаг остановки
            line = sys.stdin.readline()
            if not line:                        # EOF
                return
            raw = line.strip()
            if not raw:
                continue
            pend = ch.pending()
            if pend:
                ch.answer(pend[0].id, raw)      # безопасно между потоками
            else:
                ch.send(raw)                    # в inbox, не прерывая работу в полёте

    threading.Thread(target=loop, daemon=True, name="stdin").start()
    return stop

Все три пункта набиты на практике:

  • Daemon-поток, а не asyncio.to_thread(input, ...). Заблокированный input() нельзя отменить, а asyncio.run перед выходом делает join потоков дефолтного executor — в итоге работа закончена, а чтобы выйти, нужно ещё раз нажать Enter.
  • Опрос через select, а не input() прямо в цикле. Та же невозможность отмены: поток, застрявший в input(), уже не разбудить никаким stop.set().
  • Читать постоянно, а не только когда есть вопрос. Если читать только при наличии вопроса, то всё, что человек набрал за те несколько часов работы, останется в буфере терминала и будет съедено как ответ на следующий вопрос — человек ещё не увидел вопроса, а на него уже «ответили».

Web: очередь + WebSocket

events: asyncio.Queue[dict] = asyncio.Queue()


def sink(ev: Event) -> None:            # синхронно, в потоке event loop, блокировать нельзя
    try:
        events.put_nowait({"kind": ev.kind, "text": ev.text,
                           "tool": ev.tool, "payload": ev.payload})
    except Exception:                   # ошибка фронтенда не должна уносить трёхчасовую работу
        pass


async def pump(ws) -> None:
    while True:
        await ws.send_json(await events.get())


@app.post("/answer")                    # поток обработки запроса — другой поток, и это норма
def answer(ask_id: str, text: str) -> dict:
    return {"ok": ch.answer(ask_id, text)}

ev.raw — это исходный объект SDK (в событии askAsk), он не сериализуется в JSON и не должен уезжать на фронтенд: воспользовались raw — значит, привязали фронтенд обратно к типам SDK, и весь этот слой сделан зря. Четырёх полей kind / text / tool / payload достаточно.

HTTP: порядковый номер + опрос

Когда постоянного соединения нет, нумеруйте события, чтобы клиент их вычитывал:

import itertools
from collections import deque

seq = itertools.count(1)
log: deque[dict] = deque(maxlen=2000)   # храним только последние, память не растёт вместе с длительностью run


def sink(ev: Event) -> None:
    log.append({"seq": next(seq), "kind": ev.kind, "text": ev.text,
                "tool": ev.tool, "payload": ev.payload})


@app.get("/events")                     # GET /events?after=128
def events(after: int = 0) -> list[dict]:
    return [e for e in log if e["seq"] > after]


@app.get("/asks")                       # что сейчас висит в ожидании ответа
def asks() -> list[dict]:
    return [{"id": a.id, "question": a.question, "options": a.options,
             "waited_s": a.waited_s} for a in ch.pending()]


@app.post("/answer")
def answer(ask_id: str, text: str) -> dict:
    return {"ok": ch.answer(ask_id, text)}      # False = этот вопрос уже никто не ждёт

Здесь две границы, которые надо признать: при заполнении maxlen теряются самые старые записи, и клиент, вернувшийся с очень старым after, получит неполную выборку — интервал опроса должен соответствовать этой длине; и timeout_s обязан иметь конечное значение — когда никто не опрашивает, вопрос сам не завершится, а timeout_s=None подвесит весь run навсегда. Значение по умолчанию 1800.0 секунд подходит.

Полностью автоматический режим без человека

wf = starter_flow("帮我做一个 X", workspace=".", run_dir="runs", timeout_s=0)
rt = Runtime(workspace=".", run_dir="runs", workbench=wf.workbench)
ctx = await wf.run(rt, on_event=None)       # все события отбрасываются

Эквивалент в командной строке — flower "帮我做一个 X" --timeout 0.

timeout_s=0 (и любое отрицательное значение) — это полностью автоматический режим: вопрос не попадает в очередь ожидания и не порождает событие asked, он сразу закрывается как state="timeout", и инструмент возвращает такой фиксированный текст —

无人应答。按你自己的判断继续,并把这个问题和你采用的假设写进「未知与假设」那一段。不要重复提问,也不要停在这里。

— и run продолжается без остановки. Вопросы и ответы всё равно дописываются в HumanChannel(log_path=...) (starter_flow по умолчанию подключает <工作台>/notes/问答记录.md), так что потом видно, что он спрашивал и какие допущения принял.

Если вообще не хотите, чтобы он открывал рот, используйте max_asks=0: вопрос сразу отклоняется (state="over_budget") и тоже не блокирует. Обратите внимание: это не «убрать инструмент» — allowed_tools не является исключающим списком, и как только к координатору подключён channel, ему выдаются оба инструмента, mcp__human__ask и mcp__human__inbox, и он может вызвать их независимо от того, перечислены они или нет. Остановить вопрос могут только квота и таймаут.

Без человека не давайте вопросу ждать вечно

timeout_s=None означает «ждать вечно». Когда за run никто не следит, одного вопроса хватит, чтобы десятичасовой run встал на месте — без ошибки, без таймаута, и по логу это неотличимо. В автономном режиме корректных значений всего два: 0 (сразу пустой ответ) или конечное число секунд.

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

Форма Event

@dataclass
class Event:
    kind: EventKind                     # 15 значений, см. таблицу ниже
    text: str = ""
    tool: str = ""                      # заполнено только у tool_call
    payload: dict[str, Any] = field(default_factory=dict)
    raw: Any = None                     # исходный объект SDK / Ask, тронул — привязался к SDK

str(ev): для tool_call это [имя инструмента] краткое описание, для остальных — text; если text пуст — <kind>.

15 значений EventKind

kind Кто порождает Когда появляется text payload
text normalize() Основной текст модели Текст subagent, parent_tool_use_id?, context?
thinking normalize() Блок размышления Содержание размышления То же
prompt normalize() Ввод: ваш prompt, задание, выданное subagent Текст ввода То же
tool_call normalize() Модель инициирует вызов инструмента Краткое описание (file_path / command / pattern, обрезано до 200 символов) id, input + то же; tool — имя инструмента
tool_result normalize() Возврат инструмента Первые 500 символов (пусто, если содержимое не строка) tool_use_id, is_error + то же
result normalize() Завершение одного запроса к SDK subtype session_id, cost_usd, num_turns, is_error
error normalize() Синтетическое сообщение при обрыве связи Текст ошибки synthetic: True
reset normalize() Граница compact или сброс сессии 压缩(trigger) 167000 → 42000 tokens; при сбросе сессии — conversation reset trigger, pre_tokens, post_tokens, micro, subtype (пусто при сбросе сессии)
system normalize() Прочие системные сообщения SDK subtype data передаётся как есть
task normalize() Сообщение о прогрессе задачи Пусто kind = имя класса сообщения SDK
unknown normalize() Нераспознанный тип сообщения Имя класса

У task пустой текст, не печатайте его напрямую

TaskProgressMessage и подобные — внутренние типы сообщений SDK. Раньше normalize() отдавал имя класса как основной текст; на экране это чистый шум, а вперемешку с основным текстом agent выглядит как ошибка (проверено на практике). Теперь это событие без текста, а имя класса лежит в payload["kind"] — показывать его или нет, решает сам слой взаимодействия (events.py).

| ask | HumanChannel | Нужен ответ человека, у вопроса появился исход, или человек сам что-то сказал | Вопрос / реплика человека | Две роли, см. ниже | | retry | Runtime | Идёт повтор / ожидание сети | Одна поясняющая строка | step, attempt | | step | Workflow.run | Граница шага | Имя шага | index, total, resumed, woke | | handoff | Runtime | Handoff: приближение / запись / завершение | Пояснение с уровнем заполнения | phase, step, context, window + см. ниже |

Четыре kind порождаются не в normalize(): ask приходит из HumanChannel, retry и handoff — из Runtime, step — из Workflow.run. Помещать их в один и тот же EventKind — сознательное решение: UI знает один набор Event и не должен заводить отдельный путь для «нужен ответ человека» или «граница шага».

Когда пишете UI, оставляйте ветку else. В EventKind будут добавляться новые значения, и старый UI не должен из-за этого падать.

Три phase у handoff

phase Когда отправляется Что дополнительно в payload
near Уровень заполнения перешёл warn_at. Одно событие на поколение, экран не заливается at (порог handoff)
writing Начата запись handoff-документа. Запись занимает десяток секунд, без этого события интерфейс выглядит зависшим
done Handoff записан, начата новая сессия degraded (деградированная версия или нет), path (куда записано, пустая строка при отсутствии workbench), sections

Сам механизм описан в handoff.

Две роли ask

Event("ask") несёт одновременно и «вопрос», и «реплику, которую человек сказал сам», поэтому UI обязан сначала посмотреть payload["kind"]:

Роль Как распознать payload
Вопрос Ключа kind нет id, options, state, answer, remaining, asked_at; в raw лежит тот самый Ask
Реплика человека payload["kind"] == "mail" kind, state (queued — положено / delivered — забрано), id, amended (в какой файл дописано, пустая строка если не настроено). Нет options и remaining

Один вопрос порождает не менее двух событий: при постановке (state="asked") и при появлении исхода (answered / timeout / declined / over_budget / invalid). UI достаточно обновлять одну и ту же запись по payload["id"].

Спросить человека: Ask и HumanChannel

@dataclass
class Ask:
    id: str                                             # "q1", "q2"…
    question: str
    options: list[str] = field(default_factory=list)
    asked_at: float = field(default_factory=time.time)
    state: str = "asked"                                # пять исходов, см. выше
    answer: str = ""

    @property
    def waited_s(self) -> float: ...                    # сколько секунд ждал, один знак после запятой
    def event(self, remaining: int = 0) -> Event: ...

HumanChannel — это внутрипроцессный MCP-сервер плюс набор методов для UI. Со стороны модели видны только два инструмента: mcp__human__ask (задать вопрос, вызов подвисает в ожидании) и mcp__human__inbox (проверить inbox, не блокирует, при пустом ящике сразу возвращает поясняющую строку). Полный конструктор:

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

amend_path пропускают чаще всего, а именно он определяет, переживёт ли границу шага изменённое человеком по ходу дела требование. Каждый шаг — это новая сессия и read-only срез: сказанное во время run попало только в контекст того agent, который тогда работал, а следующий шаг (например, вердикт) — совершенно новая сессия, читающая 需求.md и 目标.md, и она не видит вашей реплики, поэтому судит по старым границам и признаёт исправленное выходом за рамки. amend_path дописывает каждое сообщение в brief — именно дописывает, а не перезаписывает: прежнее требование остаётся историей, и видеть, что именно изменилось, лучше, чем не видеть. starter_flow по умолчанию подключает <工作台>/notes/需求.md.

На практике ($0.6767) это сработало даже лучше ожидаемого: человек сказал «заодно сообщи суммарное число байт», координатор проверил inbox и ответил — «hand уже прочитал это из дописанного по ходу run раздела в .flower/notes/需求.md и посчитал, повторно отправлять не нужно». Subagent прочитал это из файла, а не с чьего-то пересказа.

Публичные члены:

Член Сигнатура Семантика
tool_name -> str "mcp__human__ask"
inbox_name -> str "mcp__human__inbox"
mcp_servers () -> dict Кладётся напрямую в AgentSpec.mcp_servers. Имя ключа обязано совпадать с именем сервера, поэтому оно выдаётся вместе
ask async (question, options=None) -> Ask Подвисает в ожидании человека. Никогда не бросает исключений, кроме CancelledError — отсутствие ответа тоже ответ, различайте по ask.state
pending () -> list[Ask] Вопросы, висящие в ожидании ответа
next_ask async (timeout=None) -> Ask \| None Для pull-режима UI. По таймауту возвращает None, при отмене бросает
answer (ask_id, text) -> bool Ответить. False = этот вопрос уже никто не ждёт (таймаут / уже отвечено)
decline (ask_id, reason="") -> bool Пропустить, модель решает сама и записывает допущение в раздел «未知与假设»
send (text) -> Mail \| None Человек говорит сам, реплика идёт в inbox. Agent не прерывается; внутри автоматически вызывается amend()
amend (text, *, label="运行中补充") -> bool Дописать в amend_path. Возвращает, была ли запись реально сделана (не настроен путь / пустой текст / OSError — всё это False)
pending_mail () -> list[Mail] Реплики, которые ещё не забрали
remaining -> int Сколько вопросов осталось. При max_asks=None возвращает -1, а не 0
transcript () -> str Markdown с записью вопросов и ответов
asks / mail / ui_errors list Все вопросы / все реплики человека / исключения, брошенные callback'ами UI

answer, decline, send можно вызывать из любого потока. Поток обработки запросов Web-бэкенда и поток ввода TUI живут в других потоках — это норма, а не краевой случай. Внутри используется loop.call_soon_threadsafe, потому что asyncio.Future.set_result не потокобезопасен.

Push и pull — выбирайте что-то одно:

Как получать Кому подходит
Push HumanChannel(on_event=…), ловим kind == "ask" и payload["state"] == "asked" Событийно-управляемый UI (Web push, перерисовка TUI)
Pull await channel.next_ask() Отдельная задача ввода

Три варианта «0 / None» означают разное, и если их перепутать — в автономном режиме будет либо зависание, либо ни одного вопроса:

Запись Значение
max_asks=None Без ограничения по числу вопросов (по умолчанию)
max_asks=0 Спрашивать нельзя, сразу отказ
timeout_s=None Ждать вечно
timeout_s<=0 Не ждать, вопрос сразу закрывается пустым
remaining возвращает -1 Значение при max_asks=None, а не 0

Прерывание: остановить можно из любого потока

rt.interrupt("别改 Makefile,那两行直接改"); пустая строка — просто прервать, ничего не говоря. Три свойства:

  • Продолжение той же сессии (resume), а не старт с нуля — сделанная работа и контекст на месте. Переиспользуется готовый путь повтора после обрыва сети, меняется только «причина сбоя» на «человек прервал» и resume_prompt на реплику человека.
  • Не расходует max_attempts. Это квота для сбоев, а не для человека.
  • Кооперативно: разрыв происходит на границе сообщения, задача не отменяется жёстко. Цена — задержка до следующего сообщения (если subagent сейчас работает, надо дождаться его возврата), выигрыш — состояние не рвётся на полпути.

Скажем и о цене честно: прерывание заставляет subagent в полёте потерять полуфабрикат (проверено на обрыве сети в HT001, см. issue #2). Референсная реализация в терминале пишет об этом прямо в подсказке, чтобы человек знал до нажатия; в своём UI стоит сделать так же.

Если прерывать не хочется, а надо лишь добавить требование, используйте inbox (ch.send(...)) — он ничего не прерывает, задержка равна ближайшей контрольной точке agent.

Oracle: спросить, не мешая run

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

Oracle — это read-only обходной путь. У него только инструменты Read / Glob / Grep, workbench включён, ограничители по умолчанию: max_turns=12, max_budget_usd=0.5. В терминале он вызывается строкой, начинающейся с ?, и отвечает по двум источникам: окно последних событий (фиксированные 60 штук) и brief, цель, заметки и артефакты в workbench. Он использует отдельный Runtime (<run_dir>/aside), поэтому затраты и происхождение сессий не попадают в основной manifest — этот манифест фиксирует, «какие шаги выполнил данный run», а мимоходом заданный вопрос шагом не является.

По замерам два вопроса стоили в сумме $0.5190, и manifest основного run не вырос ни на байт.

Два жёстких правила

on_event нельзя ни блокировать, ни выпускать из него исключения

Первое: on_event — синхронная функция, вызываемая в потоке event loop. Поэтому asyncio.Queue.put_nowait() безопасен, а await — нет (это не корутина), и блокировка здесь означает блокировку всего run. Медленную работу отправляйте в очередь, пусть её делает другая задача.

Второе: исключение из on_event вредит самому run. События с основным текстом отправляются внутри блока try в Runtime._attempt, исключение записывается в result.error — шаг считается упавшим; события retry отправляются вне этого блока, и исключение вылетает прямо из Runtime.run. Фронтенд не должен уносить трёхчасовую работу — оборачивайте тело callback в try.

Исключение: события ask, которые HumanChannel шлёт сам, уже обёрнуты; исключение попадает в channel.ui_errors и не прерывает run.

Когда этого делать не нужно

Чего не следует делать в слое взаимодействия

Чего не делать Почему Как надо
from claude_agent_sdk import ... Как только слой взаимодействия зависит от типов SDK, при обновлении SDK меняется и фронтенд — весь этот слой сделан зря Только kind / text / tool / payload из Event
Читать ev.raw То же самое, плюс он не сериализуется в JSON Не хватает деталей — добавьте их в payload внутри normalize(), не обходите границу
await, сетевые запросы, медленная запись на диск внутри on_event Функция синхронна и вызывается в потоке event loop, блокировка здесь блокирует весь run put_nowait() в очередь, потребляйте отдельной задачей
Выпускать исключения из on_event Исключение на событии с текстом превращается в result.error, и шаг считается упавшим Оберните всё тело callback в try
Отключать вопросы через disallowed_tools Он действует на уровне сессии и заодно запретит одноимённый инструмент у subagent (реальный текст ошибки: "Bash is disabled for this session, in subagents as well as here") max_asks=0 или timeout_s=0
Убирать mcp__human__ask из allowed_tools как способ запретить вопросы allowed_tools не исключающий: это список без запроса подтверждения, а не whitelist; подключён channel — выданы оба инструмента То же
Самому собирать путь и искать 需求.md / 目标.md У workbench два возможных расположения, при ошибке ничего не падает — просто тихо не работает wake_state() или wf.workbench
Собирать прогресс и результат из потока Event Основной текст рвётся handoff и повторами на несколько кусков on_step(step, result) даёт полный StepResult
timeout_s=None в автономном режиме Отвечать некому, run висит вечно, без ошибки и без таймаута 0 либо конечное число секунд

Когда менять вообще не надо

  • Хочется лишь поменять цвета, добавить или убрать одну строку вывода — достаточно поправить функцию рендеринга. Прерывание, oracle, квитанции inbox, спасение по SIGHUP / SIGTERM, ожидание завершения обходного пути перед выходом в референсной терминальной реализации переписывать заново дорого.
  • Нужно выполнить один шаг без интерактива — используйте flower once. Он не идёт по пути интерактивного драйвера, и там изначально нет ни прерывания по Ctrl+C, ни потока ответов на stdin, ни oracle, ни спасения по сигналам.
  • На самом деле хочется поменять workflow, а не UI — см. проектирование workflow. Слой взаимодействия решает только, кто смотрит и кто отвечает; сколько шагов выполнять, как выносить вердикт и когда выходить досрочно, решает Workflow.
  • Хочется поменять session store, модель или бюджет — ни одно из трёх не лежит на этой границе, см. справочник Python API.