Skip to main content

dk_protocol/
context.rs

1use tonic::{Response, Status};
2use tracing::info;
3
4use crate::server::ProtocolServer;
5use crate::{
6    CallEdgeRef, ContextDepth, ContextRequest, ContextResponse, SymbolRef, SymbolResult,
7};
8
9/// Handle a CONTEXT RPC.
10///
11/// 1. Validates the session (and keeps it alive).
12/// 2. Runs a full-text search for the given query.
13/// 3. Depending on depth:
14///    - `SIGNATURES` -- return symbol metadata only.
15///    - `FULL`       -- also include source code.
16///    - `CALL_GRAPH` -- include source code + caller/callee edges.
17/// 4. Estimates token usage and truncates if `max_tokens` is set.
18///
19/// File reads now go through the session workspace overlay, so agents
20/// see their own in-progress modifications reflected in CONTEXT results.
21pub async fn handle_context(
22    server: &ProtocolServer,
23    req: ContextRequest,
24) -> Result<Response<ContextResponse>, Status> {
25    // 1. Validate session
26    let session = server.validate_session(&req.session_id)?;
27    crate::require_live_session::require_live_session(server, &req.session_id).await?;
28
29    // Touch session to keep it alive
30    let sid = req
31        .session_id
32        .parse::<uuid::Uuid>()
33        .map_err(|_| Status::invalid_argument("Invalid session ID"))?;
34    server.session_mgr().touch_session(&sid);
35
36    // 2-3. Query symbols and build results.
37    //      `GitRepository` is !Sync, so all usage must stay within a single
38    //      block that does not hold it across `.await` points.
39    let depth = req.depth();
40    let include_source = depth == ContextDepth::Full || depth == ContextDepth::CallGraph;
41    let include_call_graph = depth == ContextDepth::CallGraph;
42
43    let max_results = if req.max_tokens > 0 {
44        ((req.max_tokens / 100) as usize).max(10)
45    } else {
46        50
47    };
48
49    let engine = server.engine();
50
51    let (symbol_results, call_edges) = {
52        let (repo_id, git_repo) = engine
53            .get_repo(&session.codebase)
54            .await
55            .map_err(|e| Status::internal(format!("Repo error: {e}")))?;
56
57        let symbols = engine
58            .query_symbols(repo_id, &req.query, max_results)
59            .await
60            .map_err(|e| Status::internal(format!("Query error: {e}")))?;
61
62        // Try to get the workspace for session-aware reads.
63        // If no workspace exists (shouldn't happen after CONNECT, but be defensive),
64        // fall through to direct file reads.
65        let maybe_ws = engine.workspace_manager().get_workspace(&sid);
66
67        let mut results = Vec::with_capacity(symbols.len());
68        let mut edges = Vec::new();
69
70        for sym in &symbols {
71            let mut result = SymbolResult {
72                symbol: Some(symbol_to_ref(sym)),
73                source: None,
74                caller_ids: vec![],
75                callee_ids: vec![],
76                test_symbol_ids: vec![],
77            };
78
79            // FULL / CALL_GRAPH depth: include source code (symbol span only)
80            if include_source {
81                let source_bytes = if let Some(ref ws) = maybe_ws {
82                    // Read through workspace overlay (sees session modifications)
83                    ws.read_file(
84                        &sym.file_path.to_string_lossy(),
85                        &git_repo,
86                    )
87                    .ok()
88                    .map(|r| r.content)
89                } else {
90                    // Fallback: read directly from working directory
91                    let file_path = git_repo.path().join(&sym.file_path);
92                    std::fs::read(&file_path).ok()
93                };
94
95                if let Some(source) = source_bytes {
96                    let start = sym.span.start_byte as usize;
97                    let end = sym.span.end_byte as usize;
98                    if end <= source.len() {
99                        result.source = Some(
100                            String::from_utf8_lossy(&source[start..end]).to_string(),
101                        );
102                    }
103                }
104            }
105
106            // CALL_GRAPH depth: include callers/callees
107            if include_call_graph {
108                // Drop git_repo borrow concern: get_call_graph doesn't need it
109                if let Ok((callers, callees)) = engine.get_call_graph(repo_id, sym.id).await {
110                    result.caller_ids = callers.iter().map(|s| s.id.to_string()).collect();
111                    result.callee_ids = callees.iter().map(|s| s.id.to_string()).collect();
112
113                    for caller in &callers {
114                        edges.push(CallEdgeRef {
115                            caller_id: caller.id.to_string(),
116                            callee_id: sym.id.to_string(),
117                            kind: "direct_call".to_string(),
118                        });
119                    }
120                }
121            }
122
123            results.push(result);
124        }
125
126        (results, edges)
127    };
128
129    // 4. Estimate tokens (~4 chars per token, rough heuristic)
130    let total_chars: usize = symbol_results
131        .iter()
132        .map(|r| {
133            let sym_size = r
134                .symbol
135                .as_ref()
136                .map(|s| s.name.len() + s.signature.len())
137                .unwrap_or(0);
138            let source_size = r.source.as_ref().map(|s| s.len()).unwrap_or(0);
139            sym_size + source_size
140        })
141        .sum();
142    let mut estimated_tokens = (total_chars / 4) as u32;
143
144    // 5. Truncate if max_tokens is set
145    let mut symbol_results = symbol_results;
146    if req.max_tokens > 0 && estimated_tokens > req.max_tokens {
147        let mut remaining = req.max_tokens;
148
149        for result in &mut symbol_results {
150            let sym_tokens = result
151                .symbol
152                .as_ref()
153                .map(|s| ((s.name.len() + s.signature.len()) / 4) as u32)
154                .unwrap_or(0);
155
156            if remaining < sym_tokens {
157                // Can't fit even the symbol header -- drop source.
158                result.source = None;
159                continue;
160            }
161            remaining -= sym_tokens;
162
163            if let Some(ref source) = result.source {
164                let source_tokens = (source.len() / 4) as u32;
165                if remaining < source_tokens {
166                    let max_chars = (remaining as usize) * 4;
167                    result.source = Some(source[..max_chars.min(source.len())].to_string());
168                    remaining = 0;
169                } else {
170                    remaining -= source_tokens;
171                }
172            }
173        }
174
175        estimated_tokens = req.max_tokens - remaining;
176    }
177
178    info!(
179        session_id = %req.session_id,
180        query = %req.query,
181        results = symbol_results.len(),
182        estimated_tokens,
183        "CONTEXT: query served"
184    );
185
186    Ok(Response::new(ContextResponse {
187        symbols: symbol_results,
188        call_graph: call_edges,
189        dependencies: if req.include_dependencies {
190            let (repo_id, _git_repo) = engine
191                .get_repo(&session.codebase)
192                .await
193                .map_err(|e| Status::internal(format!("Repo error: {e}")))?;
194
195            let deps = engine
196                .dep_store()
197                .find_by_repo(repo_id)
198                .await
199                .unwrap_or_default();
200
201            let mut dep_refs = Vec::with_capacity(deps.len());
202            for dep in &deps {
203                let symbol_ids = engine
204                    .dep_store()
205                    .find_symbols_for_dep(dep.id)
206                    .await
207                    .unwrap_or_default();
208
209                dep_refs.push(crate::DependencyRef {
210                    package: dep.package.clone(),
211                    version_req: dep.version_req.clone(),
212                    used_by_symbol_ids: symbol_ids.iter().map(|id| id.to_string()).collect(),
213                });
214            }
215            dep_refs
216        } else {
217            vec![]
218        },
219        estimated_tokens,
220    }))
221}
222
223/// Convert a `dk_core::Symbol` into the protobuf `SymbolRef` message.
224fn symbol_to_ref(sym: &dk_core::Symbol) -> SymbolRef {
225    SymbolRef {
226        id: sym.id.to_string(),
227        name: sym.name.clone(),
228        qualified_name: sym.qualified_name.clone(),
229        kind: sym.kind.to_string(),
230        visibility: format!("{:?}", sym.visibility),
231        file_path: sym.file_path.to_string_lossy().to_string(),
232        start_byte: sym.span.start_byte,
233        end_byte: sym.span.end_byte,
234        signature: sym.signature.clone().unwrap_or_default(),
235        doc_comment: sym.doc_comment.clone(),
236        parent_id: sym.parent.map(|p| p.to_string()),
237    }
238}