Configuración¶
flower no tiene formato de fichero de configuración, ni ningún subcomando de configuración que llegue a ejecutarse de verdad — toda la configuración son variables de entorno más ficheros .env, más un puñado de objetos de política que solo se pueden pasar desde Python. Esta página reúne en un solo sitio lo que está repartido por cinco lugares: cada variable, en qué orden se buscan las credenciales, qué sintaxis acepta .env, qué aísla realmente setting_sources=[], qué deja una ejecución en disco, qué tira cada una de las tres capas del almacén de sesiones y cómo se espera cuando se cae la red. La terminología sigue el glosario.
| Qué quieres saber | Dónde |
|---|---|
| Qué variables de entorno reconoce flower | Tabla completa de variables de entorno |
| De dónde sale realmente mi token | Prioridad de búsqueda de credenciales |
Por qué esa línea de mi .env no hace nada | Reglas de parseo de .env |
| Qué llevarme al cambiar de máquina | El precio de la portabilidad |
Qué hay dentro de .flower/ y runs/ | Disposición en disco |
| Qué mensajes no se le devuelven al modelo | Las tres capas del almacén de sesiones |
| Qué está esperando cuando se cae la red | Resiliencia ante caídas de red |
Tabla completa de variables de entorno¶
Cinco grupos: credenciales y endpoint que flower lee directamente, selección de modelo, búsqueda de rutas, interruptores de comportamiento, y las que flower escribe hacia el subproceso del agente. Este último grupo no lo configuras tú — si lo haces, se sobrescribe.
Credenciales y endpoint¶
| Variable | Función | Por defecto | Requerido | Origen |
|---|---|---|---|---|
ANTHROPIC_API_KEY | Key oficial de Anthropic. Si existe, la petición va con cabecera x-api-key | ninguno | una de las dos, obligatorio junto a ANTHROPIC_AUTH_TOKEN | env.py:28, :146, :157-158 |
ANTHROPIC_AUTH_TOKEN | Token emitido por una pasarela. Sin ANTHROPIC_API_KEY, se usa authorization: Bearer | ninguno | ídem | env.py:28, :147, :159-160 |
ANTHROPIC_BASE_URL | Raíz del endpoint de la API. Una pasarela de terceros pone aquí su propia dirección, sin /v1 — la sonda compone <BASE_URL>/v1/messages | https://api.anthropic.com | No | env.py:151, :162, :210; resilience.py:70 |
Si no hay ninguna de las dos (o ambas son cadena vacía), check_credentials() devuelve ese error de cuatro líneas y Runtime.__init__ lanza RuntimeError (env.py:184-194; runtime.py:156-158).
Selección de modelo¶
flower solo lee tres de ellas para sus propias decisiones; el resto se cargan y se pasan tal cual al SDK.
| Variable | Función | Por defecto | Requerido | Origen |
|---|---|---|---|---|
ANTHROPIC_MODEL | Nombre del modelo principal. Determina además el valor por defecto de la ventana de relevo: si el nombre lleva 1m o no lleva haiku → 1 millón; si lleva haiku → 200 mil | ninguno (lo decide el extremo) | No | env.py:153; agent.py:77-81 |
ANTHROPIC_DEFAULT_OPUS_MODEL | Mapeo del modelo para el nivel opus. Si ANTHROPIC_MODEL está vacío, la decisión de ventana cae aquí | ninguno | No | agent.py:78; cli.py:1384 |
ANTHROPIC_DEFAULT_SONNET_MODEL | Mapeo del modelo para el nivel sonnet. flower no lo lee; solo lo carga y lo toma prestado | ninguno | No | env.py:34; cli.py:1385 |
ANTHROPIC_DEFAULT_HAIKU_MODEL | Mapeo del modelo para el nivel haiku. La sonda de credenciales lo prefiere | la sonda cae a ANTHROPIC_MODEL y luego a claude-3-5-haiku-20241022 | No | env.py:152-153 |
CLAUDE_CODE_SUBAGENT_MODEL | Qué modelo usan los subagent. flower no la interpreta; la consume el SDK | ninguno | No | env.py:35; .env.example |
CLAUDE_CODE_EFFORT_LEVEL | Nivel de razonamiento. Ídem: solo se carga, no se interpreta | ninguno | No | env.py:35 |
Si en flower setup pones un nombre de modelo, se escriben ANTHROPIC_MODEL, ANTHROPIC_DEFAULT_OPUS_MODEL y ANTHROPIC_DEFAULT_SONNET_MODEL las tres a la vez (cli.py:1383-1385).
Rutas y búsqueda¶
| Variable | Función | Por defecto | Requerido | Origen |
|---|---|---|---|---|
FLOWER_ENV | Indica la ruta de un fichero .env que va antes de todos los demás | ninguno | No | env.py:48-49 |
XDG_CONFIG_HOME | Determina la ubicación del fichero global de credenciales $XDG_CONFIG_HOME/flower/.env | ~/.config | No | env.py:41-42 |
HOME | Origen de Path.home(); de ahí salen tanto ~/.config como ~/.claude | lo da el sistema | No | env.py:41, :67 |
Interruptores de comportamiento¶
Los dos son vías de escape: lo normal es no ponerlos; se ponen para que flower haga una cosa menos. Cualquier valor no vacío las activa, el valor en sí no se interpreta (update.py:121; cli.py:1413).
| Variable | Función | Por defecto | Requerido | Origen |
|---|---|---|---|---|
FLOWER_NO_UPDATE | Desactiva la actualización automática. Si no está puesta, un flower instalado con pip / pipx / uv lanza al arrancar un hilo en segundo plano que comprueba si hay versión nueva y la instala; solo surte efecto en la siguiente ejecución de flower; como mucho una comprobación cada 24 horas, con la marca de tiempo en ~/.config/flower/.update | ninguno (actualización automática activada) | No | update.py:32-33, :121-124 |
FLOWER_NO_PROBE | Salta la sonda de credenciales del arranque. En no interactivo (pipe / CI / stdin redirigido) no se sondea de todas formas; esta variable es la salida para terminales interactivas | ninguno (en interactivo sí sondea) | No | cli.py:1413 |
Un flower ejecutado desde el código fuente en git no se ve afectado por la actualización automática; FLOWER_NO_UPDATE es un no-op para él — el paso del comando de actualización detecta que hay un .git en el repositorio y devuelve None directamente (update.py:83-87).
Las que flower escribe hacia el subproceso del agente¶
Estas tres las genera CompactPolicy.env() y se meten en ClaudeAgentOptions.env (agent.py:48-58, :241-245), y controlan el compact integrado del harness. Ponerlas en tu shell no sirve de nada — lo que manda es la copia que flower pasa al subproceso.
| Variable | Función | Por defecto | Requerido | Origen |
|---|---|---|---|---|
DISABLE_AUTO_COMPACT | =1 desactiva el compact automático. Con el relevo activo se escribe siempre — con los dos mecanismos corriendo a la vez no se distingue quién provocó la bajada de contexto | el relevo está activo por defecto, así que en la práctica es siempre 1 | No (lo escribe flower) | agent.py:51; runtime.py:444-447 |
DISABLE_COMPACT | =1 desactiva también /compact. Solo se escribe con CompactPolicy(mode="off") | no se escribe | No (lo escribe flower) | agent.py:52-53 |
CLAUDE_CODE_AUTO_COMPACT_WINDOW | Ventana del compact automático (tokens). Solo se escribe con CompactPolicy(window=N) | no se escribe | No (lo escribe flower) | agent.py:56-57 |
Las que lee el wrapper de contenedor¶
Estas dos no las lee flower en sí, sino el wrapper de shell docker/flowerbox. Uso completo en despliegue.
| Variable | Función | Por defecto | Requerido | Origen |
|---|---|---|---|---|
FLOWER_HOME | Dónde buscar el .env que se pasa a --env-file | el directorio padre de la ubicación del propio script | No | docker/flowerbox:12 |
FLOWER_IMAGE | Qué imagen usar | flower-box | No | docker/flowerbox:13 |
Las claves de .env no se limitan a las de arriba. El parser carga en os.environ todas las líneas k=v, sin lista blanca (env.py:30, :102-107). El KNOWN formado por esas 9 claves de credenciales solo actúa en dos sitios: la lista blanca al tomar prestada la configuración de ~/.claude (env.py:72), y el conjunto de campos que imprime describe() al arrancar con -v (env.py:205).
Prioridad de búsqueda de credenciales¶
Cuando a load_dotenv() no se le pasa ruta, lee en el siguiente orden todos los ficheros que existan (env.py:45-53, :78-112):
- Variables de entorno del proceso — siempre las de mayor prioridad. Ningún
.envpuede sobreescribir un valor ya exportado. (env.py:91) - El fichero al que apunta
$FLOWER_ENV— solo existe si la has puesto. (env.py:48-49) $PWD/.env— el directorio de trabajo actual. Según a qué proyecto hagascd, se lee el suyo. (env.py:50)${XDG_CONFIG_HOME:-~/.config}/flower/.env— la ubicación global, una por usuario; es la que escribeflower setup. (env.py:51,:39-42)- El
.envde la raíz del repositorio de código — tres niveles por encima deflower/core/env.py. Solo existe si se ejecuta desde el código fuente; un flower instalado con pip / pipx / uv vive en site-packages y no tiene esta entrada. (env.py:52) - El bloque
envde~/.claude/settings.json, y después el de~/.claude/settings.local.json— el último recurso, solo esas 9 claves de credenciales. (env.py:56-75,:109-111)
Qué fichero gana: la entrada 3 (.env del proyecto) gana a la 4 (.env global), la 4 gana a la 5 (.env de la raíz del repositorio), las tres ganan a la 6 (la configuración de Claude Code), y todas pierden frente a la 1 (el entorno del proceso).
La implementación es "una clave que ya tiene valor no se sobrescribe" (env.py:90-93): los primeros ocupan las claves y los siguientes solo rellenan huecos. Así que la prioridad se calcula por clave, no por fichero — si el .env del proyecto solo define ANTHROPIC_BASE_URL, el token puede seguir viniendo del global. El primer valor con que aparece una clave es el definitivo.
La entrada 6 solo se activa en la búsqueda automática. Si se da una ruta explícita (load_dotenv("/path/to/.env")), se lee ese único fichero y no se aplica ningún fallback (env.py:86-87, :109).
Entrada 6: tomar prestado el token de Claude Code¶
Se leen en orden ~/.claude/settings.json y ~/.claude/settings.local.json, se coge el dict data["env"] y de ahí se extraen estas 9 claves (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 el fichero no existe, no se puede leer, o no es JSON válido (OSError / ValueError), se devuelve un dict vacío y se sigue adelante — que falle el fallback no debe llevarse por delante esta ejecución (env.py:62-63, :66-69).
La postura en el código es: lo único que se toma prestado es "dónde encontrar el token"; nada más de settings.json (reglas de permisos, hook, ajustes de modelo) se hereda, así que esto no contradice la promesa de portabilidad de setting_sources=[] (env.py:17-19, :59-61). install.sh:77 lo anuncia como una característica: quien ya tenga Claude Code configurado en su máquina no verá ni la pantalla de configuración.
El texto de error del producto dice lo contrario de lo que hace
Cuando no se encuentra ninguna credencial, la última línea del error que imprime flower es:
(env.py:184-194, esa frase está en :192; la misma afirmación aparece también en .env.example:2, env.py:3-4, agent.py:10-12.) Manda el código: sí lo lee. env.py:56-75 más :109-111 leen explícitamente esos dos ficheros, e install.sh:77 lo vende como argumento. Ese texto es hoy engañoso — en una máquina donde hayas configurado Claude Code, es muy probable que tu token venga precisamente de ahí.
Reglas de parseo de .env¶
Las reglas son tan cortas que se memorizan (env.py:95-107, 13 líneas): strip línea a línea; se saltan las líneas vacías, las que empiezan por # y las que no contienen =; el resto se parte por el primer = en clave y valor, se hace strip a cada lado, y al valor se le aplica además .strip("'\"") — se quitan las comillas simples o dobles del principio y del final, sin exigir que estén emparejadas.
Se reconoce esto:
| Forma | Resultado |
|---|---|
KEY=VALUE | Normal |
KEY = VALUE | Normal — los espacios a ambos lados del igual se eliminan con strip |
KEY="VALUE" / KEY='VALUE' | Normal — se quitan las comillas de los extremos |
KEY=a=b | el valor es a=b — se parte por el primer =, los demás iguales quedan tal cual en el valor |
# comentario | línea entera saltada |
| Línea vacía | saltada |
Esto no se reconoce. No da error: simplemente obtienes en silencio un valor inesperado:
| Forma | Resultado real |
|---|---|
export KEY=VALUE | la clave pasa a ser export KEY; KEY en sí sigue sin valor |
KEY=value # nota | el valor es value # nota — los comentarios de fin de línea no se eliminan |
KEY=$OTHER | el literal $OTHER, no hay interpolación de variables |
| Valores multilínea (entrecomillados a varias líneas) | se procesa línea a línea; la segunda no contiene = y se salta entera |
Un valor vacío ocupa la clave. Si ANTHROPIC_AUTH_TOKEN= aparece en un fichero de prioridad alta, take() ejecuta os.environ["ANTHROPIC_AUTH_TOKEN"] = "", y entonces los ficheros posteriores no pueden rellenarla porque "la clave ya existe" (env.py:90-93); mientras que check_credentials() evalúa por valor de verdad, y la cadena vacía cuenta igualmente como no configurada (env.py:186). El resultado es que no tienes ni credencial ni fallback. Si no quieres una clave, borra la línea entera; no dejes una vacía.
El precio de la portabilidad¶
Todo el mecanismo es esa línea de build_options() (agent.py:207):
portable=True es el valor por defecto de Runtime, y no hay ningún interruptor de línea de comandos para desactivarlo — solo se puede desactivar desde la API de Python escribiendo Runtime(portable=False), lo que lo convierte en ["project"], es decir, leer el .claude/ del proyecto.
Qué queda aislado¶
| Qué queda aislado | Consecuencia |
|---|---|
Los ajustes de ~/.claude/ de la máquina anfitriona | Las reglas de permisos, hook y ajustes de modelo de ahí no surten efecto. Las credenciales son la única excepción, ver préstamo |
El .claude/ del proyecto | Ídem; solo se lee con portable=False |
Las capacidades de dominio no van por esta vía — se distribuyen con el repositorio y se cargan mediante plugins=[{"type": "local", "path": PLUGIN_DIR}] (agent.py:26, :210-212), ver despliegue. Las instrucciones de dominio se añaden después del prompt de sistema nativo de Claude Code, no lo sustituyen (agent.py:198-202), así que la especialización no se paga con pérdida de capacidad general.
Qué llevarse al cambiar de máquina¶
- Credenciales: un fichero. Basta con copiar
~/.config/flower/.env, o volver a configurarlo en la máquina nueva. Si no lo llevas, no arranca nada — no se hereda nada automáticamente. - Estado de continuidad: el directorio entero.
runs/(almacén de sesiones, manifiesto, linaje) y.flower/(workbench). - Pero las rutas tienen que coincidir.
lineage.jsonguarda la ruta absoluta del espacio de trabajo; si no coincide se ignora y se cae en silencio a una sesión nueva, sin error (lineage.py:65-66). La razón es que el SDK deriva elproject_keyde la ruta del espacio de trabajo (/,_y.se sustituyen por-,runtime.py:40-41): si el directorio cambia de sitio, elsession_idantiguo ya no se encuentra.
Disposición en disco¶
Una ejecución de flower escribe dos árboles: <run_dir>/ guarda la contabilidad y las sesiones, y <workspace>/.flower/ guarda el workbench. Por defecto ambos cuelgan del directorio actual, pero su base no es la misma.
runs/ sigue al directorio actual, no a -w
-r/--run-dir es "runs" por defecto, y lo que Runtime hace con él es Path(run_dir).resolve() (runtime.py:93-94) — relativo al directorio de trabajo actual, no al espacio de trabajo indicado con -w. Si ejecutas flower -w /path/to/proj desde ~, el almacén de sesiones acaba en ~/runs/, no dentro del proyecto.
<run_dir>/ — por defecto ./runs/¶
runs/
sessions.db SQLite, transcript completo (incluida la propia de cada subagent)
manifest.json manifiesto de ejecución: session_id / coste / reintentos / motivo de fallo de cada paso, acumulado entre procesos
lineage.json linaje: nombre de paso → session_id; volver a ejecutar en el mismo directorio se engancha gracias a él
aside/ Runtime independiente para las preguntas al oráculo, con su propio sessions.db + manifest.json
workbench/ solo por la vía run / once y si se pasa -W
| Ruta | Contenido | Origen |
|---|---|---|
runs/sessions.db | Transcript completo. Lo escribe PruningSessionStore; las tres capas de política están más abajo | runtime.py:109-112 |
runs/manifest.json | Array JSON, el manifiesto de ejecución acumulado entre procesos. Campos en la tabla siguiente | runtime.py:532-533, :564-586 |
runs/lineage.json | {"workspace": "…", "woke": N, "steps": {"步骤名": "session_id"}}. Se escribe primero un .tmp y luego replace: sustitución atómica | lineage.py:31, :87-97 |
runs/aside/ | Runtime independiente del oráculo. Su coste y su linaje no se mezclan con el manifiesto principal | cli.py:741-743 |
runs/workbench/ | Ubicación por defecto del workbench con Runtime(workbench=True), fuera del espacio de trabajo. La vía go no lo usa | runtime.py:148-151 |
Cada fila de manifest.json es un asdict(StepResult) más dos parches (runtime.py:44-71, :579-582):
| Campo | Tipo | Significado |
|---|---|---|
step | str | Nombre del paso. Cuatro formas: <名>, <名>#round<N> (devuelto para rehacer), <名>#retry<N> (reintento normal), <名>·判定#<N> (juez) |
session_id | str \| None | La sesión que quedó viva al final de este paso |
ok | bool | Si salió bien o no |
cost_usd | float | Cuánto costó este paso |
num_turns | int | Cuántos turnos se dieron |
text | str | La respuesta final de este paso |
error | str \| None | Motivo del fallo. Si lo mató SIGHUP / SIGTERM es killed-by-signal (runtime.py:556-558) |
started_at / ended_at | float | Segundos epoch |
attempts | int | Número real de intentos. >1 significa que hubo reintentos |
errors | list[str] | Motivos de todos los fallos. Solo aquí; el modelo no los ve |
resumed | bool | Si se enganchó desde el punto de interrupción con resume en vez de empezar de cero |
retired | list[str] | Los session_id quemados en el relevo de este paso, en orden |
context | int | El tamaño de contexto que realmente vio el hilo principal en el último turno |
duration_s | float | Parcheado a mano — es una @property y asdict() no la recoge |
run | str | Marca de este proceso: YYYYmmdd-HHMMSS-<6 位 hex>. Debe ser único por instancia |
La estrategia de escritura es añadir, no sobrescribir: en cada volcado se relee primero el fichero, se reemplazan por las versiones nuevas las filas cuyo run coincide con el propio, y las filas de otros se dejan intactas (runtime.py:564-586). Así, varios flower en paralelo sobre el mismo directorio no se pisan la contabilidad.
Lo que hay en runs/ son datos puros: se pueden inspeccionar en cualquier momento con sqlite3 o tools/analyze_run.py sin conexión.
<workspace>/.flower/ — el workbench¶
.flower/
INDEX.md índice autogenerado, inyectado en el system prompt del agente principal
scripts/ scripts que se van a ejecutar una segunda vez. La primera línea `# desc: una frase` aparece en el índice
artifacts/ salidas largas de más de 2000 caracteres: informes, datos, logs
notes/ registro de decisiones entre pasos
spill/ resultados de herramienta grandes volcados a disco; nombre de fichero = primeros 16 caracteres del sha256 del contenido + `.txt`
Los tres subdirectorios y el índice los crea Workbench (workbench.py:73-92). INDEX.md va por el system_prompt.append de la sesión, y los subagent no lo heredan — por eso la regla de "las salidas largas van a artifacts/" tiene que repetirla el coordinador en el task brief: ese es el único canal.
La vía go genera siempre estos ficheros bajo notes/:
| Fichero | Contenido | Origen |
|---|---|---|
notes/需求.md | El brief congelado, cuatro secciones: objetivo / criterios de aceptación / límites / incógnitas y supuestos | brief.py:44-45; clarify.py:105 |
notes/目标.md | Dos secciones congeladas: objetivo / lista de comprobación del veredicto | workflow/goal.py:124 |
notes/问答记录.md | Registro acumulado de todas las preguntas y respuestas, incluidas las entradas de la bandeja de "lo que la persona dice por iniciativa propia". No entra en el contexto; solo queda archivado | human.py:421-433 |
notes/交接-<步骤名>.md | El documento de relevo. La generación anterior se guarda en notes/archive/交接/<步骤名>-<时间戳>.md | runtime.py:388-403 |
notes/archive/<YYYYmmdd-HHMMSS>/ | El lineage.json + 需求.md + 目标.md archivados por --new / /new (se mueven, no se borran) | lineage.py:100-117 |
Con --isolate el workbench se mueve fuera del repositorio: <directorio padre del workspace>/.flower-<nombre del workspace>/ (starter.py:47-55). El worktree es una copia privada de cada agente y el workbench es la capa compartida entre agentes: lo compartido no puede vivir dentro de una valla privada. En ese caso, la ruta que se le da al modelo es absoluta (workbench.py:69-71, :142-145).
spill/ tiene dos escritores, con algoritmos de destino distintos:
| Quién escribe | Cuándo | Dónde escribe | Umbral |
|---|---|---|---|
spill_guard (hook PostToolUse) | antes de que el resultado de la herramienta entre en el modelo | <root del workbench>/spill/ (guard.py:130) | spill_threshold, 4000 caracteres por defecto |
TrimPolicy (en load) | al reproducir el historial antes de un resume | <workspace>/.flower/spill/ — cadena fija relativa al espacio de trabajo (trim.py:49, :303) | min_chars, 2000 caracteres por defecto |
Con la disposición por defecto es el mismo directorio. Pero cuando el workbench se mueve (con -W acaba en runs/workbench/, o con --isolate acaba fuera del repositorio) los dos se separan — la copia de TrimPolicy está siempre dentro del espacio de trabajo, porque el Read del agente tiene que poder llegar a ella.
Lo que spill_guard deja en su lugar no es una línea, sino una línea de puntero más los primeros 400 caracteres (guard.py:132-140). Las llamadas que leen el propio fichero volcado se dejan pasar; si no, "usa Read para leer el contenido completo" sería una frase vacía — lo lees, vuelve a superar el umbral, se vuelve a volcar, bucle infinito (guard.py:155-170).
Esquema de tablas de sessions.db¶
Tres tablas; las sentencias de creación están en 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)
);
| Tabla | Qué es una fila | Puntos clave |
|---|---|---|
entries | Una entrada del transcript; payload es el JSON original | uid es el uuid de la entrada y hace de clave de idempotencia: los lotes fallidos se reintentan 3 veces y la reproducción no puede generar filas duplicadas. Las entradas sin uuid (títulos, etiquetas, marcas de modo) no se deduplican, de ahí el WHERE uid IS NOT NULL del índice único |
meta | El cursor de una sesión | next_seq es el siguiente número de secuencia y mtime es una marca de tiempo en milisegundos estrictamente monótona (sqlite.py:72-79) — list_sessions y los resúmenes comparten este reloj, y si no es monótono el SDK toma el camino rápido equivocado al decidir qué es más reciente |
summaries | El sidecar de resumen de un hilo principal | Solo participa el transcript principal; los de los subagent no cuentan (sqlite.py:122-123) |
Construcción de store_key (sqlite.py:54-58): <project_key>/<session_id>, y los subagent añaden otro tramo subpath. El project_key lo deriva el SDK de la ruta del espacio de trabajo: /, _ y . se sustituyen por -.
Un vistazo a una muestra real (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
Esa muestra tiene 956 entries, 10 meta y 4 summaries — de las 10 sesiones, 4 son transcripts principales y 6 son de subagent, y summaries coincide exactamente con el número de transcripts principales.
Las tres capas del almacén de sesiones¶
Las tres capas son una cadena de herencia, no una combinación opcional
PruningSessionStore hereda de TrimmingSessionStore, que hereda de SqliteSessionStore. Runtime construye siempre la capa más externa (runtime.py:109-112), y sus parámetros de construcción no ofrecen ninguna vía para cambiar de backend. La forma de "desactivar una capa" es poner a False el enabled de su objeto de política, no cambiar de clase.
append (escritura) siempre vuelca todo a disco sin cambiar ni un carácter. Las tres capas solo afectan a load (la copia que se le devuelve al modelo). El orden real de load es:
SqliteSessionStore.load lee todo de la tabla entries por seq
→ TrimmingSessionStore.expire() resultados de Bash con caducidad → sustituidos por "caducado"
→ TrimmingSessionStore.trim() tool_result grandes y antiguos → volcado a disco + puntero
→ PruningSessionStore.prune() mensajes de error sintéticos / llamadas denegadas antiguas → entrada eliminada y cadena reenganchada
| Capa | Clase | Qué tira | Criterio |
|---|---|---|---|
| 1 | SqliteSessionStore | Nada | —— |
| 2 | TrimmingSessionStore | El cuerpo de resultados de herramienta grandes, resultados caducados de comandos efímeros | Tamaño + caducidad |
| 3 | PruningSessionStore | Restos de desconexión, llamadas denegadas antiguas | Si es un error o no |
La capa 2 es el recorte y la capa 3 es la poda — el recorte tira por tamaño y valor, la poda tira por "es un error o no"; no los mezcles. Firmas completas en la API de Python.
SqliteSessionStore — los cimientos¶
Implementación en SQLite sin dependencias externas. Para cambiar a Postgres / S3 / Redis basta con implementar el mismo protocolo; el SDK trae una suite de tests de conformidad, claude_agent_sdk.testing.session_store_conformance, que se puede usar directamente para verificarlo (sqlite.py:1-8).
Aparte de los métodos del protocolo, hay tres consultas síncronas para uso interno de flower:
| Método | Devuelve | Para qué |
|---|---|---|
projects() | list[str] | Los project_key que realmente existen en la base. El SDK lo deriva del cwd; confírmalo con esto antes de consultar, no lo adivines |
has_session(project_key, session_id) | bool | Consulta una sola fila de meta, sin leer el payload. Consúltalo antes de arrancar la continuidad — hacer resume de una sesión inexistente revienta solo después de haber levantado el subproceso, y para entonces ya has gastado dinero y tiempo |
last_context(project_key, session_id, scan=60) | int | Cuánto contexto vio esta sesión en su último turno. Solo escanea hacia atrás las últimas 60 entradas. Cuenta input_tokens más los dos cache_* — mirar solo el primero da casi 0 cuando hay acierto de caché y subestima gravemente |
TrimmingSessionStore + TrimPolicy / EphemeralPolicy¶
TrimmingSessionStore(path, workspace, policy: TrimPolicy | None = None,
ephemeral: EphemeralPolicy | None = None)
Dos reglas ortogonales. TrimPolicy se ocupa del tamaño:
| Parámetro | Tipo | Por defecto | Semántica |
|---|---|---|---|
keep_recent | int | 20 | Los N tool_result más recientes conservan el texto original — el contexto en uso no se debe recortar |
min_chars | int | 2000 | Nada más corto se recorta. Sustituirlo por un puntero gastaría más tokens |
spill_dirname | str | ".flower/spill" | Directorio de archivo, relativo al workspace. Debe estar dentro del espacio de trabajo o el Read del agente no llega |
enabled | bool | True | Con Runtime(trim=False) (el valor por defecto) es False |
El cuerpo recortado se escribe como <primeros 16 caracteres del sha256>.txt, y en su lugar queda [工具结果已归档:N 字符。完整内容在 <路径>,需要时用 Read 读取] (trim.py:54-57, :308-317).
EphemeralPolicy se ocupa de la caducidad: los resultados de git status, ls, ps y similares son muy cortos, así que por tamaño nunca les tocaría el recorte, pero su corrección decae con el tiempo — aquel git status de hace 20 turnos no es "inútil", es que induce a error.
| Parámetro | Tipo | Por defecto | Semántica |
|---|---|---|---|
enabled | bool | True | Se deriva de Runtime(ephemeral=…); activado por defecto |
keep_recent | int | 6 | Los N más recientes conservan el texto original. Mucho menor que los 20 de TrimPolicy — para este tipo de cosas la ventana de "reciente" es corta de por sí |
max_chars | int | 2000 | Por encima de esto pasa a TrimPolicy para archivarse en disco, y no sigue esta vía |
text | str | "[{cmd} 的结果已过期(第 {age} 轮前),当前状态可能已变。需要请重新执行]" | Texto de sustitución |
Solo actúa sobre resultados de la herramienta Bash, y el comando debe casar con EPHEMERAL_CMD. Read no entra aquí: el contenido de un fichero no se distorsiona con el paso del tiempo hasta el punto de inducir a error, y además puede ser justo la base del razonamiento del modelo (trim.py:153-160). El contenido caducado no se vuelca a disco — archivar un git status caducado no tiene sentido: se vuelve a ejecutar y ya está.
La función de decisión es is_ephemeral(cmd), y es a la vez la lista de permisos que se le devuelve al coordinador: delegate_guard(allow_glance=True) usa esa misma función (trim.py:63-68, :128-150). Los dos conjuntos tienen que ser siempre iguales — si permites pero no recortas, un git status caducado ocupa el contexto para siempre; si recortas pero no permites, el coordinador despacha un subagent para un simple ls, 4,3k de coste de arranque a cambio de unas decenas de caracteres. Añadir un comando a la lista blanca equivale a decir las dos cosas a la vez.
Cuándo usar cada cosa:
- Si solo quieres que los restos de desconexión no entren en el contexto → no hace falta hacer nada:
Runtimeya usaPruningSessionStorepor defecto.trim=Falsesolo significa no recortar los resultados grandes; la eliminación se sigue haciendo. - Ejecuciones largas con salidas de herramienta muy grandes →
trim=True. La CLI de la víagoya lo tiene activado por defecto; se desactiva con--no-trim. - Si el coordinador tiene
glance=True→ephemeraldebe seguir activado, por el motivo del párrafo anterior.
PruningSessionStore + PrunePolicy¶
PruningSessionStore(path, workspace, policy: TrimPolicy | None = None,
prune: PrunePolicy | None = None,
ephemeral: EphemeralPolicy | None = None)
| Parámetro | Tipo | Por defecto | Semántica |
|---|---|---|---|
drop_api_errors | bool | True | Elimina los mensajes sintéticos con isApiErrorMessage=true o message.model == "<synthetic>" |
neutralize_interrupts | bool | True | En los tool_result con [Request interrupted …] se sustituye el cuerpo, no se elimina el bloque |
interrupt_text | str | "[上一轮在此处被中断,该工具结果未产生]" | Texto de sustitución de lo anterior |
heal_orphans | bool | True | Añade un resultado sintético a las llamadas huérfanas que tienen tool_use pero no tool_result |
orphan_text | str | "[这一步被打断了,没有结果。需要的话重做。]" | Cuerpo del tool_result añadido |
keep_denials | int | 1 | Conserva las N llamadas de herramienta más recientes denegadas por el permission hook; las anteriores se eliminan junto con la llamada y su resultado |
keep_denials es el único parámetro de construcción de Runtime que se propaga hasta esta capa (Runtime(keep_denials=N)). La razón de que sea 1 y no 0: la denegación más reciente es una señal útil, evita que el modelo reintente en el mismo turno una y otra vez el mismo comando bloqueado. No lo subas — las llamadas denegadas nunca se ejecutaron, así que en su resultado no hay información alguna; medido, cada una ocupa 273 caracteres (93 caracteres de texto de denegación más 180 del comando muerto original), y además induce a error: en pruebas, tras leer varias veces "no uses Bash directamente", el coordinador dejaba de intentar incluso el git status permitido y decía directamente "Bash está restringido, despacho un agente para mirarlo" (prune.py:135-148).
heal_orphans cura el caso de cada resume da 400 después de una interrupción: la interrupción corta en un límite de mensaje, y el tool_use que estaba en vuelo puede quedarse sin tool_result detrás, mientras que la API exige que vayan en pareja. Ese historial roto se queda en el transcript y no desaparece solo, así que todos los resume posteriores rebotan por su culpa. La cura consiste en insertar una entrada user después de la assistant que contiene los huérfanos, completando de golpe los resultados de todos los huérfanos de esa entrada, y luego reapuntar a esa nueva entrada el parentUuid que apuntaba a la assistant original (prune.py:95-147). Se completa, no se borra: borrar los huérfanos obligaría a reenganchar la cadena padre-hijo, y en esa misma assistant puede haber bloques normales, texto y thinking, con riesgo de llevárselos por delante (prune.py:195-204).
Tres líneas rojas estructurales; violarlas hace que la API dé error directamente:
- El bloque
tool_resulttiene que seguir ahí, solo se puede sustituir sucontent. Si falta uno, es "Missing Tool Result Block" (trim.py:20-22;prune.py:79-92). - Las entradas
isCompactSummary/isMetano se tocan — son la única forma en la que existe ese tramo de historial ya compactado (trim.py:179-181). - Si eliminas una entrada, tienes que enganchar sus hijos a su padre. El transcript es una cadena simple de
parentUuidy el harness recorre desde la hoja hacia atrás: donde se rompa la cadena, todo el historial anterior se pierde (prune.py:95-122). Por esorelink()necesita recibir la lista completa incluyendo las entradas a eliminar; el filtrado lo hace ella misma.
En SQLite no se cambia ni un carácter del original — las tres capas solo afectan a "la copia que se le devuelve al modelo" (trim.py:18; prune.py:8).
Resiliencia ante caídas de red¶
Un workflow de largo horizonte corre durante horas, y la red se va a caer alguna vez, seguro. El comportamiento por defecto es pésimo: en el instante de la desconexión el harness mete en el transcript un mensaje assistant sintético (model="<synthetic>", isApiErrorMessage=true) cuyo cuerpo es API Error: Can't reach the API server …; ese mensaje pasa a ser la hoja de la sesión, y a partir de ahí cada resume se lo devuelve al modelo como "lo último que dijo él", así que el modelo cree que está hablando de un fallo de red; además se cuela en StepResult.text y viaja por el workflow hasta el prompt del paso siguiente (resilience.py:1-22).
La capa de resiliencia hace tres cosas, y las tres son imprescindibles: sondear, continuar en vez de empezar de cero, y mantener los errores fuera del contexto.
Parámetros de Resilience¶
| Parámetro | Tipo | Por defecto | Semántica |
|---|---|---|---|
enabled | bool | True | Se deriva de Runtime(resilience=…) |
max_attempts | int | 6 | Cuántos intentos como máximo por paso, incluido el primero |
base_delay | float | 4.0 | Punto de partida del backoff exponencial, en segundos |
max_delay | float | 120.0 | Tope del backoff, en segundos |
probe_timeout | float | 5.0 | Timeout de una sonda, en segundos |
probe_interval | float | 15.0 | Cada cuánto sondear mientras no hay red, en segundos |
max_offline_wait | float | 3600.0 | Cuánto esperar como máximo sin red. Por defecto 1 hora — más que eso normalmente no es una fluctuación, es que ha pasado algo de verdad |
retry_unknown | bool | True | Reintentar también los errores que no se pueden clasificar. La mayoría de los errores desconocidos son transitorios, y los fatales ya están bloqueados por separado |
resume_prompt | str | "上一轮在中途被打断,没有跑完。检查一下工作台里已经落盘的东西,从中断处接着做,不要重头来过。" | Lo que se le dice al modelo al continuar |
Fórmula del backoff (resilience.py:119-121):
Es decir, ±25% de jitter, para evitar que en el instante en que vuelve la red un montón de procesos se abalancen a la vez. Con los valores por defecto: el primer backoff es de 4 segundos (en la práctica 3~5), el segundo de 8 (6~10), y a partir del quinto se topa en 120 segundos (90~150).
Estrategia de la sonda¶
- Se sondea el host:port de
ANTHROPIC_BASE_URL, noapi.anthropic.com(resilience.py:67-72). Con una pasarela propia, que lo segundo responda no dice nada de lo primero. - Solo DNS más handshake TCP:
getaddrinfo, luegoconnect_tcp, y cerrar inmediatamente. No se manda HTTP, no lleva credenciales, no cuesta dinero (resilience.py:75-85). La sonda tiene que ser gratis; si no, "sondear cada 15 segundos mientras no hay red" se convierte ella misma en la avería. - Cualquier fallo cuenta como inalcanzable — no se distingue si se cayó el DNS o si TCP rechazó.
wait_online()se queda ahí esperando: devuelveTruesi vuelve la conexión, yFalsesi se agotamax_offline_wait. En la primera vez que detecta inalcanzabilidad notifica una línea<host>:<port> 不可达,等待恢复(最多 60 分钟), y al recuperarse otra línea<host>:<port> 恢复,继续; en medio no inunda la pantalla (resilience.py:126-140).
La sonda de credenciales previa al arranque es otra cosa: esa sí lanza un POST <BASE_URL>/v1/messages de verdad, con max_tokens=16 y timeout de 20 segundos por defecto (env.py:126-181). No pongas max_tokens a 1 — medido: los modelos con cadena de pensamiento forzada no tienen ni sitio para pensar, y el servidor tarda 30 segundos en responder a duras penas; con 16 tarda solo 3,6 segundos (env.py:120-123).
Clasificación de errores¶
classify(text) devuelve uno de tres valores. Primero se comprueba lo fatal: el texto de un 401, por ejemplo, suele traer también palabras como "connection", y con el orden invertido te quedas esperando eternamente (resilience.py:53-64).
| Clase | Qué casa (regex en resilience.py:37-50) | Comportamiento |
|---|---|---|
fatal | 400 401 403 404, invalid api key, authentication, unauthorized, permission denied, invalid_request, credit balance, quota exceeded, budget, max_turns, CLINotFound | Parar de inmediato, sin reintentos. Reintentar da el mismo resultado siempre, y cada intento cuesta dinero |
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 | Esperar a que vuelva la red y continuar con resume |
unknown | No casa con nada | Con retry_unknown=True (por defecto) también se reintenta |
Distinguir lo reintentable de lo no reintentable es el núcleo de esta capa: una fluctuación de red merece espera, una credencial incorrecta merece parada inmediata — esperar sin red es lo correcto; esperar con la key mal escrita es quemar tiempo.
Qué se queda fuera del contexto¶
- Los mensajes de error sintéticos.
PruningSessionStorelos elimina enteros en elloady reengancha elparentUuid(prune.py:27-32,:191-195). En SQLite se conservan tal cual; simplemente no se devuelven al modelo. - En el flujo de eventos son
kind="error"y no"text", así que no entran enStepResult.texty por tanto no viajan por el workflow hasta el prompt del paso siguiente (resilience.py:17-18). resume_promptno contiene ningún detalle del error, a propósito. El modelo necesita saber "te interrumpieron, sigue"; no necesita saber si fueENOTFOUNDo503. Eso pertenece al log, no al contexto (resilience.py:112-113). Para el log, mira el campoerrorsdemanifest.json.
Continuar en vez de empezar de cero: cuando ocurre el fallo ya se tiene el session_id, así que se engancha con resume desde el punto de interrupción y el gasto anterior no se tira.
Relacionado¶
- Línea de comandos — cómo se mapea cada interruptor a la configuración de esta página.
- API de Python — firmas completas de
Runtime, los tres stores yResilience. - Despliegue — ejecutar en contenedor, distribuir capacidades de dominio con plugin.
- Glosario — el significado exacto de cada término usado en esta página.