Skip to main content

sqlite_graphrag/cli/
globals.rs

1//! Global flag surface: the root `Cli` parser and its validation.
2//!
3//! Every flag that applies before a subcommand is chosen lives here, together
4//! with the cross-flag validation `main` runs before dispatching.
5
6use super::commands::Commands;
7use crate::backend_choice::{EmbeddingBackendChoice, LlmBackendChoice};
8use crate::i18n::{current, Language};
9use clap::Parser;
10
11/// Returns the maximum simultaneous invocations allowed by the CPU heuristic.
12fn max_concurrency_ceiling() -> usize {
13    std::thread::available_parallelism()
14        .map(|n| n.get() * 2)
15        .unwrap_or(8)
16}
17
18#[derive(Parser)]
19#[command(name = "sqlite-graphrag")]
20#[command(version)]
21#[command(about = "Local GraphRAG memory for LLMs in a single SQLite file")]
22#[command(arg_required_else_help = true)]
23#[command(after_help = "DATABASE PATH (GAP-SG-32):\n  \
24    `--db` is a PER-SUBCOMMAND flag, so it must come AFTER the subcommand:\n    \
25    sqlite-graphrag remember --db ./graphrag.sqlite --name mem --type note ...\n  \
26    Placing it before the subcommand (e.g. `sqlite-graphrag --db x.sqlite remember`) is rejected.\n  \
27    Prefer `--db` on every invocation (one-shot agents). Optional XDG defaults:\n    \
28    `sqlite-graphrag config set db.path ./graphrag.sqlite`\n  \
29    Product environment variables are not read at runtime; use flags + `config set/get`.")]
30/// CLI.
31pub struct Cli {
32    /// Maximum number of simultaneous CLI invocations allowed (default: 4).
33    ///
34    /// Caps the counting semaphore used for CLI concurrency slots. The value must
35    /// stay within [1, 2×nCPUs]. Values above the ceiling are rejected with exit 2.
36    #[arg(long, global = true, value_name = "N")]
37    pub max_concurrency: Option<usize>,
38
39    /// Wait up to SECONDS for a free concurrency slot before giving up (exit 75).
40    ///
41    /// Useful in retrying agent pipelines: the process polls every 500 ms until a
42    /// slot opens or the timeout expires. Default: 300s (5 minutes).
43    #[arg(long, global = true, value_name = "SECONDS")]
44    pub wait_lock: Option<u64>,
45
46    /// Skip the available-memory check before loading the model.
47    ///
48    /// Exclusive use in automated tests where real allocation does not occur.
49    #[arg(long, global = true, hide = true, default_value_t = false)]
50    pub skip_memory_guard: bool,
51
52    // `--strict-env-clear` lived here until v1.2.2. It controlled which
53    // environment variables an LLM subprocess inherited, and this release
54    // removed every LLM subprocess. The flag still parsed, still flowed into
55    // `RuntimeOverrides`, and was read by nothing — the inverse of the dead
56    // configuration channel this release also closed: there the text promised a
57    // channel the code ignored, here a flag promised an effect it could no
58    // longer have. Both are removed rather than documented.
59    /// Fail instead of degrading when the query embedding cannot be produced.
60    ///
61    /// `recall` and `hybrid-search` fall back to FTS5-only ranking when the
62    /// provider is unreachable, raise `vec_degraded` on the envelope, and exit
63    /// `0`. That is the right default for a human reading results, and the wrong
64    /// one for an agent that parses `.results` and never looks at the flags: it
65    /// silently receives a keyword search where it asked for a hybrid one.
66    ///
67    /// Under this flag a degraded read exits non-zero with the usual error
68    /// envelope, so the retry verdict travels with it. A degradation the caller
69    /// ASKED for with `--fallback-fts-only` is deliberate and never fails.
70    #[arg(long, global = true, default_value_t = false)]
71    pub fail_on_degraded: bool,
72
73    /// Language for human-facing stderr messages. Accepts `en` or `pt`.
74    ///
75    /// Without the flag, detection uses XDG `i18n.lang` then OS locale
76    /// (`LC_ALL`/`LC_MESSAGES`/`LANG`). JSON stdout stays deterministic and
77    /// identical across languages; only human-facing strings are affected.
78    #[arg(long, global = true, value_enum, value_name = "LANG")]
79    pub lang: Option<crate::i18n::Language>,
80
81    /// Time zone for `*_iso` fields in JSON output (for example `America/Sao_Paulo`).
82    ///
83    /// Accepts any IANA time zone name. Without the flag, it falls back to
84    /// XDG `display.tz`; if unset, UTC is used. Integer epoch fields
85    /// are not affected.
86    #[arg(long, global = true, value_name = "IANA")]
87    pub tz: Option<chrono_tz::Tz>,
88
89    /// Directory holding `config.toml`. Overrides the OS config directory.
90    ///
91    /// Precedence (G-T-XDG-04): this flag > OS default. It deliberately does
92    /// NOT consult a `config set` key, because the config file itself lives in
93    /// this directory and reading it to find itself would be circular.
94    /// Hidden: it exists for hermetic test isolation and sandboxed hosts.
95    #[arg(long, global = true, hide = true, value_name = "DIR")]
96    pub config_dir: Option<std::path::PathBuf>,
97
98    /// Directory for lock files, model files and other cache artifacts.
99    ///
100    /// Precedence (G-T-XDG-04): this flag > XDG `cache.dir` > OS default.
101    /// Hidden for the same reason as `--config-dir`.
102    #[arg(long, global = true, hide = true, value_name = "DIR")]
103    pub cache_dir: Option<std::path::PathBuf>,
104
105    /// Increase logging verbosity (-v=info, -vv=debug, -vvv=trace).
106    ///
107    /// Overrides XDG `log.level` when present. Logs are emitted
108    /// to stderr; JSON stdout is unaffected.
109    #[arg(short = 'v', long, global = true, action = clap::ArgAction::Count)]
110    pub verbose: u8,
111
112    /// Suppress non-error tracing on stderr (sets log level to `error`).
113    ///
114    /// Prefer this in pipelines that capture stdout JSON (`> out.json`).
115    /// Never combine stdout and stderr into the same file (`&>` / `2>&1`) —
116    /// that contaminates the JSON envelope (v1.1.05 Bug 2). Conflicts with
117    /// `-v` / `--verbose` only in spirit: quiet wins when both are present.
118    #[arg(short = 'q', long, global = true, default_value_t = false)]
119    pub quiet: bool,
120
121    // `--extraction-backend` lived here until v1.2.2, and was the most
122    // advertised dead flag in the crate: ten shipped documents described its
123    // four values while `src/` held exactly ONE mention of it — this very
124    // declaration. It never reached `RuntimeOverrides`, let alone a consumer.
125    // Its doc still described selecting between a headless `claude`/`codex`
126    // extractor and a `fastembed` pipeline, and BOTH were removed from the
127    // product. Found by asking who READS the field rather than how many times
128    // the identifier appears, which is the check `tests/inert_flag_guard.rs`
129    // now performs for the whole `Cli` struct.
130    /// Embedding dimensionality override (default 1024 since v1.2.0).
131    ///
132    /// Precedence: this flag > XDG `embedding.dim` >
133    /// the `dim` recorded in the database `schema_meta` > 1024. Existing
134    /// databases keep their recorded dimensionality automatically; use
135    /// this flag only to migrate a corpus to a new dimensionality
136    /// (followed by `enrich --operation re-embed`). Range: [8, 4096].
137    #[arg(long, global = true, value_name = "N", value_parser = clap::value_parser!(u64).range(8..=4096))]
138    pub embedding_dim: Option<u64>,
139
140    /// LLM backend for embedding. Accepts `openrouter` (OpenRouter REST)
141    /// or `none` (skips embedding; useful for tests). Prefer the flag;
142    /// optional XDG `llm.backend` via `config set`.
143    ///
144    /// Kept `Option` with no `default_value_t`, for the reason `--llm-fallback`
145    /// already documents below: a clap default makes the field always `Some`,
146    /// which silently swallows the XDG layer the doc promises. The default
147    /// lives in [`crate::runtime_config::llm_backend`] instead, so
148    /// flag > XDG > `open-router` actually resolves.
149    #[arg(long, global = true, value_enum)]
150    pub llm_backend: Option<LlmBackendChoice>,
151
152    /// v1.0.82 (GAP-003): model to invoke on the chosen backend.
153    /// Prefer the flag; optional XDG `llm.model`.
154    #[arg(long, global = true, value_name = "MODEL")]
155    pub llm_model: Option<String>,
156
157    /// Chain of LLM backends tried in order when the primary fails.
158    ///
159    /// Defaults to `none`. The default lives in the runtime registry rather
160    /// than in `default_value` here on purpose: a clap default makes the field
161    /// always `Some`, which silently swallows the XDG layer the doc promises —
162    /// `config set llm.fallback` would have been read by nothing. Leaving it
163    /// `None` when unset is what lets flag > XDG > constant actually resolve.
164    #[arg(long, global = true)]
165    pub llm_fallback: Option<String>,
166
167    /// v1.0.82 (GAP-005): persists with a NULL embedding when all
168    /// backends in the chain fail. The memory stays in `pending_embeddings`
169    /// for reprocessing via `embedding retry`. Prefer the flag; optional XDG
170    /// XDG `llm.skip_embedding_on_failure`.
171    #[arg(
172        long,
173        global = true,
174        default_value_t = false,
175        value_parser = clap::builder::BoolishValueParser::new(),
176            )]
177    pub skip_embedding_on_failure: bool,
178
179    // GAP-SG-204 (drive-by): the rendered text read "optional XDG XDG
180    // `llm.max_host_concurrency`" — a duplicated word from a mechanical edit —
181    // and described the ceiling as covering "LLM subprocesses", a thing v1.2.0
182    // removed. It bounds concurrent host SLOTS.
183    /// Host-wide ceiling of concurrent LLM slots. Default derived from `ncpus`.
184    ///
185    /// Prefer the flag; optional XDG `llm.max_host_concurrency`.
186    #[arg(long, global = true, value_name = "N")]
187    pub llm_max_host_concurrency: Option<u32>,
188
189    /// v1.0.82 (GAP-004): seconds to wait for a free LLM slot
190    /// before failing with exit 75. Default 30s. Prefer the flag; optional XDG
191    /// XDG `llm.slot_wait_secs`.
192    #[arg(long, global = true, value_name = "SECONDS")]
193    pub llm_slot_wait_secs: Option<u64>,
194
195    /// v1.0.82 (GAP-004): if set, fails immediately (exit 75)
196    /// when no LLM slot is free. Prefer the flag; optional XDG
197    /// XDG `llm.slot_no_wait`.
198    #[arg(
199        long,
200        global = true,
201        default_value_t = false,
202        value_parser = clap::builder::BoolishValueParser::new(),
203            )]
204    pub llm_slot_no_wait: bool,
205
206    /// Embedding backend selector.
207    ///
208    /// `openrouter` uses the REST API and requires a stored key. `auto` resolves
209    /// to the same path when a key is reachable and degrades to no embedding
210    /// when it is not. There is no subprocess backend: generation happens over
211    /// HTTP, in-process, one shot.
212    ///
213    /// Prefer the flag; optional XDG `config set embedding.backend`.
214    ///
215    /// Kept `Option` with no `default_value_t`: a clap default makes the field
216    /// always `Some` and the XDG layer promised right above would be read by
217    /// nothing. The default lives in
218    /// [`crate::runtime_config::embedding_backend`] instead.
219    #[arg(long, global = true, value_enum)]
220    pub embedding_backend: Option<EmbeddingBackendChoice>,
221
222    /// v1.0.93: embedding model for the OpenRouter API. Required when
223    /// `--embedding-backend openrouter`. Prefer the flag; optional XDG `embedding.model`.
224    #[arg(long, global = true, value_name = "MODEL")]
225    pub embedding_model: Option<String>,
226
227    /// OpenRouter API key for a single invocation.
228    ///
229    /// Prefer `config add-key --provider openrouter --from-stdin`, which stores
230    /// the key at rest under XDG with mode 0600 and keeps it out of both the
231    /// shell history and the process table. No environment variable supplies
232    /// this value: the product never reads one (G-T-XDG-04).
233    #[arg(long, global = true, value_name = "KEY", hide = true)]
234    pub openrouter_api_key: Option<String>,
235
236    /// Per-request budget, in seconds, for every OpenRouter call.
237    ///
238    /// Global because the deadline binds the EMBEDDING client too, and that
239    /// client is built once per process at startup. Declared only on `enrich`,
240    /// the flag reached the chat path and nothing else: `remember`, `ingest`,
241    /// `edit`, `restore` and `split-body` were pinned to the compiled default
242    /// with no way to widen it, and a slow provider turned into exit 11 with no
243    /// operator recourse. `enrich --openrouter-timeout <N>` keeps working
244    /// unchanged, because a clap global argument accepts being written at the
245    /// subcommand position.
246    ///
247    /// Kept optional so an EXPLICIT value is distinguishable from an omitted
248    /// one, which is what lets flag > XDG > constant resolve instead of the
249    /// flag always winning with a default nobody asked for.
250    #[arg(long, global = true, value_name = "SECONDS")]
251    pub openrouter_timeout: Option<u64>,
252
253    /// GAP-SG-142: keep only these keys in each result object (comma separated).
254    ///
255    /// Accepts dotted paths (`stats.total`). Keys missing from an element are
256    /// skipped rather than emitted as `null`, so a projection never invents
257    /// fields. Envelopes without a result array are projected themselves.
258    /// `--fields` is an accepted spelling of the same flag.
259    #[arg(
260        long,
261        visible_alias = "fields",
262        global = true,
263        value_name = "KEYS",
264        value_delimiter = ','
265    )]
266    pub select: Vec<String>,
267
268    /// GAP-SG-142: keep only result elements satisfying `EXPR`.
269    ///
270    /// Grammar: `key=value`, `key!=value`, `key~substring` (case-insensitive
271    /// containment). `==` is a synonym of `=`. Repeat the flag to conjoin
272    /// predicates with AND. A malformed expression fails fast with exit 2 so a
273    /// typo is never mistaken for an empty result set. Failure envelopes are
274    /// never filtered: `error: true` / `ok: false` always reaches the caller.
275    #[arg(long, global = true, value_name = "EXPR")]
276    pub filter: Vec<String>,
277
278    /// GAP-SG-201: declare what `--filter` is allowed to observe.
279    ///
280    /// Omitted, a predicate over a page the query already truncated is refused
281    /// with exit 2, because the answer would describe a set the predicate never
282    /// saw: `--filter type=skill list` reports 39 of 1892 memories, and the same
283    /// request with `--limit 50` reported 0. `page` accepts the narrower reading
284    /// and records it; `universe` states the requirement explicitly.
285    ///
286    /// Only paginated commands with a countable universe are ever refused. A
287    /// `-k` in `hybrid-search` or `recall` bounds a ranking rather than paging a
288    /// table, so the top-k IS the answer and filtering it is legitimate.
289    #[arg(long, global = true, value_enum, value_name = "SCOPE")]
290    pub filter_scope: Option<crate::agent_surface::universe::FilterScope>,
291
292    /// GAP-SG-202: accept a `--select` / `--filter` / `--sort` / `--dedupe-by`
293    /// key that this envelope carries nowhere.
294    ///
295    /// Without it such a key is refused with exit 2, because an unresolvable key
296    /// produces an empty answer indistinguishable from missing data — a typo
297    /// reads as "the memory does not exist". With it the pre-v1.2.6 behaviour is
298    /// restored for callers who genuinely probe a heterogeneous payload.
299    #[arg(long, global = true, default_value_t = false)]
300    pub allow_unknown_keys: bool,
301
302    /// GAP-SG-207: accept the ambient database target for a verb that changes
303    /// durable state.
304    ///
305    /// A mutating subcommand normally has to name its target with `--db`, and is
306    /// refused with exit 2 when it does not. This is the explicit dispensation
307    /// the Explicit Target Designation rule requires beside that requirement:
308    /// the inheritance still happens, but a human asked for it and the envelope
309    /// records that they did, so it is a decision rather than an accident.
310    #[arg(long, global = true, default_value_t = false)]
311    pub use_active: bool,
312
313    /// GAP-SG-142: emit at most N result elements.
314    ///
315    /// Distinct from the per-subcommand `--limit` and from `-k`, which bound
316    /// the *query*; this bounds only what is written to stdout, after
317    /// filtering. Precedence: this flag > XDG `agent_surface.max_items` > 0
318    /// (no cap).
319    ///
320    /// The name is not a stylistic choice and no `--limit` alias is offered:
321    /// eight subcommands (`related`, `pending`, `pending-embeddings`, `list`,
322    /// `export`, `embedding`, `graph entities`, `enrich`) already declare their
323    /// own `--limit`, and a global argument sharing that long flag would give
324    /// clap two definitions for one name inside those subcommands.
325    ///
326    /// Applies to EVERY array in the envelope, not only the primary one: an
327    /// agent asking for two nodes must not be handed sixty thousand edges
328    /// alongside them. `--select` stays on the primary array — see the module
329    /// documentation of [`crate::agent_surface`] for why projecting a
330    /// heterogeneous secondary array would erase it rather than shrink it.
331    #[arg(long, global = true, value_name = "N")]
332    pub max_items: Option<usize>,
333
334    /// GAP-SG-142: sort result elements ascending by this key (dotted path).
335    ///
336    /// Numbers compare numerically, everything else as text. Elements without
337    /// the key keep their relative order at the end of the list.
338    #[arg(long, global = true, value_name = "KEY")]
339    pub sort: Option<String>,
340
341    /// GAP-SG-142: drop later result elements repeating this key's value.
342    ///
343    /// Elements lacking the key are always kept, since they were never proven
344    /// duplicate.
345    #[arg(long, global = true, value_name = "KEY")]
346    pub dedupe_by: Option<String>,
347
348    /// GAP-SG-142: replace the payload with `{"count": N}`.
349    ///
350    /// `N` is the number of result elements left after `--filter`,
351    /// `--dedupe-by` and `--max-items`.
352    ///
353    /// GAP-SG-201, and this CHANGED the exit code in v1.2.8: over a paginated
354    /// command whose limit actually cut rows, this is now refused with exit `2`
355    /// rather than answering a page count that reads as the inventory. Declare
356    /// `--filter-scope page` to accept the narrower reading, and the
357    /// `count_scope` field of the agent-surface record then reports `page`
358    /// instead of `matched`. A top-k bound is never refused: the k IS the answer.
359    ///
360    /// GAP-SG-209: refused with exit `2` on `export` and `ingest`, which emit one
361    /// record per line. A count applied there ran once per line and answered
362    /// about a single record instead of the stream.
363    ///
364    /// GAP-SG-206: after a subcommand that actually persists, the knob is
365    /// SUPPRESSED rather than honoured, and the `count_only_suppressed` field of
366    /// the agent-surface record says so. Replacing a write envelope with a number
367    /// would discard `memory_id` and `entities_created` for an operation that
368    /// already happened and cannot be replayed.
369    #[arg(long, global = true, default_value_t = false)]
370    pub count_only: bool,
371
372    /// GAP-SG-142: shorten every string longer than N characters.
373    ///
374    /// Counts characters, never bytes, so a UTF-8 sequence is never split.
375    /// Truncation is recorded under `agent_surface.content_truncated` and
376    /// raises the top-level `truncated` flag. Precedence: this flag > XDG
377    /// `agent_surface.truncate_content` > 0 (disabled).
378    #[arg(long, global = true, value_name = "N")]
379    pub truncate_content: Option<usize>,
380
381    /// GAP-SG-142: cap the serialized envelope at N bytes.
382    ///
383    /// Enforced by dropping trailing result elements until the payload fits —
384    /// never by slicing the JSON text, which would not parse. What was dropped
385    /// is recorded under `agent_surface.output_truncated` / `dropped`.
386    /// Precedence: this flag > XDG `agent_surface.max_output_bytes` > 0
387    /// (no ceiling).
388    ///
389    /// GAP-SG-209: refused with exit `2` on `export` and `ingest`. A byte budget
390    /// spent once per emitted line is not a budget on the output.
391    #[arg(long, global = true, value_name = "N")]
392    pub max_output_bytes: Option<usize>,
393
394    /// Refuse to read stdin anywhere in this invocation.
395    ///
396    /// The refusal is declarative, not emergent: without the flag, a stdin path
397    /// only fails once the read is attempted (immediately on a TTY, after the
398    /// deadline otherwise). With it, `--body-stdin`, `--graph-stdin`,
399    /// `remember-batch` and every other stdin reader fail up front with exit 1
400    /// (`AppError::Validation`), even when a pipe is attached and would have
401    /// supplied data.
402    ///
403    /// Precedence: this flag > XDG `cli.no_input` > `false`.
404    // Until v1.2.4 the paragraph above promised exit 65, and seven
405    // operator-facing documents repeated it. No arm of `AppError::exit_code`
406    // returns 65, so an agent branching on it never matched this case. The
407    // correction lives in a plain comment, not a doc comment: clap renders doc
408    // comments as help text, and naming the wrong code — even to disown it —
409    // puts the number back in front of the reader `tests/no_input_exit_contract.rs`
410    // is there to keep it away from.
411    #[arg(long, global = true, default_value_t = false)]
412    pub no_input: bool,
413
414    /// Subcommand to execute.
415    #[command(subcommand)]
416    pub command: Option<Commands>,
417}
418
419impl Cli {
420    /// Validates concurrency flags and returns a localised descriptive error if invalid.
421    ///
422    /// Requires that `crate::i18n::init()` has already been called (happens before this
423    /// function in the `main` flow). In English it emits EN messages; in Portuguese it emits PT.
424    pub fn validate_flags(&self) -> Result<(), String> {
425        if let Some(n) = self.max_concurrency {
426            if n == 0 {
427                return Err(match current() {
428                    Language::English => "--max-concurrency must be >= 1".to_string(),
429                    Language::Portuguese => "--max-concurrency deve ser >= 1".to_string(),
430                });
431            }
432            let ceiling = max_concurrency_ceiling();
433            if n > ceiling {
434                return Err(match current() {
435                    Language::English => format!(
436                        "--max-concurrency {n} exceeds the ceiling of {ceiling} (2×nCPUs) on this system"
437                    ),
438                    Language::Portuguese => format!(
439                        "--max-concurrency {n} excede o teto de {ceiling} (2×nCPUs) neste sistema"
440                    ),
441                });
442            }
443        }
444        self.install_agent_surface()?;
445        self.install_write_policy();
446        // Installed here rather than at each call site so the guarantee holds
447        // for every stdin reader, present and future, from a single point.
448        crate::stdin_helper::install_no_input(crate::runtime_config::no_input(self.no_input));
449        Ok(())
450    }
451
452    /// GAP-SG-207: records whether this process may inherit its database target
453    /// from ambient configuration, for [`crate::paths::AppPaths::resolve`].
454    ///
455    /// Runs beside [`Self::install_agent_surface`] and for the same reason: this
456    /// is the one hook that sees the parsed command line before any handler
457    /// executes, which is the only window where refusing still means "nothing
458    /// happened" rather than "it happened somewhere you did not name".
459    ///
460    /// With no subcommand there is nothing to mutate, so the requirement is off.
461    /// That is not a permissive default — a bare invocation prints help and
462    /// resolves no path at all.
463    fn install_write_policy(&self) {
464        let requires_explicit_target = self.command.as_ref().is_some_and(Commands::persists);
465        crate::paths::install_write_policy(crate::paths::WritePolicy {
466            requires_explicit_target,
467            use_active: self.use_active,
468        });
469    }
470
471    /// GAP-SG-142: resolves the agent-native output surface and installs it
472    /// process-wide, so [`crate::output`] can reshape every envelope from a
473    /// single point.
474    ///
475    /// Runs from [`Self::validate_flags`] because that is the one bootstrap
476    /// hook already invoked after language and XDG initialisation and before
477    /// any subcommand dispatch — exactly the window where a malformed
478    /// `--filter` must abort with exit 2 rather than be mistaken for an empty
479    /// result set.
480    ///
481    /// # Errors
482    /// Returns the localized parse error of the first malformed `--filter`.
483    fn install_agent_surface(&self) -> Result<(), String> {
484        let mut filters = Vec::with_capacity(self.filter.len());
485        for raw in &self.filter {
486            filters.push(crate::agent_surface::filter::FilterExpr::parse(raw)?);
487        }
488        crate::agent_surface::init(crate::agent_surface::AgentSurface {
489            // Alias suppression is only correct for the subcommand that declared
490            // the alias, so the surface has to know which one emitted the
491            // envelope. Resolvable here at zero cost because `validate_flags`
492            // already runs on the parsed `Cli`, after dispatch is decided and
493            // before any command runs.
494            command: self
495                .command
496                .as_ref()
497                .and_then(Commands::agent_surface_slug)
498                .map(str::to_string),
499            mutates: self.command.as_ref().is_none_or(Commands::mutates),
500            allow_unknown_keys: self.allow_unknown_keys,
501            use_active: self.use_active,
502            filter_scope: self.filter_scope,
503            select: self.select.clone(),
504            filters,
505            sort: self.sort.clone(),
506            dedupe_by: self.dedupe_by.clone(),
507            max_items: crate::runtime_config::agent_surface_max_items(self.max_items),
508            count_only: self.count_only,
509            streamed: self.command.as_ref().is_some_and(Commands::streams),
510            writes_receipt: self.command.as_ref().is_some_and(Commands::persists),
511            truncate_content: crate::runtime_config::agent_surface_truncate_content(
512                self.truncate_content,
513            ),
514            max_output_bytes: crate::runtime_config::agent_surface_max_output_bytes(
515                self.max_output_bytes,
516            ),
517        });
518        Ok(())
519    }
520}