@@ -7,6 +7,9 @@ use crate::context_engine::ContextEngineApi;
77use crate :: error:: ToolError ;
88use super :: { Tool , ToolContext , ToolResult } ;
99
10+ /// Max rows rendered per graph section (callers / deps / related) under bench policy.
11+ const GRAPH_SECTION_CAP : usize = 50 ;
12+
1013pub struct CodebaseGraphTool {
1114 context_engine : Arc < dyn ContextEngineApi > ,
1215}
@@ -73,7 +76,13 @@ impl Tool for CodebaseGraphTool {
7376 ) ) ;
7477 }
7578
76- let _ = ctx; // ToolContext not needed for graph queries (no working-copy overlay)
79+ // Bench policy: cap each rendered section so a high-degree node doesn't dump
80+ // hundreds of rows into the context. No-op (unbounded) under the app policy.
81+ let section_cap = if ctx. policy . codebase_result_caps {
82+ GRAPH_SECTION_CAP
83+ } else {
84+ usize:: MAX
85+ } ;
7786
7887 log:: info!(
7988 "[codebase_graph] query={:?}, function_name={:?}, file_path={:?}, query_type={:?}" ,
@@ -165,50 +174,47 @@ impl Tool for CodebaseGraphTool {
165174
166175 if show_callers && !callers. is_empty ( ) {
167176 output. push_str ( "## Blast Radius (upstream callers)\n \n " ) ;
168- for ( i, item) in callers. iter ( ) . enumerate ( ) {
169- output. push_str ( & format ! (
170- "{}. {} ({}, depth: {})\n " ,
171- i + 1 ,
172- item. name,
173- item. file_path,
174- item. depth,
175- ) ) ;
176- }
177+ render_section ( & mut output, & callers, section_cap) ;
177178 output. push ( '\n' ) ;
178179 }
179180
180181 if show_deps && !dependencies. is_empty ( ) {
181182 output. push_str ( "## Dependencies (downstream)\n \n " ) ;
182- for ( i, item) in dependencies. iter ( ) . enumerate ( ) {
183- output. push_str ( & format ! (
184- "{}. {} ({}, depth: {})\n " ,
185- i + 1 ,
186- item. name,
187- item. file_path,
188- item. depth,
189- ) ) ;
190- }
183+ render_section ( & mut output, & dependencies, section_cap) ;
191184 output. push ( '\n' ) ;
192185 }
193186
194187 if !other. is_empty ( ) && query_type. is_none ( ) {
195188 output. push_str ( "## Related\n \n " ) ;
196- for ( i, item) in other. iter ( ) . enumerate ( ) {
197- output. push_str ( & format ! (
198- "{}. {} ({}, depth: {})\n " ,
199- i + 1 ,
200- item. name,
201- item. file_path,
202- item. depth,
203- ) ) ;
204- }
189+ render_section ( & mut output, & other, section_cap) ;
205190 output. push ( '\n' ) ;
206191 }
207192
208193 Ok ( ToolResult :: success ( output) )
209194 }
210195}
211196
197+ /// Render up to `cap` graph rows; append a `… and N more` line when the section
198+ /// is truncated so the model knows results were withheld.
199+ fn render_section (
200+ output : & mut String ,
201+ items : & [ & crate :: context_engine:: GraphResultItem ] ,
202+ cap : usize ,
203+ ) {
204+ for ( i, item) in items. iter ( ) . take ( cap) . enumerate ( ) {
205+ output. push_str ( & format ! (
206+ "{}. {} ({}, depth: {})\n " ,
207+ i + 1 ,
208+ item. name,
209+ item. file_path,
210+ item. depth,
211+ ) ) ;
212+ }
213+ if items. len ( ) > cap {
214+ output. push_str ( & format ! ( "… and {} more\n " , items. len( ) - cap) ) ;
215+ }
216+ }
217+
212218#[ cfg( test) ]
213219mod tests {
214220 use super :: * ;
@@ -405,6 +411,57 @@ mod tests {
405411 assert ! ( result. output. contains( "'query' or 'function_name'" ) ) ;
406412 }
407413
414+ #[ tokio:: test]
415+ async fn test_bench_policy_caps_section ( ) {
416+ let results: Vec < GraphResultItem > = ( 0 ..60 )
417+ . map ( |i| GraphResultItem {
418+ name : format ! ( "caller_{i}" ) ,
419+ file_path : format ! ( "src/f{i}.go" ) ,
420+ chunk_id : format ! ( "g{i}" ) ,
421+ depth : 1 ,
422+ direction : Some ( "caller" . into ( ) ) ,
423+ } )
424+ . collect ( ) ;
425+ let mock = MockContextEngine :: with_graph_results ( results) ;
426+ let tool = CodebaseGraphTool :: new ( Arc :: new ( mock) ) ;
427+ let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
428+ let ctx = ToolContext :: test_context_bench ( dir. path ( ) ) ;
429+
430+ let result = tool
431+ . execute ( json ! ( { "function_name" : "f" , "query_type" : "blast_radius" } ) , & ctx)
432+ . await
433+ . unwrap ( ) ;
434+ assert ! ( !result. is_error) ;
435+ assert ! ( result. output. contains( "caller_49" ) , "row 50 (index 49) should render" ) ;
436+ assert ! ( !result. output. contains( "caller_50" ) , "row 51 must be capped under bench policy" ) ;
437+ assert ! ( result. output. contains( "… and 10 more" ) ) ;
438+ }
439+
440+ #[ tokio:: test]
441+ async fn test_default_policy_no_section_cap ( ) {
442+ let results: Vec < GraphResultItem > = ( 0 ..60 )
443+ . map ( |i| GraphResultItem {
444+ name : format ! ( "caller_{i}" ) ,
445+ file_path : format ! ( "src/f{i}.go" ) ,
446+ chunk_id : format ! ( "g{i}" ) ,
447+ depth : 1 ,
448+ direction : Some ( "caller" . into ( ) ) ,
449+ } )
450+ . collect ( ) ;
451+ let mock = MockContextEngine :: with_graph_results ( results) ;
452+ let tool = CodebaseGraphTool :: new ( Arc :: new ( mock) ) ;
453+ let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
454+ let ctx = ToolContext :: test_context ( dir. path ( ) ) ;
455+
456+ let result = tool
457+ . execute ( json ! ( { "function_name" : "f" , "query_type" : "blast_radius" } ) , & ctx)
458+ . await
459+ . unwrap ( ) ;
460+ assert ! ( !result. is_error) ;
461+ assert ! ( result. output. contains( "caller_59" ) , "default policy must render all rows" ) ;
462+ assert ! ( !result. output. contains( "more" ) ) ;
463+ }
464+
408465 #[ tokio:: test]
409466 async fn test_graph_function_name_only ( ) {
410467 let mock = MockContextEngine :: with_graph_results ( make_caller_results ( ) ) ;
0 commit comments