Configuration¶
flower has no config file format, and no config subcommand that actually goes anywhere — all configuration is environment variables plus .env files, plus a batch of policy objects that can only be supplied from the Python side. This page gathers what's scattered across five places into one: every variable, the order credentials are looked up in, what syntax .env accepts, what setting_sources=[] actually isolates, what a single run leaves on disk, what each of the three session store layers drops, and how it waits when the network is down. Terminology follows the glossary throughout.
| Want to know | Go to |
|---|---|
| Which env vars flower reads | Full environment variable table |
| Where my token actually comes from | Credential lookup priority |
Why that line in .env didn't take effect | .env parsing rules |
| What to carry when switching machines | The cost of portability |
What's in .flower/ and runs/ | Disk layout |
| Which messages aren't fed back to the model | The three session store layers |
| What it's waiting for when the network's down | Network resilience |
Full environment variable table¶
Five groups: credentials and endpoints flower reads directly, model selection, path lookup, behavior switches, and what flower writes to the agent subprocess. You don't set the last group — set it and it gets overwritten anyway.
Credentials and endpoints¶
| Variable | Effect | Default | Required | Source |
|---|---|---|---|---|
ANTHROPIC_API_KEY | Anthropic's official key. If present, requests go out with an x-api-key header | none | exactly one of it and ANTHROPIC_AUTH_TOKEN is required | env.py:28, :146, :157-158 |
ANTHROPIC_AUTH_TOKEN | Token issued by a gateway. Used with authorization: Bearer when there's no ANTHROPIC_API_KEY | none | same as above | env.py:28, :147, :159-160 |
ANTHROPIC_BASE_URL | The API endpoint root. A third-party gateway fills in its own address, without /v1 — the probe assembles <BASE_URL>/v1/messages | https://api.anthropic.com | no | env.py:151, :162, :210; resilience.py:70 |
If neither is set (or both are empty strings), check_credentials() returns that four-line error, and Runtime.__init__ also raises RuntimeError (env.py:184-194; runtime.py:156-158).
Model selection¶
flower reads only three of these for its own decisions; the rest are loaded and passed straight through to the SDK.
| Variable | Effect | Default | Required | Source |
|---|---|---|---|---|
ANTHROPIC_MODEL | The main model name. Also decides the default handoff window: 1m in the name or no haiku → 1M, haiku present → 200K | none (decided on the server side) | no | env.py:153; agent.py:77-81 |
ANTHROPIC_DEFAULT_OPUS_MODEL | Model mapping for the opus tier. When ANTHROPIC_MODEL is empty, the window decision falls back to it | none | no | agent.py:78; cli.py:1384 |
ANTHROPIC_DEFAULT_SONNET_MODEL | Model mapping for the sonnet tier. flower doesn't read it itself, only loads and lends it | none | no | env.py:34; cli.py:1385 |
ANTHROPIC_DEFAULT_HAIKU_MODEL | Model mapping for the haiku tier. The credential probe prefers it | probe falls back to ANTHROPIC_MODEL, then to claude-3-5-haiku-20241022 | no | env.py:152-153 |
CLAUDE_CODE_SUBAGENT_MODEL | Which model subagents use. flower doesn't interpret it; the SDK consumes it | none | no | env.py:35; .env.example |
CLAUDE_CODE_EFFORT_LEVEL | Thinking tier. Same as above, only loaded, not interpreted | none | no | env.py:35 |
If flower setup filled in a model name, ANTHROPIC_MODEL, ANTHROPIC_DEFAULT_OPUS_MODEL, and ANTHROPIC_DEFAULT_SONNET_MODEL all three get written together (cli.py:1383-1385).
Paths and lookup¶
| Variable | Effect | Default | Required | Source |
|---|---|---|---|---|
FLOWER_ENV | Points at a .env file path, placed before all others | none | no | env.py:48-49 |
XDG_CONFIG_HOME | Determines the location of the global credential file $XDG_CONFIG_HOME/flower/.env | ~/.config | no | env.py:41-42 |
HOME | The source of Path.home(); both ~/.config and ~/.claude are derived from it | given by the system | no | env.py:41, :67 |
Behavior switches¶
Both are escape hatches: not setting them is the norm, setting them makes flower do one thing less. Any non-empty value takes effect; the value itself isn't parsed (update.py:121; cli.py:1413).
| Variable | Effect | Default | Required | Source |
|---|---|---|---|---|
FLOWER_NO_UPDATE | Turns off auto-update. When unset, a flower installed via pip / pipx / uv spins up a background thread at startup to check for a new version, installs it if found, and it takes effect on the next flower run; checks at most once every 24 hours, timestamp recorded in ~/.config/flower/.update | none (auto-update on) | no | update.py:32-33, :121-124 |
FLOWER_NO_PROBE | Skips the startup credential probe. Non-interactive runs (pipe / CI / redirected stdin) don't probe anyway; this variable is the escape hatch left for interactive terminals | none (probes under interactive) | no | cli.py:1413 |
A flower run from git source isn't affected by auto-update; FLOWER_NO_UPDATE is a no-op for it — the update command recognizes a .git in the repo at that step and returns None immediately (update.py:83-87).
What flower writes to the agent subprocess¶
These three are generated by CompactPolicy.env() and stuffed into ClaudeAgentOptions.env (agent.py:48-58, :241-245), controlling the harness's built-in compact. Setting them in your shell means nothing — what actually takes effect is the copy flower passes to the subprocess.
| Variable | Effect | Default | Required | Source |
|---|---|---|---|---|
DISABLE_AUTO_COMPACT | =1 turns off auto-compact. Force-written when handoff is on — with both mechanisms running at once you can't tell which one caused a context rollback | handoff is on by default, so in practice always 1 | no (flower writes) | agent.py:51; runtime.py:444-447 |
DISABLE_COMPACT | =1 turns off /compact too. Only written by CompactPolicy(mode="off") | not written | no (flower writes) | agent.py:52-53 |
CLAUDE_CODE_AUTO_COMPACT_WINDOW | The auto-compact window (tokens). Only written by CompactPolicy(window=N) | not written | no (flower writes) | agent.py:56-57 |
What the container wrapper reads¶
These two aren't read by flower proper; they're read by the docker/flowerbox shell wrapper. See Deployment for full usage.
| Variable | Effect | Default | Required | Source |
|---|---|---|---|---|
FLOWER_HOME | Where to find the .env for --env-file | the parent directory of the script's own location | no | docker/flowerbox:12 |
FLOWER_IMAGE | Which image to use | flower-box | no | docker/flowerbox:13 |
The keys in .env aren't limited to the above. The parser loads every k=v line into os.environ, with no allowlist (env.py:30, :102-107). The KNOWN set made up of the 9 credential keys above only does two things: acts as an allowlist when borrowing ~/.claude config (env.py:72), and bounds the fields printed by describe() when starting with -v (env.py:205).
Credential lookup priority¶
When load_dotenv() is called with no path, it reads every file that exists in the following order (env.py:45-53, :78-112):
- Process environment variables — always highest. No
.envcan override an already-exported value. (env.py:91) - The file
$FLOWER_ENVpoints at — only present if set. (env.py:48-49) $PWD/.env— the current working directory.cdinto a project and it reads the nearest one. (env.py:50)${XDG_CONFIG_HOME:-~/.config}/flower/.env— the per-user global location, the oneflower setupwrites. (env.py:51,:39-42)- The
.envat the source repo root — three levels up fromflower/core/env.py. Only present when running from source; a flower installed via pip / pipx / uv lives in site-packages and has no such file. (env.py:52) - The
envblock of~/.claude/settings.json, then~/.claude/settings.local.json— the last fallback, takes only the 9 credential keys. (env.py:56-75,:109-111)
Which file wins: entry 3 (project .env) beats entry 4 (global .env), entry 4 beats entry 5 (repo-root .env), all three beat entry 6 (Claude Code's config), and none of them beat entry 1 (the process environment).
The mechanism is "don't overwrite a key that already has a value" (env.py:90-93): the ones earlier in the order claim keys first, the later ones only fill gaps. So priority is computed per key, not per file — if the project .env only writes ANTHROPIC_BASE_URL, the token can still come from the global one. The first value that appears for a given key is settled for life.
Entry 6 is only enabled during automatic lookup. Give an explicit path (load_dotenv("/path/to/.env")) and it reads only that one file, with no fallback whatsoever (env.py:86-87, :109).
Entry 6: borrowing Claude Code's token¶
It reads ~/.claude/settings.json then ~/.claude/settings.local.json in order, takes the data["env"] dict, and picks out these 9 keys (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
If the file doesn't exist, can't be read, or isn't valid JSON (OSError / ValueError), it returns an empty dict and moves on — a broken fallback shouldn't take the run down with it (env.py:62-63, :66-69).
The stance in the code: all it borrows is "where to find the token"; nothing else in settings.json (permission rules, hooks, model settings) is taken over, so this doesn't violate the portability promise of setting_sources=[] (env.py:17-19, :59-61). install.sh:77 pitches it as a feature: someone with Claude Code already configured on the machine won't even see the config screen.
The in-product error text contradicts the actual behavior
When credentials can't be found at all, the last line of the error flower prints is:
(env.py:184-194, that line at :192; the same claim also appears in .env.example:2, env.py:3-4, agent.py:10-12.) Trust the code: it does read it. env.py:56-75 plus :109-111 explicitly reads those two files, and install.sh:77 even sells this as a selling point. That text is currently misleading — on a machine that has Claude Code configured, your token very likely comes from exactly there.
.env parsing rules¶
The parsing rules are short enough to memorize (env.py:95-107, 13 lines): strip each line, skip blank lines, lines starting with #, and lines with no =; split the rest at the first = into key and value, strip each side, then run the value through .strip("'\"") once — any leading or trailing single or double quotes are stripped, without requiring them to be paired.
Accepted forms:
| Form | Result |
|---|---|
KEY=VALUE | normal |
KEY = VALUE | normal — spaces around the equals sign are stripped |
KEY="VALUE" / KEY='VALUE' | normal — leading and trailing quotes stripped |
KEY=a=b | value is a=b — split at the first =, later equals signs stay in the value verbatim |
# comment | whole line skipped |
| blank line | skipped |
Not accepted. Writing these raises no error, you just silently get an unexpected value:
| Form | Actual result |
|---|---|
export KEY=VALUE | key becomes export KEY, KEY itself still has no value |
KEY=value # note | value is value # note — trailing comments aren't stripped |
KEY=$OTHER | the literal $OTHER, no variable interpolation |
| multi-line value (quoted across lines) | processed line by line; the second line has no = and gets skipped wholesale |
An empty value claims the key. If ANTHROPIC_AUTH_TOKEN= appears in a higher-priority file, take() runs os.environ["ANTHROPIC_AUTH_TOKEN"] = "", and then later files can't fill it in because "the key already exists" (env.py:90-93); meanwhile check_credentials() tests for truthiness, so an empty string still counts as unconfigured (env.py:186). The result is neither credentials nor a fallback. If you don't want a key, delete the whole line — don't leave an empty one.
The cost of portability¶
That one line in build_options() is the whole mechanism (agent.py:207):
portable=True is the default for Runtime, and no command-line switch can turn it off — to turn it off you have to go through the Python API and write Runtime(portable=False), which becomes ["project"], i.e. reads the project's .claude/.
What gets isolated¶
| Isolated | Consequence |
|---|---|
The host's ~/.claude/ settings | The permission rules, hooks, and model settings there take no effect. Credentials are the sole exception, see borrowing |
The project's .claude/ | Same as above; only read when portable=False |
Domain capability doesn't go through this path — it ships with the repo, loaded via plugins=[{"type": "local", "path": PLUGIN_DIR}] (agent.py:26, :210-212), see Deployment. Domain instructions are appended after the native Claude Code system prompt, not a replacement (agent.py:198-202), so specialization doesn't cost you general capability.
What to carry when switching machines¶
- Credentials: one file. Copy
~/.config/flower/.envover, or reconfigure once on the new machine. Don't bring it and nothing runs — nothing gets inherited automatically. - Continuity state: the whole directory.
runs/(session store, manifest, lineage) and.flower/(workbench). - But paths must match.
lineage.jsonstores the workspace's absolute path; if it doesn't match it's treated as absent, silently falling back to a new session, with no error (lineage.py:65-66). The reason is that the SDK'sproject_keyis derived from the workspace path (/,_,.all replaced with-,runtime.py:40-41); move the directory and the oldsession_idcan no longer be found.
Disk layout¶
A single flower run writes two trees: <run_dir>/ holds the ledger and sessions, and <workspace>/.flower/ holds the workbench. Both default to under the current directory, but their bases differ.
runs/ follows the current directory, not -w
-r/--run-dir defaults to "runs", and what Runtime does with it is Path(run_dir).resolve() (runtime.py:93-94) — relative to the current working directory, not to the workspace given by -w. Run flower -w /path/to/proj from ~ and the session store lands in ~/runs/, not in the project.
<run_dir>/ — default ./runs/¶
runs/
sessions.db SQLite, full transcript (including each subagent's own)
manifest.json run manifest: each step's session_id / cost / retries / failure reason, accumulated across processes
lineage.json lineage: step name → session_id, reconnecting a re-run in the same directory relies on it
aside/ the oracle's separate Runtime, its own sessions.db + manifest.json
workbench/ only when going through the run / once path and given -W
| Path | Contents | Source |
|---|---|---|
runs/sessions.db | Full transcript. Written by PruningSessionStore, the three-layer policy is described below | runtime.py:109-112 |
runs/manifest.json | A JSON array, the run manifest accumulated across processes. Fields in the table below | runtime.py:532-533, :564-586 |
runs/lineage.json | {"workspace": "…", "woke": N, "steps": {"步骤名": "session_id"}}. Writes to .tmp first then replace, atomic swap | lineage.py:31, :87-97 |
runs/aside/ | The oracle's separate Runtime. Its cost and lineage aren't mixed into the main manifest | cli.py:741-743 |
runs/workbench/ | The default workbench location for Runtime(workbench=True), outside the workspace. The go path doesn't use it | runtime.py:148-151 |
Each line of manifest.json is asdict(StepResult) plus two patches (runtime.py:44-71, :579-582):
| Field | Type | Meaning |
|---|---|---|
step | str | Step name. Four shapes: <name>, <name>#round<N> (sent back for redo), <name>#retry<N> (plain retry), <name>·判定#<N> (judge) |
session_id | str \| None | The last live session of this step |
ok | bool | Whether it succeeded |
cost_usd | float | How much this step cost |
num_turns | int | How many turns it ran |
text | str | The final reply of this step |
error | str \| None | Failure reason. When killed by SIGHUP / SIGTERM it's killed-by-signal (runtime.py:556-558) |
started_at / ended_at | float | epoch seconds |
attempts | int | Actual number of attempts. >1 means it retried |
errors | list[str] | The failure reasons over the attempts. Only here — the model can't see them |
resumed | bool | Whether it reconnected from the interruption point via resume rather than running from scratch |
retired | list[str] | The session_ids burned during this step's handoff, in order |
context | int | The context size the main thread actually saw on the last turn |
duration_s | float | Patched in by hand — it's a @property, asdict() can't collect it |
run | str | This process's marker YYYYmmdd-HHMMSS-<6-digit hex>. Must be unique per instance |
The write policy is append, don't overwrite: each flush re-reads the file, replaces the lines whose run equals its own with the latest, and leaves other lines untouched (runtime.py:564-586). So running multiple flowers in parallel in the same directory won't have their ledgers clobber each other.
The stuff in runs/ is pure data, readable offline anytime with sqlite3 or tools/analyze_run.py.
<workspace>/.flower/ — the workbench¶
.flower/
INDEX.md auto-generated index, injected into the main agent's system prompt
scripts/ scripts to be run a second time. The first line `# desc: 一句话` appears in the index
artifacts/ long outputs over 2000 characters: reports, data, logs
notes/ cross-step decision records
spill/ spilled large tool results, filename = first 16 chars of the content sha256 + `.txt`
The three subdirectories plus the index are created by Workbench (workbench.py:73-92). INDEX.md goes through the session-level system_prompt.append, which subagents can't inherit — so the rule "write long outputs to artifacts/" must be relayed by the coordinator in the task brief, that being the only channel.
The go path always generates these under notes/:
| File | Contents | Source |
|---|---|---|
notes/需求.md | The frozen brief, four sections: goal / acceptance criteria / boundaries / unknowns and assumptions | brief.py:44-45; clarify.py:105 |
notes/目标.md | Two frozen sections: goal / verdict checklist | workflow/goal.py:124 |
notes/问答记录.md | An appended record of all the Q&A, including inbox entries from "the human speaking up unprompted". Doesn't enter context, kept only for the record | human.py:421-433 |
notes/交接-<步骤名>.md | The handoff document. The previous generation gets filed into notes/archive/交接/<步骤名>-<时间戳>.md | runtime.py:388-403 |
notes/archive/<YYYYmmdd-HHMMSS>/ | The lineage.json + 需求.md + 目标.md archived by --new / /new (moved, not deleted) | lineage.py:100-117 |
With --isolate the workbench moves outside the repo: <parent of workspace>/.flower-<workspace name>/ (starter.py:47-55). A worktree is each agent's private copy, the workbench is a shared layer across agents, and shared things can't go inside a private fence. In this case the path given to the model is absolute (workbench.py:69-71, :142-145).
spill/ has two writers, with different landing algorithms:
| Who writes | When | Where to | Threshold |
|---|---|---|---|
spill_guard (PostToolUse hook) | before a tool result enters the model | <workbench root>/spill/ (guard.py:130) | spill_threshold, default 4000 characters |
TrimPolicy (at load) | when replaying history before resume | <workspace>/.flower/spill/ — a fixed string relative to the workspace (trim.py:49, :303) | min_chars, default 2000 characters |
Under the default layout these are the same directory. But when the workbench is moved away (-W lands it in runs/workbench/, or --isolate lands it outside the repo) the two split apart — the TrimPolicy copy is always inside the workspace, because the agent's Read must be able to reach it.
What spill_guard swaps in isn't one line, it's one line of pointer plus the first 400 characters (guard.py:132-140). Calls that read the spill file itself are let through, otherwise "use Read to read the full text" is an empty phrase — read it back and it's over threshold again, spilled again, an infinite loop (guard.py:155-170).
The sessions.db schema¶
Three tables, the CREATE statements are in 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 | What a row is | Key point |
|---|---|---|
entries | One entry in the transcript, payload is the raw JSON | uid is the entry's uuid, serving as an idempotency key: a failed batch gets retried 3 times, and the replay must not produce duplicate rows. Entries with no uuid (titles, tags, mode markers) aren't deduped, so the unique index carries WHERE uid IS NOT NULL |
meta | A session's cursor | next_seq is the next sequence number, mtime is a millisecond timestamp and strictly monotonic (sqlite.py:72-79) — list_sessions and summaries share this clock, and non-monotonicity would send the SDK's new/old judgment down the wrong fast path |
summaries | A main-thread summary sidecar | Only the main transcript participates, subagents' don't (sqlite.py:122-123) |
store_key is built (sqlite.py:54-58) as <project_key>/<session_id>, with subagents adding a further subpath. project_key is derived by the SDK from the workspace path — /, _, . all replaced with -.
Look at a real sample (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
That one has 956 entries, 10 meta, and 4 summaries — of the 10 sessions 4 are main transcripts, 6 are subagents', and summaries exactly equals the number of main transcripts.
The three session store layers¶
The three layers are an inheritance chain, not an optional combination
PruningSessionStore inherits TrimmingSessionStore inherits SqliteSessionStore. Runtime always constructs the outermost one (runtime.py:109-112); there's no entry point in the constructor arguments to swap the backend. The way to "turn off a layer" is to set its policy object's enabled to False, not to swap the class.
append (write) is always a full spill to disk, not a word changed. The three layers only affect load (the copy read back and fed to the model). The actual order of load is:
SqliteSessionStore.load read out all entries from the entries table by seq
→ TrimmingSessionStore.expire() time-sensitive Bash results → swapped for "expired"
→ TrimmingSessionStore.trim() old large tool_results → spilled + swapped for a pointer
→ PruningSessionStore.prune() synthetic error messages / old denied calls → removed wholesale and the chain relinked
| Layer | Class | Drops what | Criterion |
|---|---|---|---|
| 1 | SqliteSessionStore | drops nothing | —— |
| 2 | TrimmingSessionStore | large tool result bodies, expired ephemeral command results | size + timeliness |
| 3 | PruningSessionStore | disconnection debris, old denied calls | whether it's an error |
Layer 2 is trim, layer 3 is prune — trim drops by size and value, prune drops by "is it an error", don't conflate them. Full signatures in the Python API.
SqliteSessionStore — the foundation¶
A SQLite implementation with zero external dependencies. To swap in Postgres / S3 / Redis, implement the same protocol; the SDK ships a conformance test suite claude_agent_sdk.testing.session_store_conformance you can validate against directly (sqlite.py:1-8).
Besides the protocol methods, there are three synchronous queries, for flower's own use:
| Method | Returns | Use |
|---|---|---|
projects() | list[str] | The project_keys that actually exist in the store. The SDK derives it from cwd; confirm with this before querying rather than guessing |
has_session(project_key, session_id) | bool | Queries only one meta row, doesn't read the payload. Query before starting continuity — resuming a session that doesn't exist blows up only after the subprocess starts, by which point money and time are spent |
last_context(project_key, session_id, scan=60) | int | How large a context the last turn of this session saw. Scans only the last 60 entries backward. input_tokens plus the two cache_* all count — looking at only the former, which is near 0 on a cache hit, would badly underestimate |
TrimmingSessionStore + TrimPolicy / EphemeralPolicy¶
TrimmingSessionStore(path, workspace, policy: TrimPolicy | None = None,
ephemeral: EphemeralPolicy | None = None)
Two orthogonal rules. TrimPolicy handles size:
| Parameter | Type | Default | Semantics |
|---|---|---|---|
keep_recent | int | 20 | The most recent N tool_results keep their original text — the context in active use shouldn't be trimmed |
min_chars | int | 2000 | Anything shorter isn't trimmed. Swapping in a pointer would cost more tokens instead |
spill_dirname | str | ".flower/spill" | The archive directory, relative to workspace. Must be inside the workspace, otherwise the agent's Read can't reach it |
enabled | bool | True | False when Runtime(trim=False) (the default) |
The trimmed body is written as <first 16 chars of sha256>.txt, and the original position is swapped for [工具结果已归档:N 字符。完整内容在 <路径>,需要时用 Read 读取] (trim.py:54-57, :308-317).
EphemeralPolicy handles timeliness: results from git status, ls, ps and the like are short, and by size would never get their turn to be trimmed, but their correctness decays over time — a git status from 20 turns ago isn't "useless", it's misleading.
| Parameter | Type | Default | Semantics |
|---|---|---|---|
enabled | bool | True | Converted from Runtime(ephemeral=…), on by default |
keep_recent | int | 6 | The most recent N keep their original text. Much smaller than TrimPolicy's 20 — the "recent" window for this kind of thing is inherently short |
max_chars | int | 2000 | Beyond this it's handed to TrimPolicy to spill and archive, not this path |
text | str | "[{cmd} 的结果已过期(第 {age} 轮前),当前状态可能已变。需要请重新执行]" | Replacement text |
Applies only to Bash tool results, and the command must match EPHEMERAL_CMD. Read isn't included: file contents don't distort into being misleading merely because time passes, and it may be exactly what the model's reasoning rests on (trim.py:153-160). Expired content is not spilled — archiving an expired git status is pointless, rerun it once and you have it.
The judging function is is_ephemeral(cmd), and it is also the permission list handed back to the coordinator: delegate_guard(allow_glance=True) uses the same function (trim.py:63-68, :128-150). The two sets must always be equal — allow it but don't trim it and an expired git status occupies context forever; trim it but don't allow it and the coordinator dispatches a subagent for a single ls, trading 4.3k of startup cost for a few dozen characters. Adding one command to the allowlist says both of those things at once.
When to use which:
- Want only disconnection debris kept out of context → do nothing,
Runtimedefaults toPruningSessionStore.trim=Falsejust doesn't trim large results; removal still happens. - Long runs, large tool output →
trim=True. Thegopath CLI has it on by default, use--no-trimto turn it off in reverse. - The coordinator has
glance=Trueon →ephemeralmust stay on, reasons in the previous paragraph.
PruningSessionStore + PrunePolicy¶
PruningSessionStore(path, workspace, policy: TrimPolicy | None = None,
prune: PrunePolicy | None = None,
ephemeral: EphemeralPolicy | None = None)
| Parameter | Type | Default | Semantics |
|---|---|---|---|
drop_api_errors | bool | True | Removes synthetic messages with isApiErrorMessage=true or message.model == "<synthetic>" |
neutralize_interrupts | bool | True | For a [Request interrupted …] tool_result, swap the body, don't remove the block |
interrupt_text | str | "[上一轮在此处被中断,该工具结果未产生]" | Replacement text for the previous entry |
heal_orphans | bool | True | Adds a synthetic result for orphaned calls that "have a tool_use but no tool_result" |
orphan_text | str | "[这一步被打断了,没有结果。需要的话重做。]" | The body of the tool_result added |
keep_denials | int | 1 | Keep the most recent N tool calls denied by the permission hook, remove earlier ones call and result together |
keep_denials is the only Runtime constructor argument passed straight through to this layer (Runtime(keep_denials=N)). The reason for defaulting to 1 rather than 0: the most recent denial is a valid signal, preventing the model from repeatedly retrying the same blocked command within a single turn. Don't raise it — a denied call was never executed, its result carries no information, and in measurement one occupies 273 characters (a 93-character refusal plus 180 characters of the dead command's text), and it misleads: in measurement, after the coordinator reads a few "don't use Bash directly" lines, it stops even trying an allowed git status and just says "Bash is restricted, dispatch an agent to look" (prune.py:135-148).
heal_orphans cures resume 400-ing every time after an interruption: the interruption breaks at a message boundary, the tool_use in flight at the time may have no tool_result following it at all, while the API requires the two to be paired. This bad history left in the transcript won't go away on its own, so every subsequent resume gets bounced by it. The fix is to insert a user entry after the assistant entry containing the orphan, filling in the results for all orphans in that entry at once, then reroute the parentUuid that originally pointed at that assistant to point at this inserted one (prune.py:95-147). Add, don't delete: deleting an orphan requires relinking the parent-child chain, and the same assistant entry may still hold valid blocks, text, and thinking that would get caught in the crossfire (prune.py:195-204).
Three structural red lines, violating which makes the API error out directly:
- The
tool_resultblock itself must be present, onlycontentcan be swapped. Miss one and it's "Missing Tool Result Block" (trim.py:20-22;prune.py:79-92). isCompactSummary/isMetaentries can't be touched — that's the only form in which the compacted history exists (trim.py:179-181).- Remove an entry and you must reattach its children to its parent. The transcript is a single
parentUuidchain, the harness walks back from the leaf, and wherever the chain breaks all the history before it is lost (prune.py:95-122). Sorelink()must receive the full list including the entry to be removed, and does the filtering itself.
Not one word of the original in SQLite is changed — the three layers only affect "the copy fed back to the model" (trim.py:18; prune.py:8).
Network resilience¶
A long-horizon workflow runs for hours at a stretch, and the network will drop at least once. The default behavior is bad: the moment it drops, the harness stuffs a synthetic assistant message into the transcript (model="<synthetic>", isApiErrorMessage=true), with the body API Error: Can't reach the API server …; it becomes the session's leaf, so a later resume feeds it back as "what the model said last", and the model thinks it's discussing a network failure; it also mixes into StepResult.text and passes down the workflow to the next step's prompt (resilience.py:1-22).
The resilience layer does three things, none optional: probe, resume instead of restart, keep errors out of context.
Resilience parameters¶
| Parameter | Type | Default | Semantics |
|---|---|---|---|
enabled | bool | True | Converted from Runtime(resilience=…) |
max_attempts | int | 6 | How many times a step can be attempted at most, including the first |
base_delay | float | 4.0 | Exponential backoff starting point, seconds |
max_delay | float | 120.0 | Backoff ceiling, seconds |
probe_timeout | float | 5.0 | Single-probe timeout, seconds |
probe_interval | float | 15.0 | How often to probe while offline, seconds |
max_offline_wait | float | 3600.0 | Maximum wait while offline. Default 1 hour — longer than that is usually not jitter, it's something actually wrong |
retry_unknown | bool | True | Retry errors that can't be classified too. Most unknown errors are transient, and fatal errors are already blocked separately |
resume_prompt | str | "上一轮在中途被打断,没有跑完。检查一下工作台里已经落盘的东西,从中断处接着做,不要重头来过。" | What to say to the model when resuming |
The backoff formula (resilience.py:119-121):
That is ±25% jitter, avoiding a bunch of processes rushing in together the instant the network recovers. At defaults: the 1st backoff is 4 seconds (actually 3–5), the 2nd is 8 seconds (6–10), and from the 5th it caps at 120 seconds (90–150).
Probe strategy¶
- What it probes is the host:port of
ANTHROPIC_BASE_URL, notapi.anthropic.com(resilience.py:67-72). With a self-hosted gateway, the latter being reachable says nothing about the former. - DNS plus a TCP handshake only:
getaddrinfothenconnect_tcpthen close immediately. No HTTP, no credentials, no cost (resilience.py:75-85). The probe must be free, otherwise "probing every 15 seconds while offline" itself becomes a failure. - Any failure counts as unreachable — no distinction between DNS being down and TCP being refused.
wait_online()hangs there waiting: returnsTruewhen reachable, returnsFalseafter a fullmax_offline_wait. On the first unreachability it notifies one line<host>:<port> 不可达,等待恢复(最多 60 分钟), and on recovery notifies one more line<host>:<port> 恢复,继续, without flooding the screen in between (resilience.py:126-140).
The credential probe before the run starts is a separate matter: it actually sends one POST <BASE_URL>/v1/messages, max_tokens=16, with a default timeout of 20 seconds (env.py:126-181). Don't set max_tokens to 1 — in measurement a model with forced chain-of-thought can't even fit its thinking, and the server struggles until 30 seconds before returning; set it to 16 and it takes only 3.6 seconds (env.py:120-123).
Error classification¶
classify(text) returns one of three. Judge fatal first: a 401 and the like often carry words like "connection" in their text too, and getting the order backwards causes a deadlock wait (resilience.py:53-64).
| Class | What it matches (regex in resilience.py:37-50) | Behavior |
|---|---|---|
fatal | 400 401 403 404, invalid api key, authentication, unauthorized, permission denied, invalid_request, credit balance, quota exceeded, budget, max_turns, CLINotFound | Stop immediately, no retry. However many times you retry the result is the same, and each one costs money |
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 | Wait for the network to come back, then resume |
unknown | matches none | Retried too when retry_unknown=True (the default) |
Distinguishing retryable from non-retryable is the core of this layer: network jitter should wait, a credential error should stop immediately — deadlock-waiting while offline is right, but deadlock-waiting on a mistyped key is just burning time.
What gets held out of context¶
- Synthetic error messages.
PruningSessionStoreremoves them wholesale atloadand relinksparentUuid(prune.py:27-32,:191-195). Kept verbatim in SQLite, just not fed back. - In the event stream it's
kind="error", not"text", so it doesn't enterStepResult.text, and therefore doesn't pass down the workflow into the next step's prompt (resilience.py:17-18). resume_promptdeliberately contains no error detail. The model needs to know "you were interrupted, keep going", not whether it wasENOTFOUNDor503. That belongs in the log, not the context (resilience.py:112-113). For the log, look at theerrorsfield ofmanifest.json.
Resume instead of restart: by the time the failure occurs the session_id is already in hand, use resume to reconnect from the interruption point, and the earlier cost isn't wasted.
Related¶
- Command line — how each switch maps to the configuration on this page.
- Python API — full signatures of
Runtime, the three stores, andResilience. - Deployment — running in a container, distributing domain capability via a plugin.
- Glossary — the precise meaning of every term used on this page.