Конфигурация¶
У flower нет формата файла конфигурации и нет подкоманды конфигурирования, которая реально работала бы, — вся конфигурация это переменные окружения плюс файл .env, плюс набор объектов-политик, которые можно задать только со стороны Python. Эта страница собирает в одно место то, что разбросано по пяти местам: каждую переменную, порядок поиска учётных данных, синтаксис, который понимает .env, что именно изолирует setting_sources=[], что один прогон оставляет на диске, что теряет каждый из трёх слоёв хранилища сессий и как оно ждёт при обрыве сети. Терминология везде по глоссарию.
| Что нужно узнать | Куда |
|---|---|
| Какие переменные окружения понимает flower | Полная таблица переменных окружения |
| Откуда вообще взялся мой токен | Приоритет поиска учётных данных |
Почему та строка в .env не сработала | Правила разбора .env |
| Что взять с собой при переезде на другую машину | Цена переносимости |
Что лежит в .flower/ и runs/ | Раскладка на диске |
| Какие сообщения не будут скормлены модели обратно | Три слоя хранилища сессий |
| Чего оно ждёт, когда сеть отвалилась | Устойчивость к обрыву сети |
Полная таблица переменных окружения¶
Пять групп: учётные данные и эндпоинт, которые flower читает напрямую, выбор модели, поиск путей, переключатели поведения и то, что flower пишет дочернему процессу агента. Последнюю группу задавать не нужно — если зададите, она всё равно будет перезаписана.
Учётные данные и эндпоинт¶
| Переменная | Назначение | По умолчанию | Обязательно | Источник |
|---|---|---|---|---|
ANTHROPIC_API_KEY | Официальный ключ Anthropic. Если он есть, запрос уходит с заголовком x-api-key | нет | Обязательна одна из двух вместе с ANTHROPIC_AUTH_TOKEN | env.py:28, :146, :157-158 |
ANTHROPIC_AUTH_TOKEN | Токен, выданный шлюзом. Когда нет ANTHROPIC_API_KEY, используется authorization: Bearer | нет | То же | env.py:28, :147, :159-160 |
ANTHROPIC_BASE_URL | Корневой адрес эндпоинта API. Сторонний шлюз указывает свой адрес, без /v1 — проба склеивает <BASE_URL>/v1/messages | https://api.anthropic.com | Нет | env.py:151, :162, :210; resilience.py:70 |
Если не заданы обе (или обе — пустые строки), check_credentials() возвращает ту самую ошибку из четырёх строк, а Runtime.__init__ бросает RuntimeError (env.py:184-194; runtime.py:156-158).
Выбор модели¶
flower читает только три из них для собственных решений, остальные просто загружаются и прозрачно передаются в SDK.
| Переменная | Назначение | По умолчанию | Обязательно | Источник |
|---|---|---|---|---|
ANTHROPIC_MODEL | Имя основной модели. Одновременно определяет значение окна хендоффа по умолчанию: в имени есть 1m или нет haiku → 1 миллион, есть haiku → 200 тысяч | нет (решает сторона) | Нет | env.py:153; agent.py:77-81 |
ANTHROPIC_DEFAULT_OPUS_MODEL | Отображение модели уровня opus. Когда ANTHROPIC_MODEL пуста, определение окна откатывается к ней | нет | Нет | agent.py:78; cli.py:1384 |
ANTHROPIC_DEFAULT_SONNET_MODEL | Отображение модели уровня sonnet. flower её сам не читает, только загружает и заимствует | нет | Нет | env.py:34; cli.py:1385 |
ANTHROPIC_DEFAULT_HAIKU_MODEL | Отображение модели уровня haiku. Проба учётных данных использует её в первую очередь | проба откатывается к ANTHROPIC_MODEL, затем к claude-3-5-haiku-20241022 | Нет | env.py:152-153 |
CLAUDE_CODE_SUBAGENT_MODEL | Какую модель использует subagent. flower её не интерпретирует, потребляет SDK | нет | Нет | env.py:35; .env.example |
CLAUDE_CODE_EFFORT_LEVEL | Уровень размышления. То же самое: только загружается, не интерпретируется | нет | Нет | env.py:35 |
Если в flower setup указано имя модели, то ANTHROPIC_MODEL, ANTHROPIC_DEFAULT_OPUS_MODEL и ANTHROPIC_DEFAULT_SONNET_MODEL пишутся все три сразу (cli.py:1383-1385).
Пути и поиск¶
| Переменная | Назначение | По умолчанию | Обязательно | Источник |
|---|---|---|---|---|
FLOWER_ENV | Задаёт путь к одному файлу .env, который идёт перед всеми остальными файлами | нет | Нет | env.py:48-49 |
XDG_CONFIG_HOME | Определяет расположение глобального файла с учётными данными $XDG_CONFIG_HOME/flower/.env | ~/.config | Нет | env.py:41-42 |
HOME | Источник для Path.home(), оба пути — ~/.config и ~/.claude — выводятся из него | даёт система | Нет | env.py:41, :67 |
Переключатели поведения¶
Оба — аварийные выходы: не задавать их нормально, а задают их, чтобы flower делал на одну вещь меньше. Срабатывают при любом непустом значении, само значение не разбирается (update.py:121; cli.py:1413).
| Переменная | Назначение | По умолчанию | Обязательно | Источник |
|---|---|---|---|---|
FLOWER_NO_UPDATE | Выключает автообновление. Если не задана, установленный через pip / pipx / uv flower при старте поднимает фоновый поток, проверяет, нет ли новой версии, и при наличии ставит её, но действует это только со следующего запуска flower; проверка максимум раз в 24 часа, отметка времени лежит в ~/.config/flower/.update | нет (автообновление включено) | Нет | update.py:32-33, :121-124 |
FLOWER_NO_PROBE | Пропускает ту самую пробу учётных данных на старте. В неинтерактивном режиме (пайп / CI / перенаправленный stdin) проба и так не выполняется, эта переменная — лазейка для интерактивного терминала | нет (в интерактивном режиме проба выполняется) | Нет | cli.py:1413 |
flower, запущенный из git-исходников, автообновления не касается, и FLOWER_NO_UPDATE для него — пустая операция: шаг команды обновления, увидев .git в репозитории, сразу возвращает None (update.py:83-87).
Что flower пишет дочернему процессу агента¶
Эти три генерирует CompactPolicy.env() и кладёт в ClaudeAgentOptions.env (agent.py:48-58, :241-245), они управляют встроенным в harness compact. Задавать их у себя в shell бессмысленно — реально действует та копия, которую flower передаёт дочернему процессу.
| Переменная | Назначение | По умолчанию | Обязательно | Источник |
|---|---|---|---|---|
DISABLE_AUTO_COMPACT | =1 выключает автоматический compact. Когда включён хендофф, записывается принудительно — если два механизма работают одновременно, невозможно понять, чьих рук откат контекста | хендофф включён по умолчанию, поэтому фактически всегда 1 | Нет (пишет flower) | agent.py:51; runtime.py:444-447 |
DISABLE_COMPACT | =1 выключает заодно и /compact. Пишется только при CompactPolicy(mode="off") | не пишется | Нет (пишет flower) | agent.py:52-53 |
CLAUDE_CODE_AUTO_COMPACT_WINDOW | Окно автоматического compact (в токенах). Пишется только при CompactPolicy(window=N) | не пишется | Нет (пишет flower) | agent.py:56-57 |
Что читает контейнерная обёртка¶
Эти две читает не сам flower, а shell-обёртка docker/flowerbox. Полное описание — в развёртывании.
| Переменная | Назначение | По умолчанию | Обязательно | Источник |
|---|---|---|---|---|
FLOWER_HOME | Где искать тот .env, который нужен для --env-file | каталог на уровень выше самого скрипта | Нет | docker/flowerbox:12 |
FLOWER_IMAGE | Какой образ использовать | flower-box | Нет | docker/flowerbox:13 |
Ключи в .env не ограничены перечисленными выше. Парсер загружает все строки вида k=v в os.environ, белого списка нет (env.py:30, :102-107). Набор KNOWN из тех 9 ключей учётных данных работает только в двух местах: как белый список при заимствовании конфигурации из ~/.claude (env.py:72) и как набор полей, печатаемых describe() при запуске с -v (env.py:205).
Приоритет поиска учётных данных¶
Когда load_dotenv() вызывается без пути, он читает все существующие файлы в следующем порядке (env.py:45-53, :78-112):
- Переменные окружения процесса — всегда наивысший приоритет. Никакой
.envне перебьёт уже экспортированное значение. (env.py:91) - Файл, на который указывает
$FLOWER_ENV— этот пункт есть, только если она задана. (env.py:48-49) $PWD/.env— текущий рабочий каталог. В какой проектcd-нулся, тот и читается. (env.py:50)${XDG_CONFIG_HOME:-~/.config}/flower/.env— глобальное место, по одному на пользователя; именно его пишетflower setup. (env.py:51,:39-42).envв корне репозитория с исходниками — три уровня вверх отflower/core/env.py. Существует только при запуске из исходников; у flower, установленного через pip / pipx / uv, он лежит в site-packages, и этого пункта нет. (env.py:52)- Блок
envиз~/.claude/settings.json, затем из~/.claude/settings.local.json— последний откат, берутся только 9 ключей учётных данных. (env.py:56-75,:109-111)
Какой файл побеждает: пункт 3 (проектный .env) побеждает пункт 4 (глобальный .env), пункт 4 побеждает пункт 5 (.env в корне репозитория), все трое побеждают пункт 6 (конфигурация Claude Code), и все вместе проигрывают пункту 1 (окружение процесса).
Реализовано это как «уже имеющий значение ключ не перезаписывается» (env.py:90-93): те, кто идёт раньше, занимают ключи, а идущие следом лишь заполняют пустоты. Поэтому приоритет считается по ключам, а не по файлам — если в проектном .env написан только ANTHROPIC_BASE_URL, токен спокойно может прийти из глобального. Первое встреченное значение одноимённого ключа фиксируется навсегда.
Пункт 6 включается только при автоматическом поиске. Если путь задан явно (load_dotenv("/path/to/.env")), читается только этот один файл, никаких откатов (env.py:86-87, :109).
Пункт 6: заимствование токена у Claude Code¶
Последовательно читаются ~/.claude/settings.json и ~/.claude/settings.local.json, берётся словарь data["env"], из него выбираются эти 9 ключей (env.py:31-36, :65-74):
ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN ANTHROPIC_BASE_URL
ANTHROPIC_MODEL ANTHROPIC_DEFAULT_OPUS_MODEL ANTHROPIC_DEFAULT_SONNET_MODEL
ANTHROPIC_DEFAULT_HAIKU_MODEL CLAUDE_CODE_SUBAGENT_MODEL CLAUDE_CODE_EFFORT_LEVEL
Если файла нет, он не читается или это не валидный JSON (OSError / ValueError), возвращается пустой словарь и работа продолжается — отказ отката не должен уносить с собой этот прогон (env.py:62-63, :66-69).
Позиция в коде такова: заимствуется только «где искать токен», всё остальное из settings.json (правила разрешений, hook-и, настройки моделей) не перенимается, поэтому обещание переносимости setting_sources=[] не нарушается (env.py:17-19, :59-61). В install.sh:77 это подаётся как фича: тот, у кого на машине настроен Claude Code, вообще не увидит экрана конфигурации.
Текст ошибки в продукте противоречит реальному поведению
Когда учётные данные не найдены совсем, последняя строка выводимой flower ошибки такая:
(env.py:184-194, сама фраза на :192; то же утверждение встречается в .env.example:2, env.py:3-4, agent.py:10-12.) Верить нужно коду: он читает. env.py:56-75 вместе с :109-111 явно читают эти два файла, а install.sh:77 ещё и подаёт это как преимущество. Этот текст сейчас вводит в заблуждение — на машине, где настроен Claude Code, ваш токен, скорее всего, пришёл именно оттуда.
Правила разбора .env¶
Правила разбора настолько коротки, что их можно запомнить наизусть (env.py:95-107, 13 строк): построчный strip, пропускаются пустые строки, строки, начинающиеся с #, и строки без =; остальное режется по первому = на key и value, к обеим частям применяется strip, а к value ещё и .strip("'\"") — одинарные и двойные кавычки по краям срезаются, парность не требуется.
Понимает вот это:
| Запись | Результат |
|---|---|
KEY=VALUE | Нормально |
KEY = VALUE | Нормально — пробелы вокруг знака равенства срезаются |
KEY="VALUE" / KEY='VALUE' | Нормально — кавычки по краям срезаются |
KEY=a=b | value равно a=b — режется по первому =, последующие знаки равенства остаются в значении как есть |
# комментарий | Строка пропускается целиком |
| Пустая строка | Пропускается |
Вот это не понимает. Ошибки не будет, вы просто молча получите неожиданное значение:
| Запись | Фактический результат |
|---|---|
export KEY=VALUE | ключом становится export KEY, а у самого KEY значения по-прежнему нет |
KEY=value # пояснение | value равно value # пояснение — концевой комментарий не отрезается |
KEY=$OTHER | литерал $OTHER, подстановка переменных не выполняется |
| Многострочное значение (в кавычках через строки) | обрабатывается построчно, вторая строка не содержит = и пропускается целиком |
Пустое значение занимает ключ. Если ANTHROPIC_AUTH_TOKEN= встретился в файле с высоким приоритетом, take() выполнит os.environ["ANTHROPIC_AUTH_TOKEN"] = "", и последующие файлы уже ничего не подставят, потому что «ключ существует» (env.py:90-93); а check_credentials() проверяет истинность, и пустая строка считается «не настроено» (env.py:186). В итоге нет ни учётных данных, ни отката. Не нужен ключ — удаляйте строку целиком, не оставляйте пустую.
Цена переносимости¶
Весь механизм — в одной строке build_options() (agent.py:207):
portable=True — значение Runtime по умолчанию, и выключателя в командной строке для него нет — отключить можно только через Python API, написав Runtime(portable=False); тогда будет ["project"], то есть чтение проектного .claude/.
Что изолируется¶
| Что изолируется | Последствие |
|---|---|
Настройки ~/.claude/ на хосте | Правила разрешений, hook-и и настройки моделей оттуда не действуют вовсе. Учётные данные — единственное исключение, см. заимствование |
Проектный .claude/ | То же самое, читается только при portable=False |
Доменные возможности идут не этим путём — они распространяются вместе с репозиторием и загружаются через plugins=[{"type": "local", "path": PLUGIN_DIR}] (agent.py:26, :210-212), см. развёртывание. Доменные инструкции же дописываются после нативного системного промпта Claude Code, а не заменяют его (agent.py:198-202), так что специализация не покупается ценой потери общих способностей.
Что взять с собой при переезде на другую машину¶
- Учётные данные: один файл. Достаточно скопировать
~/.config/flower/.envлибо настроить заново на новой машине. Без него не запустится ничего — автоматически не наследуется ровным счётом ничего. - Состояние преемственности: весь каталог.
runs/(база сессий, манифест, родословная) и.flower/(верстак). - Но пути должны совпадать. В
lineage.jsonсохранён абсолютный путь рабочей области; если он не совпадает, файл считается отсутствующим, происходит молчаливый откат к новой сессии, без ошибки (lineage.py:65-66). Причина в том, что SDK выводитproject_keyиз пути рабочей области (/,_,.заменяются на-,runtime.py:40-41), и при переносе каталога старыйsession_idуже не находится.
Раскладка на диске¶
Один прогон flower пишет два дерева: <run_dir>/ — счета и сессии, <workspace>/.flower/ — верстак. Оба по умолчанию лежат в текущем каталоге, но база отсчёта у них разная.
runs/ следует за текущим каталогом, а не за -w
-r/--run-dir по умолчанию "runs", а Runtime делает с ним Path(run_dir).resolve() (runtime.py:93-94) — относительно текущего рабочего каталога, а не относительно рабочей области, заданной через -w. Если запустить flower -w /path/to/proj из ~, база сессий окажется в ~/runs/, а не в проекте.
<run_dir>/ — по умолчанию ./runs/¶
runs/
sessions.db SQLite, полный transcript (включая отдельную запись каждого subagent)
manifest.json манифест прогона: session_id / расход / повторы / причина отказа по каждому шагу, накапливается между процессами
lineage.json родословная: имя шага → session_id, по ней подхватывается повторный запуск в том же каталоге
aside/ отдельный Runtime для вопросов оракулу, со своими sessions.db + manifest.json
workbench/ только на пути run / once и только если задан -W
| Путь | Содержимое | Источник |
|---|---|---|
runs/sessions.db | Полный transcript. Пишет его PruningSessionStore, три слоя политик — ниже | runtime.py:109-112 |
runs/manifest.json | JSON-массив, манифест прогона, накапливаемый между процессами. Поля — в таблице ниже | runtime.py:532-533, :564-586 |
runs/lineage.json | {"workspace": "…", "woke": N, "steps": {"步骤名": "session_id"}}. Сначала пишется .tmp, потом replace — атомарная замена | lineage.py:31, :87-97 |
runs/aside/ | Отдельный Runtime оракула. Расход и родословная не смешиваются с основным манифестом | cli.py:741-743 |
runs/workbench/ | Расположение верстака по умолчанию при Runtime(workbench=True), вне рабочей области. Путь go его не использует | runtime.py:148-151 |
Каждая строка manifest.json — это asdict(StepResult) плюс две заплатки (runtime.py:44-71, :579-582):
| Поле | Тип | Смысл |
|---|---|---|
step | str | Имя шага. Четыре формы: <名>, <名>#round<N> (возвращён на переделку), <名>#retry<N> (обычный повтор), <名>·判定#<N> (судья) |
session_id | str \| None | Последняя живая сессия этого шага |
ok | bool | Получилось или нет |
cost_usd | float | Сколько стоил этот шаг |
num_turns | int | Сколько раундов отработано |
text | str | Итоговый ответ этого шага |
error | str \| None | Причина отказа. При убийстве по SIGHUP / SIGTERM — killed-by-signal (runtime.py:556-558) |
started_at / ended_at | float | Секунды epoch |
attempts | int | Фактическое число попыток. >1 означает, что были повторы |
errors | list[str] | Причины всех предыдущих отказов. Только здесь, модель их не видит |
resumed | bool | Подхвачено ли с места обрыва через resume, а не запущено заново |
retired | list[str] | session_id, сожжённые при хендоффе на этом шаге, по порядку |
context | int | Размер контекста, реально увиденный главным потоком в последнем раунде |
duration_s | float | Дописывается вручную — это @property, и asdict() его не берёт |
run | str | Метка данного процесса YYYYmmdd-HHMMSS-<6 位 hex>. Обязана быть уникальной для каждого экземпляра |
Стратегия записи — дописывание, а не перезапись: перед каждым сбросом на диск файл перечитывается, строки с run, равным своему, заменяются на актуальные, чужие строки остаются как есть (runtime.py:564-586). Поэтому при параллельном запуске нескольких flower в одном каталоге счета не затирают друг друга.
Содержимое runs/ — чистые данные, их в любой момент можно офлайн полистать через sqlite3 или tools/analyze_run.py.
<workspace>/.flower/ — верстак¶
.flower/
INDEX.md автоматически генерируемый индекс, вставляется в system prompt главного агента
scripts/ скрипты, которые придётся запустить второй раз. Первая строка `# desc: одна фраза` попадает в индекс
artifacts/ длинные результаты свыше 2000 символов: отчёты, данные, логи
notes/ записи решений, переживающие смену шагов
spill/ крупные результаты инструментов, сброшенные на диск; имя файла = первые 16 знаков sha256 содержимого + `.txt`
Три подкаталога и индекс создаёт Workbench (workbench.py:73-92). INDEX.md идёт через system_prompt.append уровня сессии, и subagent его не наследует — поэтому правило «длинные результаты пиши в artifacts/» обязан пересказать координатор в таск-брифе, это единственный канал.
Путь go всегда создаёт в notes/ вот это:
| Файл | Содержимое | Источник |
|---|---|---|
notes/需求.md | Замороженный бриф, четыре раздела: цель / критерии приёмки / границы / неизвестное и допущения | brief.py:44-45; clarify.py:105 |
notes/目标.md | Замороженные два раздела: цель / чек-лист вердикта | workflow/goal.py:124 |
notes/问答记录.md | Дописываемая запись всех вопросов и ответов, включая записи входящих «человек сказал сам». В контекст не идёт, только для архива | human.py:421-433 |
notes/交接-<步骤名>.md | Документ хендоффа. Предыдущее поколение убирается в notes/archive/交接/<步骤名>-<时间戳>.md | runtime.py:388-403 |
notes/archive/<YYYYmmdd-HHMMSS>/ | Архив lineage.json + 需求.md + 目标.md при --new / /new (перемещение, не удаление) | lineage.py:100-117 |
При --isolate верстак переезжает за пределы репозитория: <родительский каталог workspace>/.flower-<имя workspace>/ (starter.py:47-55). worktree — приватная копия каждого агента, а верстак — общий слой между агентами, и общее нельзя класть внутрь приватной ограды. В этом случае модели выдаётся абсолютный путь (workbench.py:69-71, :142-145).
У spill/ два писателя, и алгоритм выбора места у них разный:
| Кто пишет | Когда | Куда пишет | Порог |
|---|---|---|---|
spill_guard (hook PostToolUse) | до того, как результат инструмента попадёт в модель | <корень верстака>/spill/ (guard.py:130) | spill_threshold, по умолчанию 4000 символов |
TrimPolicy (при load) | при переигрывании истории перед resume | <workspace>/.flower/spill/ — фиксированная строка относительно рабочей области (trim.py:49, :303) | min_chars, по умолчанию 2000 символов |
При раскладке по умолчанию это один и тот же каталог. Но когда верстак уезжает (-W кладёт его в runs/workbench/, либо --isolate выносит его за пределы репозитория), они расходятся — копия TrimPolicy всегда остаётся в рабочей области, потому что Read агента обязан до неё дотянуться.
spill_guard подставляет не одну строку, а строку-указатель плюс первые 400 символов (guard.py:132-140). Вызовы, читающие сам сброшенный файл, пропускаются, иначе «читай полный текст через Read» — пустые слова: прочитанное снова превысит порог, снова будет сброшено на диск, бесконечный цикл (guard.py:155-170).
Схема таблиц sessions.db¶
Три таблицы, DDL — в stores/sqlite.py:27-51:
CREATE TABLE entries (
store_key TEXT NOT NULL,
seq INTEGER NOT NULL,
uid TEXT,
payload TEXT NOT NULL,
PRIMARY KEY (store_key, seq)
);
CREATE UNIQUE INDEX entries_uid
ON entries(store_key, uid) WHERE uid IS NOT NULL;
CREATE TABLE meta (
store_key TEXT PRIMARY KEY,
mtime INTEGER NOT NULL,
next_seq INTEGER NOT NULL
);
CREATE TABLE summaries (
project_key TEXT NOT NULL,
session_id TEXT NOT NULL,
mtime INTEGER NOT NULL,
data TEXT NOT NULL,
PRIMARY KEY (project_key, session_id)
);
| Таблица | Что такое одна строка | Ключевое |
|---|---|---|
entries | Одна запись transcript, payload — исходный JSON | uid — это uuid записи, служит ключом идемпотентности: неудачная пачка повторяется 3 раза, и переигрывание не должно порождать дубликатов. Записи без uuid (заголовки, метки, маркеры режима) не дедуплицируются, поэтому в уникальном индексе стоит WHERE uid IS NOT NULL |
meta | Курсор одной сессии | next_seq — следующий порядковый номер, mtime — метка времени в миллисекундах и строго монотонная (sqlite.py:72-79): list_sessions и summary используют эти общие часы, при немонотонности SDK ошибётся в определении «новее/старше» и уйдёт не по тому быстрому пути |
summaries | Sidecar-сводка одного главного потока | Участвует только основной transcript, subagent-овские не считаются (sqlite.py:122-123) |
构造 store_key (sqlite.py:54-58): <project_key>/<session_id>, для subagent дописывается ещё сегмент subpath. project_key SDK выводит из пути рабочей области — /, _, . заменяются на -.
Посмотрим на реальном образце (human-test/HT002/runs/sessions.db):
-Users-hechenyu-explore-test-ide/601c8c91-6c4b-4525-8a5f-295b99bf9515|37
-Users-hechenyu-explore-test-ide/47395075-bec7-466e-80cd-f4d60b360235|80
-Users-hechenyu-explore-test-ide/47395075-…/subagents/agent-a99a6ce30a5471f44|104
В этом файле 956 записей entries, 10 записей meta, 4 записи summaries — из 10 сессий 4 являются основными transcript, 6 принадлежат subagent-ам, и summaries в точности совпадает с числом основных transcript.
Три слоя хранилища сессий¶
Три слоя — это цепочка наследования, а не набор опций
PruningSessionStore наследует TrimmingSessionStore, который наследует SqliteSessionStore. Runtime всегда конструирует самый внешний (runtime.py:109-112), и среди параметров конструктора нет точки входа для смены бэкенда. Способ «выключить какой-то слой» — выставить enabled его объекту политики в False, а не менять класс.
append (запись) всегда сбрасывает на диск всё целиком, не меняя ни знака. Три слоя влияют только на load (ту копию, которую скармливают модели обратно). Фактический порядок в load такой:
SqliteSessionStore.load читает всё из таблицы entries по seq
→ TrimmingSessionStore.expire() результаты Bash с истёкшим сроком → заменяются на «устарело»
→ TrimmingSessionStore.trim() старые крупные tool_result → сброс на диск + замена на указатель
→ PruningSessionStore.prune() синтетические сообщения об ошибках / старые отклонённые вызовы → удаляются целиком с перепривязкой цепочки
| Слой | Класс | Что теряет | Критерий |
|---|---|---|---|
| 1 | SqliteSessionStore | Ничего не теряет | —— |
| 2 | TrimmingSessionStore | Тела крупных результатов инструментов, устаревшие результаты одноразовых команд | объём + срок годности |
| 3 | PruningSessionStore | Мусор от обрывов связи, старые отклонённые вызовы | ошибка это или нет |
Слой 2 — это подрезка, слой 3 — отсечение: подрезка выбрасывает по объёму и ценности, отсечение — по признаку «ошибка или нет», не путайте. Полные сигнатуры — в Python API.
SqliteSessionStore — фундамент¶
Реализация на SQLite без внешних зависимостей. Чтобы перейти на Postgres / S3 / Redis, достаточно реализовать тот же протокол; у SDK есть готовый набор тестов на соответствие claude_agent_sdk.testing.session_store_conformance для прямой проверки (sqlite.py:1-8).
Кроме методов протокола есть ещё три синхронных запроса для нужд самого flower:
| Метод | Возвращает | Назначение |
|---|---|---|
projects() | list[str] | Реально существующие в базе project_key. SDK выводит его из cwd, но перед запросом лучше уточнить этим методом, а не гадать |
has_session(project_key, session_id) | bool | Читает одну строку meta, payload не трогает. Проверяется до старта преемственности — resume несуществующей сессии взорвётся уже после запуска дочернего процесса, когда деньги и время потрачены |
last_context(project_key, session_id, scan=60) | int | Какой размер контекста видел последний раунд этой сессии. Сканирует с конца только последние 60 записей. Считаются input_tokens плюс оба cache_* — если смотреть только на первый, при попадании в кэш он близок к 0 и сильно занижает оценку |
TrimmingSessionStore + TrimPolicy / EphemeralPolicy¶
TrimmingSessionStore(path, workspace, policy: TrimPolicy | None = None,
ephemeral: EphemeralPolicy | None = None)
Два ортогональных правила. TrimPolicy отвечает за объём:
| Параметр | Тип | По умолчанию | Смысл |
|---|---|---|---|
keep_recent | int | 20 | Последние N tool_result сохраняются в оригинале — контекст, которым сейчас пользуются, подрезать нельзя |
min_chars | int | 2000 | Короче этого не подрезается. Замена на указатель обошлась бы дороже по токенам |
spill_dirname | str | ".flower/spill" | Каталог архива, относительно workspace. Обязан быть внутри рабочей области, иначе Read агента до него не дотянется |
enabled | bool | True | При Runtime(trim=False) (по умолчанию) равен False |
Подрезанное тело пишется как <первые 16 знаков sha256>.txt, а на его месте появляется [工具结果已归档:N 字符。完整内容在 <路径>,需要时用 Read 读取] (trim.py:54-57, :308-317).
EphemeralPolicy отвечает за срок годности: результаты вроде git status, ls, ps очень короткие, по объёму до подрезки дело не дойдёт никогда, но их корректность затухает со временем — тот git status двадцать раундов назад не «бесполезен», он вводит в заблуждение.
| Параметр | Тип | По умолчанию | Смысл |
|---|---|---|---|
enabled | bool | True | Преобразуется из Runtime(ephemeral=…), по умолчанию включено |
keep_recent | int | 6 | Последние N записей сохраняются в оригинале. Намного меньше, чем 20 у TrimPolicy, — окно «недавнего» у таких вещей и так короткое |
max_chars | int | 2000 | Сверх этого отдаётся TrimPolicy на сброс и архивирование, этим путём не идёт |
text | str | "[{cmd} 的结果已过期(第 {age} 轮前),当前状态可能已变。需要请重新执行]" | Текст замены |
Действует только на результаты инструмента Bash, и команда должна попадать под EPHEMERAL_CMD. Read сюда не входит: содержимое файла не искажается со временем настолько, чтобы вводить в заблуждение, и оно вполне может быть основанием рассуждений модели (trim.py:153-160). Устаревшее содержимое на диск не сбрасывается — архивировать протухший git status бессмысленно, достаточно выполнить его заново.
Функция принятия решения — is_ephemeral(cmd), и она одновременно является списком разрешений, возвращаемым координатору: delegate_guard(allow_glance=True) использует ту же самую функцию (trim.py:63-68, :128-150). Эти два множества обязаны всегда совпадать: если пропустили, но не подрезаем — протухший git status навсегда занимает контекст; если подрезаем, но не пропускаем — координатор ради одного ls отправляет subagent, меняя стоимость запуска 4.3k на несколько десятков символов. Добавить команду в белый список — значит сказать обе эти фразы сразу.
Когда что использовать:
- Нужно только не пускать в контекст мусор от обрывов связи → делать ничего не нужно,
Runtimeпо умолчанию и так используетPruningSessionStore.trim=Falseлишь не подрезает крупные результаты, отсечение всё равно выполняется. - Долгий прогон, крупный вывод инструментов →
trim=True. В CLI на путиgoэто уже включено по умолчанию, выключается обратно через--no-trim. - У координатора включён
glance=True→ephemeralобязан оставаться включённым, обоснование в предыдущем абзаце.
PruningSessionStore + PrunePolicy¶
PruningSessionStore(path, workspace, policy: TrimPolicy | None = None,
prune: PrunePolicy | None = None,
ephemeral: EphemeralPolicy | None = None)
| Параметр | Тип | По умолчанию | Смысл |
|---|---|---|---|
drop_api_errors | bool | True | Удаляет синтетические сообщения с isApiErrorMessage=true или message.model == "<synthetic>" |
neutralize_interrupts | bool | True | У tool_result с [Request interrupted …] заменяется тело, блок не удаляется |
interrupt_text | str | "[上一轮在此处被中断,该工具结果未产生]" | Текст замены предыдущего пункта |
heal_orphans | bool | True | Дописывает синтетический результат осиротевшим вызовам, у которых есть tool_use, но нет tool_result |
orphan_text | str | "[这一步被打断了,没有结果。需要的话重做。]" | Тело дописанного tool_result |
keep_denials | int | 1 | Сохраняет последние N вызовов инструментов, отклонённых permission hook; более ранние удаляются вместе с вызовом и результатом |
keep_denials — единственный параметр конструктора Runtime, пробрасываемый в этот слой (Runtime(keep_denials=N)). Причина, по которой по умолчанию 1, а не 0: самый свежий отказ — полезный сигнал, он мешает модели в одном и том же раунде раз за разом повторять заблокированную команду. Больше не ставьте — отклонённый вызов никогда не выполнялся, в результате нет никакой информации, по замерам один такой занимает 273 символа (93 знака текста отказа плюс 180 знаков исходной мёртвой команды), и он вводит в заблуждение: по замерам, прочитав несколько «не использовать Bash напрямую», координатор перестаёт пробовать даже разрешённый git status и просто говорит: «Bash ограничен, отправлю агента посмотреть» (prune.py:135-148).
heal_orphans лечит стабильный 400 на каждом resume после прерывания: прерывание обрывает по границе сообщения, и за находившимся в полёте tool_use может вообще не оказаться tool_result, а API требует их парности. Эта испорченная история остаётся в transcript и сама не исчезнет, так что каждый последующий resume ею и отбивается. Лечение: после того assistant-сообщения, где есть сироты, вставляется запись user, в которой разом дописываются результаты для всех сирот этого сообщения, а parentUuid, который раньше указывал на то assistant-сообщение, перенаправляется на вставленную запись (prune.py:95-147). Дописать, а не удалить: удаление сироты потребовало бы перепривязки цепочки родитель–потомок, а в том же assistant-сообщении могут быть и нормальные блоки, и текст, и thinking — легко зацепить лишнее (prune.py:195-204).
Три структурные красные линии, нарушение любой из которых даёт прямую ошибку API:
- Сам блок
tool_resultобязан остаться, менять можно толькоcontent. Не хватает одного — получаете «Missing Tool Result Block» (trim.py:20-22;prune.py:79-92). - Записи
isCompactSummary/isMetaтрогать нельзя — это единственная форма существования той истории, которую compact уже сжал (trim.py:179-181). - Удалив запись, обязательно перевесьте её потомков на её родителя. transcript — односвязная цепочка по
parentUuid, harness идёт от листа назад, и где цепочка порвалась, там вся предыдущая история потеряна (prune.py:95-122). Поэтомуrelink()должна получать полный список, включающий удаляемые записи, а фильтрацию выполняет сама.
В SQLite оригинал не меняется ни на знак — три слоя влияют только на «ту копию, которую скармливают модели обратно» (trim.py:18; prune.py:8).
Устойчивость к обрыву сети¶
Long-horizon workflow работает часами, и сеть обязательно отвалится хотя бы раз. Поведение по умолчанию скверное: в момент обрыва harness кладёт в transcript синтетическое сообщение assistant (model="<synthetic>", isApiErrorMessage=true) с телом API Error: Can't reach the API server …; оно становится листом сессии, и при последующем resume скармливается обратно как «то, что модель сказала в прошлой реплике», а модель считает, что обсуждает сетевой сбой; кроме того, оно попадает в StepResult.text и по workflow уходит в промпт следующего шага (resilience.py:1-22).
Слой устойчивости делает три вещи, и ни одну нельзя убрать: проба, продолжение вместо перезапуска, недопуск ошибок в контекст.
Параметры Resilience¶
| Параметр | Тип | По умолчанию | Смысл |
|---|---|---|---|
enabled | bool | True | Преобразуется из Runtime(resilience=…) |
max_attempts | int | 6 | Сколько максимум попыток у одного шага, включая первую |
base_delay | float | 4.0 | Стартовая точка экспоненциальной задержки, секунды |
max_delay | float | 120.0 | Потолок задержки, секунды |
probe_timeout | float | 5.0 | Таймаут одной пробы, секунды |
probe_interval | float | 15.0 | Как часто пробовать при обрыве сети, секунды |
max_offline_wait | float | 3600.0 | Сколько максимум ждать при обрыве. По умолчанию 1 час — если дольше, это обычно не дрожание, а настоящая авария |
retry_unknown | bool | True | Повторять и ошибки, которые не удалось классифицировать. Большинство неизвестных ошибок кратковременны, а фатальные уже отсечены отдельно |
resume_prompt | str | "上一轮在中途被打断,没有跑完。检查一下工作台里已经落盘的东西,从中断处接着做,不要重头来过。" | Что говорится модели при продолжении |
Формула задержки (resilience.py:119-121):
То есть дрожание ±25%, чтобы в момент восстановления сети туда не ломанулась разом куча процессов. При значениях по умолчанию: 1-я задержка 4 секунды (фактически 3~5), 2-я 8 секунд (6~10), с 5-й потолок 120 секунд (90~150).
Стратегия пробы¶
- Пробуется host:port из
ANTHROPIC_BASE_URL, а неapi.anthropic.com(resilience.py:67-72). При собственном шлюзе доступность второго ничего не говорит о доступности первого. - Только DNS плюс TCP-рукопожатие:
getaddrinfo, затемconnect_tcp, затем немедленное закрытие. Никакого HTTP, никаких учётных данных, никаких трат (resilience.py:75-85). Проба обязана быть бесплатной, иначе «при обрыве пробуем каждые 15 секунд» само становится аварией. - Любой сбой считается недоступностью — не различается, отвалился ли DNS или TCP отказал.
wait_online()висит и ждёт: восстановилось — возвращаетTrue, вышлоmax_offline_wait— возвращаетFalse. При первой недоступности выводится одна строка<host>:<port> 不可达,等待恢复(最多 60 分钟), при восстановлении — ещё одна строка<host>:<port> 恢复,继续, между ними экран не засоряется (resilience.py:126-140).
Проба учётных данных перед стартом — это другое: она реально выполняет один POST <BASE_URL>/v1/messages с max_tokens=16 и таймаутом по умолчанию 20 секунд (env.py:126-181). max_tokens не ставьте в 1 — по замерам модель с принудительной цепочкой рассуждений не помещает туда даже размышление, сервер мучается и отвечает через 30 секунд; при 16 достаточно 3.6 секунды (env.py:120-123).
Классификация ошибок¶
classify(text) возвращает одно из трёх. Сначала проверяется фатальность: в текстах вроде 401 часто попадаются слова типа "connection", и при обратном порядке можно уйти в вечное ожидание (resilience.py:53-64).
| Класс | Что попадает (регулярные выражения — в resilience.py:37-50) | Поведение |
|---|---|---|
fatal | 400 401 403 404, invalid api key, authentication, unauthorized, permission denied, invalid_request, credit balance, quota exceeded, budget, max_turns, CLINotFound | Немедленная остановка без повторов. Сколько ни повторяй, результат тот же, и каждый раз стоит денег |
transient | ENOTFOUND EAI_AGAIN ECONNRESET ECONNREFUSED ETIMEDOUT EPIPE EHOSTUNREACH ENETDOWN, socket hang up, fetch failed, Can't reach the API server, 429 500 502 503 504 529, overloaded, rate limit, timeout, service unavailable | Ждать возвращения сети, затем продолжить через resume |
unknown | Ничего не совпало | При retry_unknown=True (по умолчанию) тоже повторяется |
Разделение на повторяемое и неповторяемое — суть этого слоя: сетевое дрожание надо переждать, ошибку учётных данных надо немедленно остановить. Ждать при обрыве сети правильно, а ждать при неверно записанном ключе — просто жечь время.
Что не пускается в контекст¶
- Синтетические сообщения об ошибках.
PruningSessionStoreприloadудаляет их целиком и перепривязываетparentUuid(prune.py:27-32,:191-195). В SQLite они сохраняются как есть, просто не скармливаются обратно. - В потоке событий это
kind="error", а не"text", поэтому оно не попадает вStepResult.textи, соответственно, не уходит по workflow в промпт следующего шага (resilience.py:17-18). resume_promptнамеренно не содержит никаких деталей ошибки. Модели нужно знать «тебя прервали, продолжай», ей не нужно знать, был этоENOTFOUNDили503. Это принадлежит логам, а не контексту (resilience.py:112-113). За логами — в полеerrorsвmanifest.json.
Продолжение вместо перезапуска: к моменту отказа session_id уже получен, через resume работа подхватывается с места обрыва, и предыдущие траты не пропадают зря.
Смежное¶
- Командная строка — как каждый флаг отображается на конфигурацию с этой страницы.
- Python API — полные сигнатуры
Runtime, трёх store иResilience. - Развёртывание — запуск в контейнере, распространение доменных возможностей через plugin.
- Глоссарий — точное значение каждого термина, использованного на этой странице.