Замена слоя взаимодействия¶
Ядро 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():
- Реплики subagent помечаются (
payload["subagent"]). Иначе задание на раздачу работы и промежуточные реплики subagent смешаются с основным текстом главного потока, а дальше по workflow загрязнят prompt следующего шага. - Синтетические сообщения об ошибке при обрыве связи отводятся в
kind="error". При обрыве SDK записываетAPI Error: …в transcript как assistant-сообщение, и выглядит оно как речь модели (modelравен"<synthetic>"). Если не перехватить его здесь, оно попадёт вStepResult.textи уедет в следующий шаг. - Границы compact сообщаются явно (
kind="reset"). После границы модель «помнит» только резюме, и кэш prompt тоже рвётся именно здесь — long-horizon run обязан это видеть. - Уровень заполнения контекста идёт с каждым сообщением (
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. Если вы подключили выход сами, он не будет перезаписан:
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 (в событии ask — Ask), он не сериализуется в 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.