Configuration¶
flower n'a pas de format de fichier de configuration, et aucune sous-commande de configuration qui aille vraiment jusqu'au bout — toute la configuration passe par des variables d'environnement, un fichier .env, plus une poignée d'objets de politique qui ne se donnent que depuis Python. Cette page rassemble en un seul endroit ce qui est éparpillé sur cinq : chaque variable, l'ordre de recherche des credentials, la syntaxe reconnue par .env, ce que setting_sources=[] isole exactement, ce qu'un run laisse sur le disque, ce que chacune des trois couches du session store jette, et comment ça attend quand le réseau tombe. Les termes suivent le glossaire.
| Ce que vous cherchez | Où |
|---|---|
| Quelles variables d'environnement flower lit | Table complète des variables d'environnement |
| D'où vient réellement mon token | Priorité de recherche des credentials |
Pourquoi cette ligne du .env n'a pas pris effet | Règles de parsing .env |
| Quoi emporter en changeant de machine | Le prix de la portabilité |
Ce qu'il y a dans .flower/ et runs/ | Disposition sur disque |
| Quels messages ne sont pas redonnés au modèle | Les trois couches du session store |
| Ce qu'il attend quand le réseau est coupé | Résilience hors ligne |
Table complète des variables d'environnement¶
Cinq groupes : les credentials et endpoints que flower lit directement, la sélection de modèle, la recherche de chemins, les interrupteurs de comportement, et celles que flower écrit pour le sous-processus agent. Ce dernier groupe n'est pas à régler — si vous le réglez, il sera écrasé.
Credentials et endpoints¶
| Variable | Rôle | Défaut | Requis | Source |
|---|---|---|---|---|
ANTHROPIC_API_KEY | Clé Anthropic officielle. Si elle est là, les requêtes partent avec l'en-tête x-api-key | aucun | l'une des deux requise avec ANTHROPIC_AUTH_TOKEN | env.py:28、:146、:157-158 |
ANTHROPIC_AUTH_TOKEN | Token émis par une passerelle. Sans ANTHROPIC_API_KEY, utilise authorization: Bearer | aucun | idem | env.py:28、:147、:159-160 |
ANTHROPIC_BASE_URL | Racine de l'endpoint API. Une passerelle tierce y met sa propre adresse, sans /v1 — la sonde construit <BASE_URL>/v1/messages | https://api.anthropic.com | non | env.py:151、:162、:210 ; resilience.py:70 |
Si aucune des deux n'est réglée (ou si les deux sont des chaînes vides), check_credentials() renvoie ce message d'erreur de quatre lignes et Runtime.__init__ lève un RuntimeError (env.py:184-194 ; runtime.py:156-158).
Sélection de modèle¶
flower n'en lit que trois pour ses propres décisions ; les autres sont chargées puis transmises telles quelles au SDK.
| Variable | Rôle | Défaut | Requis | Source |
|---|---|---|---|---|
ANTHROPIC_MODEL | Nom du modèle principal. Détermine aussi la valeur par défaut de la fenêtre de passation : nom contenant 1m ou ne contenant pas haiku → 1 million, contenant haiku → 200 000 | aucun (décidé côté serveur) | non | env.py:153 ; agent.py:77-81 |
ANTHROPIC_DEFAULT_OPUS_MODEL | Mapping du palier opus. Si ANTHROPIC_MODEL est vide, le calcul de fenêtre retombe dessus | aucun | non | agent.py:78 ; cli.py:1384 |
ANTHROPIC_DEFAULT_SONNET_MODEL | Mapping du palier sonnet. flower ne la lit pas lui-même, il ne fait que la charger et l'emprunter | aucun | non | env.py:34 ; cli.py:1385 |
ANTHROPIC_DEFAULT_HAIKU_MODEL | Mapping du palier haiku. La sonde de credentials l'utilise en priorité | la sonde retombe sur ANTHROPIC_MODEL, puis sur claude-3-5-haiku-20241022 | non | env.py:152-153 |
CLAUDE_CODE_SUBAGENT_MODEL | Quel modèle pour les subagents. flower ne l'interprète pas, c'est le SDK qui la consomme | aucun | non | env.py:35 ; .env.example |
CLAUDE_CODE_EFFORT_LEVEL | Palier de réflexion. Idem, chargée mais non interprétée | aucun | non | env.py:35 |
Si flower setup reçoit un nom de modèle, ANTHROPIC_MODEL, ANTHROPIC_DEFAULT_OPUS_MODEL et ANTHROPIC_DEFAULT_SONNET_MODEL sont écrites toutes les trois ensemble (cli.py:1383-1385).
Chemins et recherche¶
| Variable | Rôle | Défaut | Requis | Source |
|---|---|---|---|---|
FLOWER_ENV | Désigne un chemin de fichier .env, placé avant tous les autres fichiers | aucun | non | env.py:48-49 |
XDG_CONFIG_HOME | Détermine l'emplacement du fichier de credentials global $XDG_CONFIG_HOME/flower/.env | ~/.config | non | env.py:41-42 |
HOME | Source de Path.home() ; les deux chemins ~/.config et ~/.claude en dérivent | fourni par le système | non | env.py:41、:67 |
Interrupteurs de comportement¶
Ce sont deux issues de secours : la norme est de ne pas les régler ; on les règle pour que flower en fasse une de moins. N'importe quelle valeur non vide les active, la valeur elle-même n'est pas interprétée (update.py:121 ; cli.py:1413).
| Variable | Rôle | Défaut | Requis | Source |
|---|---|---|---|---|
FLOWER_NO_UPDATE | Désactive les mises à jour automatiques. Sans elle, un flower installé par pip / pipx / uv lance au démarrage un thread de fond qui vérifie s'il existe une nouvelle version et l'installe le cas échéant, effective seulement au prochain flower ; au plus une vérification toutes les 24 heures, l'horodatage étant écrit dans ~/.config/flower/.update | aucun (mise à jour automatique active) | non | update.py:32-33、:121-124 |
FLOWER_NO_PROBE | Saute la sonde de credentials du démarrage. En non interactif (pipe / CI / stdin redirigé) la sonde n'a de toute façon pas lieu ; cette variable est l'échappatoire pour les terminaux interactifs | aucun (sonde en interactif) | non | cli.py:1413 |
Un flower lancé depuis les sources git n'est pas concerné par la mise à jour automatique, et FLOWER_NO_UPDATE y est un no-op — l'étape de commande de mise à jour reconnaît un .git dans le dépôt et renvoie directement None (update.py:83-87).
Ce que flower écrit pour le sous-processus agent¶
Ces trois-là sont produites par CompactPolicy.env() puis injectées dans ClaudeAgentOptions.env (agent.py:48-58、:241-245) ; elles contrôlent le compact intégré au harness. Les régler dans votre shell n'a aucun sens — ce qui compte est la copie que flower transmet au sous-processus.
| Variable | Rôle | Défaut | Requis | Source |
|---|---|---|---|---|
DISABLE_AUTO_COMPACT | =1 désactive le compact automatique. Écrite de force quand la passation est active — avec les deux mécanismes en marche, on ne sait plus lequel a fait retomber le contexte | la passation est active par défaut, donc en pratique toujours 1 | non (écrite par flower) | agent.py:51 ; runtime.py:444-447 |
DISABLE_COMPACT | =1 désactive aussi /compact. Écrite uniquement avec CompactPolicy(mode="off") | non écrite | non (écrite par flower) | agent.py:52-53 |
CLAUDE_CODE_AUTO_COMPACT_WINDOW | Fenêtre du compact automatique (tokens). Écrite uniquement avec CompactPolicy(window=N) | non écrite | non (écrite par flower) | agent.py:56-57 |
Ce que lit le wrapper de conteneur¶
Ces deux-là ne sont pas lues par flower lui-même, mais par le wrapper shell docker/flowerbox. Usage complet dans Déploiement.
| Variable | Rôle | Défaut | Requis | Source |
|---|---|---|---|---|
FLOWER_HOME | Où trouver le .env passé à --env-file | le répertoire parent de l'emplacement du script | non | docker/flowerbox:12 |
FLOWER_IMAGE | Quelle image utiliser | flower-box | non | docker/flowerbox:13 |
Les clés du .env ne se limitent pas à celles ci-dessus. Le parser charge toutes les lignes k=v dans os.environ, sans liste blanche (env.py:30、:102-107). L'ensemble KNOWN formé par les 9 clés de credentials ci-dessus ne sert qu'à deux endroits : la liste blanche lors de l'emprunt de la configuration ~/.claude (env.py:72), et l'étendue des champs affichés par describe() au démarrage avec -v (env.py:205).
Priorité de recherche des credentials¶
Sans chemin explicite, load_dotenv() lit tous les fichiers existants dans l'ordre suivant (env.py:45-53、:78-112) :
- Variables d'environnement du processus — toujours prioritaires. Aucun
.envne recouvre une valeur déjà exportée. (env.py:91) - Le fichier désigné par
$FLOWER_ENV— présent seulement si la variable est réglée. (env.py:48-49) $PWD/.env— le répertoire de travail courant. Le projet dans lequel vous êtes, celui qui est lu. (env.py:50)${XDG_CONFIG_HOME:-~/.config}/flower/.env— l'emplacement global, un par utilisateur ; c'est celui qu'écritflower setup. (env.py:51、:39-42)- Le
.envà la racine du dépôt source — trois niveaux au-dessus deflower/core/env.py. N'existe qu'en exécution depuis les sources ; un flower installé par pip / pipx / uv vit dans site-packages et n'a pas cette entrée. (env.py:52) - Le bloc
envde~/.claude/settings.json, puis de~/.claude/settings.local.json— dernier repli, 9 clés de credentials uniquement. (env.py:56-75、:109-111)
Quel fichier gagne : l'entrée 3 (.env du projet) bat l'entrée 4 (.env global), l'entrée 4 bat l'entrée 5 (.env racine du dépôt), les trois battent l'entrée 6 (configuration de Claude Code), et aucune ne bat l'entrée 1 (environnement du processus).
L'implémentation est « une clé déjà pourvue n'est pas écrasée » (env.py:90-93) : ceux qui passent en premier occupent les clés, les suivants ne comblent que les trous. La priorité est donc par clé, pas par fichier — si le .env du projet ne contient que ANTHROPIC_BASE_URL, le token peut parfaitement venir du fichier global. La première valeur rencontrée pour une clé donnée est définitive.
L'entrée 6 n'est activée qu'en recherche automatique. Un chemin explicite (load_dotenv("/path/to/.env")) ne lit que ce fichier, sans aucun repli (env.py:86-87、:109).
Entrée 6 : emprunter le token de Claude Code¶
On lit successivement ~/.claude/settings.json et ~/.claude/settings.local.json, on prend le dict data["env"], et on y sélectionne ces 9 clés (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
Si le fichier n'existe pas, n'est pas lisible, ou n'est pas du JSON valide (OSError / ValueError), on renvoie un dict vide et on continue — l'échec d'un repli ne doit pas emporter le run (env.py:62-63、:66-69).
La position prise dans le code : on n'emprunte que « où trouver le token » ; rien d'autre dans settings.json (règles de permission, hooks, réglages de modèle) n'est repris, ce qui ne trahit donc pas la promesse de portabilité de setting_sources=[] (env.py:17-19、:59-61). install.sh:77 en fait même un argument : qui a déjà configuré Claude Code localement ne verra jamais l'écran de configuration.
Le message d'erreur du produit dit le contraire du comportement réel
Quand aucun credential n'est trouvé, la dernière ligne de l'erreur affichée par flower est :
(env.py:184-194, la phrase est en :192 ; la même affirmation apparaît aussi dans .env.example:2, env.py:3-4, agent.py:10-12.) Le code fait foi : il le lit. env.py:56-75 plus :109-111 vont explicitement lire ces deux fichiers, et install.sh:77 en fait un argument de vente. Ce message est actuellement trompeur — sur une machine où Claude Code a été configuré, votre token vient très probablement de là.
Règles de parsing .env¶
Les règles sont assez courtes pour être mémorisées (env.py:95-107, 13 lignes) : strip ligne par ligne, on saute les lignes vides, celles qui commencent par #, et celles qui ne contiennent pas = ; le reste est coupé au premier = en clé et valeur, chaque côté est stripé, puis la valeur passe encore par .strip("'\"") — les guillemets simples ou doubles en tête et en queue sont retirés, sans exiger qu'ils soient appariés.
Reconnu :
| Écriture | Résultat |
|---|---|
KEY=VALUE | normal |
KEY = VALUE | normal — les espaces autour du signe égal sont strippés |
KEY="VALUE" / KEY='VALUE' | normal — les guillemets extérieurs sont retirés |
KEY=a=b | la valeur est a=b — coupe au premier =, les suivants restent dans la valeur |
# commentaire | ligne entièrement sautée |
| ligne vide | sautée |
Non reconnu. Aucune erreur n'est levée, vous obtenez simplement une valeur inattendue en silence :
| Écriture | Résultat réel |
|---|---|
export KEY=VALUE | la clé devient export KEY ; KEY lui-même reste sans valeur |
KEY=value # commentaire | la valeur est value # commentaire — le commentaire de fin de ligne n'est pas retiré |
KEY=$OTHER | littéral $OTHER, aucune interpolation de variable |
| valeur multiligne (guillemets sur plusieurs lignes) | traitement ligne par ligne ; la deuxième ligne ne contient pas = et est sautée entièrement |
Une valeur vide occupe quand même la clé. Si ANTHROPIC_AUTH_TOKEN= apparaît dans un fichier prioritaire, take() exécute os.environ["ANTHROPIC_AUTH_TOKEN"] = "" ; les fichiers suivants ne peuvent plus la combler puisque « la clé existe déjà » (env.py:90-93), tandis que check_credentials() teste la véracité et considère une chaîne vide comme non configurée (env.py:186). Résultat : ni credential, ni repli. Si vous ne voulez pas d'une clé, supprimez la ligne entière ; n'en laissez pas une vide.
Le prix de la portabilité¶
Tout le mécanisme tient dans cette ligne de build_options() (agent.py:207) :
portable=True est la valeur par défaut de Runtime, et aucune option de ligne de commande ne permet de la désactiver — il faut passer par l'API Python avec Runtime(portable=False), ce qui donne ["project"], c'est-à-dire la lecture du .claude/ du projet.
Ce qui est isolé¶
| Isolé | Conséquence |
|---|---|
Les réglages de ~/.claude/ de la machine hôte | Aucune des règles de permission, hooks ou réglages de modèle qui s'y trouvent ne prend effet. Les credentials sont la seule exception, voir emprunt |
Le .claude/ du projet | Idem, lu seulement avec portable=False |
Les capacités métier ne passent pas par là — elles sont distribuées avec le dépôt et chargées via plugins=[{"type": "local", "path": PLUGIN_DIR}] (agent.py:26、:210-212), voir Déploiement. Les instructions métier sont quant à elles appendées après le system prompt natif de Claude Code, non substituées (agent.py:198-202) : la spécialisation ne se paie donc pas par une perte de capacité générale.
Quoi emporter en changeant de machine¶
- Credentials : un fichier. Copiez
~/.config/flower/.env, ou reconfigurez-le sur la nouvelle machine. Sans lui, rien ne démarre — rien n'est hérité automatiquement. - État de continuité : le répertoire entier.
runs/(base de sessions, manifeste, lignage) et.flower/(workbench). - Mais les chemins doivent correspondre.
lineage.jsonstocke le chemin absolu de l'espace de travail ; s'il ne correspond pas, c'est comme s'il n'existait pas : repli silencieux vers une nouvelle session, sans erreur (lineage.py:65-66). La raison : leproject_keydu SDK dérive du chemin de l'espace de travail (/,_et.deviennent tous-,runtime.py:40-41) ; si le répertoire change de place, l'anciensession_iddevient introuvable.
Disposition sur disque¶
Un run de flower écrit deux arbres : <run_dir>/ pour la comptabilité et les sessions, <workspace>/.flower/ pour le workbench. Les deux sont par défaut sous le répertoire courant, mais leur référence n'est pas la même.
runs/ suit le répertoire courant, pas -w
-r/--run-dir vaut "runs" par défaut, et Runtime lui applique Path(run_dir).resolve() (runtime.py:93-94) — relatif au répertoire de travail courant, pas à l'espace de travail donné par -w. Lancer flower -w /path/to/proj depuis ~ fait atterrir la base de sessions dans ~/runs/, pas dans le projet.
<run_dir>/ — par défaut ./runs/¶
runs/
sessions.db SQLite, transcript intégral (y compris celui propre à chaque subagent)
manifest.json manifeste de run : session_id / coût / retries / cause d'échec par étape, cumulé entre processus
lineage.json lignage : nom d'étape → session_id, c'est ce qui permet de reprendre dans le même répertoire
aside/ Runtime distinct des questions-réponses de l'oracle, avec son propre sessions.db + manifest.json
workbench/ seulement en chemin run / once et avec -W
| Chemin | Contenu | Source |
|---|---|---|
runs/sessions.db | Transcript intégral. L'écrivain est PruningSessionStore, les trois couches sont décrites plus bas | runtime.py:109-112 |
runs/manifest.json | Tableau JSON, le manifeste de run cumulé entre processus. Champs dans la table ci-dessous | runtime.py:532-533、:564-586 |
runs/lineage.json | {"workspace": "…", "woke": N, "steps": {"步骤名": "session_id"}}. Écrit d'abord en .tmp puis replace, remplacement atomique | lineage.py:31、:87-97 |
runs/aside/ | Runtime distinct de l'oracle. Coûts et lignage ne se mélangent pas au manifeste principal | cli.py:741-743 |
runs/workbench/ | Emplacement par défaut du workbench pour Runtime(workbench=True), hors de l'espace de travail. Le chemin go ne l'utilise pas | runtime.py:148-151 |
Chaque ligne de manifest.json est un asdict(StepResult) plus deux correctifs (runtime.py:44-71、:579-582) :
| Champ | Type | Signification |
|---|---|---|
step | str | Nom d'étape. Quatre formes : <名>, <名>#round<N> (renvoyée à refaire), <名>#retry<N> (retry ordinaire), <名>·判定#<N> (juge) |
session_id | str \| None | La session encore vivante à la fin de cette étape |
ok | bool | Réussi ou non |
cost_usd | float | Coût de cette étape |
num_turns | int | Nombre de tours effectués |
text | str | Réponse finale de cette étape |
error | str \| None | Cause de l'échec. killed-by-signal en cas de mise à mort par SIGHUP / SIGTERM (runtime.py:556-558) |
started_at / ended_at | float | Secondes epoch |
attempts | int | Nombre réel de tentatives. >1 signifie qu'il y a eu retry |
errors | list[str] | Causes des échecs successifs. Ici seulement ; le modèle ne les voit pas |
resumed | bool | Repris depuis le point d'interruption par resume, plutôt que refait depuis le début |
retired | list[str] | Les session_id brûlés lors des passations de cette étape, dans l'ordre |
context | int | Taille de contexte réellement vue par le thread principal au dernier tour |
duration_s | float | Ajouté à la main — c'est une @property, asdict() ne la récupère pas |
run | str | Marqueur du processus, YYYYmmdd-HHMMSS-<6 位 hex>. Doit être unique par instance |
La stratégie d'écriture est ajout sans écrasement : à chaque flush, le fichier est relu, les lignes dont run correspond au sien sont remplacées par les plus récentes, les lignes des autres restent intactes (runtime.py:564-586). Plusieurs flower en parallèle dans le même répertoire ne s'écrasent donc pas mutuellement leur comptabilité.
Le contenu de runs/ est de la donnée pure, consultable hors ligne à tout moment avec sqlite3 ou tools/analyze_run.py.
<workspace>/.flower/ — le workbench¶
.flower/
INDEX.md index généré automatiquement, injecté dans le system prompt de l'agent principal
scripts/ scripts destinés à resservir. La première ligne `# desc: une phrase` apparaît dans l'index
artifacts/ productions longues de plus de 2000 caractères : rapports, données, logs
notes/ décisions consignées d'une étape à l'autre
spill/ gros résultats d'outil spillés, nom de fichier = 16 premiers caractères du sha256 du contenu + `.txt`
Les trois sous-répertoires et l'index sont créés par Workbench (workbench.py:73-92). INDEX.md passe par le system_prompt.append au niveau de la session, dont les subagents n'héritent pas — la règle « les productions longues vont dans artifacts/ » doit donc être relayée par le coordinateur dans le brief de tâche, c'est le seul canal.
Le chemin go produit systématiquement ceci sous notes/ :
| Fichier | Contenu | Source |
|---|---|---|
notes/需求.md | Le brief gelé, quatre sections : objectif / critères d'acceptation / limites / inconnues et hypothèses | brief.py:44-45 ; clarify.py:105 |
notes/目标.md | Deux sections gelées : objectif / liste de vérification du verdict | workflow/goal.py:124 |
notes/问答记录.md | Enregistrement en append de toutes les questions-réponses, y compris les entrées de boîte de réception « dites spontanément par l'humain ». N'entre pas dans le contexte, sert d'archive | human.py:421-433 |
notes/交接-<步骤名>.md | Document de passation. La génération précédente est rangée dans notes/archive/交接/<步骤名>-<时间戳>.md | runtime.py:388-403 |
notes/archive/<YYYYmmdd-HHMMSS>/ | Les lineage.json + 需求.md + 目标.md archivés par --new / /new (déplacés, pas supprimés) | lineage.py:100-117 |
Avec --isolate, le workbench sort du dépôt : <répertoire parent du workspace>/.flower-<nom du workspace>/ (starter.py:47-55). Le worktree est la copie privée de chaque agent, le workbench est la couche partagée entre agents ; ce qui est partagé ne peut pas vivre dans un enclos privé. Dans ce cas, les chemins donnés au modèle sont absolus (workbench.py:69-71、:142-145).
spill/ a deux écrivains, avec des algorithmes de destination différents :
| Qui écrit | Quand | Où | Seuil |
|---|---|---|---|
spill_guard (hook PostToolUse) | avant que le résultat d'outil n'entre dans le modèle | <racine du workbench>/spill/ (guard.py:130) | spill_threshold, 4000 caractères par défaut |
TrimPolicy (au load) | lors du rejeu de l'historique avant un resume | <workspace>/.flower/spill/ — chaîne fixe relative à l'espace de travail (trim.py:49、:303) | min_chars, 2000 caractères par défaut |
Dans la disposition par défaut, c'est le même répertoire. Mais dès que le workbench est déplacé (-W le fait atterrir dans runs/workbench/, ou --isolate le sort du dépôt), les deux divergent — la copie de TrimPolicy reste toujours dans l'espace de travail, parce que le Read de l'agent doit pouvoir l'atteindre.
Ce que spill_guard met à la place n'est pas une ligne, mais une ligne de pointeur plus les 400 premiers caractères (guard.py:132-140). Les appels qui lisent le fichier spillé lui-même sont laissés passer, sinon « lisez-le avec Read si vous voulez le texte intégral » serait une phrase creuse — on relit, ça dépasse encore le seuil, c'est spillé à nouveau, boucle infinie (guard.py:155-170).
Schéma de sessions.db¶
Trois tables, DDL dans 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)
);
| Table | Ce qu'est une ligne | Points clés |
|---|---|---|
entries | Une entrée du transcript, payload étant le JSON brut | uid est l'uuid de l'entrée, utilisé comme clé d'idempotence : un lot en échec est réessayé 3 fois, et le rejeu ne doit pas produire de doublons. Les entrées sans uuid (titres, tags, marqueurs de mode) ne sont pas dédupliquées, d'où l'index unique avec WHERE uid IS NOT NULL |
meta | Le curseur d'une session | next_seq est le prochain numéro de séquence, mtime un horodatage en millisecondes strictement monotone (sqlite.py:72-79) — list_sessions et les summaries partagent cette horloge ; sans monotonie, la comparaison ancien/nouveau du SDK emprunte le mauvais chemin rapide |
summaries | Le sidecar de résumé d'un thread principal | Seuls les transcripts principaux comptent, pas ceux des subagents (sqlite.py:122-123) |
Construction de store_key (sqlite.py:54-58) : <project_key>/<session_id>, avec un subpath supplémentaire pour les subagents. project_key est dérivé par le SDK du chemin de l'espace de travail — /, _ et . deviennent tous -.
Un coup d'œil sur un échantillon réel (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
Cet exemplaire contient 956 entries, 10 meta, 4 summaries — sur les 10 sessions, 4 sont des transcripts principaux et 6 sont des subagents, et summaries égale exactement le nombre de transcripts principaux.
Les trois couches du session store¶
Les trois couches sont une chaîne d'héritage, pas une combinaison optionnelle
PruningSessionStore hérite de TrimmingSessionStore qui hérite de SqliteSessionStore. Runtime construit toujours la couche la plus externe (runtime.py:109-112), et ses paramètres de construction n'offrent aucun point d'entrée pour changer de backend. Pour « désactiver une couche », on met enabled à False sur son objet de politique, on ne change pas de classe.
append (écriture) est toujours un enregistrement intégral, sans un mot de modifié. Les trois couches n'affectent que load (la copie relue pour être donnée au modèle). L'ordre réel de load est :
SqliteSessionStore.load lit tout depuis la table entries, par seq
→ TrimmingSessionStore.expire() résultats Bash périssables → remplacés par « expiré »
→ TrimmingSessionStore.trim() vieux gros tool_result → spill + remplacement par un pointeur
→ PruningSessionStore.prune() messages d'erreur synthétiques / vieux appels refusés → entrée retirée et chaîne recousue
| Couche | Classe | Ce qu'elle jette | Critère |
|---|---|---|---|
| 1 | SqliteSessionStore | rien | —— |
| 2 | TrimmingSessionStore | le corps des gros résultats d'outil, les résultats de commandes éphémères périmés | volume + péremption |
| 3 | PruningSessionStore | les résidus de coupure réseau, les vieux appels refusés | est-ce une erreur |
La couche 2 fait le trim, la couche 3 le prune — le trim jette selon le volume et la valeur, le prune jette selon « est-ce une erreur » ; ne les confondez pas. Signatures complètes dans l'API Python.
SqliteSessionStore — les fondations¶
Implémentation SQLite sans dépendance externe. Pour passer à Postgres / S3 / Redis, il suffit d'implémenter le même protocole ; le SDK fournit la suite de tests de conformité claude_agent_sdk.testing.session_store_conformance qui permet de vérifier directement (sqlite.py:1-8).
En plus des méthodes du protocole, trois requêtes synchrones à l'usage propre de flower :
| Méthode | Retour | Usage |
|---|---|---|
projects() | list[str] | Les project_key réellement présents dans la base. Le SDK les dérive du cwd ; confirmez avec ceci avant d'interroger, ne devinez pas |
has_session(project_key, session_id) | bool | Interroge une seule ligne de meta, sans lire le payload. À vérifier avant de lancer une continuité — un resume sur une session inexistante n'explose qu'après le démarrage du sous-processus, quand l'argent et le temps sont déjà dépensés |
last_context(project_key, session_id, scan=60) | int | La taille de contexte vue au dernier tour de cette session. Ne scanne que les 60 dernières entrées à rebours. input_tokens plus les deux cache_* sont comptés — ne regarder que le premier donne une valeur proche de 0 en cas de cache hit, et sous-estime gravement |
TrimmingSessionStore + TrimPolicy / EphemeralPolicy¶
TrimmingSessionStore(path, workspace, policy: TrimPolicy | None = None,
ephemeral: EphemeralPolicy | None = None)
Deux règles orthogonales. TrimPolicy gère le volume :
| Paramètre | Type | Défaut | Sémantique |
|---|---|---|---|
keep_recent | int | 20 | Les N derniers tool_result gardent leur texte intégral — le contexte en cours d'utilisation ne doit pas être trimmé |
min_chars | int | 2000 | En dessous, pas de trim. Remplacer par un pointeur coûterait plus de tokens |
spill_dirname | str | ".flower/spill" | Répertoire d'archive, relatif au workspace. Doit être dans l'espace de travail, sinon le Read de l'agent ne peut pas l'atteindre |
enabled | bool | True | Vaut False avec Runtime(trim=False) (le défaut) |
Le corps trimmé est écrit dans <16 premiers caractères du sha256>.txt, et la position d'origine est remplacée par [工具结果已归档:N 字符。完整内容在 <路径>,需要时用 Read 读取] (trim.py:54-57、:308-317).
EphemeralPolicy gère la péremption : les résultats de git status, ls, ps sont très courts et ne seraient jamais candidats au trim par volume, mais leur justesse se dégrade avec le temps — le git status d'il y a 20 tours n'est pas « inutile », il induit en erreur.
| Paramètre | Type | Défaut | Sémantique |
|---|---|---|---|
enabled | bool | True | Converti depuis Runtime(ephemeral=…), actif par défaut |
keep_recent | int | 6 | Les N derniers gardent leur texte. Bien plus petit que les 20 de TrimPolicy — pour ce genre de choses, la fenêtre du « récent » est intrinsèquement courte |
max_chars | int | 2000 | Au-delà, l'archivage par spill revient à TrimPolicy, pas à cette voie |
text | str | "[{cmd} 的结果已过期(第 {age} 轮前),当前状态可能已变。需要请重新执行]" | Texte de remplacement |
Ne s'applique qu'aux résultats de l'outil Bash, et la commande doit correspondre à EPHEMERAL_CMD. Read n'en fait pas partie : le contenu d'un fichier ne se dénature pas avec le temps au point d'induire en erreur, et il peut être précisément la base du raisonnement du modèle (trim.py:153-160). Le contenu périmé n'est pas spillé — archiver un git status périmé n'a aucun sens, il suffit de le relancer.
La fonction de décision est is_ephemeral(cmd), qui est en même temps la liste des permissions rendues au coordinateur : delegate_guard(allow_glance=True) utilise la même fonction (trim.py:63-68、:128-150). Les deux ensembles doivent rester identiques — autoriser sans trimmer laisse un git status périmé occuper le contexte à jamais ; trimmer sans autoriser fait déléguer un subagent par le coordinateur pour un simple ls, 4,3k de coût de démarrage pour quelques dizaines de caractères. Ajouter une commande à la liste blanche, c'est dire ces deux choses à la fois.
Quand utiliser quoi :
- Vous voulez seulement que les résidus de coupure réseau n'entrent pas dans le contexte → rien à faire,
Runtimeutilise déjàPruningSessionStorepar défaut.trim=Falsese contente de ne pas trimmer les gros résultats ; le retrait, lui, a bien lieu. - Run de longue haleine, sorties d'outil volumineuses →
trim=True. Le CLI du chemingol'active déjà par défaut ;--no-trimle désactive. - Le coordinateur a
glance=True→ephemeraldoit rester actif, raison au paragraphe précédent.
PruningSessionStore + PrunePolicy¶
PruningSessionStore(path, workspace, policy: TrimPolicy | None = None,
prune: PrunePolicy | None = None,
ephemeral: EphemeralPolicy | None = None)
| Paramètre | Type | Défaut | Sémantique |
|---|---|---|---|
drop_api_errors | bool | True | Retire les messages synthétiques marqués isApiErrorMessage=true ou message.model == "<synthetic>" |
neutralize_interrupts | bool | True | Pour un tool_result contenant [Request interrupted …], remplace le corps sans retirer le bloc |
interrupt_text | str | "[上一轮在此处被中断,该工具结果未产生]" | Texte de remplacement de l'entrée précédente |
heal_orphans | bool | True | Ajoute un résultat synthétique aux appels orphelins qui ont un tool_use sans tool_result |
orphan_text | str | "[这一步被打断了,没有结果。需要的话重做。]" | Corps du tool_result ainsi ajouté |
keep_denials | int | 1 | Conserve les N derniers appels d'outil refusés par le hook de permission ; les plus anciens sont retirés appel et résultat compris |
keep_denials est le seul paramètre de construction de Runtime transmis jusqu'à cette couche (Runtime(keep_denials=N)). La raison du défaut 1 plutôt que 0 : le refus le plus récent est un signal utile, il empêche le modèle de retenter en boucle la même commande bloquée dans un même tour. Ne l'augmentez pas — un appel refusé n'a jamais été exécuté, son résultat ne contient aucune information, il occupe 273 caractères mesurés (93 caractères de message de refus plus 180 caractères de la commande morte), et il induit en erreur : mesuré, après avoir lu quelques « n'utilise pas Bash directement », le coordinateur cesse même d'essayer le git status pourtant autorisé et déclare « Bash est restreint, envoyons un agent voir » (prune.py:135-148).
heal_orphans soigne le cas où tout resume après une interruption renvoie 400 : l'interruption coupe à une frontière de message, le tool_use en vol à ce moment-là peut n'avoir aucun tool_result derrière lui, alors que l'API exige la paire. Cette mauvaise histoire reste dans le transcript et ne disparaît pas d'elle-même, si bien que chaque resume ultérieur est rejeté par elle. La réparation consiste à insérer une entrée user juste après l'assistant contenant les orphelins, à y compléter d'un coup les résultats de tous ces orphelins, puis à réorienter vers cette nouvelle entrée le parentUuid qui pointait sur cet assistant (prune.py:95-147). On complète, on ne supprime pas : supprimer les orphelins obligerait à recoudre la chaîne parent-enfant, et le même assistant peut contenir d'autres blocs normaux, du texte et du thinking, faciles à emporter au passage (prune.py:195-204).
Trois lignes rouges structurelles, dont la violation fait immédiatement échouer l'API :
- Le bloc
tool_resultdoit rester présent, seul soncontentpeut être remplacé. Un de moins et c'est « Missing Tool Result Block » (trim.py:20-22;prune.py:79-92). - Les entrées
isCompactSummary/isMetasont intouchables — c'est la seule forme d'existence de l'historique qui a été compacté (trim.py:179-181). - Retirer une entrée oblige à rattacher ses enfants à son parent. Le transcript est une chaîne simple de
parentUuidet le harness la remonte depuis la feuille ; là où la chaîne casse, tout l'historique antérieur est perdu (prune.py:95-122). C'est pourquoirelink()doit recevoir la liste complète incluant l'entrée à retirer, et fait elle-même le filtrage.
Le texte original dans SQLite n'est pas modifié d'un caractère — les trois couches n'affectent que « la copie redonnée au modèle » (trim.py:18 ; prune.py:8).
Résilience hors ligne¶
Un workflow long-horizon tourne des heures, le réseau tombera forcément une fois. Le comportement par défaut est mauvais : à l'instant de la coupure, le harness insère dans le transcript un message assistant synthétique (model="<synthetic>", isApiErrorMessage=true) dont le corps est API Error: Can't reach the API server … ; il devient la feuille de la session, si bien qu'au resume il est redonné comme « la dernière phrase du modèle » et le modèle croit discuter d'une panne réseau ; il se glisse aussi dans StepResult.text et se propage le long du workflow jusqu'au prompt de l'étape suivante (resilience.py:1-22).
La couche de résilience fait trois choses, aucune facultative : la sonde, la reprise plutôt que le redémarrage, et l'erreur tenue hors du contexte.
Paramètres de Resilience¶
| Paramètre | Type | Défaut | Sémantique |
|---|---|---|---|
enabled | bool | True | Converti depuis Runtime(resilience=…) |
max_attempts | int | 6 | Nombre maximum de tentatives pour une étape, première incluse |
base_delay | float | 4.0 | Point de départ du backoff exponentiel, en secondes |
max_delay | float | 120.0 | Plafond du backoff, en secondes |
probe_timeout | float | 5.0 | Timeout d'une sonde, en secondes |
probe_interval | float | 15.0 | Intervalle entre sondes pendant une coupure, en secondes |
max_offline_wait | float | 3600.0 | Attente maximale hors ligne. 1 heure par défaut — au-delà, ce n'est généralement plus une secousse, c'est un vrai incident |
retry_unknown | bool | True | Réessayer aussi les erreurs non classifiables. La plupart des erreurs inconnues sont transitoires, et les erreurs fatales sont déjà interceptées séparément |
resume_prompt | str | "上一轮在中途被打断,没有跑完。检查一下工作台里已经落盘的东西,从中断处接着做,不要重头来过。" | Ce qui est dit au modèle lors de la reprise |
Formule de backoff (resilience.py:119-121) :
Soit une gigue de ±25%, pour éviter qu'une foule de processus ne se rue au même instant dès le rétablissement du réseau. Avec les valeurs par défaut : 1er backoff 4 secondes (3~5 en pratique), 2e 8 secondes (6~10), plafonné à 120 secondes (90~150) à partir du 5e.
Stratégie de sonde¶
- On sonde le host:port de
ANTHROPIC_BASE_URL, pasapi.anthropic.com(resilience.py:67-72). Avec une passerelle auto-hébergée, la joignabilité du second ne dit rien de celle du premier. - Uniquement DNS plus poignée de main TCP :
getaddrinfo, puisconnect_tcp, puis fermeture immédiate. Pas de HTTP, pas de credentials, pas un centime (resilience.py:75-85). La sonde doit être gratuite, sinon « sonder toutes les 15 secondes pendant une coupure » devient elle-même la panne. - Tout échec compte comme injoignable — on ne distingue pas un DNS mort d'un TCP refusé.
wait_online()attend sur place : renvoieTruedès que ça passe,Falseune foismax_offline_waitécoulé. À la première indisponibilité, une notification d'une ligne<host>:<port> 不可达,等待恢复(最多 60 分钟), et au rétablissement une autre ligne<host>:<port> 恢复,继续; rien entre les deux (resilience.py:126-140).
La sonde de credentials avant le démarrage est une autre affaire : elle envoie réellement un POST <BASE_URL>/v1/messages avec max_tokens=16, timeout de 20 secondes par défaut (env.py:126-181). Ne mettez pas max_tokens à 1 — mesuré, un modèle à chaîne de pensée forcée ne peut même pas y loger sa réflexion, et le serveur se débat jusqu'à 30 secondes avant de répondre ; à 16, il suffit de 3,6 secondes (env.py:120-123).
Classification des erreurs¶
classify(text) renvoie l'une de trois valeurs. On teste d'abord le fatal : un texte de 401 contient souvent aussi un mot comme « connection », et l'ordre inverse ferait attendre indéfiniment (resilience.py:53-64).
| Classe | Ce qui matche (regex en resilience.py:37-50) | Comportement |
|---|---|---|
fatal | 400 401 403 404, invalid api key, authentication, unauthorized, permission denied, invalid_request, credit balance, quota exceeded, budget, max_turns, CLINotFound | Arrêt immédiat, sans retry. Le résultat serait le même quel que soit le nombre d'essais, et chacun coûte de l'argent |
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 | Attendre le retour du réseau, puis reprendre par resume |
unknown | rien ne matche | Réessayé aussi quand retry_unknown=True (le défaut) |
Distinguer le réessayable du non-réessayable est le cœur de cette couche : une secousse réseau mérite l'attente, un credential erroné mérite l'arrêt immédiat — attendre pendant une coupure est juste, attendre à cause d'une clé mal écrite ne fait que brûler du temps.
Ce qui est tenu hors du contexte¶
- Les messages d'erreur synthétiques.
PruningSessionStoreles retire entièrement auloadet recoud leparentUuid(prune.py:27-32、:191-195). Ils restent tels quels dans SQLite, ils ne sont simplement pas redonnés. - Dans le flux d'événements, c'est un
kind="error"et non un"text", donc il n'entre pas dansStepResult.textet ne se propage pas le long du workflow jusqu'au prompt de l'étape suivante (resilience.py:17-18). resume_promptne contient délibérément aucun détail d'erreur. Le modèle doit savoir « tu as été interrompu, continue » ; il n'a pas besoin de savoir si c'étaitENOTFOUNDou503. Cela relève du log, pas du contexte (resilience.py:112-113). Pour le log, voyez le champerrorsdemanifest.json.
Reprendre plutôt que recommencer : au moment de l'échec, le session_id est déjà obtenu, on reprend au point d'interruption par resume, et le coût déjà payé n'est pas perdu.
Voir aussi¶
- Ligne de commande — comment chaque option se projette sur la configuration de cette page.
- API Python — signatures complètes de
Runtime, des trois stores et deResilience. - Déploiement — exécution en conteneur, distribution des capacités métier par plugin.
- Glossaire — le sens exact de chaque terme utilisé ici.