Skip to content

Commit 46bd18a

Browse files
committed
feat: parallelize read-only tools and fix git staged status handling
1 parent 0400a02 commit 46bd18a

5 files changed

Lines changed: 346 additions & 172 deletions

File tree

clif-pad-ide/src-tauri/src/commands/agent.rs

Lines changed: 156 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -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
820820
fn 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

Comments
 (0)