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

Конфигурация

У 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):

  1. Переменные окружения процесса — всегда наивысший приоритет. Никакой .env не перебьёт уже экспортированное значение. (env.py:91)
  2. Файл, на который указывает $FLOWER_ENV — этот пункт есть, только если она задана. (env.py:48-49)
  3. $PWD/.env — текущий рабочий каталог. В какой проект cd-нулся, тот и читается. (env.py:50)
  4. ${XDG_CONFIG_HOME:-~/.config}/flower/.env — глобальное место, по одному на пользователя; именно его пишет flower setup. (env.py:51, :39-42)
  5. .env в корне репозитория с исходниками — три уровня вверх от flower/core/env.py. Существует только при запуске из исходников; у flower, установленного через pip / pipx / uv, он лежит в site-packages, и этого пункта нет. (env.py:52)
  6. Блок 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 ошибки такая:

flower 不读 ~/.claude/settings.json —— 那是可移植性的代价。

(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):

"setting_sources": [] if portable else ["project"],

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):

sqlite3 runs/sessions.db "select store_key, next_seq from meta;"
-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 — фундамент

SqliteSessionStore(path: str | Path)

Реализация на 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=Trueephemeral обязан оставаться включённым, обоснование в предыдущем абзаце.

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:

  1. Сам блок tool_result обязан остаться, менять можно только content. Не хватает одного — получаете «Missing Tool Result Block» (trim.py:20-22; prune.py:79-92).
  2. Записи isCompactSummary / isMeta трогать нельзя — это единственная форма существования той истории, которую compact уже сжал (trim.py:179-181).
  3. Удалив запись, обязательно перевесьте её потомков на её родителя. 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):

min(base_delay * 2 ** (attempt - 1), max_delay) * (0.75 + random() * 0.5)

То есть дрожание ±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 (по умолчанию) тоже повторяется

Разделение на повторяемое и неповторяемое — суть этого слоя: сетевое дрожание надо переждать, ошибку учётных данных надо немедленно остановить. Ждать при обрыве сети правильно, а ждать при неверно записанном ключе — просто жечь время.

Что не пускается в контекст

  1. Синтетические сообщения об ошибках. PruningSessionStore при load удаляет их целиком и перепривязывает parentUuid (prune.py:27-32, :191-195). В SQLite они сохраняются как есть, просто не скармливаются обратно.
  2. В потоке событий это kind="error", а не "text", поэтому оно не попадает в StepResult.text и, соответственно, не уходит по workflow в промпт следующего шага (resilience.py:17-18).
  3. resume_prompt намеренно не содержит никаких деталей ошибки. Модели нужно знать «тебя прервали, продолжай», ей не нужно знать, был это ENOTFOUND или 503. Это принадлежит логам, а не контексту (resilience.py:112-113). За логами — в поле errors в manifest.json.

Продолжение вместо перезапуска: к моменту отказа session_id уже получен, через resume работа подхватывается с места обрыва, и предыдущие траты не пропадают зря.

Смежное

  • Командная строка — как каждый флаг отображается на конфигурацию с этой страницы.
  • Python API — полные сигнатуры Runtime, трёх store и Resilience.
  • Развёртывание — запуск в контейнере, распространение доменных возможностей через plugin.
  • Глоссарий — точное значение каждого термина, использованного на этой странице.