Skip to main content

agentstatedeveloper_core/
brief.rs

1//! Plan D t-001 + t-007: shared brief-output projections.
2//!
3//! Brief mode strips load-bearing fields out of the verbose JSON each
4//! command normally emits. Lives in core so both CLI (`asd --brief`)
5//! and MCP (`ASD_FORMAT=brief` at server startup) use the same
6//! projections — single source of truth per the prepare_change
7//! duplication-memory lesson.
8
9use crate::schema::Symbol;
10use serde_json::{Value, json};
11
12/// Plan D t-007: whether the spawned process should default to brief
13/// output. Reads `ASD_FORMAT=brief` (case-insensitive). Hosts can also
14/// flip the flag explicitly per-call without consulting this helper.
15pub fn brief_from_env() -> bool {
16    std::env::var("ASD_FORMAT")
17        .map(|v| v.eq_ignore_ascii_case("brief"))
18        .unwrap_or(false)
19}
20
21/// Plan D t-005: deterministic query id for wrapper-side dedup.
22/// `query_id("read", &["store.pricing.rates.get_rate"])` →
23/// `"Qf3a2b1c..."` (first 7 hex chars of blake3 of the joined input).
24/// Same inputs → same id; safe to compare across processes.
25pub fn query_id(cmd: &str, args: &[&str]) -> String {
26    let mut s = String::with_capacity(cmd.len() + 32);
27    s.push_str(cmd);
28    for a in args {
29        s.push('|');
30        s.push_str(a);
31    }
32    let h = blake3::hash(s.as_bytes());
33    let hex = h.to_hex();
34    let prefix: String = hex.chars().take(7).collect();
35    format!("Q{prefix}")
36}
37
38/// Project a Symbol down to the brief field set.
39pub fn brief_symbol(sym: &Symbol) -> Value {
40    let doc_line = sym
41        .doc
42        .as_deref()
43        .and_then(|d| d.lines().next().map(|l| l.trim().to_string()))
44        .filter(|l| !l.is_empty());
45    let mut out = serde_json::Map::new();
46    out.insert("qname".into(), Value::String(sym.qname.clone()));
47    out.insert(
48        "file".into(),
49        Value::String(format!("{}:{}", sym.file, sym.start.line)),
50    );
51    if let Some(sig) = &sym.signature {
52        out.insert("signature".into(), Value::String(sig.clone()));
53    }
54    if let Some(d) = doc_line {
55        out.insert("doc".into(), Value::String(d));
56    }
57    Value::Object(out)
58}
59
60/// Project a list of `{qname, file, line}` records (the call-graph output
61/// shape) down to a single `qname (file:line)` string per entry.
62pub fn brief_call_list(items: &[Value]) -> Vec<String> {
63    items
64        .iter()
65        .filter_map(|v| {
66            let q = v.get("qname").and_then(Value::as_str)?;
67            let f = v.get("file").and_then(Value::as_str)?;
68            let l = v.get("line").and_then(Value::as_u64).unwrap_or(0);
69            Some(format!("{q} ({f}:{l})"))
70        })
71        .collect()
72}
73
74/// Brief shape for `read`: keeps the call graph + effect/ledger summaries
75/// but as counts + compact strings, not full nested objects.
76pub fn brief_read(
77    sym: &Symbol,
78    callers: &[Value],
79    callees: &[Value],
80    effects: Option<&Value>,
81    ledger_count: usize,
82) -> Value {
83    let mut out = serde_json::Map::new();
84    out.insert("symbol".into(), brief_symbol(sym));
85    let caller_strs = brief_call_list(callers);
86    let callee_strs = brief_call_list(callees);
87    if !caller_strs.is_empty() {
88        out.insert("callers".into(), json!(caller_strs));
89    }
90    if !callee_strs.is_empty() {
91        out.insert("callees".into(), json!(callee_strs));
92    }
93    if let Some(eff) = effects {
94        if let Some(declared) = eff.get("declared").and_then(Value::as_array) {
95            let cats: Vec<String> = declared
96                .iter()
97                .filter_map(|e| e.get("effect").and_then(Value::as_str).map(String::from))
98                .collect();
99            if !cats.is_empty() {
100                out.insert("effects".into(), json!(cats));
101            }
102        }
103    }
104    if ledger_count > 0 {
105        out.insert("ledger_count".into(), json!(ledger_count));
106    }
107    Value::Object(out)
108}
109
110/// Plan D t-007: brief shape for `code_search` / `search`. Projects each
111/// FTS hit down to `{qname, file, line, signature, doc, score?}`.
112pub fn brief_search_results(hits: &[Value]) -> Vec<Value> {
113    hits.iter()
114        .map(|hit| {
115            let mut out = serde_json::Map::new();
116            if let Some(q) = hit.get("qname").and_then(Value::as_str) {
117                out.insert("qname".into(), Value::String(q.into()));
118            }
119            let file = hit.get("file").and_then(Value::as_str).unwrap_or("");
120            let line = hit.get("line").and_then(Value::as_u64).unwrap_or(0);
121            if !file.is_empty() {
122                out.insert("file".into(), Value::String(format!("{file}:{line}")));
123            }
124            if let Some(s) = hit.get("signature").and_then(Value::as_str) {
125                out.insert("signature".into(), Value::String(s.into()));
126            }
127            if let Some(d) = hit
128                .get("doc")
129                .and_then(Value::as_str)
130                .and_then(|s| s.lines().next())
131            {
132                let d = d.trim();
133                if !d.is_empty() {
134                    out.insert("doc".into(), Value::String(d.into()));
135                }
136            }
137            if let Some(score) = hit.get("score") {
138                out.insert("score".into(), score.clone());
139            } else if let Some(score) = hit.get("bm25_score") {
140                out.insert("score".into(), score.clone());
141            }
142            Value::Object(out)
143        })
144        .collect()
145}
146
147/// Plan F t-006: brief projection for an `investigate` response.
148/// Strips the verbose entry-point bodies + ambiguity/possible-misses
149/// metadata. Flattens `by_layer` (the MCP handler shape) into a single
150/// `entry_points` list when present; otherwise uses a top-level
151/// `entry_points` field directly (the CLI handler shape).
152pub fn brief_investigate(full: &Value) -> Value {
153    let mut out = serde_json::Map::new();
154    // Token economy (1.0.80): no `query` echo — the agent has it.
155    // `query_id` (trace marker, not input) preserved below.
156    let entry_points: Vec<Value> =
157        if let Some(eps) = full.get("entry_points").and_then(Value::as_array) {
158            eps.clone()
159        } else if let Some(by_layer) = full.get("by_layer").and_then(Value::as_object) {
160            // Flatten { layer: [eps] } → single eps list.
161            by_layer
162                .values()
163                .filter_map(|v| v.as_array().cloned())
164                .flatten()
165                .collect()
166        } else {
167            Vec::new()
168        };
169    if !entry_points.is_empty() {
170        out.insert(
171            "entry_points".into(),
172            json!(brief_search_results(&entry_points)),
173        );
174    }
175    for k in ["safe_change_recipe", "stale", "query_id"] {
176        if let Some(v) = full.get(k) {
177            if !v.is_null() {
178                out.insert(k.into(), v.clone());
179            }
180        }
181    }
182    crate::ser_helpers::drop_empty_top_level(Value::Object(out))
183}
184
185/// Plan F t-006: brief projection for a `prepare_change` response.
186/// Keeps the load-bearing slices an agent acts on (likely_edit_files,
187/// safe_change_recipe, design_invariants, known_hazards) and drops the
188/// orientation context (by_layer / recently_touched / scenario_tests /
189/// suggested_test_coverage / effects_summary) that bloats output.
190pub fn brief_prepare_change(full: &Value) -> Value {
191    let mut out = serde_json::Map::new();
192    // Token economy (1.0.80): no `description` echo (the agent
193    // literally just sent it). `intent`/`focus` are canonicalized
194    // derivatives, so they stay. `query_id`/`stale` preserved as
195    // trace/diagnostic signals.
196    for k in ["intent", "focus", "query_id", "stale"] {
197        if let Some(v) = full.get(k) {
198            if !v.is_null() {
199                out.insert(k.into(), v.clone());
200            }
201        }
202    }
203    // Project per-file entries down to {file, why, layer?, top_symbol?}.
204    if let Some(arr) = full.get("likely_edit_files").and_then(Value::as_array) {
205        let compact: Vec<Value> = arr
206            .iter()
207            .map(|f| {
208                let mut o = serde_json::Map::new();
209                for k in ["file", "why", "top_symbol", "layer"] {
210                    if let Some(v) = f.get(k) {
211                        if !v.is_null() {
212                            o.insert(k.into(), v.clone());
213                        }
214                    }
215                }
216                Value::Object(o)
217            })
218            .collect();
219        out.insert("likely_edit_files".into(), json!(compact));
220    }
221    for k in [
222        "safe_change_recipe",
223        "design_invariants",
224        "known_hazards",
225        "prior_thinking",
226    ] {
227        if let Some(v) = full.get(k) {
228            let nonempty = v.as_array().map(|a| !a.is_empty()).unwrap_or(false)
229                || v.as_object().map(|o| !o.is_empty()).unwrap_or(false);
230            if nonempty {
231                out.insert(k.into(), v.clone());
232            }
233        }
234    }
235    // AcmeFlow refinement (1.0.76): thinking_summary passes
236    // through (small, load-bearing — agents read it to tell "filtered"
237    // from "absent" even when prior_thinking is empty/missing).
238    // Token economy (1.0.80): drop_empty_top_level still strips it if
239    // the struct's own skip-predicates collapsed it to `{}` — the
240    // AcmeFlow signal needs at LEAST one non-zero field to be
241    // worth emitting.
242    if let Some(v) = full.get("thinking_summary") {
243        out.insert("thinking_summary".into(), v.clone());
244    }
245    crate::ser_helpers::drop_empty_top_level(Value::Object(out))
246}
247
248/// Plan F t-006: brief projection for `context_for`. Keeps the symbol
249/// signature + top callers/callees as compact "qname (file:line)"
250/// strings; drops nested ledger bodies + full effect declarations.
251pub fn brief_context_for(full: &Value) -> Value {
252    let mut out = serde_json::Map::new();
253    if let Some(sym) = full.get("symbol") {
254        // The symbol entry already has the brief shape if it came from
255        // brief_symbol; otherwise re-project the minimum fields.
256        let mut s = serde_json::Map::new();
257        for k in ["qname", "file", "line", "signature", "doc"] {
258            if let Some(v) = sym.get(k) {
259                if !v.is_null() {
260                    s.insert(k.into(), v.clone());
261                }
262            }
263        }
264        out.insert("symbol".into(), Value::Object(s));
265    }
266    for (k, max) in [("callers", 3usize), ("callees", 3usize)] {
267        if let Some(arr) = full.get(k).and_then(Value::as_array) {
268            let lines = brief_call_list(arr);
269            let capped: Vec<String> = lines.into_iter().take(max).collect();
270            if !capped.is_empty() {
271                out.insert(k.into(), json!(capped));
272            }
273        }
274    }
275    if let Some(eff) = full.get("effects") {
276        if let Some(declared) = eff.get("declared").and_then(Value::as_array) {
277            let cats: Vec<String> = declared
278                .iter()
279                .filter_map(|e| e.get("effect").and_then(Value::as_str).map(String::from))
280                .collect();
281            if !cats.is_empty() {
282                out.insert("effects".into(), json!(cats));
283            }
284        }
285    }
286    // Accept either `ledger` (CLI shape) or `decisions_and_notes` (MCP
287    // shape) — both are arrays of ledger-like entries.
288    let ledger_count = full
289        .get("ledger")
290        .or_else(|| full.get("decisions_and_notes"))
291        .and_then(Value::as_array)
292        .map(|a| a.len())
293        .unwrap_or(0);
294    if ledger_count > 0 {
295        out.insert("ledger_count".into(), json!(ledger_count));
296    }
297    Value::Object(out)
298}
299
300/// Plan F t-006: brief projection for a `conclusions_list` response.
301/// Drops symbol_id (internal) and skips None role/command/tags per-entry.
302pub fn brief_conclusions_list(full: &Value) -> Value {
303    let mut out = serde_json::Map::new();
304    for k in ["class", "symbol", "total"] {
305        if let Some(v) = full.get(k) {
306            if !v.is_null() {
307                out.insert(k.into(), v.clone());
308            }
309        }
310    }
311    if let Some(buckets) = full.get("buckets").and_then(Value::as_object) {
312        let compact: serde_json::Map<String, Value> = buckets
313            .iter()
314            .map(|(stem, entries)| {
315                let arr = entries.as_array().cloned().unwrap_or_default();
316                let trimmed: Vec<Value> = arr
317                    .iter()
318                    .map(|e| {
319                        let mut o = serde_json::Map::new();
320                        for k in [
321                            "entry_id",
322                            "kind",
323                            "qname",
324                            "summary",
325                            "role",
326                            "command",
327                            "created_at",
328                        ] {
329                            if let Some(v) = e.get(k) {
330                                if !v.is_null() {
331                                    o.insert(k.into(), v.clone());
332                                }
333                            }
334                        }
335                        if let Some(tags) = e.get("tags").and_then(Value::as_array) {
336                            if !tags.is_empty() {
337                                o.insert("tags".into(), json!(tags));
338                            }
339                        }
340                        Value::Object(o)
341                    })
342                    .collect();
343                (stem.clone(), json!(trimmed))
344            })
345            .collect();
346        out.insert("buckets".into(), Value::Object(compact));
347    }
348    Value::Object(out)
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354    use crate::schema::{Position, Symbol, SymbolKind};
355
356    fn sample_symbol() -> Symbol {
357        Symbol {
358            symbol_id: "sym_abc".into(),
359            symbol_fp: "fp_xyz".into(),
360            qname: "store.pricing.get_rate".into(),
361            language: "python".into(),
362            kind: SymbolKind::Function,
363            file: "store/pricing.py".into(),
364            start: Position { line: 14, col: 1 },
365            end: Position { line: 23, col: 41 },
366            signature: Some("def get_rate(region: str) -> float".into()),
367            doc: Some("Return the sales-tax rate for region.\nLong description.".into()),
368        }
369    }
370
371    #[test]
372    fn brief_symbol_drops_noise_fields() {
373        let s = sample_symbol();
374        let v = brief_symbol(&s);
375        let obj = v.as_object().unwrap();
376        assert_eq!(
377            obj.get("qname").and_then(Value::as_str),
378            Some("store.pricing.get_rate")
379        );
380        assert_eq!(
381            obj.get("file").and_then(Value::as_str),
382            Some("store/pricing.py:14")
383        );
384        for noise in [
385            "symbol_id",
386            "symbol_fp",
387            "language",
388            "kind",
389            "col",
390            "end",
391            "start",
392        ] {
393            assert!(obj.get(noise).is_none(), "brief symbol must drop `{noise}`");
394        }
395    }
396
397    #[test]
398    fn brief_symbol_omits_empty_doc_and_signature() {
399        let mut s = sample_symbol();
400        s.doc = None;
401        s.signature = None;
402        let v = brief_symbol(&s);
403        let obj = v.as_object().unwrap();
404        assert!(obj.get("doc").is_none());
405        assert!(obj.get("signature").is_none());
406    }
407
408    #[test]
409    fn brief_call_list_compacts_to_one_liners() {
410        let calls = vec![
411            json!({"qname": "a.b.c", "file": "a.py", "line": 5}),
412            json!({"qname": "x.y", "file": "x.py", "line": 12}),
413        ];
414        let out = brief_call_list(&calls);
415        assert_eq!(out, vec!["a.b.c (a.py:5)", "x.y (x.py:12)"]);
416    }
417
418    #[test]
419    fn brief_search_results_projects_fts_hit_shape() {
420        let hits = vec![json!({
421            "qname": "a.b",
422            "file": "a.py",
423            "line": 7,
424            "signature": "def b()",
425            "doc": "First line.\nSecond.",
426            "bm25_score": 12.5,
427            "ledger_text": "ignored noise",
428        })];
429        let out = brief_search_results(&hits);
430        assert_eq!(out.len(), 1);
431        let o = out[0].as_object().unwrap();
432        assert_eq!(o.get("qname").and_then(Value::as_str), Some("a.b"));
433        assert_eq!(o.get("file").and_then(Value::as_str), Some("a.py:7"));
434        assert_eq!(o.get("signature").and_then(Value::as_str), Some("def b()"));
435        assert_eq!(o.get("doc").and_then(Value::as_str), Some("First line."));
436        assert!(o.get("score").is_some());
437        assert!(o.get("ledger_text").is_none());
438    }
439
440    #[test]
441    fn query_id_is_deterministic_and_starts_with_q() {
442        let a = query_id("read", &["x"]);
443        let b = query_id("read", &["x"]);
444        assert_eq!(a, b);
445        assert!(a.starts_with('Q'));
446        assert_eq!(a.len(), 8);
447    }
448
449    // -- Plan F t-006: complex-handler projections ---------------------------
450
451    #[test]
452    fn brief_investigate_keeps_entry_points_and_drops_noise() {
453        let full = json!({
454            "query": "playhead",
455            "entry_points": [{"qname":"a.b","file":"a.py","line":3,"signature":"def b()","doc":"hi"}],
456            "ambiguous_terms": ["playhead"],
457            "possible_misses": [{"layer":"app"}],
458            "stale": null,
459        });
460        let v = brief_investigate(&full);
461        let o = v.as_object().unwrap();
462        // Token economy (1.0.80): input echo `query` is no longer
463        // emitted — the agent has it.
464        assert!(
465            !o.contains_key("query"),
466            "input echo `query` dropped by brief"
467        );
468        assert!(o.contains_key("entry_points"));
469        assert!(!o.contains_key("ambiguous_terms"));
470        assert!(!o.contains_key("possible_misses"));
471        assert!(!o.contains_key("stale"), "null stale should be dropped");
472    }
473
474    #[test]
475    fn brief_prepare_change_keeps_load_bearing_drops_orientation() {
476        let full = json!({
477            "description": "migrate tests",
478            "intent": "test",
479            "likely_edit_files": [{
480                "file": "x.py", "why": "matched 'test'", "top_symbol": "a.b",
481                "layer": "tests", "score": 3.5, "hot": false,
482            }],
483            "by_layer": {"tests": []},
484            "recently_touched": [],
485            "scenario_tests": [],
486            "suggested_test_coverage": [],
487            "design_invariants": [{"summary":"i1"}],
488            "known_hazards": [],
489            "safe_change_recipe": {"edit": []},
490        });
491        let v = brief_prepare_change(&full);
492        let o = v.as_object().unwrap();
493        assert!(o.contains_key("likely_edit_files"));
494        assert!(o.contains_key("safe_change_recipe"));
495        assert!(o.contains_key("design_invariants"));
496        assert!(!o.contains_key("by_layer"));
497        assert!(!o.contains_key("recently_touched"));
498        assert!(
499            !o.contains_key("known_hazards"),
500            "empty array should be dropped"
501        );
502        // likely_edit_files entries should keep file/why/top_symbol/layer.
503        let lef = o["likely_edit_files"].as_array().unwrap();
504        let f0 = lef[0].as_object().unwrap();
505        assert!(f0.contains_key("file"));
506        assert!(f0.contains_key("why"));
507        assert!(f0.contains_key("top_symbol"));
508        assert!(!f0.contains_key("score"));
509        assert!(!f0.contains_key("hot"));
510    }
511
512    #[test]
513    fn brief_context_for_caps_callers_callees_to_top_three() {
514        let full = json!({
515            "symbol": {"qname":"a.b","file":"a.py","line":1,"signature":"def b()","doc":"d"},
516            "callers": [
517                {"qname":"c1","file":"c1.py","line":1},
518                {"qname":"c2","file":"c2.py","line":2},
519                {"qname":"c3","file":"c3.py","line":3},
520                {"qname":"c4","file":"c4.py","line":4},
521            ],
522            "callees": [],
523            "effects": {"declared":[{"effect":"io.fs.read"}]},
524            "ledger": [{"summary":"e1"}, {"summary":"e2"}],
525        });
526        let v = brief_context_for(&full);
527        let o = v.as_object().unwrap();
528        let callers = o["callers"].as_array().unwrap();
529        assert_eq!(callers.len(), 3, "callers should be capped to 3");
530        assert!(
531            !o.contains_key("callees"),
532            "empty callees should be dropped"
533        );
534        assert_eq!(o["ledger_count"].as_u64(), Some(2));
535        assert_eq!(o["effects"].as_array().unwrap().len(), 1);
536    }
537
538    #[test]
539    fn brief_conclusions_list_drops_internal_fields_and_nulls() {
540        let full = json!({
541            "class": "decisions",
542            "symbol": null,
543            "total": 1,
544            "buckets": {
545                "decisions": [{
546                    "entry_id": "led_1",
547                    "kind": "decision",
548                    "qname": "a.b",
549                    "symbol_id": "sym_secret",
550                    "summary": "ok",
551                    "role": null,
552                    "command": null,
553                    "tags": [],
554                    "created_at": "2026-06-02T00:00:00Z",
555                }],
556            },
557        });
558        let v = brief_conclusions_list(&full);
559        let o = v.as_object().unwrap();
560        assert!(!o.contains_key("symbol"), "null symbol should be dropped");
561        let bucket = o["buckets"]["decisions"].as_array().unwrap();
562        let entry = bucket[0].as_object().unwrap();
563        assert!(
564            !entry.contains_key("symbol_id"),
565            "internal symbol_id must be dropped"
566        );
567        assert!(!entry.contains_key("role"), "null role must be dropped");
568        assert!(
569            !entry.contains_key("command"),
570            "null command must be dropped"
571        );
572        assert!(!entry.contains_key("tags"), "empty tags must be dropped");
573        assert_eq!(entry["entry_id"].as_str(), Some("led_1"));
574        assert_eq!(entry["kind"].as_str(), Some("decision"));
575    }
576
577    #[test]
578    fn brief_from_env_reads_env_var() {
579        let prev = std::env::var("ASD_FORMAT").ok();
580        unsafe {
581            std::env::set_var("ASD_FORMAT", "brief");
582        }
583        assert!(brief_from_env());
584        unsafe {
585            std::env::set_var("ASD_FORMAT", "BRIEF");
586        }
587        assert!(brief_from_env());
588        unsafe {
589            std::env::set_var("ASD_FORMAT", "json");
590        }
591        assert!(!brief_from_env());
592        unsafe {
593            std::env::remove_var("ASD_FORMAT");
594        }
595        assert!(!brief_from_env());
596        if let Some(v) = prev {
597            unsafe {
598                std::env::set_var("ASD_FORMAT", v);
599            }
600        }
601    }
602}