Skip to main content

sqlite_graphrag/agent_surface/
target.rs

1//! GAP-SG-205: the resolved database target, reported on every envelope that
2//! resolved one.
3//!
4//! # Why this is not just another knob record
5//!
6//! The Explicit Target Designation rule asks for one thing: a verb with a side
7//! effect must name its target in the argv, and the target it actually resolved
8//! must appear in the output. The second half is what makes the first half
9//! auditable — without it, a write that landed in the wrong database leaves no
10//! trace to find.
11//!
12//! v1.2.6 emitted the two members from `super::base_meta`, which sounded
13//! right and was not: `base_meta` runs downstream of TWO short-circuits —
14//! `crate::output::envelope::emit_json` skips the whole layer when
15//! [`super::active`] is false, and [`super::apply`] returns early when
16//! [`super::AgentSurface::is_noop`] is true. So the target appeared only for a
17//! caller that had already set some unrelated flag, and vanished on the default
18//! path every agent actually uses:
19//!
20//! ```text
21//! remember --db T                  → no agent_surface block at all
22//! remember --db T --max-items 50   → db_path_source: "argv"
23//! ```
24//!
25//! Hanging a universal contract off an optional block is the same proxy mistake
26//! this release already paid for twice in [`super::gate`]. The cure is not to
27//! move the field somewhere else; it is to stop conditioning the block on
28//! "some knob is set" and condition it on "there is something to report". A
29//! resolved target is something to report.
30//!
31//! # Why the members live inside `agent_surface`
32//!
33//! Measured, not assumed: 66 of the 74 published schemas close their root with
34//! `additionalProperties: false`, and all 66 already declare `agent_surface`.
35//! A new root member would therefore break 66 contracts, while the existing
36//! block absorbs the record at zero schema cost.
37//!
38//! # When nothing is reported
39//!
40//! Absent means the process never resolved a target, which is the honest answer
41//! for `config`, `completions` and `locale` — they touch no database at all. It
42//! never means "resolved but omitted"; that distinction is the whole point.
43
44use super::AgentSurface;
45use crate::paths::{AppPaths, TargetSource};
46use serde_json::{json, Map, Value};
47
48/// Member naming which configuration layer supplied the target.
49pub const SOURCE_KEY: &str = "db_path_source";
50
51/// Member carrying the absolute path this process resolved.
52pub const RESOLVED_KEY: &str = "db_path_resolved";
53
54/// Member recording that an ambient target was accepted on purpose.
55///
56/// Present only when the caller passed the dispensation flag, so its absence
57/// beside a non-`argv` source is itself the signal that nothing explicit
58/// authorised the inheritance.
59pub const DISPENSATION_KEY: &str = "db_path_dispensation";
60
61/// Wire spelling of the dispensation, matching the flag that grants it.
62pub const DISPENSATION_VALUE: &str = "use-active";
63
64/// Writes the target record into `meta`, when this process resolved a target.
65///
66/// Idempotent and total: calling it on a record that already carries the
67/// members overwrites them with the same values, so both the shaping path and
68/// the inert path can call it without coordinating.
69pub fn insert_into(meta: &mut Map<String, Value>, surface: &AgentSurface) {
70    let Some(source) = AppPaths::target_source() else {
71        return;
72    };
73    meta.insert(SOURCE_KEY.into(), json!(source.as_str()));
74    if let Some(path) = AppPaths::resolved_target() {
75        meta.insert(RESOLVED_KEY.into(), json!(path.to_string_lossy().as_ref()));
76    }
77    // Recorded only where it changed the outcome. On an `argv` target the
78    // dispensation was never consulted, so reporting it would suggest the
79    // caller leaned on an escape hatch it did not need.
80    if surface.use_active && source != TargetSource::Argv {
81        meta.insert(DISPENSATION_KEY.into(), json!(DISPENSATION_VALUE));
82    }
83}
84
85/// `true` when this process has a target worth reporting.
86///
87/// `crate::output::envelope` asks before deciding whether the layer has to run
88/// at all, so an envelope from a host-only subcommand keeps the zero-cost path.
89#[must_use]
90pub fn is_reportable() -> bool {
91    // Either fact is enough. Today every command that declares a ceiling also
92    // resolves a database, so the second test is redundant in practice — and
93    // relying on that coincidence is precisely how the target came to depend on
94    // an unrelated flag. Asking about both keeps the two independent.
95    AppPaths::target_source().is_some() || super::universe::get().is_some()
96}
97
98/// Builds the record on its own, for envelopes that carry no shaping record.
99///
100/// Returns `None` when there is no target, which lets the caller skip the
101/// insertion entirely rather than attach an empty block.
102#[must_use]
103pub fn record(
104    surface: &AgentSurface,
105    ceiling: Option<&super::universe::QueryCeiling>,
106) -> Option<Map<String, Value>> {
107    let mut meta = Map::new();
108    insert_into(&mut meta, surface);
109    // The query ceiling rides along, for the same reason the target does: it is
110    // a fact about the PROCESS rather than about the reshaping. Without it
111    // `deep-research "x"` with no knob reported which database it opened and
112    // stayed silent about having cut the ranking to five, which is half a
113    // contract and the harder half to notice is missing.
114    super::universe::insert_query_ceiling(&mut meta, ceiling);
115    (!meta.is_empty()).then_some(meta)
116}