Skip to main content

gitcortex_mcp/mcp/
agent.rs

1//! Shared compact responses for agent-facing MCP and CLI queries.
2//!
3//! This module is the contract boundary between graph retrieval and agent
4//! presentation. Both interfaces must call these functions so ranking,
5//! ambiguity handling, and response budgets cannot drift.
6
7use std::collections::{HashMap, HashSet};
8
9use gitcortex_core::{
10    error::Result,
11    graph::Node,
12    schema::{EdgeConfidence, EdgeKind, NodeKind, Visibility},
13    store::GraphStore,
14};
15use serde::Serialize;
16
17use super::{
18    helpers::{confidence_rank, is_test_file, sig_line},
19    search::SearchHit,
20};
21
22const DEFAULT_LIMIT: usize = 25;
23const MAX_LIMIT: usize = 100;
24const DEFAULT_BUDGET_TOKENS: usize = 2_000;
25const MIN_BUDGET_TOKENS: usize = 400;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
28#[serde(rename_all = "snake_case")]
29pub enum AgentStatus {
30    Ok,
31    Ambiguous,
32    NotFound,
33}
34
35#[derive(Debug, Clone, Serialize)]
36pub struct SymbolCandidate {
37    pub id: String,
38    pub name: String,
39    pub qualified_name: String,
40    pub kind: String,
41    pub file: String,
42    pub start_line: u32,
43    pub visibility: String,
44}
45
46#[derive(Debug, Clone, Default, Serialize)]
47pub struct ConfidenceMix {
48    pub extracted: usize,
49    pub resolved: usize,
50    pub inferred: usize,
51}
52
53#[derive(Debug, Clone, Serialize)]
54pub struct Coverage {
55    pub total: usize,
56    pub returned: usize,
57    pub truncated: bool,
58    pub confidence_mix: ConfidenceMix,
59}
60
61#[derive(Debug, Clone, Serialize)]
62pub struct CallerEvidence {
63    pub hop: u8,
64    pub symbol: String,
65    pub qualified_name: String,
66    pub kind: String,
67    pub file: String,
68    pub line: u32,
69    pub signature: String,
70    pub confidence: String,
71    pub is_test: bool,
72}
73
74#[derive(Debug, Clone, Serialize)]
75pub struct AgentCallersResponse {
76    pub status: AgentStatus,
77    pub answer: String,
78    pub query: String,
79    pub branch: String,
80    pub depth: u8,
81    pub risk_level: String,
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub symbol: Option<SymbolCandidate>,
84    #[serde(skip_serializing_if = "Vec::is_empty")]
85    pub candidates: Vec<SymbolCandidate>,
86    pub evidence: Vec<CallerEvidence>,
87    pub coverage: Coverage,
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub next_action: Option<String>,
90}
91
92#[derive(Debug, Clone, Serialize)]
93pub struct RelationEvidence {
94    pub relation: String,
95    pub direction: String,
96    pub symbol: String,
97    pub qualified_name: String,
98    pub kind: String,
99    pub file: String,
100    pub line: u32,
101    pub confidence: String,
102    pub is_test: bool,
103}
104
105#[derive(Debug, Clone, Serialize)]
106pub struct NeighborhoodCoverage {
107    pub graph_nodes: usize,
108    pub graph_edges: usize,
109    pub direct_relations: usize,
110    pub returned: usize,
111    pub truncated: bool,
112}
113
114#[derive(Debug, Clone, Serialize)]
115pub struct AgentSubgraphResponse {
116    pub status: AgentStatus,
117    pub answer: String,
118    pub query: String,
119    pub branch: String,
120    pub depth: u8,
121    pub direction: String,
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub symbol: Option<SymbolCandidate>,
124    #[serde(skip_serializing_if = "Vec::is_empty")]
125    pub candidates: Vec<SymbolCandidate>,
126    pub relation_counts: std::collections::BTreeMap<String, usize>,
127    pub evidence: Vec<RelationEvidence>,
128    pub coverage: NeighborhoodCoverage,
129    #[serde(skip_serializing_if = "Option::is_none")]
130    pub next_action: Option<String>,
131}
132
133#[derive(Debug, Clone, Serialize)]
134pub struct SearchEvidence {
135    pub symbol: String,
136    pub qualified_name: String,
137    pub kind: String,
138    pub file: String,
139    pub line: u32,
140    pub signature: String,
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub doc: Option<String>,
143    pub score: i32,
144}
145
146#[derive(Debug, Clone, Serialize)]
147pub struct SearchCoverage {
148    pub total: usize,
149    pub returned: usize,
150    pub truncated: bool,
151}
152
153#[derive(Debug, Clone, Serialize)]
154pub struct AgentSearchResponse {
155    pub status: AgentStatus,
156    pub answer: String,
157    pub query: String,
158    pub semantic_available: bool,
159    pub file_count: usize,
160    pub evidence: Vec<SearchEvidence>,
161    pub coverage: SearchCoverage,
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub next_action: Option<String>,
164}
165
166#[derive(Debug, Clone, Copy)]
167pub struct AgentQueryOptions {
168    pub limit: usize,
169    pub budget_tokens: usize,
170}
171
172impl Default for AgentQueryOptions {
173    fn default() -> Self {
174        Self {
175            limit: DEFAULT_LIMIT,
176            budget_tokens: DEFAULT_BUDGET_TOKENS,
177        }
178    }
179}
180
181enum Resolution {
182    Exact(Box<Node>),
183    Ambiguous(Vec<Node>),
184    NotFound(Vec<Node>),
185}
186
187/// Format ranked search hits as compact implementation evidence shared by CLI
188/// and MCP. Retrieval may be lexical-only or RRF hybrid; presentation is stable.
189pub fn format_search<S: GraphStore + ?Sized>(
190    store: &S,
191    branch: &str,
192    query: &str,
193    hits: Vec<SearchHit>,
194    semantic_available: bool,
195    budget_tokens: usize,
196) -> Result<AgentSearchResponse> {
197    let total = hits.len();
198    let ids: Vec<String> = hits.iter().map(|hit| hit.id.clone()).collect();
199    let nodes = store.get_nodes_by_ids(branch, &ids)?;
200    let by_id: HashMap<String, Node> = nodes
201        .into_iter()
202        .map(|node| (node.id.as_str(), node))
203        .collect();
204    let mut files = HashSet::new();
205    let mut evidence = Vec::new();
206    for hit in hits {
207        files.insert(hit.file.clone());
208        let node = by_id.get(&hit.id);
209        let doc = node
210            .and_then(|node| node.metadata.definition.doc_comment.as_deref())
211            .and_then(|text| text.lines().find(|line| !line.trim().is_empty()))
212            .map(|line| line.trim().chars().take(180).collect());
213        evidence.push(SearchEvidence {
214            symbol: hit.name,
215            qualified_name: hit.qualified_name,
216            kind: hit.kind,
217            file: hit.file,
218            line: hit.start_line,
219            signature: node.map(sig_line).unwrap_or_default(),
220            doc,
221            score: hit.score,
222        });
223    }
224    let answer = if total == 0 {
225        format!("No code symbols matched '{query}'.")
226    } else {
227        let top_files = evidence
228            .iter()
229            .map(|item| item.file.as_str())
230            .take(3)
231            .collect::<Vec<_>>()
232            .join(", ");
233        format!(
234            "{total} ranked symbol match(es) across {} file(s). Top files: {top_files}.",
235            files.len()
236        )
237    };
238    let mut response = AgentSearchResponse {
239        status: if total == 0 {
240            AgentStatus::NotFound
241        } else {
242            AgentStatus::Ok
243        },
244        answer,
245        query: query.to_owned(),
246        semantic_available,
247        file_count: files.len(),
248        evidence,
249        coverage: SearchCoverage {
250            total,
251            returned: 0,
252            truncated: false,
253        },
254        next_action: if total == 0 {
255            Some("Try a concrete symbol fragment or alternate spelling.".to_owned())
256        } else {
257            None
258        },
259    };
260    apply_search_budget(&mut response, budget_tokens.max(MIN_BUDGET_TOKENS));
261    Ok(response)
262}
263
264/// Find callers for exactly one symbol and return a globally-budgeted response.
265/// Ambiguous short names return candidates without traversing the graph.
266pub fn find_callers<S: GraphStore + ?Sized>(
267    store: &S,
268    branch: &str,
269    query: &str,
270    depth: u8,
271    options: AgentQueryOptions,
272) -> Result<AgentCallersResponse> {
273    let depth = depth.clamp(1, 5);
274    let options = AgentQueryOptions {
275        limit: options.limit.clamp(1, MAX_LIMIT),
276        budget_tokens: options.budget_tokens.max(MIN_BUDGET_TOKENS),
277    };
278
279    let target = match resolve_symbol(store, branch, query)? {
280        Resolution::Exact(node) => *node,
281        Resolution::Ambiguous(nodes) => {
282            let total_candidates = nodes.len();
283            let candidates = candidate_head(nodes, 5);
284            return Ok(AgentCallersResponse {
285                status: AgentStatus::Ambiguous,
286                answer: format!(
287                    "'{}' matches {total_candidates} code symbols; choose a qualified symbol before computing impact.",
288                    query
289                ),
290                query: query.to_owned(),
291                branch: branch.to_owned(),
292                depth,
293                risk_level: "UNKNOWN".to_owned(),
294                symbol: None,
295                candidates,
296                evidence: Vec::new(),
297                coverage: Coverage {
298                    total: 0,
299                    returned: 0,
300                    truncated: false,
301                    confidence_mix: ConfidenceMix::default(),
302                },
303                next_action: Some(
304                    "Repeat find_callers with one candidate's qualified_name.".to_owned(),
305                ),
306            });
307        }
308        Resolution::NotFound(nodes) => {
309            let candidates = candidate_head(nodes, 5);
310            return Ok(AgentCallersResponse {
311                status: AgentStatus::NotFound,
312                answer: format!("No exact code symbol matching '{query}' was found."),
313                query: query.to_owned(),
314                branch: branch.to_owned(),
315                depth,
316                risk_level: "UNKNOWN".to_owned(),
317                symbol: None,
318                candidates,
319                evidence: Vec::new(),
320                coverage: Coverage {
321                    total: 0,
322                    returned: 0,
323                    truncated: false,
324                    confidence_mix: ConfidenceMix::default(),
325                },
326                next_action: Some("Use search_code to find the exact qualified symbol.".to_owned()),
327            });
328        }
329    };
330
331    let target_summary = to_candidate(&target);
332    let mut seen: HashSet<String> = HashSet::new();
333    seen.insert(target.id.as_str());
334    let mut frontier = vec![target.id.as_str()];
335    let mut evidence = Vec::new();
336    let mut mix = ConfidenceMix::default();
337
338    for hop in 1..=depth {
339        let mut pairs = Vec::new();
340        for target_id in &frontier {
341            pairs.extend(store.find_callers_by_id_with_confidence(branch, target_id)?);
342        }
343        pairs.retain(|(node, _)| seen.insert(node.id.as_str()));
344        pairs.sort_by(rank_callers);
345
346        frontier = pairs.iter().map(|(node, _)| node.id.as_str()).collect();
347        for (node, confidence) in pairs {
348            match confidence {
349                EdgeConfidence::Extracted => mix.extracted += 1,
350                EdgeConfidence::Resolved => mix.resolved += 1,
351                EdgeConfidence::Inferred => mix.inferred += 1,
352            }
353            evidence.push(to_evidence(node, confidence, hop));
354        }
355        if frontier.is_empty() {
356            break;
357        }
358    }
359
360    let total = evidence.len();
361    let risk_level = match total {
362        0..=2 => "LOW",
363        3..=10 => "MEDIUM",
364        11..=30 => "HIGH",
365        _ => "CRITICAL",
366    };
367    evidence.truncate(options.limit);
368
369    let answer = if total == 0 {
370        format!(
371            "No callers found for '{}' ({}).",
372            target.name, target.qualified_name
373        )
374    } else {
375        format!(
376            "{total} caller(s) within {depth} hop(s) of '{}' — change risk {risk_level}.",
377            target.qualified_name
378        )
379    };
380
381    let mut response = AgentCallersResponse {
382        status: AgentStatus::Ok,
383        answer,
384        query: query.to_owned(),
385        branch: branch.to_owned(),
386        depth,
387        risk_level: risk_level.to_owned(),
388        symbol: Some(target_summary),
389        candidates: Vec::new(),
390        evidence,
391        coverage: Coverage {
392            total,
393            returned: 0,
394            truncated: false,
395            confidence_mix: mix,
396        },
397        next_action: None,
398    };
399    apply_budget(&mut response, options.budget_tokens);
400    Ok(response)
401}
402
403/// Return a compact, exact-ID neighborhood digest. Only direct relationships
404/// are serialized as evidence; deeper traversal contributes coverage counts.
405pub fn get_subgraph<S: GraphStore + ?Sized>(
406    store: &S,
407    branch: &str,
408    query: &str,
409    depth: u8,
410    direction: &str,
411    options: AgentQueryOptions,
412) -> Result<AgentSubgraphResponse> {
413    let depth = depth.clamp(1, 5);
414    let direction = match direction {
415        "in" | "out" | "both" => direction,
416        _ => "both",
417    };
418    let options = AgentQueryOptions {
419        limit: options.limit.clamp(1, MAX_LIMIT),
420        budget_tokens: options.budget_tokens.max(MIN_BUDGET_TOKENS),
421    };
422
423    let target = match resolve_symbol(store, branch, query)? {
424        Resolution::Exact(node) => *node,
425        Resolution::Ambiguous(nodes) => {
426            let total = nodes.len();
427            return Ok(AgentSubgraphResponse {
428                status: AgentStatus::Ambiguous,
429                answer: format!(
430                    "'{query}' matches {total} code symbols; choose a qualified symbol before traversing its neighborhood."
431                ),
432                query: query.to_owned(),
433                branch: branch.to_owned(),
434                depth,
435                direction: direction.to_owned(),
436                symbol: None,
437                candidates: candidate_head(nodes, 5),
438                relation_counts: Default::default(),
439                evidence: Vec::new(),
440                coverage: NeighborhoodCoverage {
441                    graph_nodes: 0,
442                    graph_edges: 0,
443                    direct_relations: 0,
444                    returned: 0,
445                    truncated: false,
446                },
447                next_action: Some(
448                    "Repeat get_subgraph with one candidate's qualified_name.".to_owned(),
449                ),
450            });
451        }
452        Resolution::NotFound(nodes) => {
453            return Ok(AgentSubgraphResponse {
454                status: AgentStatus::NotFound,
455                answer: format!("No exact code symbol matching '{query}' was found."),
456                query: query.to_owned(),
457                branch: branch.to_owned(),
458                depth,
459                direction: direction.to_owned(),
460                symbol: None,
461                candidates: candidate_head(nodes, 5),
462                relation_counts: Default::default(),
463                evidence: Vec::new(),
464                coverage: NeighborhoodCoverage {
465                    graph_nodes: 0,
466                    graph_edges: 0,
467                    direct_relations: 0,
468                    returned: 0,
469                    truncated: false,
470                },
471                next_action: Some("Use search_code to find the exact qualified symbol.".to_owned()),
472            });
473        }
474    };
475
476    let graph = store.get_subgraph_by_id(branch, &target.id.as_str(), depth, direction)?;
477    let by_id: HashMap<String, &Node> = graph
478        .nodes
479        .iter()
480        .filter(|node| is_code_node(node))
481        .map(|node| (node.id.as_str(), node))
482        .collect();
483    let target_id = target.id.as_str();
484    let mut evidence = Vec::new();
485    let mut seen = HashSet::new();
486    let mut counts = std::collections::BTreeMap::new();
487
488    for edge in &graph.edges {
489        let src = edge.src.as_str();
490        let dst = edge.dst.as_str();
491        let (other_id, edge_direction) = if src == target_id {
492            (dst, "out")
493        } else if dst == target_id {
494            (src, "in")
495        } else {
496            continue;
497        };
498        if direction != "both" && direction != edge_direction {
499            continue;
500        }
501        let Some(other) = by_id.get(&other_id) else {
502            continue;
503        };
504        let relation = relation_label(&edge.kind, edge_direction);
505        if !seen.insert((relation, other_id)) {
506            continue;
507        }
508        *counts.entry(relation.to_owned()).or_insert(0) += 1;
509        evidence.push(RelationEvidence {
510            relation: relation.to_owned(),
511            direction: edge_direction.to_owned(),
512            symbol: other.name.clone(),
513            qualified_name: other.qualified_name.clone(),
514            kind: other.kind.to_string(),
515            file: other.file.display().to_string(),
516            line: edge.line.unwrap_or(other.span.start_line),
517            confidence: edge.confidence.to_string(),
518            is_test: is_test_file(&other.file),
519        });
520    }
521    evidence.sort_by(|a, b| {
522        relation_rank(&a.relation)
523            .cmp(&relation_rank(&b.relation))
524            .then_with(|| {
525                confidence_label_rank(&a.confidence).cmp(&confidence_label_rank(&b.confidence))
526            })
527            .then_with(|| a.is_test.cmp(&b.is_test))
528            .then_with(|| a.file.cmp(&b.file))
529            .then_with(|| a.qualified_name.cmp(&b.qualified_name))
530    });
531    let direct_relations = evidence.len();
532    evidence.truncate(options.limit);
533    let count_summary = counts
534        .iter()
535        .map(|(relation, count)| format!("{relation}={count}"))
536        .collect::<Vec<_>>()
537        .join(", ");
538    let answer = if count_summary.is_empty() {
539        format!(
540            "'{}' has no direct relationships in the selected direction.",
541            target.qualified_name
542        )
543    } else {
544        format!(
545            "Direct relationships for '{}': {count_summary}.",
546            target.qualified_name
547        )
548    };
549    let mut response = AgentSubgraphResponse {
550        status: AgentStatus::Ok,
551        answer,
552        query: query.to_owned(),
553        branch: branch.to_owned(),
554        depth,
555        direction: direction.to_owned(),
556        symbol: Some(to_candidate(&target)),
557        candidates: Vec::new(),
558        relation_counts: counts,
559        evidence,
560        coverage: NeighborhoodCoverage {
561            graph_nodes: by_id.len(),
562            graph_edges: graph.edges.len(),
563            direct_relations,
564            returned: 0,
565            truncated: false,
566        },
567        next_action: None,
568    };
569    apply_subgraph_budget(&mut response, options.budget_tokens);
570    Ok(response)
571}
572
573#[derive(Debug, Clone, Serialize)]
574pub struct AgentSymbolContextResponse {
575    pub status: AgentStatus,
576    pub answer: String,
577    pub query: String,
578    pub branch: String,
579    #[serde(skip_serializing_if = "Option::is_none")]
580    pub symbol: Option<SymbolCandidate>,
581    #[serde(skip_serializing_if = "Vec::is_empty")]
582    pub candidates: Vec<SymbolCandidate>,
583    pub callers: Vec<RelationEvidence>,
584    pub callees: Vec<RelationEvidence>,
585    pub used_by: Vec<RelationEvidence>,
586    pub coverage: Coverage,
587    #[serde(skip_serializing_if = "Option::is_none")]
588    pub next_action: Option<String>,
589}
590
591/// 360° view of exactly one resolved symbol: direct callers, callees, and
592/// type usages. Unlike the old name-based `GraphStore::symbol_context`, this
593/// goes through `resolve_symbol` first so an ambiguous short name (e.g. two
594/// unrelated `beginArray` methods) surfaces candidates instead of silently
595/// picking one — the same ambiguity handling `find_callers`/`get_subgraph`
596/// already have.
597pub fn symbol_context<S: GraphStore + ?Sized>(
598    store: &S,
599    branch: &str,
600    query: &str,
601    options: AgentQueryOptions,
602) -> Result<AgentSymbolContextResponse> {
603    let options = AgentQueryOptions {
604        limit: options.limit.clamp(1, MAX_LIMIT),
605        budget_tokens: options.budget_tokens.max(MIN_BUDGET_TOKENS),
606    };
607
608    let target = match resolve_symbol(store, branch, query)? {
609        Resolution::Exact(node) => *node,
610        Resolution::Ambiguous(nodes) => {
611            let total = nodes.len();
612            return Ok(AgentSymbolContextResponse {
613                status: AgentStatus::Ambiguous,
614                answer: format!(
615                    "'{query}' matches {total} code symbols; choose a qualified symbol before requesting its context."
616                ),
617                query: query.to_owned(),
618                branch: branch.to_owned(),
619                symbol: None,
620                candidates: candidate_head(nodes, 5),
621                callers: Vec::new(),
622                callees: Vec::new(),
623                used_by: Vec::new(),
624                coverage: Coverage {
625                    total: 0,
626                    returned: 0,
627                    truncated: false,
628                    confidence_mix: ConfidenceMix::default(),
629                },
630                next_action: Some(
631                    "Repeat symbol_context with one candidate's qualified_name.".to_owned(),
632                ),
633            });
634        }
635        Resolution::NotFound(nodes) => {
636            return Ok(AgentSymbolContextResponse {
637                status: AgentStatus::NotFound,
638                answer: format!("No exact code symbol matching '{query}' was found."),
639                query: query.to_owned(),
640                branch: branch.to_owned(),
641                symbol: None,
642                candidates: candidate_head(nodes, 5),
643                callers: Vec::new(),
644                callees: Vec::new(),
645                used_by: Vec::new(),
646                coverage: Coverage {
647                    total: 0,
648                    returned: 0,
649                    truncated: false,
650                    confidence_mix: ConfidenceMix::default(),
651                },
652                next_action: Some("Use search_code to find the exact qualified symbol.".to_owned()),
653            });
654        }
655    };
656
657    let graph = store.get_subgraph_by_id(branch, &target.id.as_str(), 1, "both")?;
658    let by_id: HashMap<String, &Node> = graph
659        .nodes
660        .iter()
661        .filter(|node| is_code_node(node))
662        .map(|node| (node.id.as_str(), node))
663        .collect();
664    let target_id = target.id.as_str();
665
666    let mut callers = Vec::new();
667    let mut callees = Vec::new();
668    let mut used_by = Vec::new();
669    let mut seen = HashSet::new();
670    let mut mix = ConfidenceMix::default();
671
672    for edge in &graph.edges {
673        let src = edge.src.as_str();
674        let dst = edge.dst.as_str();
675        let (other_id, edge_direction) = if src == target_id {
676            (dst, "out")
677        } else if dst == target_id {
678            (src, "in")
679        } else {
680            continue;
681        };
682        let Some(other) = by_id.get(&other_id) else {
683            continue;
684        };
685        let bucket = match (&edge.kind, edge_direction) {
686            (EdgeKind::Calls, "in") => &mut callers,
687            (EdgeKind::Calls, "out") => &mut callees,
688            (EdgeKind::Uses, "in") => &mut used_by,
689            _ => continue,
690        };
691        if !seen.insert((edge_direction, other_id)) {
692            continue;
693        }
694        match edge.confidence {
695            EdgeConfidence::Extracted => mix.extracted += 1,
696            EdgeConfidence::Resolved => mix.resolved += 1,
697            EdgeConfidence::Inferred => mix.inferred += 1,
698        }
699        bucket.push(RelationEvidence {
700            relation: relation_label(&edge.kind, edge_direction).to_owned(),
701            direction: edge_direction.to_owned(),
702            symbol: other.name.clone(),
703            qualified_name: other.qualified_name.clone(),
704            kind: other.kind.to_string(),
705            file: other.file.display().to_string(),
706            line: edge.line.unwrap_or(other.span.start_line),
707            confidence: edge.confidence.to_string(),
708            is_test: is_test_file(&other.file),
709        });
710    }
711    for bucket in [&mut callers, &mut callees, &mut used_by] {
712        bucket.sort_by(|a, b| {
713            a.file
714                .cmp(&b.file)
715                .then_with(|| a.qualified_name.cmp(&b.qualified_name))
716        });
717    }
718
719    let total = callers.len() + callees.len() + used_by.len();
720    for bucket in [&mut callers, &mut callees, &mut used_by] {
721        bucket.truncate(options.limit);
722    }
723    let answer = format!(
724        "'{}' has {} caller(s), {} callee(s), {} usage site(s).",
725        target.qualified_name,
726        callers.len(),
727        callees.len(),
728        used_by.len()
729    );
730    let mut response = AgentSymbolContextResponse {
731        status: AgentStatus::Ok,
732        answer,
733        query: query.to_owned(),
734        branch: branch.to_owned(),
735        symbol: Some(to_candidate(&target)),
736        candidates: Vec::new(),
737        callers,
738        callees,
739        used_by,
740        coverage: Coverage {
741            total,
742            returned: 0,
743            truncated: false,
744            confidence_mix: mix,
745        },
746        next_action: None,
747    };
748    apply_symbol_context_budget(&mut response, options.budget_tokens);
749    Ok(response)
750}
751
752fn apply_symbol_context_budget(response: &mut AgentSymbolContextResponse, budget_tokens: usize) {
753    let budget_bytes = budget_tokens * 4;
754    while (!response.used_by.is_empty()
755        || !response.callees.is_empty()
756        || !response.callers.is_empty())
757        && serde_json::to_vec(response)
758            .map(|bytes| bytes.len() > budget_bytes)
759            .unwrap_or(false)
760    {
761        if !response.used_by.is_empty() {
762            response.used_by.pop();
763        } else if !response.callees.is_empty() {
764            response.callees.pop();
765        } else {
766            response.callers.pop();
767        }
768    }
769    response.coverage.returned =
770        response.callers.len() + response.callees.len() + response.used_by.len();
771    response.coverage.truncated = response.coverage.returned < response.coverage.total;
772}
773
774fn relation_label(kind: &EdgeKind, direction: &str) -> &'static str {
775    match (kind, direction) {
776        (EdgeKind::Calls, "out") => "calls",
777        (EdgeKind::Calls, _) => "called_by",
778        (EdgeKind::Uses, "out") => "uses",
779        (EdgeKind::Uses, _) => "used_by",
780        (EdgeKind::Implements, "out") => "implements",
781        (EdgeKind::Implements, _) => "implemented_by",
782        (EdgeKind::Imports, "out") => "imports",
783        (EdgeKind::Imports, _) => "imported_by",
784        (EdgeKind::Contains, "out") => "contains",
785        (EdgeKind::Contains, _) => "contained_by",
786        (EdgeKind::Inherits, "out") => "inherits",
787        (EdgeKind::Inherits, _) => "inherited_by",
788        (EdgeKind::References, "out") => "references",
789        (EdgeKind::References, _) => "referenced_by",
790        _ => "related",
791    }
792}
793
794fn relation_rank(relation: &str) -> u8 {
795    match relation {
796        "called_by" | "calls" => 0,
797        "used_by" | "uses" => 1,
798        "implemented_by" | "implements" | "inherited_by" | "inherits" => 2,
799        "imported_by" | "imports" => 3,
800        "contained_by" | "contains" => 4,
801        _ => 5,
802    }
803}
804
805fn confidence_label_rank(confidence: &str) -> u8 {
806    match confidence {
807        "extracted" => 0,
808        "resolved" => 1,
809        _ => 2,
810    }
811}
812
813fn resolve_symbol<S: GraphStore + ?Sized>(
814    store: &S,
815    branch: &str,
816    query: &str,
817) -> Result<Resolution> {
818    let query = query.trim();
819    let mut exact = store.lookup_symbol(branch, query, false)?;
820    exact.retain(is_code_node);
821
822    // A qualified query may not match `lookup_symbol`, which is intentionally
823    // short-name based. Search a bounded candidate set and compare exactly.
824    let mut searched = store.search_nodes(branch, query, 50)?;
825    searched.retain(is_code_node);
826    if query.contains("::") || query.contains('.') {
827        let qualified: Vec<Node> = searched
828            .iter()
829            .filter(|node| node.qualified_name.eq_ignore_ascii_case(query))
830            .cloned()
831            .collect();
832        if qualified.len() == 1 {
833            return Ok(Resolution::Exact(Box::new(qualified[0].clone())));
834        }
835        if qualified.len() > 1 {
836            return Ok(Resolution::Ambiguous(qualified));
837        }
838    }
839
840    dedup_nodes(&mut exact);
841    match exact.len() {
842        1 => Ok(Resolution::Exact(Box::new(exact.remove(0)))),
843        n if n > 1 => Ok(Resolution::Ambiguous(exact)),
844        _ => {
845            searched.sort_by(rank_candidates);
846            dedup_nodes(&mut searched);
847            Ok(Resolution::NotFound(searched))
848        }
849    }
850}
851
852fn is_code_node(node: &Node) -> bool {
853    !matches!(
854        node.kind,
855        NodeKind::Section | NodeKind::File | NodeKind::Folder | NodeKind::Module
856    )
857}
858
859fn dedup_nodes(nodes: &mut Vec<Node>) {
860    let mut seen = HashSet::new();
861    nodes.retain(|node| seen.insert(node.id.as_str()));
862}
863
864fn candidate_head(mut nodes: Vec<Node>, limit: usize) -> Vec<SymbolCandidate> {
865    nodes.sort_by(rank_candidates);
866    nodes
867        .into_iter()
868        .take(limit)
869        .map(|n| to_candidate(&n))
870        .collect()
871}
872
873fn rank_candidates(a: &Node, b: &Node) -> std::cmp::Ordering {
874    candidate_rank(a)
875        .cmp(&candidate_rank(b))
876        .then_with(|| a.file.cmp(&b.file))
877        .then_with(|| a.qualified_name.cmp(&b.qualified_name))
878}
879
880fn candidate_rank(node: &Node) -> (u8, u8) {
881    let test = is_test_file(&node.file) as u8;
882    let visibility = match node.metadata.visibility {
883        Visibility::Pub => 0,
884        Visibility::PubCrate => 1,
885        Visibility::Private => 2,
886    };
887    (test, visibility)
888}
889
890fn rank_callers(
891    (a, ac): &(Node, EdgeConfidence),
892    (b, bc): &(Node, EdgeConfidence),
893) -> std::cmp::Ordering {
894    confidence_rank(ac)
895        .cmp(&confidence_rank(bc))
896        .then_with(|| candidate_rank(a).cmp(&candidate_rank(b)))
897        .then_with(|| a.file.cmp(&b.file))
898        .then_with(|| a.qualified_name.cmp(&b.qualified_name))
899}
900
901fn to_candidate(node: &Node) -> SymbolCandidate {
902    SymbolCandidate {
903        id: node.id.as_str(),
904        name: node.name.clone(),
905        qualified_name: node.qualified_name.clone(),
906        kind: node.kind.to_string(),
907        file: node.file.display().to_string(),
908        start_line: node.span.start_line,
909        visibility: node.metadata.visibility.to_string(),
910    }
911}
912
913fn to_evidence(node: Node, confidence: EdgeConfidence, hop: u8) -> CallerEvidence {
914    CallerEvidence {
915        hop,
916        symbol: node.name.clone(),
917        qualified_name: node.qualified_name.clone(),
918        kind: node.kind.to_string(),
919        file: node.file.display().to_string(),
920        line: node.span.start_line,
921        signature: sig_line(&node),
922        confidence: confidence.to_string(),
923        is_test: is_test_file(&node.file),
924    }
925}
926
927fn apply_search_budget(response: &mut AgentSearchResponse, budget_tokens: usize) {
928    let budget_bytes = budget_tokens * 4;
929    while !response.evidence.is_empty()
930        && serde_json::to_vec(response)
931            .map(|bytes| bytes.len() > budget_bytes)
932            .unwrap_or(false)
933    {
934        response.evidence.pop();
935    }
936    response.coverage.returned = response.evidence.len();
937    response.coverage.truncated = response.coverage.returned < response.coverage.total;
938}
939
940fn apply_subgraph_budget(response: &mut AgentSubgraphResponse, budget_tokens: usize) {
941    let budget_bytes = budget_tokens * 4;
942    while !response.evidence.is_empty()
943        && serde_json::to_vec(response)
944            .map(|bytes| bytes.len() > budget_bytes)
945            .unwrap_or(false)
946    {
947        response.evidence.pop();
948    }
949    response.coverage.returned = response.evidence.len();
950    response.coverage.truncated = response.coverage.returned < response.coverage.direct_relations;
951}
952
953fn apply_budget(response: &mut AgentCallersResponse, budget_tokens: usize) {
954    let budget_bytes = budget_tokens * 4;
955    while !response.evidence.is_empty()
956        && serde_json::to_vec(response)
957            .map(|bytes| bytes.len() > budget_bytes)
958            .unwrap_or(false)
959    {
960        response.evidence.pop();
961    }
962    response.coverage.returned = response.evidence.len();
963    response.coverage.truncated = response.coverage.returned < response.coverage.total;
964}
965
966#[cfg(test)]
967mod tests {
968    use super::*;
969
970    #[test]
971    fn test_file_detection_covers_supported_languages() {
972        for path in [
973            "tests/api.rs",
974            "src/api_test.go",
975            "src/api.test.ts",
976            "src/__tests__/api.tsx",
977            "src/ApiTest.java",
978        ] {
979            assert!(
980                is_test_file(std::path::Path::new(path)),
981                "expected test path: {path}"
982            );
983        }
984        assert!(!is_test_file(std::path::Path::new("src/api.rs")));
985    }
986
987    #[test]
988    fn confidence_order_is_strongest_first() {
989        assert!(
990            confidence_rank(&EdgeConfidence::Extracted)
991                < confidence_rank(&EdgeConfidence::Resolved)
992        );
993        assert!(
994            confidence_rank(&EdgeConfidence::Resolved) < confidence_rank(&EdgeConfidence::Inferred)
995        );
996    }
997}