@@ -818,7 +818,10 @@ fn estimate_conversation_tokens(conversation: &[serde_json::Value]) -> usize {
818818/// 2. Stub old tool results (beyond recent 8 messages) to "[compacted]"
819819/// 3. Drop old conversation turns, keeping system prompt + recent 8 messages
820820fn compact_conversation ( conversation : & mut Vec < serde_json:: Value > , target_tokens : usize ) {
821- if estimate_conversation_tokens ( conversation) < target_tokens || conversation. len ( ) < 8 {
821+ // Keep the 8 most recent conversation entries intact.
822+ const KEEP_RECENT : usize = 8 ;
823+
824+ if estimate_conversation_tokens ( conversation) < target_tokens || conversation. len ( ) < KEEP_RECENT {
822825 return ;
823826 }
824827
@@ -850,8 +853,8 @@ fn compact_conversation(conversation: &mut Vec<serde_json::Value>, target_tokens
850853 return ;
851854 }
852855
853- // Tier 2: Stub old tool results (keep recent 8 messages intact)
854- let recent_start = conversation. len ( ) . saturating_sub ( 8 ) ;
856+ // Tier 2: Stub old tool results (keep recent KEEP_RECENT messages intact)
857+ let recent_start = conversation. len ( ) . saturating_sub ( KEEP_RECENT ) ;
855858 for ( i, msg) in conversation. iter_mut ( ) . enumerate ( ) {
856859 if i >= recent_start {
857860 break ;
@@ -869,9 +872,9 @@ fn compact_conversation(conversation: &mut Vec<serde_json::Value>, target_tokens
869872 return ;
870873 }
871874
872- // Tier 3: Drop old turns, keep system prompt (index 0) + recent 8
875+ // Tier 3: Drop old turns, keep system prompt (index 0) + recent KEEP_RECENT
873876 let keep_start = 1 ;
874- let keep_end = conversation. len ( ) . saturating_sub ( 8 ) ;
877+ let keep_end = conversation. len ( ) . saturating_sub ( KEEP_RECENT ) ;
875878 if keep_end <= keep_start {
876879 return ;
877880 }
@@ -1507,75 +1510,174 @@ async fn run_agent_loop(
15071510 }
15081511 conversation. push ( assistant_msg) ;
15091512
1510- // Execute each tool call (with cancellation checks between each)
1513+ // ── Parallel read-only tool execution ────────────────────────────────
1514+ // Read-only tools (read_file, search, list_files, find_file) are safe to
1515+ // run concurrently — they never mutate files or state. When the LLM emits
1516+ // a batch of such calls we run them in parallel then collect results in
1517+ // original order. Write/run tools still execute sequentially to avoid
1518+ // race conditions and to preserve the cancel/approval flow.
1519+ const READONLY_TOOLS : & [ & str ] = & [ "read_file" , "search" , "list_files" , "find_file" ] ;
1520+
1521+ // Pre-parse and validate all args first so we can handle errors uniformly.
1522+ // Produces Vec<(idx, id, name, args_str, parsed_args_or_err)>
1523+ struct PreparedCall {
1524+ id : String ,
1525+ name : String ,
1526+ args_str : String ,
1527+ args : Result < serde_json:: Value , String > ,
1528+ }
1529+
1530+ let mut prepared: Vec < PreparedCall > = Vec :: new ( ) ;
15111531 for ( _, ( id, name, args_str) ) in & sorted_tool_calls {
1532+ let args = match serde_json:: from_str :: < serde_json:: Value > ( args_str) {
1533+ Ok ( v) => Ok ( v) ,
1534+ Err ( _) => {
1535+ let repaired = repair_json ( args_str) ;
1536+ match serde_json:: from_str :: < serde_json:: Value > ( & repaired) {
1537+ Ok ( v) => {
1538+ log:: info!( "Repaired malformed JSON for tool '{}'" , name) ;
1539+ Ok ( v)
1540+ }
1541+ Err ( e) => Err ( format ! (
1542+ "Failed to parse tool arguments for '{}': {}" ,
1543+ name, e
1544+ ) ) ,
1545+ }
1546+ }
1547+ } ;
1548+ prepared. push ( PreparedCall {
1549+ id : id. clone ( ) ,
1550+ name : name. clone ( ) ,
1551+ args_str : args_str. clone ( ) ,
1552+ args,
1553+ } ) ;
1554+ }
1555+
1556+ // Partition into read-only batches (can run in parallel) and the rest
1557+ // (must run sequentially, preserving original ordering).
1558+ // We collect all readonly calls that don't have parse errors, emit their
1559+ // tool_call events, run them concurrently, then emit results.
1560+ // Sequential calls are processed in their original position so ordering
1561+ // relative to each other is maintained.
1562+ //
1563+ // Strategy: find a contiguous run of readonly tools at the front, run
1564+ // them in parallel, then fall through to sequential for the rest.
1565+ // This is safe because LLMs almost always put reads first, then writes.
1566+ let readonly_count = prepared. iter ( ) . take_while ( |c| {
1567+ c. args . is_ok ( )
1568+ && READONLY_TOOLS . contains ( & c. name . as_str ( ) )
1569+ // submit must always be sequential (it terminates the session)
1570+ && c. name != "submit"
1571+ } ) . count ( ) ;
1572+
1573+ // Run the leading read-only batch in parallel
1574+ if readonly_count > 1 {
1575+ let _ = app. emit_to ( label, "agent_status" , "Reading files..." ) ;
1576+ let batch = & prepared[ ..readonly_count] ;
1577+
1578+ // Emit all tool_call events upfront so UI shows them as running
1579+ for call in batch {
1580+ let _ = app. emit_to (
1581+ label,
1582+ "agent_tool_call" ,
1583+ json ! ( { "id" : call. id, "name" : call. name, "arguments" : call. args_str } ) ,
1584+ ) ;
1585+ }
1586+
1587+ // Execute in parallel
1588+ let workspace_clone = workspace_dir. clone ( ) ;
1589+ let results: Vec < String > = futures:: future:: join_all ( batch. iter ( ) . map ( |call| {
1590+ let args = call. args . as_ref ( ) . unwrap ( ) . clone ( ) ;
1591+ let name = call. name . clone ( ) ;
1592+ let ws = workspace_clone. clone ( ) ;
1593+ async move { execute_tool ( & name, & args, & ws) . await }
1594+ } ) )
1595+ . await ;
1596+
1597+ // Collect results, emit, and push to conversation
1598+ const MAX_RESULT_CHARS : usize = 12000 ;
1599+ for ( call, result) in batch. iter ( ) . zip ( results. into_iter ( ) ) {
1600+ let _ = app. emit_to (
1601+ label,
1602+ "agent_tool_result" ,
1603+ json ! ( { "tool_call_id" : call. id, "result" : & result } ) ,
1604+ ) ;
1605+ let context_result = if result. len ( ) > MAX_RESULT_CHARS {
1606+ let head = & result[ ..MAX_RESULT_CHARS / 2 ] ;
1607+ let tail = & result[ result. len ( ) - MAX_RESULT_CHARS / 2 ..] ;
1608+ format ! (
1609+ "{}\n \n [... {} chars omitted — use read_file with offset for full content ...]\n \n {}" ,
1610+ head,
1611+ result. len( ) - MAX_RESULT_CHARS ,
1612+ tail
1613+ )
1614+ } else {
1615+ result
1616+ } ;
1617+ conversation. push ( json ! ( {
1618+ "role" : "tool" ,
1619+ "tool_call_id" : call. id,
1620+ "content" : context_result,
1621+ } ) ) ;
1622+ }
1623+ }
1624+
1625+ // Execute remaining calls sequentially (also handles readonly_count == 1)
1626+ let sequential_start = if readonly_count > 1 { readonly_count } else { 0 } ;
1627+ for call in & prepared[ sequential_start..] {
15121628 // Check cancellation before each tool execution
15131629 if cancel_rx. try_recv ( ) . is_ok ( ) {
15141630 let _ = app. emit_to ( label, "agent_stream" , "\n *[Stopped by user]*\n " ) ;
15151631 let _ = app. emit_to ( label, "agent_stream" , "[DONE]" ) ;
15161632 return Ok ( ( ) ) ;
15171633 }
15181634
1519- let args: serde_json:: Value = match serde_json:: from_str ( args_str) {
1520- Ok ( value) => value,
1521- Err ( _e) => {
1522- // Try to repair common JSON issues from LLM output (malformed strings, trailing commas, etc.)
1523- let repaired = repair_json ( args_str) ;
1524- match serde_json:: from_str ( & repaired) {
1525- Ok ( value) => {
1526- log:: info!( "Repaired malformed JSON for tool '{}'" , name) ;
1527- value
1528- }
1529- Err ( e) => {
1530- let result = tool_error (
1531- "INVALID_TOOL_ARGUMENTS" ,
1532- format ! ( "Failed to parse tool arguments for '{}': {}" , name, e) ,
1533- true ,
1534- ) ;
1535- let _ = app. emit_to (
1536- label,
1537- "agent_tool_call" ,
1538- json ! ( { "id" : id, "name" : name, "arguments" : args_str } ) ,
1539- ) ;
1540- let _ = app. emit_to (
1541- label,
1542- "agent_tool_result" ,
1543- json ! ( { "tool_call_id" : id, "result" : & result } ) ,
1544- ) ;
1545- conversation. push ( json ! ( {
1546- "role" : "tool" ,
1547- "tool_call_id" : id,
1548- "content" : result,
1549- } ) ) ;
1550- continue ;
1551- }
1552- }
1635+ let args = match & call. args {
1636+ Ok ( v) => v. clone ( ) ,
1637+ Err ( e) => {
1638+ let result = tool_error ( "INVALID_TOOL_ARGUMENTS" , e. clone ( ) , true ) ;
1639+ let _ = app. emit_to (
1640+ label,
1641+ "agent_tool_call" ,
1642+ json ! ( { "id" : call. id, "name" : call. name, "arguments" : call. args_str } ) ,
1643+ ) ;
1644+ let _ = app. emit_to (
1645+ label,
1646+ "agent_tool_result" ,
1647+ json ! ( { "tool_call_id" : call. id, "result" : & result } ) ,
1648+ ) ;
1649+ conversation. push ( json ! ( {
1650+ "role" : "tool" ,
1651+ "tool_call_id" : call. id,
1652+ "content" : result,
1653+ } ) ) ;
1654+ continue ;
15531655 }
15541656 } ;
15551657
1556- if let Err ( e) = validate_tool_args ( name, & args) {
1658+ if let Err ( e) = validate_tool_args ( & call . name , & args) {
15571659 let result = tool_error ( "INVALID_TOOL_ARGUMENTS" , e, true ) ;
15581660 let _ = app. emit_to (
15591661 label,
15601662 "agent_tool_call" ,
1561- json ! ( { "id" : id, "name" : name, "arguments" : args_str } ) ,
1663+ json ! ( { "id" : call . id, "name" : call . name, "arguments" : call . args_str } ) ,
15621664 ) ;
15631665 let _ = app. emit_to (
15641666 label,
15651667 "agent_tool_result" ,
1566- json ! ( { "tool_call_id" : id, "result" : & result } ) ,
1668+ json ! ( { "tool_call_id" : call . id, "result" : & result } ) ,
15671669 ) ;
15681670 conversation. push ( json ! ( {
15691671 "role" : "tool" ,
1570- "tool_call_id" : id,
1672+ "tool_call_id" : call . id,
15711673 "content" : result,
15721674 } ) ) ;
15731675 continue ;
15741676 }
15751677
15761678 // Handle submit specially — emit the summary as a final assistant
15771679 // message rather than a tool call card, then finish the session.
1578- if name == "submit" {
1680+ if call . name == "submit" {
15791681 let summary = args
15801682 . get ( "summary" )
15811683 . and_then ( |v| v. as_str ( ) )
@@ -1585,7 +1687,7 @@ async fn run_agent_loop(
15851687 return Ok ( ( ) ) ;
15861688 }
15871689
1588- let tool_status = match name. as_str ( ) {
1690+ let tool_status = match call . name . as_str ( ) {
15891691 "read_file" => "Reading file..." ,
15901692 "write_file" => "Writing file..." ,
15911693 "edit_file" => "Editing file..." ,
@@ -1602,12 +1704,12 @@ async fn run_agent_loop(
16021704 let _ = app. emit_to (
16031705 label,
16041706 "agent_tool_call" ,
1605- json ! ( { "id" : id, "name" : name, "arguments" : args_str } ) ,
1707+ json ! ( { "id" : call . id, "name" : call . name, "arguments" : call . args_str } ) ,
16061708 ) ;
16071709
16081710 // For run_command: request user approval before executing.
16091711 // Emit approval request, wait for frontend response (or cancel).
1610- let result = if name == "run_command" {
1712+ let result = if call . name == "run_command" {
16111713 let command_preview = args. get ( "command" ) . and_then ( |v| v. as_str ( ) ) . unwrap_or ( "" ) . to_string ( ) ;
16121714
16131715 let ( approval_tx, approval_rx) = tokio:: sync:: oneshot:: channel :: < bool > ( ) ;
@@ -1619,7 +1721,7 @@ async fn run_agent_loop(
16191721 let _ = app. emit_to ( label, "agent_command_approval" , json ! ( {
16201722 "session_id" : session_id,
16211723 "command" : command_preview,
1622- "tool_call_id" : id,
1724+ "tool_call_id" : call . id,
16231725 } ) ) ;
16241726
16251727 // Wait for approval or cancel
@@ -1642,7 +1744,7 @@ async fn run_agent_loop(
16421744 } else {
16431745 // Execute with cancel-race
16441746 tokio:: select! {
1645- res = execute_tool( name, & args, & workspace_dir) => res,
1747+ res = execute_tool( & call . name, & args, & workspace_dir) => res,
16461748 _ = async {
16471749 loop {
16481750 tokio:: time:: sleep( std:: time:: Duration :: from_millis( 250 ) ) . await ;
@@ -1656,12 +1758,12 @@ async fn run_agent_loop(
16561758 }
16571759 }
16581760 } else {
1659- execute_tool ( name, & args, & workspace_dir) . await
1761+ execute_tool ( & call . name , & args, & workspace_dir) . await
16601762 } ;
16611763
16621764 // Notify frontend of file changes so open tabs, git status, and file tree update
16631765 // without relying solely on the OS file watcher (which can be delayed on macOS).
1664- if matches ! ( name. as_str( ) , "write_file" | "edit_file" ) {
1766+ if matches ! ( call . name. as_str( ) , "write_file" | "edit_file" ) {
16651767 if let Some ( path_str) = args. get ( "path" ) . and_then ( |v| v. as_str ( ) ) {
16661768 let full_path = std:: path:: Path :: new ( & workspace_dir) . join ( path_str) ;
16671769 let abs_path = full_path. to_string_lossy ( ) . to_string ( ) ;
@@ -1673,7 +1775,7 @@ async fn run_agent_loop(
16731775 let _ = app. emit_to (
16741776 label,
16751777 "agent_tool_result" ,
1676- json ! ( { "tool_call_id" : id, "result" : & result } ) ,
1778+ json ! ( { "tool_call_id" : call . id, "result" : & result } ) ,
16771779 ) ;
16781780
16791781 // Cap tool result before adding to conversation to prevent context explosion.
@@ -1694,7 +1796,7 @@ async fn run_agent_loop(
16941796
16951797 conversation. push ( json ! ( {
16961798 "role" : "tool" ,
1697- "tool_call_id" : id,
1799+ "tool_call_id" : call . id,
16981800 "content" : context_result,
16991801 } ) ) ;
17001802 }
0 commit comments