Skip to main content

supercode_harness/
config.rs

1use std::collections::HashMap;
2use std::path::PathBuf;
3
4use crate::event::EventSink;
5
6pub(crate) use supercode_runtime::glob_match;
7pub use supercode_runtime::CachePlan;
8
9/// The default OpenRouter base URL. Any OpenAI-compatible endpoint works too.
10pub const OPENROUTER_BASE_URL: &str = "https://openrouter.ai/api/v1";
11
12/// Environment variable consulted for the API key when none is set explicitly.
13pub const DEFAULT_API_KEY_ENV: &str = "OPENROUTER_API_KEY";
14
15/// Built-in prompt templates (slash commands), e.g. `/code-review`.
16fn default_prompts() -> std::collections::HashMap<String, String> {
17    let mut m = std::collections::HashMap::new();
18    m.insert(
19        "code-review".to_string(),
20        "Review the current code changes for correctness bugs, then for \
21reuse/simplification/efficiency cleanups. {args}\nUse the available tools to \
22inspect the diff and files. Report findings grouped by severity."
23            .to_string(),
24    );
25    m
26}
27
28/// A default, deliberately small system prompt. Override it freely.
29pub const DEFAULT_SYSTEM_PROMPT: &str = "\
30You are supercode, a precise and efficient AI coding agent operating in a user's \
31working directory. Use the available tools to inspect and modify files and run \
32commands. Prefer reading before writing. Make minimal, correct changes and explain \
33what you did concisely.";
34
35/// When the agent must seek approval before running a tool — the analog of
36/// Codex's `-a untrusted|on-request|never` and Claude's permission modes.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
38pub enum ApprovalPolicy {
39    /// Never ask — every tool call runs automatically (default).
40    #[default]
41    Never,
42    /// Ask for tools not on the auto-approve allowlist.
43    OnRequest,
44    /// Ask for every tool call.
45    Untrusted,
46    /// P5-1 (COMPOSABLE-HARNESS-DESIGN.md §3.2 S8, §4.3 cx-parity): Codex's
47    /// `-a on-request` default — escalation is INITIATED BY THE MODEL, not
48    /// decided by a client-side allowlist check the way [`Self::OnRequest`]
49    /// is (`Config::needs_approval`'s `OnRequest` arm consults
50    /// `Config::auto_approved_tools`/`tool_allow_patterns`; Codex's
51    /// `on-request` instead runs sandboxed writes/reads silently and only
52    /// asks when the MODEL itself requests to leave the sandbox —
53    /// `protocol.rs:921-924`). Using [`Self::OnRequest`] for cx-parity would
54    /// prompt on every non-allowlisted call, where stock Codex prompts
55    /// almost never — a materially different (over-prompting, but not
56    /// unsafe) posture, which is why `configfile::parse_approval_str`
57    /// previously fell back to [`Self::Untrusted`] rather than silently
58    /// picking the wrong existing variant (S8's original fail-safe). This
59    /// variant now exists so cx-parity resolves to its INTENDED posture
60    /// instead of that fail-safe. `Config::needs_approval` (the coarse,
61    /// tool-name-only legacy gate — no model-escalation signal reaches it)
62    /// treats this conservatively, the same as [`Self::OnRequest`]; the P5-1
63    /// permissions engine (`crate::permissions`, the richer canonicalized-
64    /// command-aware gate `crate::agent::Agent` consults when
65    /// `Config::permissions_enabled` is on) treats it per Codex's real
66    /// posture — see that gate's doc comment.
67    ModelRequested,
68}
69
70/// A callback consulted when a tool call needs approval. Returns `true` to allow.
71pub type ApprovalHandler = Box<dyn Fn(&crate::message::ToolCall) -> bool + Send + Sync>;
72
73/// A pre-tool hook: receives the tool name and parsed arguments before
74/// execution. Return `Some(reason)` to BLOCK the call (the reason is fed back to
75/// the model), or `None` to allow it.
76pub type PreToolHook = Box<dyn Fn(&str, &serde_json::Value) -> Option<String> + Send + Sync>;
77
78/// A post-tool hook: receives the tool name, its output, and whether it errored,
79/// after execution (observational — logging, metrics, side effects).
80pub type PostToolHook = Box<dyn Fn(&str, &str, bool) + Send + Sync>;
81
82/// How tools are advertised to the model (B6, D16).
83///
84/// `Full` sends every enabled tool's schema on every request (today's
85/// behavior). `Deferred` advertises only a `core` allowlist plus a synthetic
86/// `tool_search` meta-tool; everything else — the MCP surface above all,
87/// since `McpTool::from_client` eagerly wraps every remote tool with its full
88/// `input_schema` — is discoverable via `tool_search` and only advertised
89/// (on the *next* request) once activated.
90#[derive(Debug, Clone, Default)]
91pub enum ToolAdvertising {
92    /// All enabled tools every request (today's behavior).
93    #[default]
94    Full,
95    /// Only `core` tools + the `tool_search` meta-tool; everything else is
96    /// discoverable via `tool_search` and advertised only after activation.
97    Deferred {
98        /// Tool names advertised eagerly on every request.
99        core: Vec<String>,
100    },
101}
102
103/// Optional-policy gates resolved from `[capabilities.reduction]`.
104///
105/// `None` preserves [`crate::reduce::ReductionPolicy`]'s established
106/// default for callers that use reduced mode without the composable module
107/// surface. A preset or direct capability setting supplies only the gates it
108/// names; the CLI applies them when it constructs the live policy.
109#[derive(Debug, Clone, Default, PartialEq, Eq)]
110pub struct ReductionPolicySettings {
111    pub stale_reads: Option<bool>,
112    pub diff_reads: Option<bool>,
113    pub duplicates: Option<bool>,
114    pub tool_input_elision: Option<bool>,
115    pub supersede: Option<bool>,
116    pub normalize_output: Option<bool>,
117    pub image_redaction: Option<bool>,
118    pub span_summaries: Option<bool>,
119}
120
121/// Per-tool customization: enable/disable a tool and/or override the description
122/// the model sees for it.
123#[derive(Debug, Clone, Default)]
124pub struct ToolOverride {
125    /// If `Some(false)`, the tool is hidden from the model entirely.
126    pub enabled: Option<bool>,
127    /// If `Some`, replaces the tool's built-in description in the schema.
128    pub description: Option<String>,
129    /// If `Some`, overrides [`Config::tool_schema_tier`] (TR-8/T5) for this
130    /// specific tool — e.g. keep one fat MCP tool at `Full` while the global
131    /// knob shrinks everything else to `Minimal`.
132    pub schema_tier: Option<crate::tools::SchemaTier>,
133    /// P4e (design §3.1/§S14 `core.tools.bash.timeout_secs`): the DEFAULT
134    /// execution timeout (seconds) for the `bash` tool when a model-issued
135    /// call doesn't supply its own `timeout_ms` argument — see
136    /// `tools::builtins::BashTool::execute`'s precedence (an explicit
137    /// per-call `timeout_ms` always wins; this only replaces the BUILT-IN
138    /// `DEFAULT_BASH_TIMEOUT_MS` fallback). Only meaningful on the `bash`
139    /// entry; other tools ignore it. `None` (the default) is byte-identical
140    /// to today's behavior — `BashTool`'s internal 120s default stands.
141    pub timeout_secs: Option<u64>,
142}
143
144/// Everything that shapes an [`crate::Agent`]: the model and endpoint, the
145/// credentials, sampling parameters, the system prompt, and per-tool overrides.
146///
147/// Build one with [`Config::builder`].
148#[non_exhaustive]
149pub struct Config {
150    /// Model identifier as understood by the endpoint, e.g.
151    /// `anthropic/claude-opus-4-8` or `openai/gpt-5` on OpenRouter.
152    pub model: String,
153
154    /// Base URL of the OpenAI-compatible endpoint (no trailing `/chat/...`).
155    pub base_url: String,
156
157    /// Explicit API key. If `None`, [`Self::api_key_env`] is consulted.
158    pub api_key: Option<String>,
159
160    /// Environment variable to read the API key from when [`Self::api_key`] is unset.
161    pub api_key_env: String,
162
163    /// P4 (COMPOSABLE-HARNESS-DESIGN.md §5.2 "P4", §1.8/§3.1
164    /// `core.api_key_cmd`, D6 row): a credential-helper command (pi§6
165    /// `!command` form). Consulted by `Agent::new` when [`Self::api_key`]
166    /// is unset: the command is run through the shell, its trimmed stdout
167    /// becomes the key, and a non-zero exit or empty output falls through to
168    /// [`Self::api_key_env`] rather than failing outright. `None` (the
169    /// default) means this is never consulted — byte-identical to today's
170    /// behavior. SECURITY: this is a command string, never a secret value —
171    /// [`Self::api_key`] itself must never be file-plaintext (§3.2 S13);
172    /// `api_key_cmd` is `[project-forbidden]` at every config-file layer
173    /// (§3.3), same trust boundary as `base_url`/`api_key_env`.
174    pub api_key_cmd: Option<String>,
175
176    /// System prompt prepended to every conversation.
177    pub system_prompt: String,
178
179    /// Optional sampling temperature.
180    pub temperature: Option<f32>,
181
182    /// Optional output token cap.
183    pub max_tokens: Option<u32>,
184
185    /// Maximum number of model/tool iterations per [`crate::Agent::send`] call.
186    pub max_iterations: usize,
187
188    /// Reasoning/effort level sent to the model (`reasoning_effort`).
189    pub effort: Option<String>,
190
191    /// Structured-output constraint (`response_format`), e.g. a json_schema.
192    pub response_format: Option<serde_json::Value>,
193
194    /// Extra request-body fields merged in (provider-native passthrough:
195    /// prompt-cache controls, provider-specific knobs).
196    pub extra_body: serde_json::Map<String, serde_json::Value>,
197
198    /// Optional cap on cumulative output tokens across one [`crate::Agent::send`]
199    /// loop; the loop stops once exceeded. Output tokens only; input/prompt
200    /// tokens are not counted, so this is not a cost cap.
201    pub max_total_output_tokens: Option<u64>,
202
203    /// Max bytes of a single tool result fed back into the conversation. Output
204    /// beyond this is truncated with a notice, so one runaway command (a huge
205    /// log, a binary dump) can't explode the context window. `None` disables the
206    /// cap. Defaults to 100 KB.
207    pub max_tool_output_bytes: Option<usize>,
208
209    /// Working directory tools operate within.
210    pub cwd: PathBuf,
211
212    /// Additional roots beyond `cwd` (the analog of `--add-dir` / multi-root):
213    /// searched for project-context files and available to tools.
214    pub additional_dirs: Vec<PathBuf>,
215
216    /// Whether to auto-load `CLAUDE.md` / `AGENTS.md` into the system prompt.
217    pub load_project_context: bool,
218
219    /// Filesystem confinement applied to write-capable tools.
220    pub sandbox: crate::tools::SandboxPolicy,
221
222    /// When the agent must seek approval before running a tool.
223    pub approval: ApprovalPolicy,
224
225    /// Tools that never require approval under [`ApprovalPolicy::OnRequest`].
226    pub auto_approved_tools: std::collections::HashSet<String>,
227
228    /// P4 (design §5.2 "P4": "deny-rule patterns generalizing
229    /// auto_approved_tools" — the S-sized generalization, NOT the full P5
230    /// `capabilities.permissions.rules` deny→ask→allow engine, §2.1
231    /// dependency 3's command-canonicalization prerequisite is P5-only).
232    /// Glob patterns (`*` wildcard, see [`glob_match`]) matched against a
233    /// tool's NAME — no argument/command-level matching. Any match forces
234    /// [`Config::needs_approval`] to `true` UNCONDITIONALLY, even under
235    /// [`ApprovalPolicy::Never`] — the entire point of a deny rule is a
236    /// hard floor `--yes`/`Never` can't bypass. Sourced from
237    /// `capabilities.permissions.rules.deny` (§3.1 module 11); empty by
238    /// default (today's behavior, byte-identical).
239    pub tool_deny_patterns: Vec<String>,
240
241    /// P4: the ALLOW-pattern generalization of [`Self::auto_approved_tools`]
242    /// — glob patterns matched against a tool's NAME, exempting a match from
243    /// approval under [`ApprovalPolicy::OnRequest`] exactly like an exact
244    /// `auto_approved_tools` entry does (never consulted under `Untrusted`,
245    /// same as `auto_approved_tools`). Sourced from
246    /// `capabilities.permissions.rules.allow`; empty by default.
247    pub tool_allow_patterns: Vec<String>,
248
249    /// Consulted when a tool call needs approval; `None` denies by default.
250    pub approval_handler: Option<ApprovalHandler>,
251
252    /// Runs before each tool executes; may block the call.
253    pub pre_tool_hook: Option<PreToolHook>,
254
255    /// Runs after each tool executes (observational).
256    pub post_tool_hook: Option<PostToolHook>,
257
258    /// Named prompt templates (skills / slash commands). A user message of the
259    /// form `/<name> <args>` is expanded to the template with `{args}` filled.
260    pub prompts: HashMap<String, String>,
261
262    /// If set, the conversation is compacted once it grows beyond this many
263    /// messages (older middle turns are summarized into one marker), keeping the
264    /// system prompt and the most recent turns.
265    pub compact_after_messages: Option<usize>,
266
267    /// Per-tool enable/disable + description overrides, keyed by tool name.
268    pub tool_overrides: HashMap<String, ToolOverride>,
269
270    /// How tools are advertised to the model (B6). Defaults to [`ToolAdvertising::Full`].
271    pub tool_advertising: ToolAdvertising,
272
273    /// Extra HTTP headers sent with every request (e.g. OpenRouter's
274    /// `HTTP-Referer` / `X-Title` attribution headers).
275    pub extra_headers: HashMap<String, String>,
276
277    /// Optional sink for streaming [`crate::AgentEvent`]s.
278    pub event_sink: Option<EventSink>,
279
280    /// Prompt-caching plan (B7). Defaults to [`CachePlan::Off`]; reduced mode
281    /// (`--reduced`, D5/D14) defaults it to [`CachePlan::ImportedPrefix`]
282    /// (wired at the CLI's reduced-mode assembly point, `crates/cli/src/main.rs`).
283    pub cache_plan: CachePlan,
284
285    /// Resolved optional reduction gates. These are kept separate from the
286    /// live policy because freshness probes and prepared summaries are
287    /// per-request data, not configuration.
288    pub reduction_policy: ReductionPolicySettings,
289
290    /// Whether the explicit reversible handoff projection is available.
291    /// This is separate from [`Self::reduction_policy`] because handoff is
292    /// an offline command over an existing sidecar, not a per-request
293    /// projection pass. Defaults to `true`; only an explicit composable
294    /// `capabilities.reduction.handoff = false` disables it.
295    pub handoff_enabled: bool,
296
297    /// Global tool-schema tier (TR-8/T5): how verbose ADVERTISED tool
298    /// schemas are. Defaults to [`crate::tools::SchemaTier::Full`] (today's
299    /// behavior — byte-identical schemas). A per-tool override in
300    /// [`ToolOverride::schema_tier`] wins over this for that tool. Tool
301    /// definitions are config, never session content, so this never affects
302    /// what's stored or exported — only what's advertised on the wire.
303    pub tool_schema_tier: crate::tools::SchemaTier,
304
305    /// UX-26 (B7-warn): whether [`crate::Agent`] emits
306    /// [`crate::AgentEvent::CacheWarning`] when a turn under
307    /// [`CachePlan::ImportedPrefix`] likely paid a full-price prompt-cache
308    /// miss despite reuse being expected (idle past the provider's TTL, or
309    /// usage reporting a near-zero cache-read ratio). Defaults to `true`
310    /// (on-brand token-economics feedback, on by default like the savings
311    /// figures `inspect stats` already surfaces); the CLI's
312    /// `--no-cache-warnings` flag / `cache_warnings = false` config / the
313    /// `SUPERCODE_CACHE_WARNINGS=0` env var turn it off. A no-op — never
314    /// checked — for any caller not using `CachePlan::ImportedPrefix`, so
315    /// this changes nothing under `CachePlan::Off` (today's default outside
316    /// reduced mode).
317    pub cache_warnings: bool,
318
319    /// P3 (COMPOSABLE-HARNESS-DESIGN.md §5.2 phase P3, mandatory risk-2
320    /// mitigation, §5.3 risk 2): the `[experimental] module_registry` flag.
321    /// `false` (the default) means [`crate::tools::ToolRegistry::from_config`]
322    /// returns EXACTLY [`crate::tools::ToolRegistry::with_builtins`] — the
323    /// runtime path is byte-for-byte today's behavior. Only when explicitly
324    /// turned on does [`Self::module_activation`] start shaping the
325    /// registry/prompt assembly.
326    pub module_registry: bool,
327
328    /// P3: the resolved §2 module-activation set (pure config → set,
329    /// computed by [`crate::configfile::resolve`]/[`crate::modules::ModuleActivation::from_harness`]
330    /// with no agent loop required). Only consulted when
331    /// [`Self::module_registry`] is `true`.
332    pub module_activation: crate::modules::ModuleActivation,
333
334    /// P3: the effective `[core.tools] enabled` list (§3.1) — which of the
335    /// core four (`read_file`/`bash`/`edit_file`/`write_file`, plus any
336    /// future core tool name) are present at all. Defaults to the §1.2
337    /// default-active four, matching [`crate::tools::ToolRegistry::with_builtins`]'s
338    /// unconditional registration. Only consulted when
339    /// [`Self::module_registry`] is `true`.
340    pub core_tools_enabled: Vec<String>,
341
342    /// P3: `[core.skills].enabled` (§1.4 obligation 4, D-7) — whether the
343    /// skills prompt section may appear at all. Still gated by D-7's read
344    /// pathway (`read_file` or `bash` present in [`Self::core_tools_enabled`])
345    /// at the assembly site. Only consulted when [`Self::module_registry`]
346    /// is `true`.
347    pub skills_enabled: bool,
348
349    /// P4 (COMPOSABLE-HARNESS-DESIGN.md §5.2 "P4", §3.1
350    /// `capabilities.model_catalog.small_model`, catalog §4a "Small/utility
351    /// model routing knob"): a cheaper/faster model id a caller (e.g. a
352    /// [`crate::reduce::summarize::SpanSummarizer`] implementation, or an
353    /// auto-title side-call) MAY use instead of [`Self::model`] for
354    /// low-stakes side-calls. `None` (the default) means every such
355    /// consumer falls back to the main model — the exact §2.1 D-9 fallback
356    /// behavior — since nothing in this crate resolves this field on its
357    /// own; it is a knob a caller reads, not a routing loop this crate runs.
358    pub small_model: Option<String>,
359
360    /// P4 (§3.1 `capabilities.model_catalog.fallback`, catalog §4a "Model
361    /// aliases + failure fallback chain"): an ordered list of full model
362    /// slugs a caller MAY retry against, in order, if [`Self::model`] fails.
363    /// Empty (the default) means no fallback chain is configured. Like
364    /// [`Self::small_model`], this is the resolved TABLE only — see
365    /// [`crate::model_catalog`]'s module doc for the scope boundary between
366    /// "a resolved list of slugs" (this field, S-sized) and an actual
367    /// retry/failover loop that consumes it (a separate, larger change).
368    pub model_fallback: Vec<String>,
369
370    /// P4b (COMPOSABLE-HARNESS-DESIGN.md design doc S5.2 "P4", S1.4/S3.1
371    /// `core.env_context`, catalog S4a "Environment context block
372    /// injection"): when `true`, `Agent::with_parts` appends a short
373    /// `# Environment` block (cwd, platform, date, best-effort git branch)
374    /// to the system prompt, alongside `Self::load_project_context`'s
375    /// instruction files. `false` (the default) is byte-identical to
376    /// today's behavior.
377    pub env_context: bool,
378
379    /// P4b (S1.4/S3.1 `core.project_root_markers`, catalog:232): filenames
380    /// that mark a directory as the project root for `Self::env_context`'s
381    /// git-status probe. Defaults to `[".git"]`.
382    pub project_root_markers: Vec<String>,
383
384    /// P4b (S1.4/S3.1 `core.project_doc_max_bytes`, cx2 "project_doc_max_bytes"
385    /// analog, S5.2 P4 "instruction-walk nuances"): a hygiene cap on the
386    /// TOTAL bytes of instruction-file content (`Self::load_project_context`'s
387    /// global + project tiers combined) appended to the system prompt. `None`
388    /// (the default) is uncapped -- byte-identical to today's behavior; only
389    /// an explicit `Some(n)` truncates the assembled block (with a trailing
390    /// notice), mirroring `Self::max_tool_output_bytes`'s cap-with-notice
391    /// shape.
392    pub project_doc_max_bytes: Option<usize>,
393
394    /// P4b (S1.4/S3.1 `core.instruction_imports`, catalog:85): when `true`,
395    /// an instruction file may reference another file via an `@relative/path`
396    /// token (CC's import syntax) -- the referenced file's contents are
397    /// inlined in its place, resolved relative to the IMPORTING file's own
398    /// directory, to a max depth of 4 (CC's own default) to bound cycles.
399    /// `false` (the default) leaves `@` tokens as plain literal text --
400    /// byte-identical to today's behavior.
401    pub instruction_imports: bool,
402
403    /// P4b (S1.1/S3.1 `core.retry`, pi3 shape): whether a transient
404    /// (connection failure / 5xx) provider error is retried at all. This
405    /// EXTENDS a pre-existing, always-on transport-layer mechanism
406    /// (`provider::OpenAiProvider`'s internal `HttpOptions` retry — 2
407    /// attempts / 500ms base backoff, hardcoded, not previously
408    /// config-file-settable) rather than adding a second one: `true` (the
409    /// default, matching today's always-on behavior byte-for-byte when
410    /// `Self::retry_max_retries`/`Self::retry_base_delay_ms` are also both
411    /// unset) keeps retrying; an explicit `false` is a NEW capability —
412    /// disabling the transport retry entirely.
413    pub retry_enabled: bool,
414    /// Override the transport retry's attempt count. `None` (the default)
415    /// keeps the pre-existing built-in default (2).
416    pub retry_max_retries: Option<u32>,
417    /// Override the transport retry's base backoff delay in milliseconds
418    /// (doubles per attempt). `None` (the default) keeps the pre-existing
419    /// built-in default (500ms).
420    pub retry_base_delay_ms: Option<u64>,
421
422    /// P4b (S1.5/S3.1 `core.compaction.reserve_tokens`, pi2 shape): once
423    /// set, `Agent::maybe_compact` ALSO triggers when the estimated token
424    /// size of the live history is within `reserve_tokens` of the model's
425    /// context window -- in addition to (not instead of)
426    /// `Self::compact_after_messages`'s message-count trigger. `None` (the
427    /// default) leaves the pressure trigger off -- byte-identical to today's
428    /// message-count-only behavior.
429    pub compaction_reserve_tokens: Option<u64>,
430    /// P4b (S1.5/S3.1 `core.compaction.keep_recent_tokens`): when the
431    /// PRESSURE trigger (not the message-count one) fires, how many of the
432    /// most recent tokens (estimated) to keep verbatim instead of a fixed
433    /// message count. Only consulted when `Self::compaction_reserve_tokens`
434    /// is `Some` and the pressure trigger is what fired.
435    pub compaction_keep_recent_tokens: Option<u64>,
436    /// P4b (S1.5/S3.1 `core.compaction.focus_instructions`, catalog D2 "no
437    /// instruction steering" gap): free text appended to the synthetic
438    /// compaction marker message every time compaction fires (either
439    /// trigger), steering the model on what to keep focusing on
440    /// post-compaction (CC's manual-compact `/compact <focus>` analog).
441    /// `None` (the default) leaves the marker text byte-identical to
442    /// today's.
443    pub compaction_focus_instructions: Option<String>,
444
445    /// P4b (S1.6/S3.1 `core.session.auto_title`, catalog:150, D-9): whether
446    /// `crate::session_title::auto_title` may be invoked at all by a caller
447    /// (the caller still supplies the `SessionTitler` side-call itself --
448    /// this is only the gate, mirroring `Self::small_model`'s "a knob a
449    /// caller reads" framing). `false` (the default): callers should treat
450    /// auto-title as off.
451    pub auto_title: bool,
452
453    /// P4b (S1.7/S3.1 `core.steering`, pi3 semantics): how queued mid-turn
454    /// steering messages (`Agent::queue_steer`) are drained -- `All`
455    /// delivers every queued message at once, `OneAtATime` (the default)
456    /// delivers one per drain point.
457    pub steering_mode: SteeringMode,
458    /// P4b (S1.7/S3.1 `core.steering.follow_up_mode`): how queued follow-up
459    /// messages (`Agent::queue_follow_up`) are drained once the loop is
460    /// otherwise idle (no more tool calls pending).
461    pub follow_up_mode: SteeringMode,
462
463    /// P4b (S1.9/S3.1 `[core] stop_gate`, D3 "stop/completion gating", CC
464    /// Stop-hook semantics cc3): consulted exactly once per `run_loop`
465    /// iteration that would otherwise return a final answer (no more tool
466    /// calls pending, and the follow-up queue is empty). Receives the
467    /// would-be-final assistant message; `Some(reason)` VETOES termination
468    /// -- `reason` is injected as a new user message and the loop continues
469    /// (still bounded by `Self::max_iterations`); `None` allows the stop.
470    /// Code-only, like `Self::pre_tool_hook`/`Self::post_tool_hook` --
471    /// the CLI's declarative `[hooks] stop = "cmd"` form (module 17)
472    /// populates this SAME single slot rather than adding a second call
473    /// site, so the two can never double-fire (S2 module 17's "hooks layer
474    /// on core's gate" note). `None` (the default) is byte-identical to
475    /// today's behavior.
476    pub stop_gate: Option<StopGateHook>,
477
478    /// P4c (COMPOSABLE-HARNESS-DESIGN.md S1.2/S3.1 `core.tools.read_file
479    /// multimodal`, catalog S4a "Multimodal read (image passthrough on
480    /// `read_file`)"): when `true`, `read_file` returns a recognized image
481    /// file (`.png`/`.jpg`/`.jpeg`/`.gif`/`.webp`/`.bmp`) as a model-visible
482    /// image content block instead of decoding it as (garbled) UTF-8 text.
483    /// `false` (the default) is byte-identical to today's behavior.
484    pub read_file_multimodal: bool,
485
486    /// P4c (S1.2/S3.1 `core.tools.edit_file.require_read_before_edit`,
487    /// UNIQUE CC row, catalog:32): when `true`, `edit_file` refuses unless
488    /// the target path was read (via `read_file`) earlier in this same
489    /// conversation -- tracked in `ToolContext`. `false` (the default) is
490    /// byte-identical to today's behavior.
491    pub edit_file_require_read_before_edit: bool,
492
493    /// P4c (S1.2/S3.1 `core.tools.edit_file.notebook_aware`, UNIQUE CC row
494    /// "NotebookEdit", catalog:40): when `true`, `edit_file` additionally
495    /// accepts Jupyter cell replace/insert/delete operations against a
496    /// `.ipynb` target (see `tools::builtins::EditFileTool`'s cell-op args)
497    /// instead of only the exact-string replace it always supports. `false`
498    /// (the default) is byte-identical to today's behavior.
499    pub edit_file_notebook_aware: bool,
500
501    /// P4c (S1.2/S3.1 `core.shell_env_snapshot`, SPLIT CC+CX row,
502    /// catalog:338): when `true`, `Agent::new`/`with_parts` captures the
503    /// user's interactive login-shell environment ONCE at construction
504    /// (`$SHELL -lc env`, best-effort) and every `bash` call inherits it
505    /// directly instead of needing to re-source shell rc files per call.
506    /// `false` (the default) is byte-identical to today's behavior -- no
507    /// snapshot is captured, and `bash` sees only the ambient process
508    /// environment, exactly as before this landed.
509    pub shell_env_snapshot: bool,
510
511    /// P4c (S5.2 P4 "doom-loop breaker", oc `doom_loop` UNIQUE row,
512    /// catalog D3): when `Some(n)` with `n >= 2`, a tool call whose name AND
513    /// arguments are byte-identical to the previous `n - 1` consecutive
514    /// calls is refused (fed back to the model as an error) instead of
515    /// executed -- the counter resets the moment a call differs. `None`
516    /// (the default) is byte-identical to today's behavior: no repetition
517    /// tracking, no call is ever refused on this basis.
518    pub doom_loop_threshold: Option<u32>,
519
520    /// P4c (S1.4/S3.1 `core.nested_instructions`, catalog:84, deferred from
521    /// P4b): when `true`, a `read_file`/`edit_file` call that touches a path
522    /// inside a subdirectory carrying its OWN `CLAUDE.md`/`AGENTS.md` (a
523    /// directory other than `Config.cwd` itself, which
524    /// `Self::load_project_context` already loads once at session start)
525    /// appends that subdirectory's instructions to the tool's OWN result the
526    /// FIRST time a path under it is touched this conversation (deduped
527    /// thereafter -- tracked in `ToolContext`, mirrors CC/OC's "auto-attach
528    /// on read, deduped" semantics, catalog:84). Reuses the same
529    /// canonicalize+containment safety check P4b's `@`-import expansion
530    /// uses (`agent::import_target_is_contained`) so a symlink cannot walk
531    /// the injection outside `Config.cwd`. `false` (the default) is
532    /// byte-identical to today's behavior.
533    pub nested_instructions: bool,
534
535    /// P4c (S1.10/S3.1 `core.model_switch.allow_switch`, D9 row, dep 8):
536    /// gates whether `Agent::switch_model` does more than the pre-existing
537    /// `Agent::set_model` mechanics (design's "UX-30 dev/02" -- swap
538    /// `Config.model` for the next request, nothing else touched). `false`
539    /// (the default) makes `switch_model` byte-identical to calling
540    /// `set_model` directly: no persisted `model_change` record, no
541    /// reasoning-artifact filtering. `true` additionally (1) appends a
542    /// typed `model_change::ModelChangeRecord` to
543    /// `Agent::model_change_records`, and (2) runs
544    /// `reduce::rehydrate::filter_reasoning_artifacts` over `Agent::history`
545    /// so model-A's reasoning/thinking artifacts (`ChatMessage::metadata`
546    /// keys and any `content_parts` reasoning blocks) never reach
547    /// model-B's context (S1.13, dep 8).
548    pub model_switch_allow_switch: bool,
549
550    /// P4e (§1.4/§3.1 `core.context_injections`, catalog:91 "Synthetic
551    /// context-injection blocks"): the master gate for
552    /// [`Self::context_injection_blocks`] -- when `false` (the default),
553    /// `Agent::with_parts` never appends any of them, byte-identical to
554    /// today's behavior. `true` splices in whatever named blocks are set,
555    /// at the same assembly site P4b's `env_context` block uses, right
556    /// after it.
557    pub context_injections: bool,
558    /// P4e: named ambient context blocks a caller/embedder populates
559    /// programmatically (mirrors `Self::prompts`/`Self::stop_gate`'s
560    /// code-extensible shape) -- there is no `[core.context_injections.*]`
561    /// FILE table because the §3.1 schema's `core.context_injections` key
562    /// is already a scalar boolean gate, and TOML forbids a key being both
563    /// scalar and table (the same S-fix documented on
564    /// `[core.model_switch]`). Consulted only when
565    /// [`Self::context_injections`] is `true`; empty (the default) is a
566    /// no-op even then. Each block is appended verbatim as `\n\n# {name}\n{content}`,
567    /// in list order.
568    pub context_injection_blocks: Vec<ContextInjectionBlock>,
569
570    /// P4e (§1.5/§3.1 `core.compaction.enabled`, "no master gate exists
571    /// yet"): the master on/off switch for ALL auto-compaction
572    /// (`Agent::maybe_compact`), composing with -- not replacing -- the
573    /// existing `Self::compact_after_messages`/`Self::compaction_reserve_tokens`/
574    /// `Self::compaction_keep_recent_tokens` triggers: `false` disables
575    /// every trigger unconditionally; `true` (the default, matching
576    /// today's behavior, where nothing has ever gated compaction) changes
577    /// nothing -- whichever triggers are configured still fire exactly as
578    /// before.
579    pub compaction_enabled: bool,
580
581    /// P4e (§3.1 `core.parallel_tool_calls`, catalog:59 "Independent
582    /// sibling calls run concurrently"): when `true` and an assistant turn
583    /// requests more than one tool call, `Agent::run_loop` runs their
584    /// `Tool::execute` futures CONCURRENTLY via `Self::run_tools_concurrently`
585    /// instead of one at a time -- see that method's doc comment for
586    /// exactly which part of dispatch stays strictly sequential (approval /
587    /// doom-loop / pre-tool-hook checks, and every `record`/`history`
588    /// append, which the lossless sidecar's append-order invariant, S1.13,
589    /// requires to stay deterministic). `false` (the default) is
590    /// byte-identical to today's sequential-await-per-call loop.
591    pub parallel_tool_calls: bool,
592
593    /// P4e (§1.6/§3.1 `core.session.git_metadata`, catalog:331 "Git branch/
594    /// sha captured … closes the loop" -- the WRITE half; supercode already
595    /// preserves a foreign session's own `gitBranch`-shaped fields
596    /// verbatim on IMPORT via `Session::raw`'s byte-for-byte capture).
597    /// When `true`, `Agent::with_parts` captures a
598    /// `git_metadata::GitMetadataRecord` (best-effort branch/sha/dirty,
599    /// like `Self::env_context`'s git probe) once at construction, readable
600    /// via `Agent::git_metadata` and persistable via
601    /// `Agent::save_git_metadata`. `false` (the default) is byte-identical
602    /// to today's behavior: no capture, `Agent::git_metadata()` is always
603    /// `None`.
604    pub session_git_metadata: bool,
605
606    /// P4e (§1.6/§3.1 `core.session.dir`): overrides the session store's
607    /// root directory. A caller-read knob (like `Self::small_model`) --
608    /// the CLI's `session_store()` (main.rs) is the consumer. `None` (the
609    /// default) leaves the CLI's own default (`$SUPERCODE_HOME/sessions`)
610    /// untouched.
611    pub session_dir: Option<String>,
612    /// P4e (§1.6/§3.1 `core.session.persist`, D5 row): whether a caller
613    /// should persist this session to the store at all. A caller-read gate
614    /// only -- `Agent`/`Config` never call `SessionStore` directly (no
615    /// `SessionStore` handle lives on `Config`); a caller checks this
616    /// field directly before calling `store.save(...)`, the same
617    /// "mechanism vs. gate" split `Self::auto_title` established. `true`
618    /// (the default) matches today's behavior: every caller that already
619    /// calls `store.save(...)` keeps doing so unconditionally.
620    pub session_persist: bool,
621    /// P4e (§1.6/§3.1 `core.session.name`): an explicit session name a
622    /// caller should use instead of auto-minting one (the CLI's
623    /// `mint_session_name`). A caller-read knob, same posture as
624    /// `Self::session_dir`. `None` (the default) leaves auto-naming
625    /// untouched.
626    pub session_name: Option<String>,
627    /// P4e (§1.6/§3.1 `core.session.retention_days`): the archive-pruning
628    /// window `store::SessionStore::prune_expired` consults. `None` (the
629    /// default) means "never prune" -- byte-identical to today's behavior
630    /// (nothing ever prunes automatically).
631    pub session_retention_days: Option<u32>,
632    /// P4e (§1.6/§3.1 `core.session.export_format`, catalog:283 "transcript
633    /// export for humans"): `text` | `html`, consumed by
634    /// `human_export::render_transcript`. Defaults to
635    /// [`crate::human_export::HumanExportFormat::Text`].
636    pub session_export_format: crate::human_export::HumanExportFormat,
637
638    /// P5-1 (§3.1 `capabilities.permissions.enabled`, module 10/11
639    /// activation): the master gate for `crate::permissions` — when `false`
640    /// (the default), `Agent::prepare_tool_call`'s tool-dispatch gate uses
641    /// EXACTLY the pre-P5-1 [`Self::needs_approval`] path, byte-for-byte —
642    /// no behavior change. `true` switches the gate to the richer
643    /// canonicalized-command-aware [`crate::permissions::rules`] engine
644    /// (deny→ask→allow first-match, C5), consulting
645    /// [`Self::permissions_ask_patterns`] (together with the pre-existing
646    /// [`Self::tool_deny_patterns`]/[`Self::tool_allow_patterns`] as the
647    /// engine's deny/allow tiers) and [`Self::permissions_protected_paths`].
648    pub permissions_enabled: bool,
649
650    /// P5-1 (§3.1 `capabilities.permissions.rules.ask`, module 11): the
651    /// engine's `ask` tier — the sibling of the pre-existing
652    /// [`Self::tool_deny_patterns`]/[`Self::tool_allow_patterns`] (P4),
653    /// which become the engine's `deny`/`allow` tiers respectively when
654    /// [`Self::permissions_enabled`] is on (see
655    /// `crate::permissions::rules::RuleSet`). Empty by default. Only
656    /// consulted when [`Self::permissions_enabled`] is `true`.
657    pub permissions_ask_patterns: Vec<String>,
658
659    /// P5-1 (§3.1 `capabilities.permissions.protected_paths.paths`, module
660    /// 13): glob patterns that are an unconditional DENY floor for both
661    /// read and write access (cc§4 "never auto-approved… `.git/**`,
662    /// `.env*`, …"), expanded via
663    /// [`crate::permissions::rules::protected_path_deny_rules`] into the
664    /// engine's `deny` tier. Empty by default. Only consulted when
665    /// [`Self::permissions_enabled`] is `true`.
666    ///
667    /// **Honesty note on coverage (F4, Fable-5 adversarial review):** at
668    /// the rule-engine layer this floor is enforced for (a) `read_file`/
669    /// `write_file`/`edit_file`-shaped path calls, (b) a `bash`/`shell`
670    /// command's direct output/input redirect targets (`>`, `>>`, `&>`,
671    /// `>|`, `&>>`, `<`), (c) `apply_patch`'s target path(s), and (d) a
672    /// best-effort set of known argv-writers (`tee`, `dd of=`, `cp`/`mv`/
673    /// `install`, `sed -i`, `truncate`, `ln`) — see
674    /// `crate::permissions::canon::known_writer_targets`'s doc comment for
675    /// that heuristic's named gaps. A write this rule layer genuinely
676    /// cannot statically resolve (an opaque wrapper — `eval`, `sh -c`, …
677    /// — or a dynamic `$VAR`/`` `cmd` `` target) is forced to at least
678    /// `Ask`, never silently `Allow`. What this layer does NOT provide is
679    /// COMPLETE OS-level write confinement of arbitrary bash — that is
680    /// `capabilities.permissions.sandbox`'s job (P5 module 10, a later
681    /// unit), not this one's.
682    pub permissions_protected_paths: Vec<String>,
683
684    /// P5-1 (§3.1 `capabilities.permissions.sandbox.network.*`, module 12
685    /// carry-forward): the domain allow/deny policy `crate::tools::WebFetchTool`/
686    /// `WebSearchTool` enforce via `crate::tools::ToolContext::check_network`
687    /// — the enforcement POINT already existed (P4c); this is its real
688    /// config source (`crate::configfile::materialize_config`). `None` (the
689    /// default) is byte-identical to today's behavior: no policy is
690    /// enforced, exactly the honest gap `NetworkPolicy`'s own doc comment
691    /// (`crate::tools`) already names.
692    pub network_policy: Option<crate::tools::NetworkPolicy>,
693
694    /// P5-10 (§3.1 `capabilities.permissions.sandbox.enabled`, module 12):
695    /// whether the OS-level backstop (Landlock on Linux, seatbelt on
696    /// macOS) is engaged for the `bash`/`shell` subprocess. `None` (the
697    /// default — unset by the bare `sandbox = "<tier>"` shorthand, or a
698    /// CLI `--sandbox` flag, neither of which touch this table key) keeps
699    /// the PRE-P5-10 trigger byte-identical: `crate::sandbox::
700    /// os_sandbox_active` falls back to "confine whenever the tier isn't
701    /// `DangerFullAccess`", exactly what the macOS seatbelt path already
702    /// did off `Self::sandbox` alone. `Some(false)` (the table form's
703    /// explicit opt-out — `cc-parity`'s posture) turns the OS backstop off
704    /// even for a confining tier; `Some(true)` forces it on.
705    pub sandbox_os_enabled: Option<bool>,
706
707    /// P5-10 (§3.1 `capabilities.permissions.sandbox.escalation`, module
708    /// 12): what happens when a confining fs tier is requested but this
709    /// platform/kernel can't enforce it — see
710    /// [`crate::sandbox::SandboxEscalation`]. Defaults to `Deny`
711    /// (fail-closed), matching `capabilities.permissions.sandbox`'s own
712    /// `escalation = "deny"` config default.
713    pub sandbox_escalation: crate::sandbox::SandboxEscalation,
714
715    /// P5-10 (§3.1 `capabilities.permissions.sandbox.env_policy`, module
716    /// 12): child-process environment sanitization for the spawned
717    /// `bash`/`shell` subprocess — see
718    /// [`crate::sandbox::SandboxEnvPolicy`]. Defaults to `Inherit`
719    /// (byte-identical to pre-P5-10 behavior: the full environment passes
720    /// through unchanged).
721    pub sandbox_env_policy: crate::sandbox::SandboxEnvPolicy,
722
723    /// P5-3 (§3.1 `capabilities.subagents.enabled`, module 9 activation):
724    /// the master gate for the `spawn_subagent`/`subagent_status` agent
725    /// the master gate for the `spawn_subagent`/`subagent_status` agent
726    /// intrinsics — when `false` (the default), `Agent::tool_schemas` never
727    /// advertises them and `Agent::run_tool`'s interception is a pure
728    /// pass-through to the pre-P5-3 dispatch, byte-for-byte unchanged.
729    pub subagents_enabled: bool,
730    /// P5-3 (§3.1 `capabilities.subagents.max_depth`, resource bound): the
731    /// maximum spawn-tree depth — a depth-`max_depth` agent may not spawn
732    /// (its child would land at `max_depth + 1`). Only consulted when
733    /// [`Self::subagents_enabled`] is `true`.
734    pub subagents_max_depth: usize,
735    /// P5-3 (resource bound, NOT in the §3.1 illustrative schema snippet —
736    /// added per the build brief's explicit "max concurrent subagents…
737    /// cap, fail-closed... configurable"): the maximum number of subagents
738    /// in flight anywhere in one spawn tree at once (root-to-leaf, shared
739    /// via [`crate::agent::Agent`]'s concurrency gauge). Only consulted
740    /// when [`Self::subagents_enabled`] is `true`.
741    pub subagents_max_concurrent: usize,
742    /// P5-3 (§3.1 `capabilities.subagents.background`): whether
743    /// `spawn_subagent`'s `background: true` argument is honored at all —
744    /// `false` (the default) refuses every background spawn regardless of
745    /// [`Self::subagents_background_prompts`].
746    pub subagents_background: bool,
747    /// P5-3 (§2.2 C6, §3.1 `capabilities.subagents.background_prompts`):
748    /// the auto-policy a background child's tool approvals route through.
749    /// `None` (the default) means a background spawn is refused
750    /// (`Error::SubagentBackgroundPolicyMissing`) — a detached child must
751    /// never reach an interactive prompt it can't answer.
752    pub subagents_background_prompts: Option<crate::subagents::BackgroundPromptsPolicy>,
753    /// Claude Code emulation: advertise and accept its `Agent` tool name and
754    /// argument vocabulary in addition to Supercode's native
755    /// `spawn_subagent` intrinsic. Default `false`; enabled only for an
756    /// explicitly imported Claude continuation.
757    pub subagents_claude_agent_alias: bool,
758    /// Claude Code resume compatibility for the scheduler-shaped
759    /// `CronCreate`/`CronDelete`/`CronList`/`ScheduleWakeup` intrinsics.
760    /// The imported manifest is always paused and these tools only mutate
761    /// that inert state; no timer is started. Default `false` so ordinary
762    /// agents do not gain a harness-specific tool surface.
763    pub claude_runtime_tools_enabled: bool,
764    /// P5-3 (§3.1 `capabilities.subagents.agents.<name>`, D3 "named-defs"):
765    /// named subagent types, keyed by the name the model passes as
766    /// `spawn_subagent`'s `agent_type` argument.
767    pub subagents_definitions: HashMap<String, crate::subagents::NamedAgentDefinition>,
768    /// P5-3 (runtime-only, NEVER set from a config file — only
769    /// `Agent::run_spawn_subagent` sets it on a freshly-built CHILD
770    /// `Config` before constructing that child): how deep in the spawn
771    /// tree the agent built from this `Config` is. `0` is a top-level
772    /// agent; a config file / [`ConfigBuilder`] caller that never spawns
773    /// leaves this at its `0` default.
774    pub subagent_depth: usize,
775
776    /// P5-4 (§3.1 `capabilities.tui.enabled`, module 30 activation, §1.9
777    /// recorded deviation): the master gate for the full-screen TUI —
778    /// when `false` (the default), `crates/cli`'s `chat()` runs the
779    /// pre-P5-4 rustyline REPL loop byte-for-byte, and every P5-4 seam
780    /// below (`Agent::set_permissions_approval_handler`/
781    /// `Agent::set_child_approval_handler_factory`/
782    /// `crate::mcp::McpClient::set_elicitation_handler`) is simply never
783    /// invoked with a TUI-backed implementation. `crates/cli`'s TUI runner
784    /// additionally requires stdin/stdout/stderr all be a real tty before
785    /// activating even when this is `true` — see that crate's
786    /// `tui::should_activate` doc comment.
787    pub tui_enabled: bool,
788    /// P5-4 (§3.1 `capabilities.tui.theme`): `"dark"` | `"light"` — which
789    /// built-in [`crate::tui::Theme`] the renderer starts with. Unknown or
790    /// unset values fall back to `"dark"` (`crate::tui::Theme::default()`).
791    pub tui_theme: String,
792    /// P5-4 (§3.1 `capabilities.tui.vim_mode`, D8 "vim"): whether the
793    /// input buffer starts in vim-style modal editing (normal/insert)
794    /// rather than plain single-mode editing. See
795    /// `crate::tui::InputMode`'s doc comment for the (deliberately
796    /// basic — hjkl/i/a/o/dd/x) scope of what's implemented.
797    pub tui_vim_mode: bool,
798    /// P5-4 (§3.1 `capabilities.tui.keymap.<action> = "<key>"`,
799    /// "configurable keybindings"): per-action key overrides layered on
800    /// top of [`crate::tui::Keymap::default()`] — see that type's doc
801    /// comment for the action names and key-spec syntax understood.
802    pub tui_keymap: HashMap<String, String>,
803
804    /// P5-5 (§3.1 `capabilities.session_tree.enabled`, design §2 module 21
805    /// activation): the master gate for the native in-place session tree
806    /// (`crate::session_tree`) — a pure "does the harness advertise/prefer
807    /// tree-mode session semantics" signal for a caller (CLI/TUI) to consult.
808    /// `false` (the default, matching every `HarnessConfig` that never sets
809    /// this table) changes nothing about [`crate::session_tree::SessionTree`]
810    /// itself, which has no runtime dependency on this flag (a caller can
811    /// always construct/use one directly, exactly like
812    /// [`crate::store::SessionStore::fork`] isn't gated on any capability
813    /// either) — this field exists purely so a future integration point has
814    /// a resolved config signal to read, matching every other P5 module's
815    /// "carried on `Config`, pure config → set" convention.
816    pub session_tree_enabled: bool,
817    /// P5-5 (§3.1 `capabilities.session_tree.branch_summaries`, module 21
818    /// "branch summaries"): whether a caller wiring
819    /// [`crate::session_tree::SessionTree::splice_for_linear_export`] into a
820    /// C7 linear-export path should generate/attach summaries for off-path
821    /// branches at all, vs. leaving them unsummarized (still fully present
822    /// in the sidecar either way — this only controls the human-readable
823    /// digest, never the underlying lossless data). Defaults `true` (the
824    /// §3.1 schema's own default) when [`Self::session_tree_enabled`] is
825    /// `true` and this key is unset.
826    pub session_tree_branch_summaries: bool,
827    /// P5-5 (§3.1 `capabilities.session_tree.labels`, module 21 "entry
828    /// labels"): whether a caller's UI/CLI surface should expose
829    /// [`crate::session_tree::SessionTree::label`]/`clear_label` at all.
830    /// Defaults `true` (the §3.1 schema's own default) when
831    /// [`Self::session_tree_enabled`] is `true` and this key is unset. Like
832    /// [`Self::session_tree_branch_summaries`], this is advisory — the
833    /// underlying `SessionTree` API always supports labeling regardless.
834    pub session_tree_labels: bool,
835    /// P5-6 (§3.1 `capabilities.tools_background.enabled`, module 4
836    /// activation): the master gate for the `background_exec`/
837    /// `background_status`/`background_list`/`background_kill` agent
838    /// intrinsics — when `false` (the default), `Agent::tool_schemas`
839    /// never advertises them and `Agent::prepare_tool_call`'s interception
840    /// is a pure pass-through to the pre-P5-6 dispatch, byte-for-byte
841    /// unchanged (a hallucinated call falls through to the ordinary
842    /// unknown-tool error, exactly like `spawn_subagent`'s own disabled
843    /// posture).
844    pub tools_background_enabled: bool,
845    /// P5-6 (resource bound, NOT in the §3.1 illustrative schema snippet —
846    /// added per the build brief's explicit "max-concurrent cap,
847    /// fail-closed", mirroring [`Self::subagents_max_concurrent`]'s own
848    /// precedent): the maximum number of background jobs this agent may
849    /// have running at once. Only consulted when
850    /// [`Self::tools_background_enabled`] is `true`.
851    pub tools_background_max_concurrent: usize,
852    /// P5-6 (resource bound, "must not OOM" — mirrors
853    /// `crate::mcp::MCP_MAX_RESPONSE_BYTES`'s hardening-cap precedent): the
854    /// maximum number of bytes of combined stdout/stderr retained per
855    /// background job — output beyond this is truncated-with-marker, never
856    /// buffered further (`crate::background::CapturedOutput::append`).
857    /// Only consulted when [`Self::tools_background_enabled`] is `true`.
858    pub tools_background_max_output_bytes: usize,
859    /// P5-9 (§3.1 `capabilities.checkpoint.enabled`, module 20 activation):
860    /// the master gate for file checkpointing — when `false` (the
861    /// default), `crate::agent::build_tool_context` never touches disk for
862    /// this at all: no `crate::checkpoint::CheckpointStore` is opened, no
863    /// shadow directory is created, `ToolContext::write_observer` stays
864    /// `None`, and every write-tool call site's observer branch is a
865    /// pure no-op — byte-identical to before this module existed. See
866    /// `crate::checkpoint`'s module doc comment for the full design.
867    pub checkpoint_enabled: bool,
868    /// P5-9 (bounded-disk requirement, NOT in the §3.1 illustrative schema
869    /// snippet — added per the build brief's explicit "bounded... no
870    /// unbounded disk growth", mirroring [`Self::tools_background_max_concurrent`]'s
871    /// own precedent): the maximum number of checkpoints retained per
872    /// project before the oldest are pruned. Only consulted when
873    /// [`Self::checkpoint_enabled`] is `true`.
874    pub checkpoint_retain: usize,
875    /// P5-9 (embedder/test override, NOT a `[capabilities.checkpoint]`
876    /// schema key — this is a Rust-only knob, the same class as
877    /// [`Self::pre_tool_hook`]/[`Self::post_tool_hook`]): where the shadow
878    /// store lives. `None` (the default) means
879    /// `crate::checkpoint::observer_for_config` derives the location from
880    /// `crate::agent::global_instructions_dir()` + a hash of [`Self::cwd`]
881    /// (mirroring the CLI's own `cwd_tag` precedent) — set this to make the
882    /// location hermetic/deterministic (tests; embedders that want a
883    /// specific on-disk layout) without touching process-global env vars.
884    pub checkpoint_dir: Option<PathBuf>,
885    /// P5-11 (§3.1 `capabilities.lsp.enabled`, module 28 activation): the
886    /// master gate for LSP server lifecycle + edit-path diagnostics (D1).
887    /// `false` (the default) means `crate::agent::build_tool_context` never
888    /// touches `crate::lsp::manager_for_config` at all — no child process
889    /// is ever spawned, `ToolContext::write_observer`'s chain never gains
890    /// an LSP entry — byte-identical to before this module existed. See
891    /// `crate::lsp`'s module doc comment for the accepted gaps (no
892    /// auto-provisioned server fleet, no symbol-indexing query tool).
893    pub lsp_enabled: bool,
894    /// P5-11 (`capabilities.lsp.servers.<name>`): the configured language
895    /// servers, in alphabetical order by server name (a TOML table has no
896    /// inherent ordering — `configfile::materialize_config` sorts
897    /// explicitly for reproducibility) — first extension match wins. Only
898    /// consulted when [`Self::lsp_enabled`] is `true`. An empty `Vec` with
899    /// `lsp_enabled = true` is legal but warns once
900    /// (`crate::lsp::manager_for_config`) — very likely a config mistake.
901    pub lsp_servers: Vec<(String, crate::lsp::LspServerSpec)>,
902    /// P5-11 (bounded-context requirement, NOT in the §3.1 illustrative
903    /// schema snippet — added per the build brief's explicit "a flood
904    /// mustn't blow context", mirroring [`Self::tools_background_max_output_bytes`]'s
905    /// own precedent): the maximum number of diagnostics rendered into a
906    /// single tool result. Only consulted when [`Self::lsp_enabled`] is
907    /// `true`.
908    pub lsp_max_diagnostics: usize,
909    /// P5-11 (bounded-latency requirement): how long to wait for a
910    /// configured server to publish diagnostics after a
911    /// `didOpen`/`didChange` before giving up gracefully. Only consulted
912    /// when [`Self::lsp_enabled`] is `true`.
913    pub lsp_timeout_secs: u64,
914    /// P5-11 (§3.1 `capabilities.formatters.enabled`, module 29
915    /// activation): the master gate for format-on-write. `false` (the
916    /// default) means the shared D-5 write-observer chain never gains a
917    /// `crate::formatters::FormatObserver` entry — byte-identical to
918    /// before this module existed.
919    pub formatters_enabled: bool,
920    /// P5-11 (`capabilities.formatters.<name>`): the configured formatter
921    /// commands, in alphabetical order by formatter name (same "TOML has
922    /// no inherent ordering" rationale as [`Self::lsp_servers`]) — first
923    /// extension match wins. Only consulted when
924    /// [`Self::formatters_enabled`] is `true`.
925    pub formatters: Vec<(String, crate::formatters::FormatterSpec)>,
926    /// P5-11 (§3.1 `capabilities.formatters.diff_back`, C10): whether a
927    /// formatter's rewrite is diffed back into the calling tool's result
928    /// so the model's file-memory stays truthful (design line 534, "must
929    /// diff-back into the result"). `true` is the C10-SAFE default; `false`
930    /// still runs the formatter but withholds the annotation — legal, but
931    /// the model then has a stale belief about the file's exact bytes
932    /// until it re-reads it.
933    pub formatters_diff_back: bool,
934    /// P5-11 (bounded-latency requirement, "a hanging formatter can't hang
935    /// the loop — timeout + kill like hooks"): how long a single formatter
936    /// invocation may run before it's treated as failed (the file is left
937    /// untouched). Only consulted when [`Self::formatters_enabled`] is
938    /// `true`.
939    pub formatters_timeout_secs: u64,
940    /// P5-12 (§2 module 14 `trust`, D-10): the master gate for the
941    /// project/workspace trust concept — `false` (the default) means
942    /// [`Self::trust_default`] is never consulted and [`crate::plugins`]
943    /// treats every plugin as untrusted (see
944    /// [`crate::plugins::is_trusted`]'s doc comment). `[capabilities.trust]`
945    /// is project-forbidden (`configfile::PROJECT_FORBIDDEN_CAPABILITY_TABLES`
946    /// / `userconfig`'s own copy): only the user/global layer — or a
947    /// preset extended from it — may ever set this, exactly like
948    /// `hooks`/`plugins`/`server` (a project asserting its OWN trust would
949    /// defeat the entire point of the gate).
950    pub trust_enabled: bool,
951    /// P5-12 (`capabilities.trust.default`): the workspace-trust decision —
952    /// see [`crate::plugins::TrustDecision`]'s doc comment for why, absent a
953    /// wired interactive upgrade flow, only [`crate::plugins::TrustDecision::Always`]
954    /// actually unlocks plugin loading in this build (an honest,
955    /// documented gap — not a silent no-op: `ask`/`never` both cleanly
956    /// refuse, they don't pretend to prompt). Only consulted when
957    /// [`Self::trust_enabled`] is `true`.
958    pub trust_default: crate::plugins::TrustDecision,
959    /// P5-12 (§2 module 18 `plugins`, §3.1 `capabilities.plugins.enabled`):
960    /// the master gate for out-of-process, manifest-declared plugins (see
961    /// [`crate::plugins`]'s module doc comment for the ABI). `false` (the
962    /// default) means `crate::agent`'s tool-registration path never touches
963    /// [`crate::plugins::discover_and_load`] at all — no directory read, no
964    /// manifest parse, no subprocess — byte-identical to before this module
965    /// existed.
966    pub plugins_enabled: bool,
967    /// P5-12 (`capabilities.plugins.dirs`): EXTRA directories to scan for
968    /// `<plugin-name>/plugin.toml` manifests, on top of the always-scanned
969    /// `$SUPERCODE_HOME/plugins` (see [`crate::plugins::discover_manifests`]).
970    /// `[capabilities.plugins]` (this field included) is project-forbidden,
971    /// so this can only ever come from the trusted user/global layer or a
972    /// preset. Only consulted when [`Self::plugins_enabled`] is `true` AND
973    /// the workspace is trusted (see [`crate::plugins::is_trusted`]).
974    pub plugins_dirs: Vec<PathBuf>,
975}
976
977/// P4e (§1.4/§3.1 `core.context_injections`): one named ambient context
978/// block -- see [`Config::context_injection_blocks`].
979#[derive(Debug, Clone, PartialEq, Eq)]
980pub struct ContextInjectionBlock {
981    /// The block's heading, rendered as `# {name}`.
982    pub name: String,
983    /// The block's body text, appended verbatim under the heading.
984    pub content: String,
985}
986
987impl ContextInjectionBlock {
988    /// Build a named block.
989    pub fn new(name: impl Into<String>, content: impl Into<String>) -> Self {
990        ContextInjectionBlock {
991            name: name.into(),
992            content: content.into(),
993        }
994    }
995}
996
997/// How queued steering/follow-up messages are drained (S1.7, pi3
998/// `steeringMode`/`followUpMode`).
999#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1000pub enum SteeringMode {
1001    /// Deliver every queued message at once.
1002    All,
1003    /// Deliver exactly one queued message per drain point (the default).
1004    #[default]
1005    OneAtATime,
1006}
1007
1008impl SteeringMode {
1009    /// Parse the `"all"` / `"one-at-a-time"` config strings (S3.1).
1010    pub fn parse(s: &str) -> Option<SteeringMode> {
1011        match s {
1012            "all" => Some(SteeringMode::All),
1013            "one-at-a-time" | "one_at_a_time" => Some(SteeringMode::OneAtATime),
1014            _ => None,
1015        }
1016    }
1017}
1018
1019/// A stop-gate hook: receives the would-be-final assistant message; returns
1020/// `Some(reason)` to veto termination and continue the loop (the reason is
1021/// injected as a new user message), or `None` to allow the stop. See
1022/// `Config::stop_gate`.
1023pub type StopGateHook = Box<dyn Fn(&str) -> Option<String> + Send + Sync>;
1024
1025impl Default for Config {
1026    fn default() -> Self {
1027        Config {
1028            model: "anthropic/claude-opus-4-8".to_string(),
1029            base_url: OPENROUTER_BASE_URL.to_string(),
1030            api_key: None,
1031            api_key_env: DEFAULT_API_KEY_ENV.to_string(),
1032            api_key_cmd: None,
1033            system_prompt: DEFAULT_SYSTEM_PROMPT.to_string(),
1034            temperature: None,
1035            max_tokens: None,
1036            max_iterations: 25,
1037            effort: None,
1038            response_format: None,
1039            extra_body: serde_json::Map::new(),
1040            max_total_output_tokens: None,
1041            max_tool_output_bytes: Some(100_000),
1042            cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
1043            additional_dirs: Vec::new(),
1044            load_project_context: false,
1045            sandbox: crate::tools::SandboxPolicy::default(),
1046            approval: ApprovalPolicy::default(),
1047            auto_approved_tools: std::collections::HashSet::new(),
1048            tool_deny_patterns: Vec::new(),
1049            tool_allow_patterns: Vec::new(),
1050            approval_handler: None,
1051            pre_tool_hook: None,
1052            post_tool_hook: None,
1053            prompts: default_prompts(),
1054            compact_after_messages: None,
1055            tool_overrides: HashMap::new(),
1056            tool_advertising: ToolAdvertising::default(),
1057            extra_headers: HashMap::new(),
1058            event_sink: None,
1059            cache_plan: CachePlan::default(),
1060            reduction_policy: ReductionPolicySettings::default(),
1061            handoff_enabled: true,
1062            tool_schema_tier: crate::tools::SchemaTier::default(),
1063            cache_warnings: true,
1064            module_registry: false,
1065            module_activation: crate::modules::ModuleActivation::default(),
1066            core_tools_enabled: ["read_file", "bash", "edit_file", "write_file"]
1067                .iter()
1068                .map(|s| s.to_string())
1069                .collect(),
1070            skills_enabled: false,
1071            small_model: None,
1072            model_fallback: Vec::new(),
1073            env_context: false,
1074            project_root_markers: vec![".git".to_string()],
1075            project_doc_max_bytes: None,
1076            instruction_imports: false,
1077            retry_enabled: true,
1078            retry_max_retries: None,
1079            retry_base_delay_ms: None,
1080            compaction_reserve_tokens: None,
1081            compaction_keep_recent_tokens: None,
1082            compaction_focus_instructions: None,
1083            auto_title: false,
1084            steering_mode: SteeringMode::default(),
1085            follow_up_mode: SteeringMode::default(),
1086            stop_gate: None,
1087            read_file_multimodal: false,
1088            edit_file_require_read_before_edit: false,
1089            edit_file_notebook_aware: false,
1090            shell_env_snapshot: false,
1091            doom_loop_threshold: None,
1092            nested_instructions: false,
1093            model_switch_allow_switch: false,
1094            context_injections: false,
1095            context_injection_blocks: Vec::new(),
1096            // P4e: `true` because today's behavior (before this master gate
1097            // existed) is "compaction fires whenever a trigger is
1098            // configured" -- a default of `true` preserves that exactly;
1099            // only an explicit `false` newly suppresses it.
1100            compaction_enabled: true,
1101            parallel_tool_calls: false,
1102            session_git_metadata: false,
1103            session_dir: None,
1104            session_persist: true,
1105            session_name: None,
1106            session_retention_days: None,
1107            session_export_format: crate::human_export::HumanExportFormat::default(),
1108            permissions_enabled: false,
1109            permissions_ask_patterns: Vec::new(),
1110            permissions_protected_paths: Vec::new(),
1111            network_policy: None,
1112            sandbox_os_enabled: None,
1113            sandbox_escalation: crate::sandbox::SandboxEscalation::default(),
1114            sandbox_env_policy: crate::sandbox::SandboxEnvPolicy::default(),
1115            subagents_enabled: false,
1116            subagents_max_depth: 2,
1117            subagents_max_concurrent: 4,
1118            subagents_background: false,
1119            subagents_background_prompts: None,
1120            subagents_claude_agent_alias: false,
1121            claude_runtime_tools_enabled: false,
1122            subagents_definitions: HashMap::new(),
1123            subagent_depth: 0,
1124            tui_enabled: false,
1125            tui_theme: "dark".to_string(),
1126            tui_vim_mode: false,
1127            tui_keymap: HashMap::new(),
1128            session_tree_enabled: false,
1129            session_tree_branch_summaries: false,
1130            session_tree_labels: false,
1131            tools_background_enabled: false,
1132            tools_background_max_concurrent: crate::background::DEFAULT_MAX_CONCURRENT,
1133            tools_background_max_output_bytes: crate::background::DEFAULT_MAX_OUTPUT_BYTES,
1134            checkpoint_enabled: false,
1135            checkpoint_retain: crate::checkpoint::DEFAULT_RETAIN,
1136            checkpoint_dir: None,
1137            lsp_enabled: false,
1138            lsp_servers: Vec::new(),
1139            lsp_max_diagnostics: crate::lsp::DEFAULT_LSP_MAX_DIAGNOSTICS,
1140            lsp_timeout_secs: crate::lsp::DEFAULT_LSP_TIMEOUT_SECS,
1141            formatters_enabled: false,
1142            formatters: Vec::new(),
1143            formatters_diff_back: true,
1144            formatters_timeout_secs: crate::formatters::DEFAULT_FORMATTER_TIMEOUT_SECS,
1145            trust_enabled: false,
1146            trust_default: crate::plugins::TrustDecision::Ask,
1147            plugins_enabled: false,
1148            plugins_dirs: Vec::new(),
1149        }
1150    }
1151}
1152
1153impl Config {
1154    /// Start building a [`Config`] from defaults.
1155    pub fn builder() -> ConfigBuilder {
1156        ConfigBuilder {
1157            config: Config::default(),
1158        }
1159    }
1160
1161    /// Whether a tool is enabled given the overrides (defaults to enabled).
1162    pub fn tool_enabled(&self, name: &str) -> bool {
1163        self.tool_overrides
1164            .get(name)
1165            .and_then(|o| o.enabled)
1166            .unwrap_or(true)
1167    }
1168
1169    /// Whether a tool call requires approval before it runs, given the
1170    /// policy, the auto-approve allowlist, and (P4) the deny/allow glob
1171    /// PATTERN lists — see [`Self::tool_deny_patterns`]/
1172    /// [`Self::tool_allow_patterns`]'s doc comments for the exact
1173    /// semantics. Both are empty by default, so this is byte-identical to
1174    /// pre-P4 behavior for any `Config` that doesn't set them.
1175    pub fn needs_approval(&self, tool: &str) -> bool {
1176        // Deny wins unconditionally, even under `Never` — a deny pattern is
1177        // a hard floor, not just another allowlist entry.
1178        if self.tool_deny_patterns.iter().any(|p| glob_match(p, tool)) {
1179            return true;
1180        }
1181        match self.approval {
1182            ApprovalPolicy::Never => false,
1183            // P5-1: this coarse, tool-name-only gate has no model-escalation
1184            // signal to consult (that requires the canonicalized-command
1185            // context only `crate::permissions`'s richer gate has), so
1186            // `ModelRequested` is treated the same, conservative way
1187            // `OnRequest` is here — the safe simplification documented on
1188            // `ApprovalPolicy::ModelRequested` itself. The P5-1 engine
1189            // (active when `Self::permissions_enabled` is `true`) is where
1190            // Codex's real "mostly silent, escalation asks" posture is
1191            // approximated instead.
1192            ApprovalPolicy::OnRequest | ApprovalPolicy::ModelRequested => {
1193                !self.auto_approved_tools.contains(tool)
1194                    && !self.tool_allow_patterns.iter().any(|p| glob_match(p, tool))
1195            }
1196            ApprovalPolicy::Untrusted => true,
1197        }
1198    }
1199
1200    /// The effective description for a tool, applying any override.
1201    pub fn tool_description<'a>(&'a self, name: &str, builtin: &'a str) -> &'a str {
1202        self.tool_overrides
1203            .get(name)
1204            .and_then(|o| o.description.as_deref())
1205            .unwrap_or(builtin)
1206    }
1207
1208    /// The effective schema tier for a tool (TR-8/T5): a per-tool override if
1209    /// set, else the global [`Self::tool_schema_tier`].
1210    pub fn schema_tier_for(&self, name: &str) -> crate::tools::SchemaTier {
1211        self.tool_overrides
1212            .get(name)
1213            .and_then(|o| o.schema_tier)
1214            .unwrap_or(self.tool_schema_tier)
1215    }
1216}
1217
1218/// A single tool's file-settable overrides — the `ConfigProfile` mirror of
1219/// [`ToolOverride`] (COMPOSABLE-HARNESS-DESIGN.md §3.1 `[core.tools.<name>]`,
1220/// §3.2 mapping row `core.tools.enabled` + `[core.tools.<n>].*`).
1221#[derive(Debug, Clone, Default, serde::Deserialize)]
1222pub struct ToolOverrideProfile {
1223    /// `Some(false)` hides the tool from the model entirely.
1224    pub enabled: Option<bool>,
1225    /// Replaces the tool's built-in description.
1226    pub description: Option<String>,
1227    /// Per-tool schema tier: `full` | `medium` | `minimal`.
1228    pub schema_tier: Option<String>,
1229    /// P4e (§3.1 `core.tools.bash.timeout_secs`, S14) -- see
1230    /// `ToolOverride::timeout_secs`. Only meaningful on the `bash` entry.
1231    pub timeout_secs: Option<u64>,
1232}
1233
1234/// The serializable subset of a [`Config`] that can live in a config file.
1235/// (Callbacks/handlers are code-only and are not represented here.)
1236///
1237/// Grown from its original 9 fields to the P1 §3.2 surface
1238/// (COMPOSABLE-HARNESS-DESIGN.md §5.2 "P1" migration step): every `[core]`
1239/// scalar/table/array `Config` maps to lives here so it becomes file-settable
1240/// for the first time, per the design's stated framing gap (§3.0).
1241#[derive(Debug, Clone, Default, serde::Deserialize)]
1242pub struct ConfigProfile {
1243    /// Model id.
1244    pub model: Option<String>,
1245    /// Endpoint base URL.
1246    pub base_url: Option<String>,
1247    /// Environment variable to read the API key from (§3.1 `core.api_key_env`;
1248    /// §3.2: "today absent from BOTH files").
1249    pub api_key_env: Option<String>,
1250    /// Credential-helper command (§3.1 `core.api_key_cmd`, D6 row) — see
1251    /// [`Config::api_key_cmd`].
1252    pub api_key_cmd: Option<String>,
1253    /// System prompt.
1254    pub system_prompt: Option<String>,
1255    /// P4 (§3.1 `core.append_system_prompt`, D2 row 1): an additive suffix
1256    /// composed onto whatever [`Self::system_prompt`] resolves to (the
1257    /// profile's own value if set, else whatever the builder already had —
1258    /// see [`ConfigBuilder::apply_profile`]'s composition order), distinct
1259    /// from REPLACING it. `[project-forbidden]`, same trust boundary as
1260    /// `system_prompt` (§3.3: prompt injection).
1261    pub append_system_prompt: Option<String>,
1262    /// Sampling temperature.
1263    pub temperature: Option<f32>,
1264    /// Output token cap per request.
1265    pub max_tokens: Option<u32>,
1266    /// Reasoning/effort level.
1267    pub effort: Option<String>,
1268    /// Sandbox policy: `read_only` | `workspace_write` | `danger_full_access`.
1269    pub sandbox: Option<String>,
1270    /// Approval policy: `never` | `on_request` | `untrusted`.
1271    pub approval: Option<String>,
1272    /// Auto-load CLAUDE.md / AGENTS.md.
1273    pub project_context: Option<bool>,
1274    /// Per-`send` iteration budget (§3.1 `core.max_iterations`).
1275    pub max_iterations: Option<usize>,
1276    /// Extra roots beyond `cwd` (§3.1 `core.additional_dirs`); arrays
1277    /// replace wholesale on overlay (§3.3).
1278    pub additional_dirs: Option<Vec<String>>,
1279    /// Compact once the conversation exceeds this many messages (§3.1
1280    /// `core.compaction.after_messages`; `0` = trigger off).
1281    pub compact_after_messages: Option<usize>,
1282    /// Prompt-caching plan: `off` | `imported_prefix` (§3.1
1283    /// `capabilities.cache.plan`).
1284    pub cache_plan: Option<String>,
1285    /// How tools are advertised: `full` | `deferred` (§3.1
1286    /// `capabilities.deferred_tools`).
1287    pub tool_advertising: Option<String>,
1288    /// The eagerly-advertised core allowlist when `tool_advertising =
1289    /// "deferred"` (§3.1 `capabilities.deferred_tools.core`); arrays replace.
1290    pub tool_advertising_core: Option<Vec<String>>,
1291    /// Global tool-schema tier: `full` | `medium` | `minimal` (§3.1
1292    /// `core.tools.schema_tier`).
1293    pub schema_tier: Option<String>,
1294    /// Tools that never require approval under `ApprovalPolicy::OnRequest`
1295    /// (§3.1 `capabilities.permissions.auto_approved_tools`); arrays replace.
1296    pub auto_approved_tools: Option<Vec<String>>,
1297    /// P4: deny-pattern generalization of `auto_approved_tools` (§3.1
1298    /// `capabilities.permissions.rules.deny`) — see
1299    /// [`Config::tool_deny_patterns`]. Arrays replace.
1300    pub tool_deny_patterns: Option<Vec<String>>,
1301    /// P4: allow-pattern generalization of `auto_approved_tools` (§3.1
1302    /// `capabilities.permissions.rules.allow`) — see
1303    /// [`Config::tool_allow_patterns`]. Arrays replace.
1304    pub tool_allow_patterns: Option<Vec<String>>,
1305    /// Extra HTTP headers merged in (§3.1 `core.extra_headers`); a table,
1306    /// merged key-wise on overlay (§3.3).
1307    pub extra_headers: Option<HashMap<String, String>>,
1308    /// Extra request-body fields merged in (§3.1 `core.extra_body`); a
1309    /// table, merged key-wise on overlay (§3.3).
1310    pub extra_body: Option<serde_json::Map<String, serde_json::Value>>,
1311    /// Max bytes of a single tool result (§3.1 `core.max_tool_output_bytes`).
1312    pub max_tool_output_bytes: Option<usize>,
1313    /// Cap on cumulative output tokens per `send` loop (§3.1
1314    /// `core.max_total_output_tokens`).
1315    pub max_total_output_tokens: Option<u64>,
1316    /// Named prompt templates (§3.1 `[core.prompts]`); a table, merged
1317    /// key-wise (new/overridden names layer onto the built-ins, they don't
1318    /// wholesale-replace them).
1319    pub prompts: Option<HashMap<String, String>>,
1320    /// Per-tool enable/disable + description/schema-tier overrides, keyed
1321    /// by tool name (§3.1 `[core.tools.<name>]`, §3.2 "today in no file");
1322    /// a table, merged key-wise per tool.
1323    pub tool_overrides: Option<HashMap<String, ToolOverrideProfile>>,
1324
1325    /// P4b (S3.1 `core.env_context`) -- see `Config::env_context`.
1326    pub env_context: Option<bool>,
1327    /// P4b (S3.1 `core.project_root_markers`) -- see
1328    /// `Config::project_root_markers`; arrays replace.
1329    pub project_root_markers: Option<Vec<String>>,
1330    /// P4b (S3.1 `core.project_doc_max_bytes`) -- see
1331    /// `Config::project_doc_max_bytes`.
1332    pub project_doc_max_bytes: Option<usize>,
1333    /// P4b (S3.1 `core.instruction_imports`) -- see
1334    /// `Config::instruction_imports`.
1335    pub instruction_imports: Option<bool>,
1336    /// P4b (S3.1 `core.retry.enabled`) -- see `Config::retry_enabled`.
1337    pub retry_enabled: Option<bool>,
1338    /// P4b (S3.1 `core.retry.max_retries`) -- see `Config::retry_max_retries`.
1339    pub retry_max_retries: Option<u32>,
1340    /// P4b (S3.1 `core.retry.base_delay_ms`) -- see
1341    /// `Config::retry_base_delay_ms`.
1342    pub retry_base_delay_ms: Option<u64>,
1343    /// P4b (S3.1 `core.compaction.reserve_tokens`) -- see
1344    /// `Config::compaction_reserve_tokens`.
1345    pub compaction_reserve_tokens: Option<u64>,
1346    /// P4b (S3.1 `core.compaction.keep_recent_tokens`) -- see
1347    /// `Config::compaction_keep_recent_tokens`.
1348    pub compaction_keep_recent_tokens: Option<u64>,
1349    /// P4b (S3.1 `core.compaction.focus_instructions`) -- see
1350    /// `Config::compaction_focus_instructions`.
1351    pub compaction_focus_instructions: Option<String>,
1352    /// P4b (S3.1 `core.session.auto_title`) -- see `Config::auto_title`.
1353    pub auto_title: Option<bool>,
1354    /// P4b (S3.1 `core.steering.steering_mode`) -- see
1355    /// `Config::steering_mode`.
1356    pub steering_mode: Option<String>,
1357    /// P4b (S3.1 `core.steering.follow_up_mode`) -- see
1358    /// `Config::follow_up_mode`.
1359    pub follow_up_mode: Option<String>,
1360
1361    /// P4c (S3.1 `core.tools.read_file.multimodal`) -- see
1362    /// `Config::read_file_multimodal`.
1363    pub read_file_multimodal: Option<bool>,
1364    /// P4c (S3.1 `core.tools.edit_file.require_read_before_edit`) -- see
1365    /// `Config::edit_file_require_read_before_edit`.
1366    pub edit_file_require_read_before_edit: Option<bool>,
1367    /// P4c (S3.1 `core.tools.edit_file.notebook_aware`) -- see
1368    /// `Config::edit_file_notebook_aware`.
1369    pub edit_file_notebook_aware: Option<bool>,
1370    /// P4c (S3.1 `core.shell_env_snapshot`) -- see
1371    /// `Config::shell_env_snapshot`.
1372    pub shell_env_snapshot: Option<bool>,
1373    /// P4c (S3.1 `core.doom_loop_threshold`) -- see
1374    /// `Config::doom_loop_threshold`.
1375    pub doom_loop_threshold: Option<u32>,
1376    /// P4c (S3.1 `core.nested_instructions`) -- see
1377    /// `Config::nested_instructions`.
1378    pub nested_instructions: Option<bool>,
1379    /// P4c (S3.1 `core.model_switch.allow_switch`) -- see
1380    /// `Config::model_switch_allow_switch`.
1381    pub model_switch_allow_switch: Option<bool>,
1382
1383    /// P4e (S3.1 `core.context_injections`) -- see
1384    /// `Config::context_injections`. Only the boolean gate is
1385    /// file/profile-settable; `Config::context_injection_blocks`' actual
1386    /// content is code-only (see its doc comment).
1387    pub context_injections: Option<bool>,
1388    /// P4e (S3.1 `core.compaction.enabled`) -- see
1389    /// `Config::compaction_enabled`.
1390    pub compaction_enabled: Option<bool>,
1391    /// P4e (S3.1 `core.parallel_tool_calls`) -- see
1392    /// `Config::parallel_tool_calls`.
1393    pub parallel_tool_calls: Option<bool>,
1394    /// P4e (S3.1 `core.session.git_metadata`) -- see
1395    /// `Config::session_git_metadata`.
1396    pub session_git_metadata: Option<bool>,
1397    /// P4e (S3.1 `core.session.dir`) -- see `Config::session_dir`.
1398    pub session_dir: Option<String>,
1399    /// P4e (S3.1 `core.session.persist`) -- see `Config::session_persist`.
1400    pub session_persist: Option<bool>,
1401    /// P4e (S3.1 `core.session.name`) -- see `Config::session_name`.
1402    pub session_name: Option<String>,
1403    /// P4e (S3.1 `core.session.retention_days`) -- see
1404    /// `Config::session_retention_days`.
1405    pub session_retention_days: Option<u32>,
1406    /// P4e (S3.1 `core.session.export_format`) -- see
1407    /// `Config::session_export_format`. `"text"` | `"html"`; an
1408    /// unrecognized string is a no-op warning, like `steering_mode`.
1409    pub session_export_format: Option<String>,
1410}
1411
1412/// A config file: a set of named profiles (the analog of Codex `-p/--profile`).
1413/// This is the **SDK/embedder** config surface; the supercode CLI uses a
1414/// separate TOML config (`userconfig::FileConfig` in the `cli` crate) and does
1415/// not expose this file or a `--profile` flag.
1416#[derive(Debug, Clone, Default, serde::Deserialize)]
1417pub struct ConfigFile {
1418    /// Profiles keyed by name.
1419    #[serde(default)]
1420    pub profiles: HashMap<String, ConfigProfile>,
1421}
1422
1423impl Config {
1424    /// Load a named profile from a JSON config file into a builder. Layered:
1425    /// start from defaults, then apply the named profile's set fields.
1426    pub fn from_profile_file(
1427        path: impl AsRef<std::path::Path>,
1428        profile: &str,
1429    ) -> crate::Result<ConfigBuilder> {
1430        let text = std::fs::read_to_string(path.as_ref())?;
1431        let file: ConfigFile = serde_json::from_str(&text).map_err(crate::Error::Decode)?;
1432        let p = file
1433            .profiles
1434            .get(profile)
1435            .ok_or_else(|| crate::Error::Other(format!("no profile `{profile}` in config file")))?;
1436        Ok(ConfigBuilder::default().apply_profile(p))
1437    }
1438}
1439
1440impl ConfigBuilder {
1441    /// Apply the set fields of a [`ConfigProfile`] over the current builder.
1442    pub fn apply_profile(mut self, p: &ConfigProfile) -> Self {
1443        if let Some(m) = &p.model {
1444            self.config.model = m.clone();
1445        }
1446        if let Some(u) = &p.base_url {
1447            self.config.base_url = u.clone();
1448        }
1449        if let Some(s) = &p.system_prompt {
1450            self.config.system_prompt = s.clone();
1451        }
1452        if let Some(extra) = &p.append_system_prompt {
1453            // P4 (§3.1 `core.append_system_prompt`): additive, composed onto
1454            // whatever `system_prompt` is on the builder AT THIS POINT —
1455            // either the value just applied above, or whatever the caller
1456            // already set/left at its `Config::default()` — never a
1457            // replacement. This intentionally runs regardless of whether
1458            // `p.system_prompt` was set, so an append-only profile still
1459            // composes onto the existing base.
1460            self.config.system_prompt = format!("{}\n\n{extra}", self.config.system_prompt);
1461        }
1462        self.config.temperature = p.temperature.or(self.config.temperature);
1463        self.config.max_tokens = p.max_tokens.or(self.config.max_tokens);
1464        if p.effort.is_some() {
1465            self.config.effort = p.effort.clone();
1466        }
1467        if let Some(sb) = &p.sandbox {
1468            // Fail *safe*: an unrecognized value (typo, future variant) must not
1469            // silently grant full filesystem access. Only the explicit
1470            // danger string opts out of confinement.
1471            self.config.sandbox = match sb.as_str() {
1472                "read_only" | "read-only" | "readonly" => crate::tools::SandboxPolicy::ReadOnly,
1473                "workspace_write" | "workspace-write" => {
1474                    crate::tools::SandboxPolicy::WorkspaceWrite
1475                }
1476                "danger_full_access" | "danger-full-access" => {
1477                    crate::tools::SandboxPolicy::DangerFullAccess
1478                }
1479                other => {
1480                    tracing::warn!(
1481                        "unknown sandbox policy `{other}` in profile; defaulting to read_only"
1482                    );
1483                    crate::tools::SandboxPolicy::ReadOnly
1484                }
1485            };
1486        }
1487        if let Some(ap) = &p.approval {
1488            // Fail safe: an unrecognized value defaults to the most-prompting
1489            // policy, never to `never`.
1490            self.config.approval = match ap.as_str() {
1491                "on_request" | "on-request" => ApprovalPolicy::OnRequest,
1492                "untrusted" => ApprovalPolicy::Untrusted,
1493                "never" => ApprovalPolicy::Never,
1494                // P5-1 (§3.2 S8): cx-parity's real intended posture — see
1495                // `ApprovalPolicy::ModelRequested`'s doc comment.
1496                "model_requested" | "model-requested" => ApprovalPolicy::ModelRequested,
1497                other => {
1498                    tracing::warn!(
1499                        "unknown approval policy `{other}` in profile; defaulting to untrusted"
1500                    );
1501                    ApprovalPolicy::Untrusted
1502                }
1503            };
1504        }
1505        if let Some(pc) = p.project_context {
1506            self.config.load_project_context = pc;
1507        }
1508        if let Some(env) = &p.api_key_env {
1509            self.config.api_key_env = env.clone();
1510        }
1511        if let Some(cmd) = &p.api_key_cmd {
1512            self.config.api_key_cmd = Some(cmd.clone());
1513        }
1514        // Scalars replace (§3.3).
1515        if let Some(n) = p.max_iterations {
1516            self.config.max_iterations = n;
1517        }
1518        // Arrays replace wholesale (§3.3), not append — predictable overlay.
1519        if let Some(dirs) = &p.additional_dirs {
1520            self.config.additional_dirs = dirs.iter().map(std::path::PathBuf::from).collect();
1521        }
1522        if let Some(n) = p.compact_after_messages {
1523            self.config.compact_after_messages = Some(n);
1524        }
1525        if let Some(plan) = &p.cache_plan {
1526            // Fail safe: an unrecognized value never silently opts into
1527            // caching behavior the operator didn't ask for.
1528            self.config.cache_plan = match plan.as_str() {
1529                "off" => CachePlan::Off,
1530                "imported_prefix" | "imported-prefix" => CachePlan::ImportedPrefix,
1531                other => {
1532                    tracing::warn!("unknown cache plan `{other}` in profile; defaulting to off");
1533                    CachePlan::Off
1534                }
1535            };
1536        }
1537        if p.tool_advertising.is_some() || p.tool_advertising_core.is_some() {
1538            // F6 fix: per-key replace (§3.3) — neither key alone may clobber
1539            // the other's current value. Compute the effective core list
1540            // FIRST (the profile's new value if given, else whatever's
1541            // already active) so an explicit `"deferred"` mode with no
1542            // `_core` doesn't wipe an existing list, then only change the
1543            // MODE if the profile actually set one — setting `_core` alone
1544            // must not silently reset the mode to `Full` (which previously
1545            // discarded the array outright, since `Full` ignores it).
1546            let existing_core = match &self.config.tool_advertising {
1547                ToolAdvertising::Deferred { core } => core.clone(),
1548                ToolAdvertising::Full => Vec::new(),
1549            };
1550            let core = p.tool_advertising_core.clone().unwrap_or(existing_core);
1551            self.config.tool_advertising = match p.tool_advertising.as_deref() {
1552                Some("deferred") => ToolAdvertising::Deferred { core },
1553                Some("full") => ToolAdvertising::Full,
1554                Some(other) => {
1555                    tracing::warn!(
1556                        "unknown tool_advertising mode `{other}` in profile; defaulting to full"
1557                    );
1558                    ToolAdvertising::Full
1559                }
1560                None => match &self.config.tool_advertising {
1561                    // Mode untouched — only refresh the core list if
1562                    // already `Deferred` (`Full` has nowhere to put one).
1563                    ToolAdvertising::Deferred { .. } => ToolAdvertising::Deferred { core },
1564                    ToolAdvertising::Full => ToolAdvertising::Full,
1565                },
1566            };
1567        }
1568        if let Some(tier) = &p.schema_tier {
1569            // Fail safe: unrecognized value keeps the verbose (never
1570            // under-informative) default rather than guessing a shrink tier.
1571            self.config.tool_schema_tier =
1572                crate::tools::SchemaTier::parse(tier).unwrap_or_else(|| {
1573                    tracing::warn!("unknown schema tier `{tier}` in profile; defaulting to full");
1574                    crate::tools::SchemaTier::Full
1575                });
1576        }
1577        // Arrays replace wholesale (§3.3).
1578        if let Some(tools) = &p.auto_approved_tools {
1579            self.config.auto_approved_tools = tools.iter().cloned().collect();
1580        }
1581        if let Some(patterns) = &p.tool_deny_patterns {
1582            self.config.tool_deny_patterns = patterns.clone();
1583        }
1584        if let Some(patterns) = &p.tool_allow_patterns {
1585            self.config.tool_allow_patterns = patterns.clone();
1586        }
1587        // Tables merge key-wise (§3.3), not wholesale replace.
1588        if let Some(headers) = &p.extra_headers {
1589            for (k, v) in headers {
1590                self.config.extra_headers.insert(k.clone(), v.clone());
1591            }
1592        }
1593        if let Some(body) = &p.extra_body {
1594            for (k, v) in body {
1595                self.config.extra_body.insert(k.clone(), v.clone());
1596            }
1597        }
1598        if let Some(n) = p.max_tool_output_bytes {
1599            self.config.max_tool_output_bytes = Some(n);
1600        }
1601        if let Some(n) = p.max_total_output_tokens {
1602            self.config.max_total_output_tokens = Some(n);
1603        }
1604        if let Some(prompts) = &p.prompts {
1605            for (k, v) in prompts {
1606                self.config.prompts.insert(k.clone(), v.clone());
1607            }
1608        }
1609        if let Some(overrides) = &p.tool_overrides {
1610            for (name, o) in overrides {
1611                let entry = self.config.tool_overrides.entry(name.clone()).or_default();
1612                if let Some(en) = o.enabled {
1613                    entry.enabled = Some(en);
1614                }
1615                if let Some(desc) = &o.description {
1616                    entry.description = Some(desc.clone());
1617                }
1618                if let Some(tier) = &o.schema_tier {
1619                    entry.schema_tier = Some(crate::tools::SchemaTier::parse(tier).unwrap_or_else(|| {
1620                        tracing::warn!(
1621                            "unknown schema tier `{tier}` in tool override `{name}`; defaulting to full"
1622                        );
1623                        crate::tools::SchemaTier::Full
1624                    }));
1625                }
1626                if let Some(t) = o.timeout_secs {
1627                    entry.timeout_secs = Some(t);
1628                }
1629            }
1630        }
1631        // P4b: scalars replace (S3.3).
1632        if let Some(v) = p.env_context {
1633            self.config.env_context = v;
1634        }
1635        if let Some(v) = &p.project_root_markers {
1636            self.config.project_root_markers = v.clone();
1637        }
1638        if let Some(v) = p.project_doc_max_bytes {
1639            self.config.project_doc_max_bytes = Some(v);
1640        }
1641        if let Some(v) = p.instruction_imports {
1642            self.config.instruction_imports = v;
1643        }
1644        if let Some(v) = p.retry_enabled {
1645            self.config.retry_enabled = v;
1646        }
1647        if let Some(v) = p.retry_max_retries {
1648            self.config.retry_max_retries = Some(v);
1649        }
1650        if let Some(v) = p.retry_base_delay_ms {
1651            self.config.retry_base_delay_ms = Some(v);
1652        }
1653        if let Some(v) = p.compaction_reserve_tokens {
1654            self.config.compaction_reserve_tokens = Some(v);
1655        }
1656        if let Some(v) = p.compaction_keep_recent_tokens {
1657            self.config.compaction_keep_recent_tokens = Some(v);
1658        }
1659        if let Some(v) = &p.compaction_focus_instructions {
1660            self.config.compaction_focus_instructions = Some(v.clone());
1661        }
1662        if let Some(v) = p.auto_title {
1663            self.config.auto_title = v;
1664        }
1665        if let Some(mode) = &p.steering_mode {
1666            // Fail safe: an unrecognized value keeps the current setting
1667            // rather than guessing.
1668            match SteeringMode::parse(mode) {
1669                Some(m) => self.config.steering_mode = m,
1670                None => tracing::warn!("unknown steering_mode `{mode}` in profile; ignoring"),
1671            }
1672        }
1673        if let Some(mode) = &p.follow_up_mode {
1674            match SteeringMode::parse(mode) {
1675                Some(m) => self.config.follow_up_mode = m,
1676                None => tracing::warn!("unknown follow_up_mode `{mode}` in profile; ignoring"),
1677            }
1678        }
1679        // P4c: scalars replace (S3.3).
1680        if let Some(v) = p.read_file_multimodal {
1681            self.config.read_file_multimodal = v;
1682        }
1683        if let Some(v) = p.edit_file_require_read_before_edit {
1684            self.config.edit_file_require_read_before_edit = v;
1685        }
1686        if let Some(v) = p.edit_file_notebook_aware {
1687            self.config.edit_file_notebook_aware = v;
1688        }
1689        if let Some(v) = p.shell_env_snapshot {
1690            self.config.shell_env_snapshot = v;
1691        }
1692        if let Some(v) = p.doom_loop_threshold {
1693            self.config.doom_loop_threshold = Some(v);
1694        }
1695        if let Some(v) = p.nested_instructions {
1696            self.config.nested_instructions = v;
1697        }
1698        if let Some(v) = p.model_switch_allow_switch {
1699            self.config.model_switch_allow_switch = v;
1700        }
1701        // P4e: scalars replace (S3.3).
1702        if let Some(v) = p.context_injections {
1703            self.config.context_injections = v;
1704        }
1705        if let Some(v) = p.compaction_enabled {
1706            self.config.compaction_enabled = v;
1707        }
1708        if let Some(v) = p.parallel_tool_calls {
1709            self.config.parallel_tool_calls = v;
1710        }
1711        if let Some(v) = p.session_git_metadata {
1712            self.config.session_git_metadata = v;
1713        }
1714        if let Some(v) = &p.session_dir {
1715            self.config.session_dir = Some(v.clone());
1716        }
1717        if let Some(v) = p.session_persist {
1718            self.config.session_persist = v;
1719        }
1720        if let Some(v) = &p.session_name {
1721            self.config.session_name = Some(v.clone());
1722        }
1723        if let Some(v) = p.session_retention_days {
1724            self.config.session_retention_days = Some(v);
1725        }
1726        if let Some(fmt) = &p.session_export_format {
1727            match crate::human_export::HumanExportFormat::parse(fmt) {
1728                Some(f) => self.config.session_export_format = f,
1729                None => {
1730                    tracing::warn!("unknown session export_format `{fmt}` in profile; ignoring")
1731                }
1732            }
1733        }
1734        self
1735    }
1736}
1737
1738/// Fluent builder for [`Config`].
1739#[derive(Default)]
1740pub struct ConfigBuilder {
1741    config: Config,
1742}
1743
1744impl ConfigBuilder {
1745    /// P4d (design §5.2 P1 CLI-adapter): resume building from an
1746    /// ALREADY-constructed [`Config`] rather than [`Config::default`] — lets
1747    /// a caller that assembled a `Config` with its own precedence logic
1748    /// (e.g. the CLI's `build_config`: flag > env > project > user >
1749    /// interactive-default) layer a narrowly-scoped [`ConfigProfile`] on top
1750    /// via [`Self::apply_profile`] afterward, reusing that method's correct
1751    /// per-key merge semantics (tables merge key-wise, e.g.
1752    /// `tool_overrides`/`prompts`/`extra_headers`/`extra_body`) instead of a
1753    /// second hand-rolled copy of the same merge logic at the call site.
1754    pub fn from_config(config: Config) -> Self {
1755        ConfigBuilder { config }
1756    }
1757
1758    /// Set the model identifier.
1759    pub fn model(mut self, model: impl Into<String>) -> Self {
1760        self.config.model = model.into();
1761        self
1762    }
1763
1764    /// Set the OpenAI-compatible base URL (defaults to OpenRouter).
1765    pub fn base_url(mut self, url: impl Into<String>) -> Self {
1766        self.config.base_url = url.into();
1767        self
1768    }
1769
1770    /// Provide the API key explicitly.
1771    pub fn api_key(mut self, key: impl Into<String>) -> Self {
1772        self.config.api_key = Some(key.into());
1773        self
1774    }
1775
1776    /// Change which environment variable the key is read from.
1777    pub fn api_key_env(mut self, var: impl Into<String>) -> Self {
1778        self.config.api_key_env = var.into();
1779        self
1780    }
1781
1782    /// Set the credential-helper command — see [`Config::api_key_cmd`].
1783    pub fn api_key_cmd(mut self, cmd: impl Into<String>) -> Self {
1784        self.config.api_key_cmd = Some(cmd.into());
1785        self
1786    }
1787
1788    /// Replace the system prompt.
1789    pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
1790        self.config.system_prompt = prompt.into();
1791        self
1792    }
1793
1794    /// Set the sampling temperature.
1795    pub fn temperature(mut self, t: f32) -> Self {
1796        self.config.temperature = Some(t);
1797        self
1798    }
1799
1800    /// Set the max output tokens.
1801    pub fn max_tokens(mut self, n: u32) -> Self {
1802        self.config.max_tokens = Some(n);
1803        self
1804    }
1805
1806    /// Set the per-`send` iteration budget.
1807    pub fn max_iterations(mut self, n: usize) -> Self {
1808        self.config.max_iterations = n;
1809        self
1810    }
1811
1812    /// Set the reasoning/effort level (`reasoning_effort`).
1813    pub fn effort(mut self, level: impl Into<String>) -> Self {
1814        self.config.effort = Some(level.into());
1815        self
1816    }
1817
1818    /// Constrain output to a JSON schema (`response_format`).
1819    pub fn response_format(mut self, format: serde_json::Value) -> Self {
1820        self.config.response_format = Some(format);
1821        self
1822    }
1823
1824    /// Merge an extra request-body field (provider-native passthrough).
1825    pub fn extra_body_field(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
1826        self.config.extra_body.insert(key.into(), value);
1827        self
1828    }
1829
1830    /// Cap cumulative output tokens across one `send` loop.
1831    /// Cap a single tool result at `n` bytes (see
1832    /// [`Config::max_tool_output_bytes`]). Pass `0` only via the field to disable.
1833    pub fn max_tool_output_bytes(mut self, n: usize) -> Self {
1834        self.config.max_tool_output_bytes = Some(n);
1835        self
1836    }
1837
1838    /// Cap on cumulative output tokens across one send loop. Output tokens
1839    /// only; input/prompt tokens are not counted, so this is not a cost cap.
1840    pub fn max_total_output_tokens(mut self, n: u64) -> Self {
1841        self.config.max_total_output_tokens = Some(n);
1842        self
1843    }
1844
1845    /// Set the working directory tools operate in.
1846    pub fn cwd(mut self, dir: impl Into<PathBuf>) -> Self {
1847        self.config.cwd = dir.into();
1848        self
1849    }
1850
1851    /// Add an extra root directory (`--add-dir` / multi-root / worktree).
1852    pub fn add_dir(mut self, dir: impl Into<PathBuf>) -> Self {
1853        self.config.additional_dirs.push(dir.into());
1854        self
1855    }
1856
1857    /// Enable auto-loading of `CLAUDE.md` / `AGENTS.md` into the system prompt.
1858    pub fn project_context(mut self, enabled: bool) -> Self {
1859        self.config.load_project_context = enabled;
1860        self
1861    }
1862
1863    /// Register a named prompt template (skill / slash command).
1864    pub fn prompt(mut self, name: impl Into<String>, template: impl Into<String>) -> Self {
1865        self.config.prompts.insert(name.into(), template.into());
1866        self
1867    }
1868
1869    /// Compact the conversation once it exceeds `n` messages.
1870    pub fn compact_after_messages(mut self, n: usize) -> Self {
1871        self.config.compact_after_messages = Some(n);
1872        self
1873    }
1874
1875    /// Set the filesystem sandbox policy for write-capable tools.
1876    pub fn sandbox(mut self, policy: crate::tools::SandboxPolicy) -> Self {
1877        self.config.sandbox = policy;
1878        self
1879    }
1880
1881    /// Set the tool-approval policy.
1882    pub fn approval(mut self, policy: ApprovalPolicy) -> Self {
1883        self.config.approval = policy;
1884        self
1885    }
1886
1887    /// Add a tool to the auto-approve allowlist (no approval under `OnRequest`).
1888    pub fn auto_approve_tool(mut self, name: impl Into<String>) -> Self {
1889        self.config.auto_approved_tools.insert(name.into());
1890        self
1891    }
1892
1893    /// P4: add a glob pattern to [`Config::tool_deny_patterns`] — a match
1894    /// forces approval unconditionally, even under `ApprovalPolicy::Never`.
1895    pub fn deny_tool_pattern(mut self, pattern: impl Into<String>) -> Self {
1896        self.config.tool_deny_patterns.push(pattern.into());
1897        self
1898    }
1899
1900    /// P4: add a glob pattern to [`Config::tool_allow_patterns`] — the
1901    /// pattern generalization of [`Self::auto_approve_tool`].
1902    pub fn allow_tool_pattern(mut self, pattern: impl Into<String>) -> Self {
1903        self.config.tool_allow_patterns.push(pattern.into());
1904        self
1905    }
1906
1907    /// Set the handler consulted when a tool call needs approval.
1908    pub fn approval_handler(mut self, handler: ApprovalHandler) -> Self {
1909        self.config.approval_handler = Some(handler);
1910        self
1911    }
1912
1913    /// Set the pre-tool hook (may block a call by returning `Some(reason)`).
1914    pub fn pre_tool_hook(mut self, hook: PreToolHook) -> Self {
1915        self.config.pre_tool_hook = Some(hook);
1916        self
1917    }
1918
1919    /// Set the post-tool hook (observational).
1920    pub fn post_tool_hook(mut self, hook: PostToolHook) -> Self {
1921        self.config.post_tool_hook = Some(hook);
1922        self
1923    }
1924
1925    /// Disable a tool by name.
1926    pub fn disable_tool(mut self, name: impl Into<String>) -> Self {
1927        self.config
1928            .tool_overrides
1929            .entry(name.into())
1930            .or_default()
1931            .enabled = Some(false);
1932        self
1933    }
1934
1935    /// Enable a tool by name (overriding a prior disable).
1936    pub fn enable_tool(mut self, name: impl Into<String>) -> Self {
1937        self.config
1938            .tool_overrides
1939            .entry(name.into())
1940            .or_default()
1941            .enabled = Some(true);
1942        self
1943    }
1944
1945    /// Override the description the model sees for a tool.
1946    pub fn tool_description(
1947        mut self,
1948        name: impl Into<String>,
1949        description: impl Into<String>,
1950    ) -> Self {
1951        self.config
1952            .tool_overrides
1953            .entry(name.into())
1954            .or_default()
1955            .description = Some(description.into());
1956        self
1957    }
1958
1959    /// Set how tools are advertised to the model (B6).
1960    pub fn tool_advertising(mut self, advertising: ToolAdvertising) -> Self {
1961        self.config.tool_advertising = advertising;
1962        self
1963    }
1964
1965    /// Set the global tool-schema tier (TR-8/T5): how verbose ADVERTISED
1966    /// tool schemas are. Per-tool overrides ([`Self::tool_schema_tier`])
1967    /// still win for the specific tools they name.
1968    pub fn schema_tier(mut self, tier: crate::tools::SchemaTier) -> Self {
1969        self.config.tool_schema_tier = tier;
1970        self
1971    }
1972
1973    /// Override the schema tier for a single tool (TR-8/T5), regardless of
1974    /// the global knob — e.g. keep one load-bearing tool at `Full` while
1975    /// everything else shrinks to `Minimal`.
1976    pub fn tool_schema_tier(
1977        mut self,
1978        name: impl Into<String>,
1979        tier: crate::tools::SchemaTier,
1980    ) -> Self {
1981        self.config
1982            .tool_overrides
1983            .entry(name.into())
1984            .or_default()
1985            .schema_tier = Some(tier);
1986        self
1987    }
1988
1989    /// Add an extra HTTP header sent on every request.
1990    pub fn header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
1991        self.config.extra_headers.insert(key.into(), value.into());
1992        self
1993    }
1994
1995    /// Attach a streaming event sink.
1996    pub fn event_sink(mut self, sink: EventSink) -> Self {
1997        self.config.event_sink = Some(sink);
1998        self
1999    }
2000
2001    /// Set the prompt-caching plan (B7).
2002    pub fn cache_plan(mut self, plan: CachePlan) -> Self {
2003        self.config.cache_plan = plan;
2004        self
2005    }
2006
2007    /// UX-26 (B7-warn): enable/disable the cache-cold warning (default on).
2008    pub fn cache_warnings(mut self, enabled: bool) -> Self {
2009        self.config.cache_warnings = enabled;
2010        self
2011    }
2012
2013    /// P3: turn on the `[experimental] module_registry` gate — see
2014    /// [`Config::module_registry`].
2015    pub fn module_registry(mut self, enabled: bool) -> Self {
2016        self.config.module_registry = enabled;
2017        self
2018    }
2019
2020    /// P3: set the resolved module-activation set — see
2021    /// [`Config::module_activation`].
2022    pub fn module_activation(mut self, activation: crate::modules::ModuleActivation) -> Self {
2023        self.config.module_activation = activation;
2024        self
2025    }
2026
2027    /// P3: set the effective `[core.tools] enabled` list — see
2028    /// [`Config::core_tools_enabled`].
2029    pub fn core_tools_enabled(mut self, tools: Vec<String>) -> Self {
2030        self.config.core_tools_enabled = tools;
2031        self
2032    }
2033
2034    /// P3: set `[core.skills].enabled` — see [`Config::skills_enabled`].
2035    pub fn skills_enabled(mut self, enabled: bool) -> Self {
2036        self.config.skills_enabled = enabled;
2037        self
2038    }
2039
2040    /// P4: set the small/utility model id — see `Config::small_model`.
2041    pub fn small_model(mut self, model: impl Into<String>) -> Self {
2042        self.config.small_model = Some(model.into());
2043        self
2044    }
2045
2046    /// P4: set the model failure-fallback chain — see `Config::model_fallback`.
2047    pub fn model_fallback(mut self, chain: Vec<String>) -> Self {
2048        self.config.model_fallback = chain;
2049        self
2050    }
2051
2052    /// P4b: turn on the environment-context block — see [`Config::env_context`].
2053    pub fn env_context(mut self, enabled: bool) -> Self {
2054        self.config.env_context = enabled;
2055        self
2056    }
2057
2058    /// P4b: set the project-root marker filenames — see
2059    /// [`Config::project_root_markers`].
2060    pub fn project_root_markers(mut self, markers: Vec<String>) -> Self {
2061        self.config.project_root_markers = markers;
2062        self
2063    }
2064
2065    /// P4b: cap the total bytes of assembled instruction-file content — see
2066    /// [`Config::project_doc_max_bytes`].
2067    pub fn project_doc_max_bytes(mut self, n: usize) -> Self {
2068        self.config.project_doc_max_bytes = Some(n);
2069        self
2070    }
2071
2072    /// P4b: turn on `@path` instruction imports — see
2073    /// [`Config::instruction_imports`].
2074    pub fn instruction_imports(mut self, enabled: bool) -> Self {
2075        self.config.instruction_imports = enabled;
2076        self
2077    }
2078
2079    /// P4b: configure request retry with backoff — see
2080    /// [`Config::retry_enabled`]. `max_retries`/`base_delay_ms` override the
2081    /// transport's built-in defaults when `Some`.
2082    pub fn retry(
2083        mut self,
2084        enabled: bool,
2085        max_retries: Option<u32>,
2086        base_delay_ms: Option<u64>,
2087    ) -> Self {
2088        self.config.retry_enabled = enabled;
2089        self.config.retry_max_retries = max_retries;
2090        self.config.retry_base_delay_ms = base_delay_ms;
2091        self
2092    }
2093
2094    /// P4b: turn on the compaction token-pressure trigger — see
2095    /// [`Config::compaction_reserve_tokens`].
2096    pub fn compaction_pressure(mut self, reserve_tokens: u64, keep_recent_tokens: u64) -> Self {
2097        self.config.compaction_reserve_tokens = Some(reserve_tokens);
2098        self.config.compaction_keep_recent_tokens = Some(keep_recent_tokens);
2099        self
2100    }
2101
2102    /// P4b: set the compaction focus instructions — see
2103    /// [`Config::compaction_focus_instructions`].
2104    pub fn compaction_focus_instructions(mut self, text: impl Into<String>) -> Self {
2105        self.config.compaction_focus_instructions = Some(text.into());
2106        self
2107    }
2108
2109    /// P4b: turn on the auto-title gate — see [`Config::auto_title`].
2110    pub fn auto_title(mut self, enabled: bool) -> Self {
2111        self.config.auto_title = enabled;
2112        self
2113    }
2114
2115    /// P4b: set the mid-turn steering delivery mode — see
2116    /// [`Config::steering_mode`].
2117    pub fn steering_mode(mut self, mode: SteeringMode) -> Self {
2118        self.config.steering_mode = mode;
2119        self
2120    }
2121
2122    /// P4b: set the idle follow-up delivery mode — see
2123    /// [`Config::follow_up_mode`].
2124    pub fn follow_up_mode(mut self, mode: SteeringMode) -> Self {
2125        self.config.follow_up_mode = mode;
2126        self
2127    }
2128
2129    /// P4b: install a stop-gate hook — see [`Config::stop_gate`].
2130    pub fn stop_gate(mut self, hook: StopGateHook) -> Self {
2131        self.config.stop_gate = Some(hook);
2132        self
2133    }
2134
2135    /// P4c: turn on multimodal `read_file` — see [`Config::read_file_multimodal`].
2136    pub fn read_file_multimodal(mut self, enabled: bool) -> Self {
2137        self.config.read_file_multimodal = enabled;
2138        self
2139    }
2140
2141    /// P4c: require a prior read before `edit_file` accepts an edit — see
2142    /// [`Config::edit_file_require_read_before_edit`].
2143    pub fn edit_file_require_read_before_edit(mut self, enabled: bool) -> Self {
2144        self.config.edit_file_require_read_before_edit = enabled;
2145        self
2146    }
2147
2148    /// P4c: turn on notebook-cell-aware `edit_file` — see
2149    /// [`Config::edit_file_notebook_aware`].
2150    pub fn edit_file_notebook_aware(mut self, enabled: bool) -> Self {
2151        self.config.edit_file_notebook_aware = enabled;
2152        self
2153    }
2154
2155    /// P4c: turn on shell-environment snapshotting — see
2156    /// [`Config::shell_env_snapshot`].
2157    pub fn shell_env_snapshot(mut self, enabled: bool) -> Self {
2158        self.config.shell_env_snapshot = enabled;
2159        self
2160    }
2161
2162    /// P4c: set the doom-loop repetition threshold — see
2163    /// [`Config::doom_loop_threshold`].
2164    pub fn doom_loop_threshold(mut self, n: u32) -> Self {
2165        self.config.doom_loop_threshold = Some(n);
2166        self
2167    }
2168
2169    /// P4c: turn on on-demand nested instruction loading — see
2170    /// [`Config::nested_instructions`].
2171    pub fn nested_instructions(mut self, enabled: bool) -> Self {
2172        self.config.nested_instructions = enabled;
2173        self
2174    }
2175
2176    /// P4c: turn on mid-session model switch's persisted-record +
2177    /// reasoning-filter behavior — see [`Config::model_switch_allow_switch`].
2178    pub fn model_switch_allow_switch(mut self, enabled: bool) -> Self {
2179        self.config.model_switch_allow_switch = enabled;
2180        self
2181    }
2182
2183    /// P4e: turn on ambient context-injection blocks — see
2184    /// [`Config::context_injections`].
2185    pub fn context_injections(mut self, enabled: bool) -> Self {
2186        self.config.context_injections = enabled;
2187        self
2188    }
2189
2190    /// P4e: append one named ambient context block — see
2191    /// [`Config::context_injection_blocks`].
2192    pub fn context_injection_block(
2193        mut self,
2194        name: impl Into<String>,
2195        content: impl Into<String>,
2196    ) -> Self {
2197        self.config
2198            .context_injection_blocks
2199            .push(ContextInjectionBlock::new(name, content));
2200        self
2201    }
2202
2203    /// P4e: master gate for all auto-compaction — see
2204    /// [`Config::compaction_enabled`].
2205    pub fn compaction_enabled(mut self, enabled: bool) -> Self {
2206        self.config.compaction_enabled = enabled;
2207        self
2208    }
2209
2210    /// P4e: run independent tool calls concurrently — see
2211    /// [`Config::parallel_tool_calls`].
2212    pub fn parallel_tool_calls(mut self, enabled: bool) -> Self {
2213        self.config.parallel_tool_calls = enabled;
2214        self
2215    }
2216
2217    /// P4e: capture git branch/sha/dirty at construction — see
2218    /// [`Config::session_git_metadata`].
2219    pub fn session_git_metadata(mut self, enabled: bool) -> Self {
2220        self.config.session_git_metadata = enabled;
2221        self
2222    }
2223
2224    /// P4e DEFECT-FIX: ephemeral vs. persisted session gate — see
2225    /// [`Config::session_persist`].
2226    pub fn session_persist(mut self, enabled: bool) -> Self {
2227        self.config.session_persist = enabled;
2228        self
2229    }
2230
2231    /// P4e DEFECT-FIX: a caller-configured session name — see
2232    /// [`Config::session_name`].
2233    pub fn session_name(mut self, name: impl Into<String>) -> Self {
2234        self.config.session_name = Some(name.into());
2235        self
2236    }
2237
2238    /// P5-3 (§3.1 `capabilities.subagents.enabled`) — see
2239    /// [`Config::subagents_enabled`].
2240    pub fn subagents_enabled(mut self, enabled: bool) -> Self {
2241        self.config.subagents_enabled = enabled;
2242        self
2243    }
2244
2245    /// P5-3 (§3.1 `capabilities.subagents.max_depth`) — see
2246    /// [`Config::subagents_max_depth`].
2247    pub fn subagents_max_depth(mut self, n: usize) -> Self {
2248        self.config.subagents_max_depth = n;
2249        self
2250    }
2251
2252    /// P5-3 (resource bound) — see [`Config::subagents_max_concurrent`].
2253    pub fn subagents_max_concurrent(mut self, n: usize) -> Self {
2254        self.config.subagents_max_concurrent = n;
2255        self
2256    }
2257
2258    /// P5-3 (§3.1 `capabilities.subagents.background`) — see
2259    /// [`Config::subagents_background`].
2260    pub fn subagents_background(mut self, enabled: bool) -> Self {
2261        self.config.subagents_background = enabled;
2262        self
2263    }
2264
2265    /// P5-3 (§2.2 C6) — see [`Config::subagents_background_prompts`].
2266    pub fn subagents_background_prompts(
2267        mut self,
2268        policy: crate::subagents::BackgroundPromptsPolicy,
2269    ) -> Self {
2270        self.config.subagents_background_prompts = Some(policy);
2271        self
2272    }
2273
2274    /// Enable Claude Code's `Agent` compatibility alias for named subagents.
2275    pub fn subagents_claude_agent_alias(mut self, enabled: bool) -> Self {
2276        self.config.subagents_claude_agent_alias = enabled;
2277        self
2278    }
2279
2280    /// Enable Claude's paused runtime-state compatibility intrinsics.
2281    pub fn claude_runtime_tools_enabled(mut self, enabled: bool) -> Self {
2282        self.config.claude_runtime_tools_enabled = enabled;
2283        self
2284    }
2285
2286    /// P5-3 (§3.1 `capabilities.subagents.agents.<name>`) — register one
2287    /// named agent definition, keyed by [`crate::subagents::NamedAgentDefinition::name`].
2288    pub fn subagent_definition(mut self, def: crate::subagents::NamedAgentDefinition) -> Self {
2289        self.config
2290            .subagents_definitions
2291            .insert(def.name.clone(), def);
2292        self
2293    }
2294
2295    /// P5-3 (runtime-only) — see [`Config::subagent_depth`]. Not something
2296    /// an ordinary caller sets by hand; `Agent::run_spawn_subagent` sets it
2297    /// on the CHILD config it builds.
2298    pub fn subagent_depth(mut self, depth: usize) -> Self {
2299        self.config.subagent_depth = depth;
2300        self
2301    }
2302
2303    /// P5-6 (§3.1 `capabilities.tools_background.enabled`) — see
2304    /// [`Config::tools_background_enabled`].
2305    pub fn tools_background_enabled(mut self, enabled: bool) -> Self {
2306        self.config.tools_background_enabled = enabled;
2307        self
2308    }
2309
2310    /// P5-6 (resource bound) — see [`Config::tools_background_max_concurrent`].
2311    pub fn tools_background_max_concurrent(mut self, n: usize) -> Self {
2312        self.config.tools_background_max_concurrent = n;
2313        self
2314    }
2315
2316    /// P5-6 (resource bound) — see [`Config::tools_background_max_output_bytes`].
2317    pub fn tools_background_max_output_bytes(mut self, n: usize) -> Self {
2318        self.config.tools_background_max_output_bytes = n;
2319        self
2320    }
2321
2322    /// P5-9 (§3.1 `capabilities.checkpoint.enabled`) — see
2323    /// [`Config::checkpoint_enabled`].
2324    pub fn checkpoint_enabled(mut self, enabled: bool) -> Self {
2325        self.config.checkpoint_enabled = enabled;
2326        self
2327    }
2328
2329    /// P5-9 (bounded-disk requirement) — see [`Config::checkpoint_retain`].
2330    pub fn checkpoint_retain(mut self, n: usize) -> Self {
2331        self.config.checkpoint_retain = n;
2332        self
2333    }
2334
2335    /// P5-9 (embedder/test override) — see [`Config::checkpoint_dir`].
2336    pub fn checkpoint_dir(mut self, dir: impl Into<PathBuf>) -> Self {
2337        self.config.checkpoint_dir = Some(dir.into());
2338        self
2339    }
2340
2341    /// P5-11 (§3.1 `capabilities.lsp.enabled`) — see [`Config::lsp_enabled`].
2342    pub fn lsp_enabled(mut self, enabled: bool) -> Self {
2343        self.config.lsp_enabled = enabled;
2344        self
2345    }
2346
2347    /// P5-11 (`capabilities.lsp.servers`) — see [`Config::lsp_servers`].
2348    pub fn lsp_servers(mut self, servers: Vec<(String, crate::lsp::LspServerSpec)>) -> Self {
2349        self.config.lsp_servers = servers;
2350        self
2351    }
2352
2353    /// P5-11 (bounded-context requirement) — see [`Config::lsp_max_diagnostics`].
2354    pub fn lsp_max_diagnostics(mut self, n: usize) -> Self {
2355        self.config.lsp_max_diagnostics = n;
2356        self
2357    }
2358
2359    /// P5-11 (bounded-latency requirement) — see [`Config::lsp_timeout_secs`].
2360    pub fn lsp_timeout_secs(mut self, secs: u64) -> Self {
2361        self.config.lsp_timeout_secs = secs;
2362        self
2363    }
2364
2365    /// P5-11 (§3.1 `capabilities.formatters.enabled`) — see
2366    /// [`Config::formatters_enabled`].
2367    pub fn formatters_enabled(mut self, enabled: bool) -> Self {
2368        self.config.formatters_enabled = enabled;
2369        self
2370    }
2371
2372    /// P5-11 (`capabilities.formatters.<name>`) — see [`Config::formatters`].
2373    pub fn formatters(
2374        mut self,
2375        formatters: Vec<(String, crate::formatters::FormatterSpec)>,
2376    ) -> Self {
2377        self.config.formatters = formatters;
2378        self
2379    }
2380
2381    /// P5-11 (§3.1 `capabilities.formatters.diff_back`, C10) — see
2382    /// [`Config::formatters_diff_back`].
2383    pub fn formatters_diff_back(mut self, diff_back: bool) -> Self {
2384        self.config.formatters_diff_back = diff_back;
2385        self
2386    }
2387
2388    /// P5-11 (bounded-latency requirement) — see [`Config::formatters_timeout_secs`].
2389    pub fn formatters_timeout_secs(mut self, secs: u64) -> Self {
2390        self.config.formatters_timeout_secs = secs;
2391        self
2392    }
2393
2394    /// P5-12 (§3.1 `capabilities.trust.enabled`) — see [`Config::trust_enabled`].
2395    pub fn trust_enabled(mut self, enabled: bool) -> Self {
2396        self.config.trust_enabled = enabled;
2397        self
2398    }
2399
2400    /// P5-12 (`capabilities.trust.default`) — see [`Config::trust_default`].
2401    pub fn trust_default(mut self, default: crate::plugins::TrustDecision) -> Self {
2402        self.config.trust_default = default;
2403        self
2404    }
2405
2406    /// P5-12 (§3.1 `capabilities.plugins.enabled`) — see [`Config::plugins_enabled`].
2407    pub fn plugins_enabled(mut self, enabled: bool) -> Self {
2408        self.config.plugins_enabled = enabled;
2409        self
2410    }
2411
2412    /// P5-12 (`capabilities.plugins.dirs`) — see [`Config::plugins_dirs`].
2413    pub fn plugins_dirs(mut self, dirs: Vec<PathBuf>) -> Self {
2414        self.config.plugins_dirs = dirs;
2415        self
2416    }
2417
2418    /// Finalize the configuration.
2419    pub fn build(self) -> Config {
2420        self.config
2421    }
2422}
2423
2424#[cfg(test)]
2425mod tests {
2426    use super::*;
2427
2428    /// Every NEW `ConfigProfile` field the P1 migration adds (design
2429    /// §5.2/§3.2's explicit unblock list) actually reaches the built
2430    /// `Config` through `apply_profile`.
2431    #[test]
2432    fn apply_profile_applies_every_new_p1_field() {
2433        let mut tool_overrides = HashMap::new();
2434        tool_overrides.insert(
2435            "write_file".to_string(),
2436            ToolOverrideProfile {
2437                enabled: Some(false),
2438                description: Some("custom".to_string()),
2439                schema_tier: Some("minimal".to_string()),
2440                timeout_secs: None,
2441            },
2442        );
2443        let mut extra_headers = HashMap::new();
2444        extra_headers.insert("X-Title".to_string(), "supercode".to_string());
2445        let mut extra_body = serde_json::Map::new();
2446        extra_body.insert("provider_flag".to_string(), serde_json::json!(true));
2447        let mut prompts = HashMap::new();
2448        prompts.insert("standup".to_string(), "Summarize {args}".to_string());
2449
2450        let profile = ConfigProfile {
2451            api_key_env: Some("MY_KEY".to_string()),
2452            max_iterations: Some(40),
2453            additional_dirs: Some(vec!["../sibling".to_string()]),
2454            compact_after_messages: Some(50),
2455            cache_plan: Some("imported_prefix".to_string()),
2456            tool_advertising: Some("deferred".to_string()),
2457            tool_advertising_core: Some(vec!["bash".to_string()]),
2458            schema_tier: Some("medium".to_string()),
2459            auto_approved_tools: Some(vec!["read_file".to_string()]),
2460            extra_headers: Some(extra_headers),
2461            extra_body: Some(extra_body),
2462            max_tool_output_bytes: Some(4096),
2463            max_total_output_tokens: Some(8192),
2464            prompts: Some(prompts),
2465            tool_overrides: Some(tool_overrides),
2466            ..Default::default()
2467        };
2468
2469        let config = ConfigBuilder::default().apply_profile(&profile).build();
2470
2471        assert_eq!(config.api_key_env, "MY_KEY");
2472        assert_eq!(config.max_iterations, 40);
2473        assert_eq!(
2474            config.additional_dirs,
2475            vec![std::path::PathBuf::from("../sibling")]
2476        );
2477        assert_eq!(config.compact_after_messages, Some(50));
2478        assert_eq!(config.cache_plan, CachePlan::ImportedPrefix);
2479        match &config.tool_advertising {
2480            ToolAdvertising::Deferred { core } => assert_eq!(core, &vec!["bash".to_string()]),
2481            ToolAdvertising::Full => panic!("expected Deferred"),
2482        }
2483        assert_eq!(config.tool_schema_tier, crate::tools::SchemaTier::Medium);
2484        assert!(config.auto_approved_tools.contains("read_file"));
2485        assert_eq!(
2486            config.extra_headers.get("X-Title").map(String::as_str),
2487            Some("supercode")
2488        );
2489        assert_eq!(
2490            config.extra_body.get("provider_flag"),
2491            Some(&serde_json::json!(true))
2492        );
2493        assert_eq!(config.max_tool_output_bytes, Some(4096));
2494        assert_eq!(config.max_total_output_tokens, Some(8192));
2495        assert_eq!(
2496            config.prompts.get("standup").map(String::as_str),
2497            Some("Summarize {args}")
2498        );
2499        assert!(!config.tool_enabled("write_file"));
2500        assert_eq!(config.tool_description("write_file", "builtin"), "custom");
2501        assert_eq!(
2502            config.schema_tier_for("write_file"),
2503            crate::tools::SchemaTier::Minimal
2504        );
2505        // Built-in prompts survive — `prompts` is a table merge, not a
2506        // wholesale replace (§3.3).
2507        assert!(config.prompts.contains_key("code-review"));
2508    }
2509
2510    /// P4 (§3.1 `core.append_system_prompt`, D2 row 1): additive, composed
2511    /// onto the DEFAULT system prompt when no `system_prompt` override is
2512    /// set — distinct from replacing it.
2513    #[test]
2514    fn apply_profile_append_system_prompt_composes_onto_the_default() {
2515        let profile = ConfigProfile {
2516            append_system_prompt: Some("Always run tests before committing.".to_string()),
2517            ..Default::default()
2518        };
2519        let config = ConfigBuilder::default().apply_profile(&profile).build();
2520        assert_eq!(
2521            config.system_prompt,
2522            format!("{DEFAULT_SYSTEM_PROMPT}\n\nAlways run tests before committing.")
2523        );
2524    }
2525
2526    /// Composed onto an EXPLICIT `system_prompt` override in the SAME
2527    /// profile, not the default — replacement then append, in that order.
2528    #[test]
2529    fn apply_profile_append_system_prompt_composes_onto_an_explicit_override() {
2530        let profile = ConfigProfile {
2531            system_prompt: Some("You are terse.".to_string()),
2532            append_system_prompt: Some("Always run tests before committing.".to_string()),
2533            ..Default::default()
2534        };
2535        let config = ConfigBuilder::default().apply_profile(&profile).build();
2536        assert_eq!(
2537            config.system_prompt,
2538            "You are terse.\n\nAlways run tests before committing."
2539        );
2540    }
2541
2542    /// Default-off: no `append_system_prompt` set leaves `system_prompt`
2543    /// completely untouched (byte-identical to today's behavior).
2544    #[test]
2545    fn apply_profile_no_append_system_prompt_leaves_system_prompt_untouched() {
2546        let profile = ConfigProfile {
2547            system_prompt: Some("You are terse.".to_string()),
2548            ..Default::default()
2549        };
2550        let config = ConfigBuilder::default().apply_profile(&profile).build();
2551        assert_eq!(config.system_prompt, "You are terse.");
2552    }
2553
2554    /// Unknown enum strings fail SAFE (existing precedent, config.rs
2555    /// `sandbox`/`approval` parsing) — extended to the two NEW enum-shaped
2556    /// fields this migration adds.
2557    #[test]
2558    fn apply_profile_fails_safe_on_unknown_new_enums() {
2559        let profile = ConfigProfile {
2560            cache_plan: Some("bogus".to_string()),
2561            schema_tier: Some("bogus".to_string()),
2562            tool_advertising: Some("bogus".to_string()),
2563            ..Default::default()
2564        };
2565        let config = ConfigBuilder::default().apply_profile(&profile).build();
2566        assert_eq!(config.cache_plan, CachePlan::Off);
2567        assert_eq!(config.tool_schema_tier, crate::tools::SchemaTier::Full);
2568        // F5 fix: this was a bare `matches!(...)` with no `assert!` around
2569        // it, so the expression's bool result was silently discarded — the
2570        // fail-safe behavior it names was never actually checked.
2571        assert!(matches!(config.tool_advertising, ToolAdvertising::Full));
2572    }
2573
2574    /// F6: setting only `tool_advertising_core` (mode absent) must not
2575    /// silently reset the mode to `Full`, discarding the array — and
2576    /// setting `tool_advertising = "deferred"` with no `_core` must not wipe
2577    /// an already-set core list. Each key replaces independently (§3.3).
2578    #[test]
2579    fn apply_profile_tool_advertising_mode_and_core_replace_independently() {
2580        // Only `_core` set on top of an already-`Deferred` config: the mode
2581        // must stay `Deferred`, with the NEW core list — not reset to
2582        // `Full` (the pre-fix bug).
2583        let builder = ConfigBuilder::default().tool_advertising(ToolAdvertising::Deferred {
2584            core: vec!["bash".to_string()],
2585        });
2586        let profile = ConfigProfile {
2587            tool_advertising_core: Some(vec!["read_file".to_string(), "bash".to_string()]),
2588            ..Default::default()
2589        };
2590        let config = builder.apply_profile(&profile).build();
2591        match &config.tool_advertising {
2592            ToolAdvertising::Deferred { core } => {
2593                assert_eq!(core, &vec!["read_file".to_string(), "bash".to_string()])
2594            }
2595            ToolAdvertising::Full => panic!("mode must not reset to Full when only _core is set"),
2596        }
2597
2598        // Mode = "deferred" set with no `_core`: must keep the existing
2599        // core list, not wipe it to empty.
2600        let builder2 = ConfigBuilder::default().tool_advertising(ToolAdvertising::Deferred {
2601            core: vec!["bash".to_string()],
2602        });
2603        let profile2 = ConfigProfile {
2604            tool_advertising: Some("deferred".to_string()),
2605            ..Default::default()
2606        };
2607        let config2 = builder2.apply_profile(&profile2).build();
2608        match &config2.tool_advertising {
2609            ToolAdvertising::Deferred { core } => assert_eq!(core, &vec!["bash".to_string()]),
2610            ToolAdvertising::Full => panic!("expected Deferred to survive"),
2611        }
2612    }
2613
2614    /// Tables merge key-wise (§3.3): applying a profile with one
2615    /// `tool_overrides` entry must not blow away a different tool's
2616    /// override already on the builder.
2617    #[test]
2618    fn apply_profile_merges_tool_overrides_key_wise() {
2619        let builder = ConfigBuilder::default().disable_tool("bash");
2620        let mut overrides = HashMap::new();
2621        overrides.insert(
2622            "read_file".to_string(),
2623            ToolOverrideProfile {
2624                enabled: Some(false),
2625                description: None,
2626                schema_tier: None,
2627                timeout_secs: None,
2628            },
2629        );
2630        let profile = ConfigProfile {
2631            tool_overrides: Some(overrides),
2632            ..Default::default()
2633        };
2634        let config = builder.apply_profile(&profile).build();
2635        assert!(!config.tool_enabled("bash"));
2636        assert!(!config.tool_enabled("read_file"));
2637    }
2638
2639    // -----------------------------------------------------------------
2640    // P4: deny-rule PATTERNS generalizing auto_approved_tools (§5.2 "P4").
2641    // -----------------------------------------------------------------
2642
2643    #[test]
2644    fn glob_match_exact_and_wildcard_forms() {
2645        assert!(glob_match("bash", "bash"));
2646        assert!(!glob_match("bash", "bash2"));
2647        assert!(glob_match("bash*", "bash"));
2648        assert!(glob_match("bash*", "bash_tool"));
2649        assert!(!glob_match("bash*", "not_bash"));
2650        assert!(glob_match("*_write", "edit_write"));
2651        assert!(!glob_match("*_write", "write_edit"));
2652        assert!(glob_match("mcp__*__search", "mcp__github__search"));
2653        assert!(glob_match("*", "anything at all"));
2654        assert!(glob_match("*", ""));
2655        assert!(glob_match("", ""));
2656        assert!(!glob_match("", "x"));
2657        // Multiple `*`s in one pattern (the iterative two-pointer rewrite's
2658        // main new surface area vs. the old single-recursion-site matcher).
2659        assert!(glob_match("*a*a*a*", "aaaa"));
2660        assert!(glob_match("*a*b*c*", "xaxbxcx"));
2661        assert!(!glob_match("*a*b*c*", "xbxax"));
2662        assert!(glob_match("a*b*c", "aXbXc"));
2663        assert!(glob_match("a*b*c", "abc"));
2664        assert!(!glob_match("a*b*c", "acb"));
2665    }
2666
2667    /// LOW-2 (Fable-5 P4a review): `tool_deny_patterns`/`tool_allow_patterns`
2668    /// can be project-controlled (a project may only ADD to `rules.deny`,
2669    /// never replace it — see `configfile::merge_permissions_capability` —
2670    /// but an ADDED pattern is still attacker-chosen content), so a crafted
2671    /// pattern must not be able to make `glob_match` itself a self-DoS on
2672    /// every tool call. The old naive recursive matcher
2673    /// (`Some(b'*') => inner(&p[1..], t) || (!t.is_empty() &&
2674    /// inner(p, &t[1..]))`) backtracks exponentially on a pattern with many
2675    /// `*`s against a text with no matching suffix; this proves the
2676    /// iterative rewrite returns promptly on exactly that shape.
2677    #[test]
2678    fn glob_match_pathological_pattern_returns_promptly() {
2679        let pattern = "*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*a*b";
2680        let text = "a".repeat(40);
2681        let start = std::time::Instant::now();
2682        let result = glob_match(pattern, &text);
2683        let elapsed = start.elapsed();
2684        assert!(!result, "text has no trailing 'b', so this must not match");
2685        assert!(
2686            elapsed < std::time::Duration::from_millis(200),
2687            "glob_match took {elapsed:?} on a pathological pattern — exponential backtracking regressed"
2688        );
2689    }
2690
2691    /// Default-off: empty deny/allow patterns leave `needs_approval`
2692    /// byte-identical to pre-P4 behavior (the existing `auto_approved_tools`
2693    /// contract, unaffected).
2694    #[test]
2695    fn needs_approval_default_unaffected_by_empty_patterns() {
2696        let config = Config::builder()
2697            .approval(ApprovalPolicy::OnRequest)
2698            .auto_approve_tool("read_file")
2699            .build();
2700        assert!(config.tool_deny_patterns.is_empty());
2701        assert!(config.tool_allow_patterns.is_empty());
2702        assert!(!config.needs_approval("read_file"));
2703        assert!(config.needs_approval("bash"));
2704    }
2705
2706    /// Happy path: a deny pattern forces approval even under
2707    /// `ApprovalPolicy::Never` — the entire point of a deny rule is a hard
2708    /// floor `--yes`/`Never` can't bypass.
2709    #[test]
2710    fn needs_approval_deny_pattern_forces_approval_even_under_never() {
2711        let config = Config::builder()
2712            .approval(ApprovalPolicy::Never)
2713            .deny_tool_pattern("bash*")
2714            .build();
2715        assert!(config.needs_approval("bash"));
2716        assert!(config.needs_approval("bash_tool"));
2717        // A non-matching tool is unaffected — still `Never`.
2718        assert!(!config.needs_approval("read_file"));
2719    }
2720
2721    /// Happy path: an allow pattern exempts a matching tool from approval
2722    /// under `OnRequest`, exactly like an exact `auto_approved_tools` entry.
2723    #[test]
2724    fn needs_approval_allow_pattern_exempts_under_on_request() {
2725        let config = Config::builder()
2726            .approval(ApprovalPolicy::OnRequest)
2727            .allow_tool_pattern("read_*")
2728            .build();
2729        assert!(!config.needs_approval("read_file"));
2730        assert!(!config.needs_approval("read_dir"));
2731        assert!(config.needs_approval("bash"));
2732    }
2733
2734    /// Deny wins over allow when a tool matches both — deny is checked
2735    /// first and returns unconditionally.
2736    #[test]
2737    fn needs_approval_deny_wins_over_allow_on_the_same_tool() {
2738        let config = Config::builder()
2739            .approval(ApprovalPolicy::OnRequest)
2740            .allow_tool_pattern("bash*")
2741            .deny_tool_pattern("bash*")
2742            .build();
2743        assert!(config.needs_approval("bash"));
2744    }
2745
2746    /// An allow pattern never exempts anything under `Untrusted` — same
2747    /// scoping `auto_approved_tools` already has (only consulted under
2748    /// `OnRequest`).
2749    #[test]
2750    fn needs_approval_allow_pattern_never_exempts_under_untrusted() {
2751        let config = Config::builder()
2752            .approval(ApprovalPolicy::Untrusted)
2753            .allow_tool_pattern("*")
2754            .build();
2755        assert!(config.needs_approval("read_file"));
2756    }
2757
2758    // ---- P4b: default-off / unchanged-unless-set for every new field -----
2759
2760    #[test]
2761    fn p4b_defaults_are_byte_identical_to_pre_p4b_behavior() {
2762        let config = Config::default();
2763        assert!(!config.env_context);
2764        assert_eq!(config.project_root_markers, vec![".git".to_string()]);
2765        assert_eq!(config.project_doc_max_bytes, None);
2766        assert!(!config.instruction_imports);
2767        // retry_enabled defaults TRUE (matches the pre-existing always-on
2768        // transport retry — see `provider::HttpOptions::from_retry_config`),
2769        // but the override knobs default unset, so the transport sees its
2770        // own untouched built-in defaults.
2771        assert!(config.retry_enabled);
2772        assert_eq!(config.retry_max_retries, None);
2773        assert_eq!(config.retry_base_delay_ms, None);
2774        assert_eq!(config.compaction_reserve_tokens, None);
2775        assert_eq!(config.compaction_keep_recent_tokens, None);
2776        assert_eq!(config.compaction_focus_instructions, None);
2777        assert!(!config.auto_title);
2778        assert_eq!(config.steering_mode, SteeringMode::OneAtATime);
2779        assert_eq!(config.follow_up_mode, SteeringMode::OneAtATime);
2780        assert!(config.stop_gate.is_none());
2781    }
2782
2783    #[test]
2784    fn apply_profile_applies_every_new_p4b_field() {
2785        let profile = ConfigProfile {
2786            env_context: Some(true),
2787            project_root_markers: Some(vec![".hg".to_string()]),
2788            project_doc_max_bytes: Some(16_384),
2789            instruction_imports: Some(true),
2790            retry_enabled: Some(false),
2791            retry_max_retries: Some(9),
2792            retry_base_delay_ms: Some(750),
2793            compaction_reserve_tokens: Some(8_000),
2794            compaction_keep_recent_tokens: Some(12_000),
2795            compaction_focus_instructions: Some("keep fixing the auth bug".to_string()),
2796            auto_title: Some(true),
2797            steering_mode: Some("all".to_string()),
2798            follow_up_mode: Some("one-at-a-time".to_string()),
2799            ..Default::default()
2800        };
2801        let config = ConfigBuilder::default().apply_profile(&profile).build();
2802        assert!(config.env_context);
2803        assert_eq!(config.project_root_markers, vec![".hg".to_string()]);
2804        assert_eq!(config.project_doc_max_bytes, Some(16_384));
2805        assert!(config.instruction_imports);
2806        assert!(!config.retry_enabled);
2807        assert_eq!(config.retry_max_retries, Some(9));
2808        assert_eq!(config.retry_base_delay_ms, Some(750));
2809        assert_eq!(config.compaction_reserve_tokens, Some(8_000));
2810        assert_eq!(config.compaction_keep_recent_tokens, Some(12_000));
2811        assert_eq!(
2812            config.compaction_focus_instructions.as_deref(),
2813            Some("keep fixing the auth bug")
2814        );
2815        assert!(config.auto_title);
2816        assert_eq!(config.steering_mode, SteeringMode::All);
2817        assert_eq!(config.follow_up_mode, SteeringMode::OneAtATime);
2818    }
2819
2820    #[test]
2821    fn apply_profile_unrecognized_steering_mode_is_ignored_not_defaulted_wrongly() {
2822        let profile = ConfigProfile {
2823            steering_mode: Some("bogus".to_string()),
2824            ..Default::default()
2825        };
2826        let config = ConfigBuilder::default().apply_profile(&profile).build();
2827        // Fail safe: an unrecognized value leaves the built-in default in
2828        // place rather than panicking or guessing.
2829        assert_eq!(config.steering_mode, SteeringMode::OneAtATime);
2830    }
2831
2832    // ---- P4c: default-off / unchanged-unless-set for every new field -----
2833
2834    #[test]
2835    fn p4c_defaults_are_byte_identical_to_pre_p4c_behavior() {
2836        let config = Config::default();
2837        assert!(!config.read_file_multimodal);
2838        assert!(!config.edit_file_require_read_before_edit);
2839        assert!(!config.edit_file_notebook_aware);
2840        assert!(!config.shell_env_snapshot);
2841        assert_eq!(config.doom_loop_threshold, None);
2842        assert!(!config.nested_instructions);
2843        assert!(!config.model_switch_allow_switch);
2844    }
2845
2846    #[test]
2847    fn apply_profile_applies_every_new_p4c_field() {
2848        let profile = ConfigProfile {
2849            read_file_multimodal: Some(true),
2850            edit_file_require_read_before_edit: Some(true),
2851            edit_file_notebook_aware: Some(true),
2852            shell_env_snapshot: Some(true),
2853            doom_loop_threshold: Some(3),
2854            nested_instructions: Some(true),
2855            model_switch_allow_switch: Some(true),
2856            ..Default::default()
2857        };
2858        let config = ConfigBuilder::default().apply_profile(&profile).build();
2859        assert!(config.read_file_multimodal);
2860        assert!(config.edit_file_require_read_before_edit);
2861        assert!(config.edit_file_notebook_aware);
2862        assert!(config.shell_env_snapshot);
2863        assert_eq!(config.doom_loop_threshold, Some(3));
2864        assert!(config.nested_instructions);
2865        assert!(config.model_switch_allow_switch);
2866    }
2867
2868    #[test]
2869    fn builder_methods_set_every_new_p4c_field() {
2870        let config = Config::builder()
2871            .read_file_multimodal(true)
2872            .edit_file_require_read_before_edit(true)
2873            .edit_file_notebook_aware(true)
2874            .shell_env_snapshot(true)
2875            .doom_loop_threshold(5)
2876            .nested_instructions(true)
2877            .model_switch_allow_switch(true)
2878            .build();
2879        assert!(config.read_file_multimodal);
2880        assert!(config.edit_file_require_read_before_edit);
2881        assert!(config.edit_file_notebook_aware);
2882        assert!(config.shell_env_snapshot);
2883        assert_eq!(config.doom_loop_threshold, Some(5));
2884        assert!(config.nested_instructions);
2885        assert!(config.model_switch_allow_switch);
2886    }
2887}