Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,667 changes: 655 additions & 1,012 deletions Cargo.lock

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.143"

# Parse-specific dependencies
bytes = { version = "1.11.1", optional = true }
reqwest = { version = "0.12.23", features = ["multipart", "json", "rustls-tls"], default-features = false, optional = true }
tokio = { version = "1.47.1", features = ["full"], optional = true }
sha2 = { version = "0.10.8", optional = true }
Expand All @@ -51,7 +52,7 @@ model2vec-rs = { version = "0.1.3", optional = true }
simsimd = { version = "6.5.1", optional = true }

# Workspace-specific dependencies
lancedb = { version = "0.22.3", default-features = false, optional = true }
lancedb = { version = "0.23.1", default-features = false, optional = true }
arrow-schema = { version = "56.2.0", optional = true }
arrow-array = { version = "56.2.0", optional = true }
rand = { version = "0.8.5", optional = true }
Expand All @@ -69,7 +70,7 @@ tempfile = "3.14.0"

[features]
default = ["parse", "search", "workspace", "ask"]
parse = ["reqwest", "tokio", "sha2", "hex", "mime_guess"]
parse = ["bytes", "reqwest", "tokio", "sha2", "hex", "mime_guess"]
search = ["model2vec-rs", "simsimd"]
workspace = ["tokio", "lancedb", "arrow-schema", "arrow-array", "rand"]
ask = ["async-openai", "model2vec-rs", "simsimd", "tokio", "grep", "grep-searcher", "grep-matcher", "grep-regex", "regex"]
172 changes: 25 additions & 147 deletions src/ask/chat_agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ use async_openai::types::chat::{
};
use async_openai::{Client, types::chat::CreateChatCompletionRequestArgs};
use model2vec_rs::model::StaticModel;
use serde_json::Value;

use crate::ask::system_prompt::{STDIN_SYSTEM_PROMPT, SYSTEM_PROMPT};
use crate::ask::tool_calling::{call_tool, print_tool_summary};
use crate::ask::tools::{AgentTool, GrepTool, ReadTool, SearchTool};
use crate::search::SearchConfig;
use crate::json_mode::AskOutput;

/// Run an agent loop with the search and read tools
///
Expand All @@ -32,8 +32,13 @@ pub async fn ask_agent(
client: &Client<OpenAIConfig>,
api_model: &str,
max_iterations: Option<usize>,
) -> Result<String> {
) -> Result<AskOutput> {
let max_iterations = max_iterations.unwrap_or(20);
let mut result = AskOutput {
query: user_message.to_string(),
response: String::new(),
files_searched: vec![],
};

// Build the tools
let tools: Vec<ChatCompletionTools> = vec![
Expand Down Expand Up @@ -82,7 +87,8 @@ pub async fn ask_agent(
let args = &tool_call.function.arguments;

// Call the appropriate tool
let response_content = call_tool(name, args, &files, model).await?;
let response_content =
call_tool(name, args, &files, model, &mut result).await?;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like how I need to pass in this mutable list reference, but I don't see a better way

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I think this is what makes the most sense


// Print summary of the tool response
print_tool_summary(&response_content);
Expand Down Expand Up @@ -113,154 +119,20 @@ pub async fn ask_agent(
} else {
// No tool calls - we have a final response
if let Some(content) = response_message.content {
return Ok(content);
result.response = content.clone();
} else {
return Err(anyhow::anyhow!("No content in final response"));
result.response = "<No response>".to_string();
}

return Ok(result);
}
}

Err(anyhow::anyhow!(
result.response = format!(
"Max iterations ({}) reached without final response",
max_iterations
))
}

/// Call a tool by name with the given arguments
async fn call_tool(
name: &str,
args: &str,
files: &[String],
model: &StaticModel,
) -> Result<String> {
let function_args: Value = serde_json::from_str(args)?;

match name {
"grep" => {
let pattern = function_args["pattern"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing 'pattern' parameter"))?;

let file_paths: Option<Vec<String>> =
function_args["file_paths"].as_array().map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
});

let is_regex = function_args["is_regex"].as_bool().unwrap_or(false);
let case_sensitive = function_args["case_sensitive"].as_bool().unwrap_or(true);
let context_lines = function_args["context_lines"].as_u64().unwrap_or(3) as usize;

// Log the tool call
println!("\n[Tool Call: grep]");
println!(" pattern: \"{}\"", pattern);
println!(" is_regex: {}", is_regex);
println!(" case_sensitive: {}", case_sensitive);
println!(" context_lines: {}", context_lines);
if let Some(ref paths) = file_paths
&& !paths.is_empty()
{
println!(" file_paths: {:?}", paths);
}

GrepTool::grep(
files,
pattern,
file_paths,
is_regex,
case_sensitive,
context_lines,
)
.await
}
"search" => {
let query = function_args["query"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing 'query' parameter"))?;

let config_json = &function_args["config"];
let n_lines = config_json["n_lines"].as_u64().unwrap_or(5) as usize;
let ignore_case = config_json["ignore_case"].as_bool().unwrap_or(false);
let max_distance = config_json["max_distance"].as_f64();
let top_k = config_json["top_k"].as_u64().unwrap_or(3) as usize;

let config = SearchConfig {
n_lines,
ignore_case,
max_distance,
top_k,
};

// Log the tool call with formatted parameters
println!("\n[Tool Call: search]");
println!(" query: \"{}\"", query);
println!(" config:");
println!(" n_lines: {}", n_lines);
println!(" ignore_case: {}", ignore_case);

// Max distance and top_k are mutually exclusive
if let Some(md) = max_distance {
println!(" max_distance: {:?}", md);
} else {
println!(" top_k: {}", top_k);
}

SearchTool::search(files, query, model, config).await
}
"read" => {
let path = function_args["path"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing 'path' parameter"))?;
let start_line = function_args["start_line"]
.as_u64()
.ok_or_else(|| anyhow::anyhow!("Missing 'start_line' parameter"))?
as usize;
let end_line = function_args["end_line"]
.as_u64()
.ok_or_else(|| anyhow::anyhow!("Missing 'end_line' parameter"))?
as usize;

// Log the tool call with formatted parameters
println!("\n[Tool Call: read]");
println!(" path: {}", path);
println!(" start_line: {}", start_line);
println!(" end_line: {}", end_line);

ReadTool::read(path, start_line, end_line).await
}
_ => Err(anyhow::anyhow!("Unknown tool: {}", name)),
}
}

/// Print a summary of the tool response
fn print_tool_summary(response: &str) {
// Count the number of <chunk> tags
let chunk_count = response.matches("<chunk").count();

// Count total lines in all chunks (excluding the chunk tags themselves)
let total_lines: usize = response
.split("<chunk")
.skip(1) // Skip content before first chunk
.filter_map(|chunk| {
// Find the content between the opening tag and </chunk>
chunk
.split_once(">")
.and_then(|(_, rest)| rest.split_once("</chunk>"))
.map(|(content, _)| content.lines().count())
})
.sum();

if chunk_count > 0 {
println!(
" → Returned {} chunk(s) with {} total lines",
chunk_count, total_lines
);
} else if response.contains("No matches found") {
println!(" → No matches found");
} else {
println!(" → Returned {} lines", response.lines().count());
}
);
Ok(result)
}

/// Run an agent with stdin content injected directly (no tools available)
Expand All @@ -278,12 +150,17 @@ pub async fn ask_agent_with_stdin(
user_message: &str,
client: &Client<OpenAIConfig>,
api_model: &str,
) -> Result<String> {
) -> Result<AskOutput> {
// Construct the user message with stdin content
let full_message = format!(
"<stdin_content>\n{}\n</stdin_content>\n\n{}",
stdin_content, user_message
);
let mut result = AskOutput {
query: user_message.to_string(),
response: String::new(),
files_searched: vec!["<stdin>".to_string()],
};

// Initialize messages with system prompt and user message (no tools)
let messages: Vec<ChatCompletionRequestMessage> = vec![
Expand Down Expand Up @@ -313,7 +190,8 @@ pub async fn ask_agent_with_stdin(

// Return the content
if let Some(content) = response_message.content {
Ok(content)
result.response = content;
Ok(result)
} else {
Err(anyhow::anyhow!("No content in response"))
}
Expand Down
1 change: 1 addition & 0 deletions src/ask/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod chat_agent;
pub mod responses_agent;
mod system_prompt;
mod tool_calling;
pub mod tools;
Loading