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}