add context window monitor
Writes the current context window to logs/current_context_window (uses a symlink to a session ID). This PR was unfortunately generated by a different LLM and did a ton of superficial reformating, it's actually a fairly small and benign change, but I don't want to roll back everything. Hope that's ok.
This commit is contained in:
Generated
+2
@@ -1351,6 +1351,8 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"g3-cli",
|
||||
"g3-providers",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
|
||||
@@ -45,3 +45,9 @@ license = "MIT"
|
||||
g3-cli = { path = "crates/g3-cli" }
|
||||
tokio = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
g3-providers = { path = "crates/g3-providers" }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[[example]]
|
||||
name = "verify_message_id"
|
||||
path = "examples/verify_message_id.rs"
|
||||
|
||||
+290
-114
@@ -1,5 +1,5 @@
|
||||
use anyhow::Result;
|
||||
use crossterm::style::{Color, SetForegroundColor, ResetColor};
|
||||
use crossterm::style::{Color, ResetColor, SetForegroundColor};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -18,8 +18,13 @@ fn generate_turn_histogram(turn_metrics: &[TurnMetrics]) -> String {
|
||||
let mut histogram = String::new();
|
||||
|
||||
// Find max values for scaling
|
||||
let max_tokens = turn_metrics.iter().map(|t| t.tokens_used).max().unwrap_or(1);
|
||||
let max_time_ms = turn_metrics.iter()
|
||||
let max_tokens = turn_metrics
|
||||
.iter()
|
||||
.map(|t| t.tokens_used)
|
||||
.max()
|
||||
.unwrap_or(1);
|
||||
let max_time_ms = turn_metrics
|
||||
.iter()
|
||||
.map(|t| t.wall_clock_time.as_millis().min(u32::MAX as u128) as u32)
|
||||
.max()
|
||||
.unwrap_or(1);
|
||||
@@ -30,8 +35,15 @@ fn generate_turn_histogram(turn_metrics: &[TurnMetrics]) -> String {
|
||||
const TIME_CHAR: char = '▓';
|
||||
|
||||
histogram.push_str("\n📊 Per-Turn Performance Histogram:\n");
|
||||
histogram.push_str(&format!(" {} = Tokens Used (max: {})\n", TOKEN_CHAR, max_tokens));
|
||||
histogram.push_str(&format!(" {} = Wall Clock Time (max: {:.1}s)\n\n", TIME_CHAR, max_time_ms as f64 / 1000.0));
|
||||
histogram.push_str(&format!(
|
||||
" {} = Tokens Used (max: {})\n",
|
||||
TOKEN_CHAR, max_tokens
|
||||
));
|
||||
histogram.push_str(&format!(
|
||||
" {} = Wall Clock Time (max: {:.1}s)\n\n",
|
||||
TIME_CHAR,
|
||||
max_time_ms as f64 / 1000.0
|
||||
));
|
||||
|
||||
for metrics in turn_metrics {
|
||||
let turn_time_ms = metrics.wall_clock_time.as_millis().min(u32::MAX as u128) as u32;
|
||||
@@ -67,19 +79,17 @@ fn generate_turn_histogram(turn_metrics: &[TurnMetrics]) -> String {
|
||||
// Add turn information
|
||||
histogram.push_str(&format!(
|
||||
" Turn {:2}: {:>6} tokens │{:<40}│\n",
|
||||
metrics.turn_number,
|
||||
metrics.tokens_used,
|
||||
token_bar
|
||||
metrics.turn_number, metrics.tokens_used, token_bar
|
||||
));
|
||||
histogram.push_str(&format!(
|
||||
" {:>6} │{:<40}│\n",
|
||||
time_str,
|
||||
time_bar
|
||||
time_str, time_bar
|
||||
));
|
||||
|
||||
// Add separator line between turns (except for last turn)
|
||||
if metrics.turn_number != turn_metrics.last().unwrap().turn_number {
|
||||
histogram.push_str(" ────────────┼────────────────────────────────────────┤\n");
|
||||
histogram
|
||||
.push_str(" ────────────┼────────────────────────────────────────┤\n");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,10 +100,20 @@ fn generate_turn_histogram(turn_metrics: &[TurnMetrics]) -> String {
|
||||
let avg_time_ms = total_time.as_millis() as f64 / turn_metrics.len() as f64;
|
||||
|
||||
histogram.push_str("\n📈 Summary Statistics:\n");
|
||||
histogram.push_str(&format!(" • Total Tokens: {} across {} turns\n", total_tokens, turn_metrics.len()));
|
||||
histogram.push_str(&format!(
|
||||
" • Total Tokens: {} across {} turns\n",
|
||||
total_tokens,
|
||||
turn_metrics.len()
|
||||
));
|
||||
histogram.push_str(&format!(" • Average Tokens/Turn: {:.1}\n", avg_tokens));
|
||||
histogram.push_str(&format!(" • Total Time: {:.1}s\n", total_time.as_secs_f64()));
|
||||
histogram.push_str(&format!(" • Average Time/Turn: {:.1}s\n", avg_time_ms / 1000.0));
|
||||
histogram.push_str(&format!(
|
||||
" • Total Time: {:.1}s\n",
|
||||
total_time.as_secs_f64()
|
||||
));
|
||||
histogram.push_str(&format!(
|
||||
" • Average Time/Turn: {:.1}s\n",
|
||||
avg_time_ms / 1000.0
|
||||
));
|
||||
|
||||
histogram
|
||||
}
|
||||
@@ -181,15 +201,15 @@ use g3_config::Config;
|
||||
use g3_core::{project::Project, ui_writer::UiWriter, Agent, DiscoveryOptions};
|
||||
use rustyline::error::ReadlineError;
|
||||
use rustyline::DefaultEditor;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{error, info};
|
||||
|
||||
use g3_core::error_handling::{classify_error, ErrorType, RecoverableError};
|
||||
mod ui_writer_impl;
|
||||
mod simple_output;
|
||||
mod ui_writer_impl;
|
||||
use simple_output::SimpleOutput;
|
||||
mod machine_ui_writer;
|
||||
use machine_ui_writer::MachineUiWriter;
|
||||
@@ -285,7 +305,7 @@ pub struct Cli {
|
||||
|
||||
/// Enable fast codebase discovery before first LLM turn
|
||||
#[arg(long, value_name = "PATH")]
|
||||
pub codebase_fast_start: Option<PathBuf>
|
||||
pub codebase_fast_start: Option<PathBuf>,
|
||||
}
|
||||
|
||||
pub async fn run() -> Result<()> {
|
||||
@@ -293,9 +313,16 @@ pub async fn run() -> Result<()> {
|
||||
|
||||
// Check if flock mode is enabled
|
||||
if let (Some(project_dir), Some(flock_workspace), Some(num_segments)) =
|
||||
(&cli.project, &cli.flock_workspace, cli.segments) {
|
||||
(&cli.project, &cli.flock_workspace, cli.segments)
|
||||
{
|
||||
// Run flock mode
|
||||
return run_flock_mode(project_dir.clone(), flock_workspace.clone(), num_segments, cli.flock_max_turns).await;
|
||||
return run_flock_mode(
|
||||
project_dir.clone(),
|
||||
flock_workspace.clone(),
|
||||
num_segments,
|
||||
cli.flock_max_turns,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Otherwise, continue with normal mode
|
||||
@@ -361,7 +388,10 @@ pub async fn run() -> Result<()> {
|
||||
let project = if cli.autonomous {
|
||||
if let Some(requirements_text) = &cli.requirements {
|
||||
// Use requirements text override
|
||||
Project::new_autonomous_with_requirements(workspace_dir.clone(), requirements_text.clone())?
|
||||
Project::new_autonomous_with_requirements(
|
||||
workspace_dir.clone(),
|
||||
requirements_text.clone(),
|
||||
)?
|
||||
} else {
|
||||
// Use traditional requirements.md file
|
||||
Project::new_autonomous(workspace_dir.clone())?
|
||||
@@ -413,9 +443,7 @@ pub async fn run() -> Result<()> {
|
||||
|
||||
// Combine AGENTS.md and README content if both exist
|
||||
let combined_content = match (agents_content.clone(), readme_content.clone()) {
|
||||
(Some(agents), Some(readme)) => {
|
||||
Some(format!("{}\n\n{}", agents, readme))
|
||||
}
|
||||
(Some(agents), Some(readme)) => Some(format!("{}\n\n{}", agents, readme)),
|
||||
(Some(agents), None) => Some(agents),
|
||||
(None, Some(readme)) => Some(readme),
|
||||
(None, None) => None,
|
||||
@@ -458,7 +486,8 @@ pub async fn run() -> Result<()> {
|
||||
|
||||
if use_accumulative {
|
||||
// Run accumulative mode and return early
|
||||
run_accumulative_mode(workspace_dir.clone(), cli.clone(), combined_content.clone()).await?;
|
||||
run_accumulative_mode(workspace_dir.clone(), cli.clone(), combined_content.clone())
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -534,9 +563,12 @@ async fn run_accumulative_mode(
|
||||
output.print("g3 programming agent - autonomous mode");
|
||||
output.print(" >> describe what you want, I'll build it iteratively");
|
||||
output.print("");
|
||||
print!("{}workspace: {}{}\n",
|
||||
print!(
|
||||
"{}workspace: {}{}\n",
|
||||
SetForegroundColor(Color::DarkGrey),
|
||||
workspace_dir.display(), ResetColor);
|
||||
workspace_dir.display(),
|
||||
ResetColor
|
||||
);
|
||||
output.print("");
|
||||
output.print("💡 Each input you provide will be added to requirements");
|
||||
output.print(" and I'll automatically work on implementing them. You can");
|
||||
@@ -565,7 +597,10 @@ async fn run_accumulative_mode(
|
||||
if accumulated_requirements.is_empty() {
|
||||
output.print("📝 What would you like me to build? (describe your requirements)");
|
||||
} else {
|
||||
output.print(&format!("📝 Turn {} - What's next? (add more requirements or refinements)", turn_number + 1));
|
||||
output.print(&format!(
|
||||
"📝 Turn {} - What's next? (add more requirements or refinements)",
|
||||
turn_number + 1
|
||||
));
|
||||
}
|
||||
output.print(&format!("{}", "=".repeat(60)));
|
||||
|
||||
@@ -628,11 +663,14 @@ async fn run_accumulative_mode(
|
||||
};
|
||||
|
||||
// Combine with existing content (README/AGENTS.md)
|
||||
let chat_combined_content = match (requirements_context, combined_content.clone()) {
|
||||
(Some(req_ctx), Some(existing)) => Some(format!("{}\n\n{}", req_ctx, existing)),
|
||||
(Some(req_ctx), None) => Some(req_ctx),
|
||||
(None, existing) => existing,
|
||||
};
|
||||
let chat_combined_content =
|
||||
match (requirements_context, combined_content.clone()) {
|
||||
(Some(req_ctx), Some(existing)) => {
|
||||
Some(format!("{}\n\n{}", req_ctx, existing))
|
||||
}
|
||||
(Some(req_ctx), None) => Some(req_ctx),
|
||||
(None, existing) => existing,
|
||||
};
|
||||
|
||||
// Load configuration
|
||||
let mut config = Config::load_with_overrides(
|
||||
@@ -667,14 +705,24 @@ async fn run_accumulative_mode(
|
||||
.await?;
|
||||
|
||||
// Run interactive mode
|
||||
run_interactive(agent, cli.show_prompt, cli.show_code, chat_combined_content, &workspace_dir).await?;
|
||||
run_interactive(
|
||||
agent,
|
||||
cli.show_prompt,
|
||||
cli.show_code,
|
||||
chat_combined_content,
|
||||
&workspace_dir,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// After returning from interactive mode, exit
|
||||
output.print("\n👋 Goodbye!");
|
||||
break;
|
||||
}
|
||||
_ => {
|
||||
output.print(&format!("❌ Unknown command: {}. Type /help for available commands.", input));
|
||||
output.print(&format!(
|
||||
"❌ Unknown command: {}. Type /help for available commands.",
|
||||
input
|
||||
));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -700,7 +748,10 @@ async fn run_accumulative_mode(
|
||||
);
|
||||
|
||||
output.print("");
|
||||
output.print(&format!("📋 Current instructions and requirements (Turn {}):", turn_number));
|
||||
output.print(&format!(
|
||||
"📋 Current instructions and requirements (Turn {}):",
|
||||
turn_number
|
||||
));
|
||||
output.print(&format!(" {}", input));
|
||||
output.print("");
|
||||
output.print("🚀 Starting autonomous implementation...");
|
||||
@@ -709,7 +760,7 @@ async fn run_accumulative_mode(
|
||||
// Create a project with the accumulated requirements
|
||||
let project = Project::new_autonomous_with_requirements(
|
||||
workspace_dir.clone(),
|
||||
requirements_doc.clone()
|
||||
requirements_doc.clone(),
|
||||
)?;
|
||||
|
||||
// Ensure workspace exists and enter it
|
||||
@@ -765,8 +816,7 @@ async fn run_accumulative_mode(
|
||||
}
|
||||
};
|
||||
|
||||
match autonomous_result
|
||||
{
|
||||
match autonomous_result {
|
||||
Ok(_) => {
|
||||
output.print("");
|
||||
output.print("✅ Autonomous run completed");
|
||||
@@ -840,7 +890,9 @@ async fn run_autonomous_machine(
|
||||
);
|
||||
|
||||
println!("TASK_START");
|
||||
let result = agent.execute_task_with_timing(&task, None, false, show_prompt, show_code, true, None).await?;
|
||||
let result = agent
|
||||
.execute_task_with_timing(&task, None, false, show_prompt, show_code, true, None)
|
||||
.await?;
|
||||
println!("AGENT_RESPONSE:");
|
||||
println!("{}", result.response);
|
||||
println!("END_AGENT_RESPONSE");
|
||||
@@ -856,7 +908,6 @@ async fn run_with_console_mode(
|
||||
project: Project,
|
||||
combined_content: Option<String>,
|
||||
) -> Result<()> {
|
||||
|
||||
// Execute task, autonomous mode, or start interactive mode
|
||||
if cli.autonomous {
|
||||
// Autonomous mode with coach-player feedback loop
|
||||
@@ -874,12 +925,27 @@ async fn run_with_console_mode(
|
||||
// Single-shot mode
|
||||
let output = SimpleOutput::new();
|
||||
let result = agent
|
||||
.execute_task_with_timing(&task, None, false, cli.show_prompt, cli.show_code, true, None)
|
||||
.execute_task_with_timing(
|
||||
&task,
|
||||
None,
|
||||
false,
|
||||
cli.show_prompt,
|
||||
cli.show_code,
|
||||
true,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
output.print_smart(&result.response);
|
||||
} else {
|
||||
// Interactive mode (default)
|
||||
run_interactive(agent, cli.show_prompt, cli.show_code, combined_content, project.workspace()).await?;
|
||||
run_interactive(
|
||||
agent,
|
||||
cli.show_prompt,
|
||||
cli.show_code,
|
||||
combined_content,
|
||||
project.workspace(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -905,7 +971,15 @@ async fn run_with_machine_mode(
|
||||
} else if let Some(task) = cli.task {
|
||||
// Single-shot mode
|
||||
let result = agent
|
||||
.execute_task_with_timing(&task, None, false, cli.show_prompt, cli.show_code, true, None)
|
||||
.execute_task_with_timing(
|
||||
&task,
|
||||
None,
|
||||
false,
|
||||
cli.show_prompt,
|
||||
cli.show_code,
|
||||
true,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
println!("AGENT_RESPONSE:");
|
||||
println!("{}", result.response);
|
||||
@@ -943,9 +1017,10 @@ fn read_agents_config(workspace_dir: &Path) -> Option<String> {
|
||||
let alt_path = workspace_dir.join("agents.md");
|
||||
if alt_path.exists() {
|
||||
match std::fs::read_to_string(&alt_path) {
|
||||
Ok(content) => {
|
||||
Some(format!("🤖 Agent Configuration (from agents.md):\n\n{}", content))
|
||||
}
|
||||
Ok(content) => Some(format!(
|
||||
"🤖 Agent Configuration (from agents.md):\n\n{}",
|
||||
content
|
||||
)),
|
||||
Err(e) => {
|
||||
error!("Failed to read agents.md: {}", e);
|
||||
None
|
||||
@@ -1069,9 +1144,14 @@ async fn run_interactive<W: UiWriter>(
|
||||
// Display provider and model information
|
||||
match agent.get_provider_info() {
|
||||
Ok((provider, model)) => {
|
||||
print!("🔧 {}{}{} | {}{}{}\n",
|
||||
SetForegroundColor(Color::Cyan), provider, ResetColor,
|
||||
SetForegroundColor(Color::Yellow), model, ResetColor
|
||||
print!(
|
||||
"🔧 {}{}{} | {}{}{}\n",
|
||||
SetForegroundColor(Color::Cyan),
|
||||
provider,
|
||||
ResetColor,
|
||||
SetForegroundColor(Color::Yellow),
|
||||
model,
|
||||
ResetColor
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -1086,8 +1166,11 @@ async fn run_interactive<W: UiWriter>(
|
||||
let has_readme = content.contains("Project README");
|
||||
|
||||
if has_agents {
|
||||
print!("{}🤖 AGENTS.md configuration loaded{}\n",
|
||||
SetForegroundColor(Color::DarkGrey), ResetColor);
|
||||
print!(
|
||||
"{}🤖 AGENTS.md configuration loaded{}\n",
|
||||
SetForegroundColor(Color::DarkGrey),
|
||||
ResetColor
|
||||
);
|
||||
}
|
||||
|
||||
if has_readme {
|
||||
@@ -1095,17 +1178,22 @@ async fn run_interactive<W: UiWriter>(
|
||||
let readme_snippet = extract_readme_heading(content)
|
||||
.unwrap_or_else(|| "Project documentation loaded".to_string());
|
||||
|
||||
print!("{}📚 detected: {}{}\n",
|
||||
print!(
|
||||
"{}📚 detected: {}{}\n",
|
||||
SetForegroundColor(Color::DarkGrey),
|
||||
readme_snippet,
|
||||
ResetColor);
|
||||
ResetColor
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Display workspace path
|
||||
print!("{}workspace: {}{}\n",
|
||||
print!(
|
||||
"{}workspace: {}{}\n",
|
||||
SetForegroundColor(Color::DarkGrey),
|
||||
workspace_path.display(), ResetColor);
|
||||
workspace_path.display(),
|
||||
ResetColor
|
||||
);
|
||||
output.print("");
|
||||
|
||||
// Initialize rustyline editor with history
|
||||
@@ -1190,7 +1278,9 @@ async fn run_interactive<W: UiWriter>(
|
||||
output.print("📖 Control Commands:");
|
||||
output.print(" /compact - Trigger auto-summarization (compacts conversation history)");
|
||||
output.print(" /thinnify - Trigger context thinning (replaces large tool results with file references)");
|
||||
output.print(" /readme - Reload README.md and AGENTS.md from disk");
|
||||
output.print(
|
||||
" /readme - Reload README.md and AGENTS.md from disk",
|
||||
);
|
||||
output.print(" /stats - Show detailed context and performance statistics");
|
||||
output.print(" /help - Show this help message");
|
||||
output.print(" exit/quit - Exit the interactive session");
|
||||
@@ -1207,7 +1297,10 @@ async fn run_interactive<W: UiWriter>(
|
||||
output.print("⚠️ Summarization failed");
|
||||
}
|
||||
Err(e) => {
|
||||
output.print(&format!("❌ Error during summarization: {}", e));
|
||||
output.print(&format!(
|
||||
"❌ Error during summarization: {}",
|
||||
e
|
||||
));
|
||||
}
|
||||
}
|
||||
continue;
|
||||
@@ -1220,9 +1313,14 @@ async fn run_interactive<W: UiWriter>(
|
||||
"/readme" => {
|
||||
output.print("📚 Reloading README.md and AGENTS.md...");
|
||||
match agent.reload_readme() {
|
||||
Ok(true) => output.print("✅ README content reloaded successfully"),
|
||||
Ok(false) => output.print("⚠️ No README was loaded at startup, cannot reload"),
|
||||
Err(e) => output.print(&format!("❌ Error reloading README: {}", e)),
|
||||
Ok(true) => {
|
||||
output.print("✅ README content reloaded successfully")
|
||||
}
|
||||
Ok(false) => output
|
||||
.print("⚠️ No README was loaded at startup, cannot reload"),
|
||||
Err(e) => {
|
||||
output.print(&format!("❌ Error reloading README: {}", e))
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -1232,7 +1330,10 @@ async fn run_interactive<W: UiWriter>(
|
||||
continue;
|
||||
}
|
||||
_ => {
|
||||
output.print(&format!("❌ Unknown command: {}. Type /help for available commands.", input));
|
||||
output.print(&format!(
|
||||
"❌ Unknown command: {}. Type /help for available commands.",
|
||||
input
|
||||
));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -1421,8 +1522,12 @@ async fn run_interactive_machine(
|
||||
"/readme" => {
|
||||
println!("COMMAND: readme");
|
||||
match agent.reload_readme() {
|
||||
Ok(true) => println!("RESULT: README content reloaded successfully"),
|
||||
Ok(false) => println!("RESULT: No README was loaded at startup, cannot reload"),
|
||||
Ok(true) => {
|
||||
println!("RESULT: README content reloaded successfully")
|
||||
}
|
||||
Ok(false) => println!(
|
||||
"RESULT: No README was loaded at startup, cannot reload"
|
||||
),
|
||||
Err(e) => println!("ERROR: {}", e),
|
||||
}
|
||||
continue;
|
||||
@@ -1527,7 +1632,10 @@ async fn execute_task_machine(
|
||||
let delay_ms = 1000 * (2_u64.pow(attempt - 1));
|
||||
let delay = std::time::Duration::from_millis(delay_ms);
|
||||
|
||||
println!("TIMEOUT: attempt {} of {}, retrying in {:?}", attempt, MAX_TIMEOUT_RETRIES, delay);
|
||||
println!(
|
||||
"TIMEOUT: attempt {} of {}, retrying in {:?}",
|
||||
attempt, MAX_TIMEOUT_RETRIES, delay
|
||||
);
|
||||
|
||||
// Wait before retrying
|
||||
tokio::time::sleep(delay).await;
|
||||
@@ -1594,14 +1702,26 @@ fn display_context_progress<W: UiWriter>(agent: &Agent<W>, _output: &SimpleOutpu
|
||||
} else if percentage < 60.0 {
|
||||
Color::Yellow
|
||||
} else if percentage < 80.0 {
|
||||
Color::Rgb { r: 255, g: 165, b: 0 } // Orange
|
||||
Color::Rgb {
|
||||
r: 255,
|
||||
g: 165,
|
||||
b: 0,
|
||||
} // Orange
|
||||
} else {
|
||||
Color::Red
|
||||
};
|
||||
|
||||
// Print with colored dots (using print! directly to handle color codes)
|
||||
print!("Context: {}{}{}{} {:.0}% ({}/{} tokens)\n",
|
||||
SetForegroundColor(color), filled_str, empty_str, ResetColor, percentage, context.used_tokens, context.total_tokens);
|
||||
print!(
|
||||
"Context: {}{}{}{} {:.0}% ({}/{} tokens)\n",
|
||||
SetForegroundColor(color),
|
||||
filled_str,
|
||||
empty_str,
|
||||
ResetColor,
|
||||
percentage,
|
||||
context.used_tokens,
|
||||
context.total_tokens
|
||||
);
|
||||
}
|
||||
|
||||
/// Set up the workspace directory for autonomous mode
|
||||
@@ -1763,35 +1883,53 @@ async fn run_autonomous(
|
||||
|
||||
// Load fast-discovery messages before the loop starts (if enabled)
|
||||
let (discovery_messages, discovery_working_dir): (Vec<g3_providers::Message>, Option<String>) =
|
||||
if let Some(ref codebase_path) = codebase_fast_start {
|
||||
// Canonicalize the path to ensure it's absolute
|
||||
let canonical_path = codebase_path.canonicalize().unwrap_or_else(|_| codebase_path.clone());
|
||||
let path_str = canonical_path.to_string_lossy();
|
||||
output.print(&format!("🔍 Fast-discovery mode: will explore codebase at {}", path_str));
|
||||
// Get the provider from the agent and use async LLM-based discovery
|
||||
match agent.get_provider() {
|
||||
Ok(provider) => {
|
||||
// Create a status callback that prints to output
|
||||
let output_clone = output.clone();
|
||||
let status_callback: g3_planner::StatusCallback = Box::new(move |msg: &str| {
|
||||
output_clone.print(msg);
|
||||
});
|
||||
match g3_planner::get_initial_discovery_messages(&path_str, Some(&requirements), provider, Some(&status_callback)).await {
|
||||
Ok(messages) => (messages, Some(path_str.to_string())),
|
||||
Err(e) => {
|
||||
output.print(&format!("⚠️ LLM discovery failed: {}, skipping fast-start", e));
|
||||
(Vec::new(), None)
|
||||
if let Some(ref codebase_path) = codebase_fast_start {
|
||||
// Canonicalize the path to ensure it's absolute
|
||||
let canonical_path = codebase_path
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| codebase_path.clone());
|
||||
let path_str = canonical_path.to_string_lossy();
|
||||
output.print(&format!(
|
||||
"🔍 Fast-discovery mode: will explore codebase at {}",
|
||||
path_str
|
||||
));
|
||||
// Get the provider from the agent and use async LLM-based discovery
|
||||
match agent.get_provider() {
|
||||
Ok(provider) => {
|
||||
// Create a status callback that prints to output
|
||||
let output_clone = output.clone();
|
||||
let status_callback: g3_planner::StatusCallback = Box::new(move |msg: &str| {
|
||||
output_clone.print(msg);
|
||||
});
|
||||
match g3_planner::get_initial_discovery_messages(
|
||||
&path_str,
|
||||
Some(&requirements),
|
||||
provider,
|
||||
Some(&status_callback),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(messages) => (messages, Some(path_str.to_string())),
|
||||
Err(e) => {
|
||||
output.print(&format!(
|
||||
"⚠️ LLM discovery failed: {}, skipping fast-start",
|
||||
e
|
||||
));
|
||||
(Vec::new(), None)
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
output.print(&format!(
|
||||
"⚠️ Could not get provider: {}, skipping fast-start",
|
||||
e
|
||||
));
|
||||
(Vec::new(), None)
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
output.print(&format!("⚠️ Could not get provider: {}, skipping fast-start", e));
|
||||
(Vec::new(), None)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
(Vec::new(), None)
|
||||
};
|
||||
} else {
|
||||
(Vec::new(), None)
|
||||
};
|
||||
let has_discovery = !discovery_messages.is_empty();
|
||||
|
||||
let mut turn = 1;
|
||||
@@ -1823,7 +1961,10 @@ async fn run_autonomous(
|
||||
)
|
||||
};
|
||||
|
||||
output.print(&format!("🎯 Starting player implementation... (elapsed: {})", format_elapsed_time(loop_start.elapsed())));
|
||||
output.print(&format!(
|
||||
"🎯 Starting player implementation... (elapsed: {})",
|
||||
format_elapsed_time(loop_start.elapsed())
|
||||
));
|
||||
|
||||
// Display what feedback the player is receiving
|
||||
// If there's no coach feedback on subsequent turns, this is an error
|
||||
@@ -1863,7 +2004,9 @@ async fn run_autonomous(
|
||||
messages: &discovery_messages,
|
||||
fast_start_path: discovery_working_dir.as_deref(),
|
||||
})
|
||||
} else { None },
|
||||
} else {
|
||||
None
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -1878,7 +2021,10 @@ async fn run_autonomous(
|
||||
use g3_core::error_handling::{classify_error, ErrorType, RecoverableError};
|
||||
let error_type = classify_error(&e);
|
||||
|
||||
if matches!(error_type, ErrorType::Recoverable(RecoverableError::ContextLengthExceeded)) {
|
||||
if matches!(
|
||||
error_type,
|
||||
ErrorType::Recoverable(RecoverableError::ContextLengthExceeded)
|
||||
) {
|
||||
output.print(&format!("⚠️ Context length exceeded in player turn: {}", e));
|
||||
output.print("📝 Logging error to session and ending current turn...");
|
||||
|
||||
@@ -1924,10 +2070,7 @@ async fn run_autonomous(
|
||||
output.print("📝 Final Status: 💥 PLAYER PANIC");
|
||||
|
||||
output.print("\n📈 Token Usage Statistics:");
|
||||
output.print(&format!(
|
||||
" • Used Tokens: {}",
|
||||
context_window.used_tokens
|
||||
));
|
||||
output.print(&format!(" • Used Tokens: {}", context_window.used_tokens));
|
||||
output.print(&format!(
|
||||
" • Total Available: {}",
|
||||
context_window.total_tokens
|
||||
@@ -1954,9 +2097,8 @@ async fn run_autonomous(
|
||||
));
|
||||
|
||||
if _player_retry_count >= MAX_PLAYER_RETRIES {
|
||||
output.print(
|
||||
"🔄 Max retries reached for player, marking turn as failed...",
|
||||
);
|
||||
output
|
||||
.print("🔄 Max retries reached for player, marking turn as failed...");
|
||||
player_failed = true;
|
||||
break; // Exit retry loop
|
||||
}
|
||||
@@ -1973,7 +2115,10 @@ async fn run_autonomous(
|
||||
));
|
||||
// Record turn metrics before incrementing
|
||||
let turn_duration = turn_start_time.elapsed();
|
||||
let turn_tokens = agent.get_context_window().used_tokens.saturating_sub(turn_start_tokens);
|
||||
let turn_tokens = agent
|
||||
.get_context_window()
|
||||
.used_tokens
|
||||
.saturating_sub(turn_start_tokens);
|
||||
turn_metrics.push(TurnMetrics {
|
||||
turn_number: turn,
|
||||
tokens_used: turn_tokens,
|
||||
@@ -2006,7 +2151,8 @@ async fn run_autonomous(
|
||||
|
||||
let ui_writer = ConsoleUiWriter::new();
|
||||
let mut coach_agent =
|
||||
Agent::new_autonomous_with_readme_and_quiet(coach_config, ui_writer, None, quiet).await?;
|
||||
Agent::new_autonomous_with_readme_and_quiet(coach_config, ui_writer, None, quiet)
|
||||
.await?;
|
||||
|
||||
// Surface provider info for coach agent
|
||||
coach_agent.print_provider_banner("Coach");
|
||||
@@ -2050,7 +2196,10 @@ Remember: Be clear in your review and concise in your feedback. APPROVE iff the
|
||||
requirements
|
||||
);
|
||||
|
||||
output.print(&format!("🎓 Starting coach review... (elapsed: {})", format_elapsed_time(loop_start.elapsed())));
|
||||
output.print(&format!(
|
||||
"🎓 Starting coach review... (elapsed: {})",
|
||||
format_elapsed_time(loop_start.elapsed())
|
||||
));
|
||||
|
||||
// Execute coach task with retry on error
|
||||
let mut coach_retry_count = 0;
|
||||
@@ -2060,13 +2209,22 @@ Remember: Be clear in your review and concise in your feedback. APPROVE iff the
|
||||
|
||||
loop {
|
||||
match coach_agent
|
||||
.execute_task_with_timing(&coach_prompt, None, false, show_prompt, show_code, true,
|
||||
.execute_task_with_timing(
|
||||
&coach_prompt,
|
||||
None,
|
||||
false,
|
||||
show_prompt,
|
||||
show_code,
|
||||
true,
|
||||
if has_discovery {
|
||||
Some(DiscoveryOptions {
|
||||
messages: &discovery_messages,
|
||||
fast_start_path: discovery_working_dir.as_deref(),
|
||||
})
|
||||
} else { None })
|
||||
} else {
|
||||
None
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
@@ -2078,7 +2236,10 @@ Remember: Be clear in your review and concise in your feedback. APPROVE iff the
|
||||
use g3_core::error_handling::{classify_error, ErrorType, RecoverableError};
|
||||
let error_type = classify_error(&e);
|
||||
|
||||
if matches!(error_type, ErrorType::Recoverable(RecoverableError::ContextLengthExceeded)) {
|
||||
if matches!(
|
||||
error_type,
|
||||
ErrorType::Recoverable(RecoverableError::ContextLengthExceeded)
|
||||
) {
|
||||
output.print(&format!("⚠️ Context length exceeded in coach turn: {}", e));
|
||||
output.print("📝 Logging error to session and ending current turn...");
|
||||
|
||||
@@ -2174,7 +2335,10 @@ Remember: Be clear in your review and concise in your feedback. APPROVE iff the
|
||||
coach_feedback = "The implementation needs review. Please ensure all requirements are met and the code compiles without errors.".to_string();
|
||||
// Record turn metrics before incrementing
|
||||
let turn_duration = turn_start_time.elapsed();
|
||||
let turn_tokens = agent.get_context_window().used_tokens.saturating_sub(turn_start_tokens);
|
||||
let turn_tokens = agent
|
||||
.get_context_window()
|
||||
.used_tokens
|
||||
.saturating_sub(turn_start_tokens);
|
||||
turn_metrics.push(TurnMetrics {
|
||||
turn_number: turn,
|
||||
tokens_used: turn_tokens,
|
||||
@@ -2210,7 +2374,10 @@ Remember: Be clear in your review and concise in your feedback. APPROVE iff the
|
||||
coach_feedback = "The implementation needs review. Please ensure all requirements are met and the code compiles without errors.".to_string();
|
||||
// Record turn metrics before incrementing
|
||||
let turn_duration = turn_start_time.elapsed();
|
||||
let turn_tokens = agent.get_context_window().used_tokens.saturating_sub(turn_start_tokens);
|
||||
let turn_tokens = agent
|
||||
.get_context_window()
|
||||
.used_tokens
|
||||
.saturating_sub(turn_start_tokens);
|
||||
turn_metrics.push(TurnMetrics {
|
||||
turn_number: turn,
|
||||
tokens_used: turn_tokens,
|
||||
@@ -2241,7 +2408,10 @@ Remember: Be clear in your review and concise in your feedback. APPROVE iff the
|
||||
coach_feedback = coach_feedback_text;
|
||||
// Record turn metrics before incrementing
|
||||
let turn_duration = turn_start_time.elapsed();
|
||||
let turn_tokens = agent.get_context_window().used_tokens.saturating_sub(turn_start_tokens);
|
||||
let turn_tokens = agent
|
||||
.get_context_window()
|
||||
.used_tokens
|
||||
.saturating_sub(turn_start_tokens);
|
||||
turn_metrics.push(TurnMetrics {
|
||||
turn_number: turn,
|
||||
tokens_used: turn_tokens,
|
||||
@@ -2296,9 +2466,15 @@ Remember: Be clear in your review and concise in your feedback. APPROVE iff the
|
||||
output.print(&"=".repeat(60));
|
||||
|
||||
if implementation_approved {
|
||||
output.print(&format!("\n🎉 Autonomous mode completed successfully (total loop time: {})", format_elapsed_time(loop_start.elapsed())));
|
||||
output.print(&format!(
|
||||
"\n🎉 Autonomous mode completed successfully (total loop time: {})",
|
||||
format_elapsed_time(loop_start.elapsed())
|
||||
));
|
||||
} else {
|
||||
output.print(&format!("\n🔄 Autonomous mode terminated (max iterations) (total loop time: {})", format_elapsed_time(loop_start.elapsed())));
|
||||
output.print(&format!(
|
||||
"\n🔄 Autonomous mode terminated (max iterations) (total loop time: {})",
|
||||
format_elapsed_time(loop_start.elapsed())
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -89,7 +89,7 @@ impl UiWriter for MachineUiWriter {
|
||||
}
|
||||
|
||||
fn wants_full_output(&self) -> bool {
|
||||
true // Machine mode wants complete, untruncated output
|
||||
true // Machine mode wants complete, untruncated output
|
||||
}
|
||||
|
||||
fn prompt_user_yes_no(&self, message: &str) -> bool {
|
||||
|
||||
@@ -6,7 +6,9 @@ pub struct SimpleOutput {
|
||||
|
||||
impl SimpleOutput {
|
||||
pub fn new() -> Self {
|
||||
SimpleOutput { machine_mode: false }
|
||||
SimpleOutput {
|
||||
machine_mode: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_with_mode(machine_mode: bool) -> Self {
|
||||
|
||||
@@ -48,7 +48,8 @@ impl ConsoleUiWriter {
|
||||
println!("{}☐ {}", indent, task);
|
||||
} else if trimmed.starts_with("- [x]") || trimmed.starts_with("- [X]") {
|
||||
// Completed task
|
||||
let task = trimmed.strip_prefix("- [x]")
|
||||
let task = trimmed
|
||||
.strip_prefix("- [x]")
|
||||
.or_else(|| trimmed.strip_prefix("- [X]"))
|
||||
.unwrap_or(trimmed)
|
||||
.trim();
|
||||
@@ -108,9 +109,9 @@ impl UiWriter for ConsoleUiWriter {
|
||||
|
||||
// Flash animation: print with bright background, then normal
|
||||
let frames = vec![
|
||||
"\x1b[1;97;46m", // Frame 1: Bold white on cyan background
|
||||
"\x1b[1;97;42m", // Frame 2: Bold white on green background
|
||||
"\x1b[1;96;40m", // Frame 3: Bold cyan on black background
|
||||
"\x1b[1;97;46m", // Frame 1: Bold white on cyan background
|
||||
"\x1b[1;97;42m", // Frame 2: Bold white on green background
|
||||
"\x1b[1;96;40m", // Frame 3: Bold cyan on black background
|
||||
];
|
||||
|
||||
println!();
|
||||
@@ -143,8 +144,7 @@ impl UiWriter for ConsoleUiWriter {
|
||||
*self.in_todo_tool.lock().unwrap() = is_todo;
|
||||
|
||||
// For todo tools, we'll skip the normal header and print a custom one later
|
||||
if is_todo {
|
||||
}
|
||||
if is_todo {}
|
||||
}
|
||||
|
||||
fn print_tool_arg(&self, key: &str, value: &str) {
|
||||
@@ -192,7 +192,8 @@ impl UiWriter for ConsoleUiWriter {
|
||||
// Truncate long values for display
|
||||
let display_value = if first_line.len() > 80 {
|
||||
// Use char_indices to safely truncate at character boundary
|
||||
let truncate_at = first_line.char_indices()
|
||||
let truncate_at = first_line
|
||||
.char_indices()
|
||||
.nth(77)
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(first_line.len());
|
||||
@@ -208,8 +209,16 @@ impl UiWriter for ConsoleUiWriter {
|
||||
let has_end = args.iter().any(|(k, _)| k == "end");
|
||||
|
||||
if has_start || has_end {
|
||||
let start_val = args.iter().find(|(k, _)| k == "start").map(|(_, v)| v.as_str()).unwrap_or("0");
|
||||
let end_val = args.iter().find(|(k, _)| k == "end").map(|(_, v)| v.as_str()).unwrap_or("end");
|
||||
let start_val = args
|
||||
.iter()
|
||||
.find(|(k, _)| k == "start")
|
||||
.map(|(_, v)| v.as_str())
|
||||
.unwrap_or("0");
|
||||
let end_val = args
|
||||
.iter()
|
||||
.find(|(k, _)| k == "end")
|
||||
.map(|(_, v)| v.as_str())
|
||||
.unwrap_or("end");
|
||||
format!(" [{}..{}]", start_val, end_val)
|
||||
} else {
|
||||
String::new()
|
||||
@@ -219,7 +228,10 @@ impl UiWriter for ConsoleUiWriter {
|
||||
};
|
||||
|
||||
// Print with bold green tool name, purple (non-bold) for pipe and args
|
||||
println!("┌─\x1b[1;32m {}\x1b[0m\x1b[35m | {}{}\x1b[0m", tool_name, display_value, header_suffix);
|
||||
println!(
|
||||
"┌─\x1b[1;32m {}\x1b[0m\x1b[35m | {}{}\x1b[0m",
|
||||
tool_name, display_value, header_suffix
|
||||
);
|
||||
} else {
|
||||
// Print with bold green formatting using ANSI escape codes
|
||||
println!("┌─\x1b[1;32m {}\x1b[0m", tool_name);
|
||||
@@ -379,4 +391,3 @@ impl UiWriter for ConsoleUiWriter {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,12 +34,17 @@ fn main() {
|
||||
.expect("Failed to find .build/release directory");
|
||||
|
||||
// Copy the dylib to the output directory so it can be found at runtime
|
||||
let target_dir = manifest_dir.parent().unwrap().parent().unwrap().join("target");
|
||||
let target_dir = manifest_dir
|
||||
.parent()
|
||||
.unwrap()
|
||||
.parent()
|
||||
.unwrap()
|
||||
.join("target");
|
||||
let profile = env::var("PROFILE").unwrap_or_else(|_| "debug".to_string());
|
||||
|
||||
// Determine the actual target directory (could be llvm-cov-target or regular target)
|
||||
let target_dir_name = env::var("CARGO_TARGET_DIR")
|
||||
.unwrap_or_else(|_| target_dir.to_string_lossy().to_string());
|
||||
let target_dir_name =
|
||||
env::var("CARGO_TARGET_DIR").unwrap_or_else(|_| target_dir.to_string_lossy().to_string());
|
||||
let actual_target_dir = PathBuf::from(&target_dir_name);
|
||||
let output_dir = actual_target_dir.join(&profile);
|
||||
|
||||
@@ -47,13 +52,21 @@ fn main() {
|
||||
let dylib_dst = output_dir.join("libVisionBridge.dylib");
|
||||
|
||||
// Create output directory if it doesn't exist
|
||||
std::fs::create_dir_all(&output_dir)
|
||||
.expect(&format!("Failed to create output directory {}", output_dir.display()));
|
||||
std::fs::create_dir_all(&output_dir).expect(&format!(
|
||||
"Failed to create output directory {}",
|
||||
output_dir.display()
|
||||
));
|
||||
|
||||
std::fs::copy(&dylib_src, &dylib_dst)
|
||||
.expect(&format!("Failed to copy dylib from {} to {}", dylib_src.display(), dylib_dst.display()));
|
||||
std::fs::copy(&dylib_src, &dylib_dst).expect(&format!(
|
||||
"Failed to copy dylib from {} to {}",
|
||||
dylib_src.display(),
|
||||
dylib_dst.display()
|
||||
));
|
||||
|
||||
println!("cargo:warning=Copied libVisionBridge.dylib to {}", dylib_dst.display());
|
||||
println!(
|
||||
"cargo:warning=Copied libVisionBridge.dylib to {}",
|
||||
dylib_dst.display()
|
||||
);
|
||||
|
||||
// Add rpath so the dylib can be found at runtime
|
||||
println!("cargo:rustc-link-arg=-Wl,-rpath,@executable_path");
|
||||
@@ -68,5 +81,8 @@ fn main() {
|
||||
println!("cargo:rustc-link-lib=framework=CoreGraphics");
|
||||
println!("cargo:rustc-link-lib=framework=CoreImage");
|
||||
|
||||
println!("cargo:warning=VisionBridge built successfully at {}", lib_path.display());
|
||||
println!(
|
||||
"cargo:warning=VisionBridge built successfully at {}",
|
||||
lib_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,14 +23,23 @@ fn main() {
|
||||
println!("\nRow alignment:");
|
||||
println!(" Actual bytes per row: {}", bytes_per_row);
|
||||
println!(" Expected (width * 4): {}", expected_bytes_per_row);
|
||||
println!(" Padding per row: {}", bytes_per_row - expected_bytes_per_row);
|
||||
println!(
|
||||
" Padding per row: {}",
|
||||
bytes_per_row - expected_bytes_per_row
|
||||
);
|
||||
|
||||
// Sample some pixels from different locations
|
||||
println!("\nFirst 3 pixels (raw bytes):");
|
||||
for i in 0..3 {
|
||||
let offset = i * 4;
|
||||
println!(" Pixel {}: [{:3}, {:3}, {:3}, {:3}]",
|
||||
i, data[offset], data[offset+1], data[offset+2], data[offset+3]);
|
||||
println!(
|
||||
" Pixel {}: [{:3}, {:3}, {:3}, {:3}]",
|
||||
i,
|
||||
data[offset],
|
||||
data[offset + 1],
|
||||
data[offset + 2],
|
||||
data[offset + 3]
|
||||
);
|
||||
}
|
||||
|
||||
// Check a pixel from the middle
|
||||
@@ -40,7 +49,12 @@ fn main() {
|
||||
println!("\nMiddle pixel (row {}, col {}):", mid_row, mid_col);
|
||||
println!(" Offset: {}", mid_offset);
|
||||
if mid_offset + 3 < data.len() as usize {
|
||||
println!(" Bytes: [{:3}, {:3}, {:3}, {:3}]",
|
||||
data[mid_offset], data[mid_offset+1], data[mid_offset+2], data[mid_offset+3]);
|
||||
println!(
|
||||
" Bytes: [{:3}, {:3}, {:3}, {:3}]",
|
||||
data[mid_offset],
|
||||
data[mid_offset + 1],
|
||||
data[mid_offset + 2],
|
||||
data[mid_offset + 3]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use core_graphics::window::{kCGWindowListOptionOnScreenOnly, kCGNullWindowID, CGWindowListCopyWindowInfo};
|
||||
use core_foundation::base::{TCFType, ToVoid};
|
||||
use core_foundation::dictionary::CFDictionary;
|
||||
use core_foundation::string::CFString;
|
||||
use core_foundation::base::{TCFType, ToVoid};
|
||||
use core_graphics::window::{
|
||||
kCGNullWindowID, kCGWindowListOptionOnScreenOnly, CGWindowListCopyWindowInfo,
|
||||
};
|
||||
|
||||
fn main() {
|
||||
println!("Listing all on-screen windows...");
|
||||
@@ -9,13 +11,14 @@ fn main() {
|
||||
println!("{}", "-".repeat(80));
|
||||
|
||||
unsafe {
|
||||
let window_list = CGWindowListCopyWindowInfo(
|
||||
kCGWindowListOptionOnScreenOnly,
|
||||
kCGNullWindowID
|
||||
);
|
||||
let window_list =
|
||||
CGWindowListCopyWindowInfo(kCGWindowListOptionOnScreenOnly, kCGNullWindowID);
|
||||
|
||||
let count = core_foundation::array::CFArray::<CFDictionary>::wrap_under_create_rule(window_list).len();
|
||||
let array = core_foundation::array::CFArray::<CFDictionary>::wrap_under_create_rule(window_list);
|
||||
let count =
|
||||
core_foundation::array::CFArray::<CFDictionary>::wrap_under_create_rule(window_list)
|
||||
.len();
|
||||
let array =
|
||||
core_foundation::array::CFArray::<CFDictionary>::wrap_under_create_rule(window_list);
|
||||
|
||||
for i in 0..count {
|
||||
let dict = array.get(i).unwrap();
|
||||
@@ -23,7 +26,8 @@ fn main() {
|
||||
// Get window ID
|
||||
let window_id_key = CFString::from_static_string("kCGWindowNumber");
|
||||
let window_id: i64 = if let Some(value) = dict.find(window_id_key.to_void()) {
|
||||
let num: core_foundation::number::CFNumber = TCFType::wrap_under_get_rule(*value as *const _);
|
||||
let num: core_foundation::number::CFNumber =
|
||||
TCFType::wrap_under_get_rule(*value as *const _);
|
||||
num.to_i64().unwrap_or(0)
|
||||
} else {
|
||||
0
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use g3_computer_control::SafariDriver;
|
||||
use g3_computer_control::webdriver::WebDriverController;
|
||||
use anyhow::Result;
|
||||
use g3_computer_control::webdriver::WebDriverController;
|
||||
use g3_computer_control::SafariDriver;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
@@ -47,7 +47,9 @@ async fn main() -> Result<()> {
|
||||
|
||||
// Execute JavaScript
|
||||
println!("Executing JavaScript...");
|
||||
let result = driver.execute_script("return document.title", vec![]).await?;
|
||||
let result = driver
|
||||
.execute_script("return document.title", vec![])
|
||||
.await?;
|
||||
println!("JS result: {:?}\n", result);
|
||||
|
||||
// Take a screenshot
|
||||
|
||||
@@ -6,7 +6,10 @@ async fn main() {
|
||||
|
||||
let controller = create_controller().expect("Failed to create controller");
|
||||
|
||||
match controller.take_screenshot("/tmp/test_with_prompt.png", None, None).await {
|
||||
match controller
|
||||
.take_screenshot("/tmp/test_with_prompt.png", None, None)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
println!("\n✅ Screenshot saved to /tmp/test_with_prompt.png");
|
||||
println!("Opening screenshot...");
|
||||
|
||||
@@ -22,7 +22,11 @@ fn main() {
|
||||
|
||||
// Check file exists and size
|
||||
if let Ok(metadata) = std::fs::metadata(path) {
|
||||
println!("File size: {} bytes ({:.1} MB)", metadata.len(), metadata.len() as f64 / 1_000_000.0);
|
||||
println!(
|
||||
"File size: {} bytes ({:.1} MB)",
|
||||
metadata.len(),
|
||||
metadata.len() as f64 / 1_000_000.0
|
||||
);
|
||||
}
|
||||
|
||||
// Open it
|
||||
|
||||
@@ -11,9 +11,15 @@ fn main() {
|
||||
let data = image.data();
|
||||
|
||||
println!("Testing screenshot fix...");
|
||||
println!("Image: {}x{}, bytes_per_row: {}", width, height, bytes_per_row);
|
||||
println!(
|
||||
"Image: {}x{}, bytes_per_row: {}",
|
||||
width, height, bytes_per_row
|
||||
);
|
||||
println!("Expected bytes per row: {}", width * 4);
|
||||
println!("Padding per row: {} bytes", bytes_per_row - (width as usize * 4));
|
||||
println!(
|
||||
"Padding per row: {} bytes",
|
||||
bytes_per_row - (width as usize * 4)
|
||||
);
|
||||
|
||||
// OLD METHOD (broken) - treating data as continuous
|
||||
println!("\n=== OLD METHOD (BROKEN) ===");
|
||||
@@ -48,7 +54,11 @@ fn main() {
|
||||
let crop_size = 200;
|
||||
|
||||
// Old method crop
|
||||
let old_crop: Vec<u8> = old_rgba.iter().take((crop_size * crop_size * 4) as usize).copied().collect();
|
||||
let old_crop: Vec<u8> = old_rgba
|
||||
.iter()
|
||||
.take((crop_size * crop_size * 4) as usize)
|
||||
.copied()
|
||||
.collect();
|
||||
if let Some(old_img) = ImageBuffer::from_raw(crop_size, crop_size, old_crop) {
|
||||
let old_img: RgbaImage = old_img;
|
||||
old_img.save("/tmp/screenshot_old_method.png").unwrap();
|
||||
@@ -56,7 +66,11 @@ fn main() {
|
||||
}
|
||||
|
||||
// New method crop
|
||||
let new_crop: Vec<u8> = new_rgba.iter().take((crop_size * crop_size * 4) as usize).copied().collect();
|
||||
let new_crop: Vec<u8> = new_rgba
|
||||
.iter()
|
||||
.take((crop_size * crop_size * 4) as usize)
|
||||
.copied()
|
||||
.collect();
|
||||
if let Some(new_img) = ImageBuffer::from_raw(crop_size, crop_size, new_crop) {
|
||||
let new_img: RgbaImage = new_img;
|
||||
new_img.save("/tmp/screenshot_new_method.png").unwrap();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use g3_computer_control::ocr::{OCREngine, DefaultOCR};
|
||||
use anyhow::Result;
|
||||
use g3_computer_control::ocr::{DefaultOCR, OCREngine};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
@@ -47,7 +47,10 @@ async fn main() -> Result<()> {
|
||||
println!("⚠️ No text found in image");
|
||||
} else {
|
||||
println!(" Top 20 results:");
|
||||
println!(" {:<4} {:<40} {:<15} {:<12} {:<8}", "#", "Text", "Position", "Size", "Conf");
|
||||
println!(
|
||||
" {:<4} {:<40} {:<15} {:<12} {:<8}",
|
||||
"#", "Text", "Position", "Size", "Conf"
|
||||
);
|
||||
println!(" {}", "-".repeat(85));
|
||||
|
||||
for (i, loc) in locations.iter().take(20).enumerate() {
|
||||
@@ -57,7 +60,8 @@ async fn main() -> Result<()> {
|
||||
loc.text.clone()
|
||||
};
|
||||
|
||||
println!(" {:<4} {:<40} ({:>4},{:>4}) {:>4}x{:<4} {:.2}",
|
||||
println!(
|
||||
" {:<4} {:<40} ({:>4},{:>4}) {:>4}x{:<4} {:.2}",
|
||||
i + 1,
|
||||
text,
|
||||
loc.x,
|
||||
@@ -76,7 +80,10 @@ async fn main() -> Result<()> {
|
||||
println!("\n📈 Performance:");
|
||||
println!(" OCR Speed: {:.3}s", duration.as_secs_f64());
|
||||
println!(" Text elements: {}", locations.len());
|
||||
println!(" Avg per element: {:.1}ms", duration.as_millis() as f64 / locations.len() as f64);
|
||||
println!(
|
||||
" Avg per element: {:.1}ms",
|
||||
duration.as_millis() as f64 / locations.len() as f64
|
||||
);
|
||||
}
|
||||
|
||||
println!("\n✅ Test complete!");
|
||||
|
||||
@@ -8,10 +8,15 @@ async fn main() {
|
||||
|
||||
// Test 1: Capture iTerm2 window
|
||||
println!("\n1. Capturing iTerm2 window...");
|
||||
match controller.take_screenshot("/tmp/iterm_window.png", None, Some("iTerm2")).await {
|
||||
match controller
|
||||
.take_screenshot("/tmp/iterm_window.png", None, Some("iTerm2"))
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
println!(" ✅ iTerm2 window captured to /tmp/iterm_window.png");
|
||||
let _ = std::process::Command::new("open").arg("/tmp/iterm_window.png").spawn();
|
||||
let _ = std::process::Command::new("open")
|
||||
.arg("/tmp/iterm_window.png")
|
||||
.spawn();
|
||||
}
|
||||
Err(e) => println!(" ❌ Failed: {}", e),
|
||||
}
|
||||
@@ -21,10 +26,15 @@ async fn main() {
|
||||
|
||||
// Test 2: Full screen capture for comparison
|
||||
println!("\n2. Capturing full screen for comparison...");
|
||||
match controller.take_screenshot("/tmp/fullscreen.png", None, None).await {
|
||||
match controller
|
||||
.take_screenshot("/tmp/fullscreen.png", None, None)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
println!(" ✅ Full screen captured to /tmp/fullscreen.png");
|
||||
let _ = std::process::Command::new("open").arg("/tmp/fullscreen.png").spawn();
|
||||
let _ = std::process::Command::new("open")
|
||||
.arg("/tmp/fullscreen.png")
|
||||
.spawn();
|
||||
}
|
||||
Err(e) => println!(" ❌ Failed: {}", e),
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
// Suppress warnings from objc crate macros
|
||||
#![allow(unexpected_cfgs)]
|
||||
|
||||
pub mod types;
|
||||
pub mod platform;
|
||||
pub mod ocr;
|
||||
pub mod webdriver;
|
||||
pub mod macax;
|
||||
pub mod ocr;
|
||||
pub mod platform;
|
||||
pub mod types;
|
||||
pub mod webdriver;
|
||||
|
||||
// Re-export webdriver types for convenience
|
||||
pub use webdriver::{WebDriverController, WebElement, safari::SafariDriver};
|
||||
pub use webdriver::{safari::SafariDriver, WebDriverController, WebElement};
|
||||
|
||||
// Re-export macax types for convenience
|
||||
pub use macax::{MacAxController, AXElement, AXApplication};
|
||||
pub use macax::{AXApplication, AXElement, MacAxController};
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
@@ -20,13 +20,22 @@ use types::*;
|
||||
#[async_trait]
|
||||
pub trait ComputerController: Send + Sync {
|
||||
// Screen capture
|
||||
async fn take_screenshot(&self, path: &str, region: Option<Rect>, window_id: Option<&str>) -> Result<()>;
|
||||
async fn take_screenshot(
|
||||
&self,
|
||||
path: &str,
|
||||
region: Option<Rect>,
|
||||
window_id: Option<&str>,
|
||||
) -> Result<()>;
|
||||
|
||||
// OCR operations
|
||||
async fn extract_text_from_screen(&self, region: Rect, window_id: &str) -> Result<String>;
|
||||
async fn extract_text_from_image(&self, path: &str) -> Result<String>;
|
||||
async fn extract_text_with_locations(&self, path: &str) -> Result<Vec<TextLocation>>;
|
||||
async fn find_text_in_app(&self, app_name: &str, search_text: &str) -> Result<Option<TextLocation>>;
|
||||
async fn find_text_in_app(
|
||||
&self,
|
||||
app_name: &str,
|
||||
search_text: &str,
|
||||
) -> Result<Option<TextLocation>>;
|
||||
|
||||
// Mouse operations
|
||||
fn move_mouse(&self, x: i32, y: i32) -> Result<()>;
|
||||
|
||||
@@ -3,7 +3,9 @@ use anyhow::{Context, Result};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use accessibility::{AXUIElement, AXUIElementAttributes, ElementFinder, TreeVisitor, TreeWalker, TreeWalkerFlow};
|
||||
use accessibility::{
|
||||
AXUIElement, AXUIElementAttributes, ElementFinder, TreeVisitor, TreeWalker, TreeWalkerFlow,
|
||||
};
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use core_foundation::base::TCFType;
|
||||
@@ -99,7 +101,9 @@ impl MacAxController {
|
||||
|
||||
// Skip background-only apps
|
||||
let activation_policy: i64 = msg_send![app, activationPolicy];
|
||||
if activation_policy == NSApplicationActivationPolicy::NSApplicationActivationPolicyRegular as i64 {
|
||||
if activation_policy
|
||||
== NSApplicationActivationPolicy::NSApplicationActivationPolicyRegular as i64
|
||||
{
|
||||
apps.push(AXApplication {
|
||||
name,
|
||||
bundle_id,
|
||||
@@ -263,16 +267,17 @@ impl MacAxController {
|
||||
let indent = " ".repeat(depth);
|
||||
|
||||
// Get role
|
||||
let role = element.role().ok().map(|s| s.to_string())
|
||||
let role = element
|
||||
.role()
|
||||
.ok()
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| "Unknown".to_string());
|
||||
|
||||
// Get title
|
||||
let title = element.title().ok()
|
||||
.map(|s| s.to_string());
|
||||
let title = element.title().ok().map(|s| s.to_string());
|
||||
|
||||
// Get identifier
|
||||
let identifier = element.identifier().ok()
|
||||
.map(|s| s.to_string());
|
||||
let identifier = element.identifier().ok().map(|s| s.to_string());
|
||||
|
||||
// Format output
|
||||
output.push_str(&format!("{}Role: {}", indent, role));
|
||||
@@ -352,9 +357,7 @@ impl MacAxController {
|
||||
&app_element,
|
||||
move |element| {
|
||||
// Check role
|
||||
let elem_role = element.role()
|
||||
.ok()
|
||||
.map(|s| s.to_string());
|
||||
let elem_role = element.role().ok().map(|s| s.to_string());
|
||||
|
||||
if let Some(r) = elem_role {
|
||||
if !r.contains(&role_str) {
|
||||
@@ -366,9 +369,7 @@ impl MacAxController {
|
||||
|
||||
// Check title if specified
|
||||
if let Some(ref title_filter) = title_str {
|
||||
let elem_title = element.title()
|
||||
.ok()
|
||||
.map(|s| s.to_string());
|
||||
let elem_title = element.title().ok().map(|s| s.to_string());
|
||||
|
||||
if let Some(t) = elem_title {
|
||||
if !t.contains(title_filter) {
|
||||
@@ -381,9 +382,7 @@ impl MacAxController {
|
||||
|
||||
// Check identifier if specified
|
||||
if let Some(ref id_filter) = identifier_str {
|
||||
let elem_id = element.identifier()
|
||||
.ok()
|
||||
.map(|s| s.to_string());
|
||||
let elem_id = element.identifier().ok().map(|s| s.to_string());
|
||||
|
||||
if let Some(id) = elem_id {
|
||||
if !id.contains(id_filter) {
|
||||
@@ -448,7 +447,8 @@ impl MacAxController {
|
||||
// Set the value - convert CFString to CFType
|
||||
let cf_value = CFString::new(value);
|
||||
|
||||
element.set_value(cf_value.as_CFType())
|
||||
element
|
||||
.set_value(cf_value.as_CFType())
|
||||
.map_err(|e| anyhow::anyhow!("Failed to set value: {:?}", e))?;
|
||||
|
||||
Ok(())
|
||||
@@ -478,7 +478,8 @@ impl MacAxController {
|
||||
let element = self.find_element(app_name, role, title, identifier)?;
|
||||
|
||||
// Get the value
|
||||
let value_type = element.value()
|
||||
let value_type = element
|
||||
.value()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to get value: {:?}", e))?;
|
||||
|
||||
// Try to downcast to CFString
|
||||
@@ -579,7 +580,8 @@ impl MacAxController {
|
||||
use core_foundation::boolean::CFBoolean;
|
||||
let cf_true = CFBoolean::true_value();
|
||||
|
||||
element.set_attribute(&accessibility::AXAttribute::focused(), cf_true)
|
||||
element
|
||||
.set_attribute(&accessibility::AXAttribute::focused(), cf_true)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to focus element: {:?}", e))?;
|
||||
|
||||
Ok(())
|
||||
@@ -587,15 +589,8 @@ impl MacAxController {
|
||||
|
||||
/// Press a keyboard shortcut
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn press_key(
|
||||
&self,
|
||||
app_name: &str,
|
||||
key: &str,
|
||||
modifiers: Vec<&str>,
|
||||
) -> Result<()> {
|
||||
use core_graphics::event::{
|
||||
CGEvent, CGEventFlags, CGEventTapLocation,
|
||||
};
|
||||
pub fn press_key(&self, app_name: &str, key: &str, modifiers: Vec<&str>) -> Result<()> {
|
||||
use core_graphics::event::{CGEvent, CGEventFlags, CGEventTapLocation};
|
||||
use core_graphics::event_source::{CGEventSource, CGEventSourceStateID};
|
||||
|
||||
// First, make sure the app is active
|
||||
@@ -605,8 +600,8 @@ impl MacAxController {
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
|
||||
// Map key string to key code
|
||||
let key_code = Self::key_to_keycode(key)
|
||||
.ok_or_else(|| anyhow::anyhow!("Unknown key: {}", key))?;
|
||||
let key_code =
|
||||
Self::key_to_keycode(key).ok_or_else(|| anyhow::anyhow!("Unknown key: {}", key))?;
|
||||
|
||||
// Map modifiers to flags
|
||||
let mut flags = CGEventFlags::CGEventFlagNull;
|
||||
@@ -622,16 +617,19 @@ impl MacAxController {
|
||||
|
||||
// Create event source
|
||||
let source = CGEventSource::new(CGEventSourceStateID::HIDSystemState)
|
||||
.ok().context("Failed to create event source")?;
|
||||
.ok()
|
||||
.context("Failed to create event source")?;
|
||||
|
||||
// Create key down event
|
||||
let key_down = CGEvent::new_keyboard_event(source.clone(), key_code, true)
|
||||
.ok().context("Failed to create key down event")?;
|
||||
.ok()
|
||||
.context("Failed to create key down event")?;
|
||||
key_down.set_flags(flags);
|
||||
|
||||
// Create key up event
|
||||
let key_up = CGEvent::new_keyboard_event(source, key_code, false)
|
||||
.ok().context("Failed to create key up event")?;
|
||||
.ok()
|
||||
.context("Failed to create key up event")?;
|
||||
key_up.set_flags(flags);
|
||||
|
||||
// Post events
|
||||
@@ -643,12 +641,7 @@ impl MacAxController {
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn press_key(
|
||||
&self,
|
||||
_app_name: &str,
|
||||
_key: &str,
|
||||
_modifiers: Vec<&str>,
|
||||
) -> Result<()> {
|
||||
pub fn press_key(&self, _app_name: &str, _key: &str, _modifiers: Vec<&str>) -> Result<()> {
|
||||
anyhow::bail!("Not supported on this platform")
|
||||
}
|
||||
|
||||
@@ -749,52 +742,45 @@ impl<'a> TreeVisitor for ElementCollector<'a> {
|
||||
}
|
||||
|
||||
// Get element properties
|
||||
let role = element.role()
|
||||
let role = element
|
||||
.role()
|
||||
.ok()
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| "Unknown".to_string());
|
||||
|
||||
let title = element.title()
|
||||
.ok()
|
||||
.map(|s| s.to_string());
|
||||
let title = element.title().ok().map(|s| s.to_string());
|
||||
|
||||
let identifier = element.identifier()
|
||||
.ok()
|
||||
.map(|s| s.to_string());
|
||||
let identifier = element.identifier().ok().map(|s| s.to_string());
|
||||
|
||||
// Check if this element matches the filters
|
||||
let role_matches = self.role_filter.as_ref().map_or(true, |r| role.contains(r));
|
||||
let title_matches = self.title_filter.as_ref().map_or(true, |t| {
|
||||
title.as_ref().map_or(false, |title_str| title_str.contains(t))
|
||||
title
|
||||
.as_ref()
|
||||
.map_or(false, |title_str| title_str.contains(t))
|
||||
});
|
||||
let identifier_matches = self.identifier_filter.as_ref().map_or(true, |id| {
|
||||
identifier.as_ref().map_or(false, |id_str| id_str.contains(id))
|
||||
identifier
|
||||
.as_ref()
|
||||
.map_or(false, |id_str| id_str.contains(id))
|
||||
});
|
||||
|
||||
if role_matches && title_matches && identifier_matches {
|
||||
// Get additional properties
|
||||
let value = element.value()
|
||||
let value = element
|
||||
.value()
|
||||
.ok()
|
||||
.and_then(|v| {
|
||||
v.downcast::<CFString>().map(|s| s.to_string())
|
||||
});
|
||||
.and_then(|v| v.downcast::<CFString>().map(|s| s.to_string()));
|
||||
|
||||
let label = element.description()
|
||||
.ok()
|
||||
.map(|s| s.to_string());
|
||||
let label = element.description().ok().map(|s| s.to_string());
|
||||
|
||||
let enabled = element.enabled()
|
||||
.ok()
|
||||
.map(|b| b.into())
|
||||
.unwrap_or(false);
|
||||
let enabled = element.enabled().ok().map(|b| b.into()).unwrap_or(false);
|
||||
|
||||
let focused = element.focused()
|
||||
.ok()
|
||||
.map(|b| b.into())
|
||||
.unwrap_or(false);
|
||||
let focused = element.focused().ok().map(|b| b.into()).unwrap_or(false);
|
||||
|
||||
// Count children
|
||||
let children_count = element.children()
|
||||
let children_count = element
|
||||
.children()
|
||||
.ok()
|
||||
.map(|arr| arr.len() as usize)
|
||||
.unwrap_or(0);
|
||||
|
||||
@@ -14,12 +14,14 @@ impl TesseractOCR {
|
||||
.output();
|
||||
|
||||
if tesseract_check.is_err() || !tesseract_check.as_ref().unwrap().status.success() {
|
||||
anyhow::bail!("Tesseract OCR is not installed on your system.\n\n\
|
||||
anyhow::bail!(
|
||||
"Tesseract OCR is not installed on your system.\n\n\
|
||||
To install tesseract:\n macOS: brew install tesseract\n \
|
||||
Linux: sudo apt-get install tesseract-ocr (Ubuntu/Debian)\n \
|
||||
sudo yum install tesseract (RHEL/CentOS)\n \
|
||||
Windows: Download from https://github.com/UB-Mannheim/tesseract/wiki\n\n\
|
||||
After installation, restart your terminal and try again.");
|
||||
After installation, restart your terminal and try again."
|
||||
);
|
||||
}
|
||||
|
||||
Ok(Self)
|
||||
@@ -38,7 +40,10 @@ impl OCREngine for TesseractOCR {
|
||||
.map_err(|e| anyhow::anyhow!("Failed to run tesseract: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
anyhow::bail!("Tesseract failed: {}", String::from_utf8_lossy(&output.stderr));
|
||||
anyhow::bail!(
|
||||
"Tesseract failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
let tsv_text = String::from_utf8_lossy(&output.stdout);
|
||||
@@ -46,7 +51,9 @@ impl OCREngine for TesseractOCR {
|
||||
|
||||
// Parse TSV output (skip header line)
|
||||
for (i, line) in tsv_text.lines().enumerate() {
|
||||
if i == 0 { continue; } // Skip header
|
||||
if i == 0 {
|
||||
continue;
|
||||
} // Skip header
|
||||
|
||||
let parts: Vec<&str> = line.split('\t').collect();
|
||||
if parts.len() >= 12 {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::OCREngine;
|
||||
use crate::types::TextLocation;
|
||||
use anyhow::{Result, Context};
|
||||
use anyhow::{Context, Result};
|
||||
use async_trait::async_trait;
|
||||
use std::ffi::{CStr, CString};
|
||||
use std::os::raw::{c_char, c_float, c_uint};
|
||||
@@ -41,8 +41,7 @@ impl AppleVisionOCR {
|
||||
impl OCREngine for AppleVisionOCR {
|
||||
async fn extract_text_with_locations(&self, path: &str) -> Result<Vec<TextLocation>> {
|
||||
// Convert path to C string
|
||||
let c_path = CString::new(path)
|
||||
.context("Failed to convert path to C string")?;
|
||||
let c_path = CString::new(path).context("Failed to convert path to C string")?;
|
||||
|
||||
let mut boxes_ptr: *mut std::ffi::c_void = std::ptr::null_mut();
|
||||
let mut count: c_uint = 0;
|
||||
@@ -71,9 +70,7 @@ impl OCREngine for AppleVisionOCR {
|
||||
for box_data in boxes_slice {
|
||||
// Convert C string to Rust String
|
||||
let text = if !box_data.text.is_null() {
|
||||
CStr::from_ptr(box_data.text)
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
CStr::from_ptr(box_data.text).to_string_lossy().into_owned()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::{ComputerController, types::*};
|
||||
use crate::{types::*, ComputerController};
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use tesseract::Tesseract;
|
||||
@@ -62,7 +62,12 @@ impl ComputerController for LinuxController {
|
||||
anyhow::bail!("Linux implementation not yet available")
|
||||
}
|
||||
|
||||
async fn take_screenshot(&self, _path: &str, _region: Option<Rect>, _window_id: Option<&str>) -> Result<()> {
|
||||
async fn take_screenshot(
|
||||
&self,
|
||||
_path: &str,
|
||||
_region: Option<Rect>,
|
||||
_window_id: Option<&str>,
|
||||
) -> Result<()> {
|
||||
// Enforce that window_id must be provided
|
||||
if _window_id.is_none() {
|
||||
anyhow::bail!("window_id is required. You must specify which window to capture (e.g., 'Firefox', 'Terminal', 'gedit'). Use list_windows to see available windows.");
|
||||
@@ -82,26 +87,31 @@ impl ComputerController for LinuxController {
|
||||
.output();
|
||||
|
||||
if tesseract_check.is_err() || !tesseract_check.as_ref().unwrap().status.success() {
|
||||
anyhow::bail!("Tesseract OCR is not installed on your system.\n\n\
|
||||
anyhow::bail!(
|
||||
"Tesseract OCR is not installed on your system.\n\n\
|
||||
To install tesseract:\n \
|
||||
Ubuntu/Debian: sudo apt-get install tesseract-ocr\n \
|
||||
RHEL/CentOS: sudo yum install tesseract\n \
|
||||
Arch Linux: sudo pacman -S tesseract\n\n\
|
||||
After installation, restart your terminal and try again.");
|
||||
After installation, restart your terminal and try again."
|
||||
);
|
||||
}
|
||||
|
||||
// Initialize Tesseract
|
||||
let tess = Tesseract::new(None, Some("eng"))
|
||||
.map_err(|e| {
|
||||
anyhow::anyhow!("Failed to initialize Tesseract: {}\n\n\
|
||||
let tess = Tesseract::new(None, Some("eng")).map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"Failed to initialize Tesseract: {}\n\n\
|
||||
This usually means:\n1. Tesseract is not properly installed\n\
|
||||
2. Language data files are missing\n\nTo fix:\n \
|
||||
Ubuntu/Debian: sudo apt-get install tesseract-ocr-eng\n \
|
||||
RHEL/CentOS: sudo yum install tesseract-langpack-eng\n \
|
||||
Arch Linux: sudo pacman -S tesseract-data-eng", e)
|
||||
})?;
|
||||
Arch Linux: sudo pacman -S tesseract-data-eng",
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
let text = tess.set_image(_path)
|
||||
let text = tess
|
||||
.set_image(_path)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to load image '{}': {}", _path, e))?
|
||||
.get_text()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to extract text from image: {}", e))?;
|
||||
@@ -112,7 +122,12 @@ impl ComputerController for LinuxController {
|
||||
Ok(OCRResult {
|
||||
text,
|
||||
confidence,
|
||||
bounds: Rect { x: 0, y: 0, width: 0, height: 0 }, // Would need image dimensions
|
||||
bounds: Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
}, // Would need image dimensions
|
||||
})
|
||||
}
|
||||
|
||||
@@ -123,12 +138,14 @@ impl ComputerController for LinuxController {
|
||||
.output();
|
||||
|
||||
if tesseract_check.is_err() || !tesseract_check.as_ref().unwrap().status.success() {
|
||||
anyhow::bail!("Tesseract OCR is not installed on your system.\n\n\
|
||||
anyhow::bail!(
|
||||
"Tesseract OCR is not installed on your system.\n\n\
|
||||
To install tesseract:\n \
|
||||
Ubuntu/Debian: sudo apt-get install tesseract-ocr\n \
|
||||
RHEL/CentOS: sudo yum install tesseract\n \
|
||||
Arch Linux: sudo pacman -S tesseract\n\n\
|
||||
After installation, restart your terminal and try again.");
|
||||
After installation, restart your terminal and try again."
|
||||
);
|
||||
}
|
||||
|
||||
// Take full screen screenshot
|
||||
@@ -136,17 +153,20 @@ impl ComputerController for LinuxController {
|
||||
self.take_screenshot(&temp_path, None, None).await?;
|
||||
|
||||
// Use Tesseract to find text with bounding boxes
|
||||
let tess = Tesseract::new(None, Some("eng"))
|
||||
.map_err(|e| {
|
||||
anyhow::anyhow!("Failed to initialize Tesseract: {}\n\n\
|
||||
let tess = Tesseract::new(None, Some("eng")).map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"Failed to initialize Tesseract: {}\n\n\
|
||||
This usually means:\n1. Tesseract is not properly installed\n\
|
||||
2. Language data files are missing\n\nTo fix:\n \
|
||||
Ubuntu/Debian: sudo apt-get install tesseract-ocr-eng\n \
|
||||
RHEL/CentOS: sudo yum install tesseract-langpack-eng\n \
|
||||
Arch Linux: sudo pacman -S tesseract-data-eng", e)
|
||||
})?;
|
||||
Arch Linux: sudo pacman -S tesseract-data-eng",
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
let full_text = tess.set_image(temp_path.as_str())
|
||||
let full_text = tess
|
||||
.set_image(temp_path.as_str())
|
||||
.map_err(|e| anyhow::anyhow!("Failed to load screenshot: {}", e))?
|
||||
.get_text()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to extract text from screen: {}", e))?;
|
||||
@@ -157,7 +177,9 @@ impl ComputerController for LinuxController {
|
||||
// Simple text search - full implementation would use get_component_images
|
||||
// to get bounding boxes for each word
|
||||
if full_text.contains(_text) {
|
||||
tracing::warn!("Text found but precise coordinates not available in simplified implementation");
|
||||
tracing::warn!(
|
||||
"Text found but precise coordinates not available in simplified implementation"
|
||||
);
|
||||
Ok(Some(Point { x: 0, y: 0 }))
|
||||
} else {
|
||||
Ok(None)
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
use crate::{ComputerController, types::{Rect, TextLocation}};
|
||||
use crate::ocr::{OCREngine, DefaultOCR};
|
||||
use anyhow::{Result, Context};
|
||||
use crate::ocr::{DefaultOCR, OCREngine};
|
||||
use crate::{
|
||||
types::{Rect, TextLocation},
|
||||
ComputerController,
|
||||
};
|
||||
use anyhow::{Context, Result};
|
||||
use async_trait::async_trait;
|
||||
use std::path::Path;
|
||||
use core_graphics::window::{kCGWindowListOptionOnScreenOnly, kCGNullWindowID, CGWindowListCopyWindowInfo};
|
||||
use core_foundation::array::CFArray;
|
||||
use core_foundation::base::{TCFType, ToVoid};
|
||||
use core_foundation::dictionary::CFDictionary;
|
||||
use core_foundation::string::CFString;
|
||||
use core_foundation::base::{TCFType, ToVoid};
|
||||
use core_foundation::array::CFArray;
|
||||
use core_graphics::window::{
|
||||
kCGNullWindowID, kCGWindowListOptionOnScreenOnly, CGWindowListCopyWindowInfo,
|
||||
};
|
||||
use std::path::Path;
|
||||
|
||||
pub struct MacOSController {
|
||||
ocr_engine: Box<dyn OCREngine>,
|
||||
@@ -20,13 +25,21 @@ impl MacOSController {
|
||||
let ocr = Box::new(DefaultOCR::new()?);
|
||||
let ocr_name = ocr.name().to_string();
|
||||
tracing::info!("Initialized macOS controller with OCR engine: {}", ocr_name);
|
||||
Ok(Self { ocr_engine: ocr, ocr_name })
|
||||
Ok(Self {
|
||||
ocr_engine: ocr,
|
||||
ocr_name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ComputerController for MacOSController {
|
||||
async fn take_screenshot(&self, path: &str, region: Option<Rect>, window_id: Option<&str>) -> Result<()> {
|
||||
async fn take_screenshot(
|
||||
&self,
|
||||
path: &str,
|
||||
region: Option<Rect>,
|
||||
window_id: Option<&str>,
|
||||
) -> Result<()> {
|
||||
// Enforce that window_id must be provided
|
||||
if window_id.is_none() {
|
||||
return Err(anyhow::anyhow!("window_id is required. You must specify which window to capture (e.g., 'Safari', 'Terminal', 'Google Chrome'). Use list_windows to see available windows."));
|
||||
@@ -56,10 +69,8 @@ impl ComputerController for MacOSController {
|
||||
|
||||
// Get the window ID for the specified application
|
||||
let cg_window_id = unsafe {
|
||||
let window_list = CGWindowListCopyWindowInfo(
|
||||
kCGWindowListOptionOnScreenOnly,
|
||||
kCGNullWindowID
|
||||
);
|
||||
let window_list =
|
||||
CGWindowListCopyWindowInfo(kCGWindowListOptionOnScreenOnly, kCGNullWindowID);
|
||||
|
||||
let array = CFArray::<CFDictionary>::wrap_under_create_rule(window_list);
|
||||
let count = array.len();
|
||||
@@ -79,7 +90,11 @@ impl ComputerController for MacOSController {
|
||||
continue;
|
||||
};
|
||||
|
||||
tracing::debug!("Checking window: owner='{}', looking for '{}'", owner, app_name);
|
||||
tracing::debug!(
|
||||
"Checking window: owner='{}', looking for '{}'",
|
||||
owner,
|
||||
app_name
|
||||
);
|
||||
let owner_lower = owner.to_lowercase();
|
||||
|
||||
// Normalize by removing spaces for exact matching
|
||||
@@ -88,18 +103,21 @@ impl ComputerController for MacOSController {
|
||||
|
||||
// ONLY accept exact matches (case-insensitive, with or without spaces)
|
||||
// This prevents "Goose" from matching "GooseStudio"
|
||||
let is_match = owner_lower == app_name_lower || owner_normalized == app_name_normalized;
|
||||
let is_match =
|
||||
owner_lower == app_name_lower || owner_normalized == app_name_normalized;
|
||||
|
||||
if is_match {
|
||||
// Get window ID
|
||||
let window_id_key = CFString::from_static_string("kCGWindowNumber");
|
||||
if let Some(value) = dict.find(window_id_key.to_void()) {
|
||||
let num: core_foundation::number::CFNumber = TCFType::wrap_under_get_rule(*value as *const _);
|
||||
let num: core_foundation::number::CFNumber =
|
||||
TCFType::wrap_under_get_rule(*value as *const _);
|
||||
if let Some(id) = num.to_i64() {
|
||||
// Get window layer to filter out menu bar windows
|
||||
let layer_key = CFString::from_static_string("kCGWindowLayer");
|
||||
let layer: i32 = if let Some(value) = dict.find(layer_key.to_void()) {
|
||||
let num: core_foundation::number::CFNumber = TCFType::wrap_under_get_rule(*value as *const _);
|
||||
let num: core_foundation::number::CFNumber =
|
||||
TCFType::wrap_under_get_rule(*value as *const _);
|
||||
num.to_i32().unwrap_or(0)
|
||||
} else {
|
||||
0
|
||||
@@ -107,27 +125,31 @@ impl ComputerController for MacOSController {
|
||||
|
||||
// Get window bounds to verify it's a real window
|
||||
let bounds_key = CFString::from_static_string("kCGWindowBounds");
|
||||
let has_real_bounds = if let Some(value) = dict.find(bounds_key.to_void()) {
|
||||
let bounds_dict: CFDictionary = TCFType::wrap_under_get_rule(*value as *const _);
|
||||
let width_key = CFString::from_static_string("Width");
|
||||
let height_key = CFString::from_static_string("Height");
|
||||
let has_real_bounds =
|
||||
if let Some(value) = dict.find(bounds_key.to_void()) {
|
||||
let bounds_dict: CFDictionary =
|
||||
TCFType::wrap_under_get_rule(*value as *const _);
|
||||
let width_key = CFString::from_static_string("Width");
|
||||
let height_key = CFString::from_static_string("Height");
|
||||
|
||||
if let (Some(w_val), Some(h_val)) = (
|
||||
bounds_dict.find(width_key.to_void()),
|
||||
bounds_dict.find(height_key.to_void()),
|
||||
) {
|
||||
let w_num: core_foundation::number::CFNumber = TCFType::wrap_under_get_rule(*w_val as *const _);
|
||||
let h_num: core_foundation::number::CFNumber = TCFType::wrap_under_get_rule(*h_val as *const _);
|
||||
let width = w_num.to_f64().unwrap_or(0.0);
|
||||
let height = h_num.to_f64().unwrap_or(0.0);
|
||||
// Real windows should be at least 100x100 pixels
|
||||
width >= 100.0 && height >= 100.0
|
||||
if let (Some(w_val), Some(h_val)) = (
|
||||
bounds_dict.find(width_key.to_void()),
|
||||
bounds_dict.find(height_key.to_void()),
|
||||
) {
|
||||
let w_num: core_foundation::number::CFNumber =
|
||||
TCFType::wrap_under_get_rule(*w_val as *const _);
|
||||
let h_num: core_foundation::number::CFNumber =
|
||||
TCFType::wrap_under_get_rule(*h_val as *const _);
|
||||
let width = w_num.to_f64().unwrap_or(0.0);
|
||||
let height = h_num.to_f64().unwrap_or(0.0);
|
||||
// Real windows should be at least 100x100 pixels
|
||||
width >= 100.0 && height >= 100.0
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
};
|
||||
|
||||
// Only accept windows that are:
|
||||
// 1. At layer 0 (normal windows, not menu bar)
|
||||
@@ -137,7 +159,13 @@ impl ComputerController for MacOSController {
|
||||
found_window_id = Some((id as u32, owner.clone()));
|
||||
break;
|
||||
} else {
|
||||
tracing::debug!("Skipping window ID {} for '{}': layer={}, has_real_bounds={}", id, owner, layer, has_real_bounds);
|
||||
tracing::debug!(
|
||||
"Skipping window ID {} for '{}': layer={}, has_real_bounds={}",
|
||||
id,
|
||||
owner,
|
||||
layer,
|
||||
has_real_bounds
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -150,7 +178,11 @@ impl ComputerController for MacOSController {
|
||||
let (cg_window_id, matched_owner) = cg_window_id.ok_or_else(|| {
|
||||
anyhow::anyhow!("Could not find window for application '{}'. Use list_windows to see available windows.", app_name)
|
||||
})?;
|
||||
tracing::info!("Taking screenshot of window ID {} for app '{}'", cg_window_id, matched_owner);
|
||||
tracing::info!(
|
||||
"Taking screenshot of window ID {} for app '{}'",
|
||||
cg_window_id,
|
||||
matched_owner
|
||||
);
|
||||
|
||||
// Use screencapture with the window ID for now
|
||||
// TODO: Implement direct CGWindowListCreateImage approach with proper image saving
|
||||
@@ -161,7 +193,10 @@ impl ComputerController for MacOSController {
|
||||
|
||||
if let Some(region) = region {
|
||||
cmd.arg("-R");
|
||||
cmd.arg(format!("{},{},{},{}", region.x, region.y, region.width, region.height));
|
||||
cmd.arg(format!(
|
||||
"{},{},{},{}",
|
||||
region.x, region.y, region.width, region.height
|
||||
));
|
||||
}
|
||||
|
||||
cmd.arg(&final_path);
|
||||
@@ -170,7 +205,11 @@ impl ComputerController for MacOSController {
|
||||
|
||||
if !screenshot_result.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&screenshot_result.stderr);
|
||||
return Err(anyhow::anyhow!("screencapture failed for window {}: {}", cg_window_id, stderr));
|
||||
return Err(anyhow::anyhow!(
|
||||
"screencapture failed for window {}: {}",
|
||||
cg_window_id,
|
||||
stderr
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -179,7 +218,8 @@ impl ComputerController for MacOSController {
|
||||
async fn extract_text_from_screen(&self, region: Rect, window_id: &str) -> Result<String> {
|
||||
// Take screenshot of region first
|
||||
let temp_path = format!("/tmp/g3_ocr_{}.png", uuid::Uuid::new_v4());
|
||||
self.take_screenshot(&temp_path, Some(region), Some(window_id)).await?;
|
||||
self.take_screenshot(&temp_path, Some(region), Some(window_id))
|
||||
.await?;
|
||||
|
||||
// Extract text from the screenshot
|
||||
let result = self.extract_text_from_image(&temp_path).await?;
|
||||
@@ -193,7 +233,11 @@ impl ComputerController for MacOSController {
|
||||
async fn extract_text_from_image(&self, path: &str) -> Result<String> {
|
||||
// Extract all text and concatenate
|
||||
let locations = self.ocr_engine.extract_text_with_locations(path).await?;
|
||||
Ok(locations.iter().map(|loc| loc.text.as_str()).collect::<Vec<_>>().join(" "))
|
||||
Ok(locations
|
||||
.iter()
|
||||
.map(|loc| loc.text.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" "))
|
||||
}
|
||||
|
||||
async fn extract_text_with_locations(&self, path: &str) -> Result<Vec<TextLocation>> {
|
||||
@@ -201,11 +245,21 @@ impl ComputerController for MacOSController {
|
||||
self.ocr_engine.extract_text_with_locations(path).await
|
||||
}
|
||||
|
||||
async fn find_text_in_app(&self, app_name: &str, search_text: &str) -> Result<Option<TextLocation>> {
|
||||
async fn find_text_in_app(
|
||||
&self,
|
||||
app_name: &str,
|
||||
search_text: &str,
|
||||
) -> Result<Option<TextLocation>> {
|
||||
// Take screenshot of specific app window
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
|
||||
let temp_path = format!("{}/tmp/g3_find_text_{}_{}.png", home, app_name, uuid::Uuid::new_v4());
|
||||
self.take_screenshot(&temp_path, None, Some(app_name)).await?;
|
||||
let temp_path = format!(
|
||||
"{}/tmp/g3_find_text_{}_{}.png",
|
||||
home,
|
||||
app_name,
|
||||
uuid::Uuid::new_v4()
|
||||
);
|
||||
self.take_screenshot(&temp_path, None, Some(app_name))
|
||||
.await?;
|
||||
|
||||
// Get screenshot dimensions before we delete it
|
||||
let screenshot_dims = get_image_dimensions(&temp_path)?;
|
||||
@@ -224,11 +278,8 @@ impl ComputerController for MacOSController {
|
||||
for location in locations {
|
||||
if location.text.to_lowercase().contains(&search_lower) {
|
||||
// Transform coordinates from screenshot space to screen space
|
||||
let transformed = transform_screenshot_to_screen_coords(
|
||||
location,
|
||||
window_bounds,
|
||||
screenshot_dims,
|
||||
);
|
||||
let transformed =
|
||||
transform_screenshot_to_screen_coords(location, window_bounds, screenshot_dims);
|
||||
return Ok(Some(transformed));
|
||||
}
|
||||
}
|
||||
@@ -237,23 +288,22 @@ impl ComputerController for MacOSController {
|
||||
}
|
||||
|
||||
fn move_mouse(&self, x: i32, y: i32) -> Result<()> {
|
||||
use core_graphics::event::{
|
||||
CGEvent, CGEventTapLocation, CGEventType, CGMouseButton,
|
||||
};
|
||||
use core_graphics::event_source::{
|
||||
CGEventSource, CGEventSourceStateID,
|
||||
};
|
||||
use core_graphics::event::{CGEvent, CGEventTapLocation, CGEventType, CGMouseButton};
|
||||
use core_graphics::event_source::{CGEventSource, CGEventSourceStateID};
|
||||
use core_graphics::geometry::CGPoint;
|
||||
|
||||
let source = CGEventSource::new(CGEventSourceStateID::HIDSystemState)
|
||||
.ok().context("Failed to create event source")?;
|
||||
.ok()
|
||||
.context("Failed to create event source")?;
|
||||
|
||||
let event = CGEvent::new_mouse_event(
|
||||
source,
|
||||
CGEventType::MouseMoved,
|
||||
CGPoint::new(x as f64, y as f64),
|
||||
CGMouseButton::Left,
|
||||
).ok().context("Failed to create mouse event")?;
|
||||
)
|
||||
.ok()
|
||||
.context("Failed to create mouse event")?;
|
||||
|
||||
event.post(CGEventTapLocation::HID);
|
||||
|
||||
@@ -261,14 +311,10 @@ impl ComputerController for MacOSController {
|
||||
}
|
||||
|
||||
fn click_at(&self, x: i32, y: i32, _app_name: Option<&str>) -> Result<()> {
|
||||
use core_graphics::event::{
|
||||
CGEvent, CGEventTapLocation, CGEventType, CGMouseButton,
|
||||
};
|
||||
use core_graphics::event_source::{
|
||||
CGEventSource, CGEventSourceStateID,
|
||||
};
|
||||
use core_graphics::geometry::CGPoint;
|
||||
use core_graphics::display::CGDisplay;
|
||||
use core_graphics::event::{CGEvent, CGEventTapLocation, CGEventType, CGMouseButton};
|
||||
use core_graphics::event_source::{CGEventSource, CGEventSourceStateID};
|
||||
use core_graphics::geometry::CGPoint;
|
||||
|
||||
// IMPORTANT: Coordinates passed here are in NSScreen/CGWindowListCopyWindowInfo space
|
||||
// (Y=0 at BOTTOM, increases UPWARD)
|
||||
@@ -279,15 +325,22 @@ impl ComputerController for MacOSController {
|
||||
let cgevent_x = x;
|
||||
let cgevent_y = screen_height - y;
|
||||
|
||||
tracing::debug!("click_at: NSScreen coords ({}, {}) -> CGEvent coords ({}, {}) [screen_height={}]",
|
||||
x, y, cgevent_x, cgevent_y, screen_height);
|
||||
tracing::debug!(
|
||||
"click_at: NSScreen coords ({}, {}) -> CGEvent coords ({}, {}) [screen_height={}]",
|
||||
x,
|
||||
y,
|
||||
cgevent_x,
|
||||
cgevent_y,
|
||||
screen_height
|
||||
);
|
||||
|
||||
let (global_x, global_y) = (cgevent_x, cgevent_y);
|
||||
|
||||
let point = CGPoint::new(global_x as f64, global_y as f64);
|
||||
|
||||
let source = CGEventSource::new(CGEventSourceStateID::HIDSystemState)
|
||||
.ok().context("Failed to create event source")?;
|
||||
.ok()
|
||||
.context("Failed to create event source")?;
|
||||
|
||||
// Move mouse to position first
|
||||
let move_event = CGEvent::new_mouse_event(
|
||||
@@ -295,7 +348,9 @@ impl ComputerController for MacOSController {
|
||||
CGEventType::MouseMoved,
|
||||
point,
|
||||
CGMouseButton::Left,
|
||||
).ok().context("Failed to create mouse move event")?;
|
||||
)
|
||||
.ok()
|
||||
.context("Failed to create mouse move event")?;
|
||||
move_event.post(CGEventTapLocation::HID);
|
||||
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
@@ -306,18 +361,18 @@ impl ComputerController for MacOSController {
|
||||
CGEventType::LeftMouseDown,
|
||||
point,
|
||||
CGMouseButton::Left,
|
||||
).ok().context("Failed to create mouse down event")?;
|
||||
)
|
||||
.ok()
|
||||
.context("Failed to create mouse down event")?;
|
||||
mouse_down.post(CGEventTapLocation::HID);
|
||||
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
|
||||
// Mouse up
|
||||
let mouse_up = CGEvent::new_mouse_event(
|
||||
source,
|
||||
CGEventType::LeftMouseUp,
|
||||
point,
|
||||
CGMouseButton::Left,
|
||||
).ok().context("Failed to create mouse up event")?;
|
||||
let mouse_up =
|
||||
CGEvent::new_mouse_event(source, CGEventType::LeftMouseUp, point, CGMouseButton::Left)
|
||||
.ok()
|
||||
.context("Failed to create mouse up event")?;
|
||||
mouse_up.post(CGEventTapLocation::HID);
|
||||
|
||||
Ok(())
|
||||
@@ -328,10 +383,8 @@ impl MacOSController {
|
||||
/// Get window bounds for an application (helper method)
|
||||
fn get_window_bounds(&self, app_name: &str) -> Result<(i32, i32, i32, i32)> {
|
||||
unsafe {
|
||||
let window_list = CGWindowListCopyWindowInfo(
|
||||
kCGWindowListOptionOnScreenOnly,
|
||||
kCGNullWindowID
|
||||
);
|
||||
let window_list =
|
||||
CGWindowListCopyWindowInfo(kCGWindowListOptionOnScreenOnly, kCGNullWindowID);
|
||||
|
||||
let array = CFArray::<CFDictionary>::wrap_under_create_rule(window_list);
|
||||
let count = array.len();
|
||||
@@ -358,13 +411,15 @@ impl MacOSController {
|
||||
|
||||
// ONLY accept exact matches (case-insensitive, with or without spaces)
|
||||
// This prevents "Goose" from matching "GooseStudio"
|
||||
let is_match = owner_lower == app_name_lower || owner_normalized == app_name_normalized;
|
||||
let is_match =
|
||||
owner_lower == app_name_lower || owner_normalized == app_name_normalized;
|
||||
|
||||
if is_match {
|
||||
// Get window layer to filter out menu bar windows
|
||||
let layer_key = CFString::from_static_string("kCGWindowLayer");
|
||||
let layer: i32 = if let Some(value) = dict.find(layer_key.to_void()) {
|
||||
let num: core_foundation::number::CFNumber = TCFType::wrap_under_get_rule(*value as *const _);
|
||||
let num: core_foundation::number::CFNumber =
|
||||
TCFType::wrap_under_get_rule(*value as *const _);
|
||||
num.to_i32().unwrap_or(0)
|
||||
} else {
|
||||
0
|
||||
@@ -372,14 +427,19 @@ impl MacOSController {
|
||||
|
||||
// Skip menu bar windows (layer >= 20)
|
||||
if layer >= 20 {
|
||||
tracing::debug!("Skipping window for '{}' at layer {} (menu bar)", owner, layer);
|
||||
tracing::debug!(
|
||||
"Skipping window for '{}' at layer {} (menu bar)",
|
||||
owner,
|
||||
layer
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get window bounds to verify it's a real window
|
||||
let bounds_key = CFString::from_static_string("kCGWindowBounds");
|
||||
if let Some(value) = dict.find(bounds_key.to_void()) {
|
||||
let bounds_dict: CFDictionary = TCFType::wrap_under_get_rule(*value as *const _);
|
||||
let bounds_dict: CFDictionary =
|
||||
TCFType::wrap_under_get_rule(*value as *const _);
|
||||
|
||||
let x_key = CFString::from_static_string("X");
|
||||
let y_key = CFString::from_static_string("Y");
|
||||
@@ -392,10 +452,14 @@ impl MacOSController {
|
||||
bounds_dict.find(width_key.to_void()),
|
||||
bounds_dict.find(height_key.to_void()),
|
||||
) {
|
||||
let x_num: core_foundation::number::CFNumber = TCFType::wrap_under_get_rule(*x_val as *const _);
|
||||
let y_num: core_foundation::number::CFNumber = TCFType::wrap_under_get_rule(*y_val as *const _);
|
||||
let w_num: core_foundation::number::CFNumber = TCFType::wrap_under_get_rule(*w_val as *const _);
|
||||
let h_num: core_foundation::number::CFNumber = TCFType::wrap_under_get_rule(*h_val as *const _);
|
||||
let x_num: core_foundation::number::CFNumber =
|
||||
TCFType::wrap_under_get_rule(*x_val as *const _);
|
||||
let y_num: core_foundation::number::CFNumber =
|
||||
TCFType::wrap_under_get_rule(*y_val as *const _);
|
||||
let w_num: core_foundation::number::CFNumber =
|
||||
TCFType::wrap_under_get_rule(*w_val as *const _);
|
||||
let h_num: core_foundation::number::CFNumber =
|
||||
TCFType::wrap_under_get_rule(*h_val as *const _);
|
||||
|
||||
let x: i32 = x_num.to_i64().unwrap_or(0) as i32;
|
||||
let y: i32 = y_num.to_i64().unwrap_or(0) as i32;
|
||||
@@ -407,7 +471,12 @@ impl MacOSController {
|
||||
tracing::info!("Found valid window bounds for '{}': x={}, y={}, w={}, h={} (layer={})", owner, x, y, w, h, layer);
|
||||
return Ok((x, y, w, h));
|
||||
} else {
|
||||
tracing::debug!("Skipping window for '{}': too small ({}x{})", owner, w, h);
|
||||
tracing::debug!(
|
||||
"Skipping window for '{}': too small ({}x{})",
|
||||
owner,
|
||||
w,
|
||||
h
|
||||
);
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
@@ -418,7 +487,10 @@ impl MacOSController {
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow::anyhow!("Could not find window bounds for '{}'", app_name))
|
||||
Err(anyhow::anyhow!(
|
||||
"Could not find window bounds for '{}'",
|
||||
app_name
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -454,7 +526,7 @@ fn get_image_dimensions(path: &str) -> Result<(i32, i32)> {
|
||||
fn transform_screenshot_to_screen_coords(
|
||||
location: TextLocation,
|
||||
window_bounds: (i32, i32, i32, i32), // (x, y, width, height) in screen space
|
||||
screenshot_dims: (i32, i32), // (width, height) in pixels
|
||||
screenshot_dims: (i32, i32), // (width, height) in pixels
|
||||
) -> TextLocation {
|
||||
let (win_x, win_y, win_width, win_height) = window_bounds;
|
||||
let (screenshot_width, screenshot_height) = screenshot_dims;
|
||||
@@ -464,8 +536,17 @@ fn transform_screenshot_to_screen_coords(
|
||||
let scale_x = win_width as f64 / screenshot_width as f64;
|
||||
let scale_y = win_height as f64 / screenshot_height as f64;
|
||||
|
||||
tracing::debug!("Transform: screenshot={}x{}, window={}x{} at ({},{}), scale=({:.2},{:.2})",
|
||||
screenshot_width, screenshot_height, win_width, win_height, win_x, win_y, scale_x, scale_y);
|
||||
tracing::debug!(
|
||||
"Transform: screenshot={}x{}, window={}x{} at ({},{}), scale=({:.2},{:.2})",
|
||||
screenshot_width,
|
||||
screenshot_height,
|
||||
win_width,
|
||||
win_height,
|
||||
win_x,
|
||||
win_y,
|
||||
scale_x,
|
||||
scale_y
|
||||
);
|
||||
|
||||
// Transform coordinates from image space to screen space
|
||||
// IMPORTANT: macOS screen coordinates have origin at BOTTOM-LEFT (Y increases upward)
|
||||
@@ -474,9 +555,18 @@ fn transform_screenshot_to_screen_coords(
|
||||
// So we need to: (win_y + win_height) to get window TOP, then subtract screenshot_y
|
||||
let window_top_y = win_y + win_height;
|
||||
|
||||
tracing::debug!("[transform] Input location in image space: x={}, y={}, width={}, height={}",
|
||||
location.x, location.y, location.width, location.height);
|
||||
tracing::debug!("[transform] Scale factors: scale_x={:.4}, scale_y={:.4}", scale_x, scale_y);
|
||||
tracing::debug!(
|
||||
"[transform] Input location in image space: x={}, y={}, width={}, height={}",
|
||||
location.x,
|
||||
location.y,
|
||||
location.width,
|
||||
location.height
|
||||
);
|
||||
tracing::debug!(
|
||||
"[transform] Scale factors: scale_x={:.4}, scale_y={:.4}",
|
||||
scale_x,
|
||||
scale_y
|
||||
);
|
||||
|
||||
let transformed_x = win_x + (location.x as f64 * scale_x) as i32;
|
||||
let transformed_y = window_top_y - (location.y as f64 * scale_y) as i32;
|
||||
@@ -484,13 +574,41 @@ fn transform_screenshot_to_screen_coords(
|
||||
let transformed_height = (location.height as f64 * scale_y) as i32;
|
||||
|
||||
tracing::debug!("[transform] Calculation details:");
|
||||
tracing::debug!(" - transformed_x = {} + ({} * {:.4}) = {} + {:.2} = {}", win_x, location.x, scale_x, win_x, location.x as f64 * scale_x, transformed_x);
|
||||
tracing::debug!(" - transformed_width = ({} * {:.4}) = {:.2} -> {}", location.width, scale_x, location.width as f64 * scale_x, transformed_width);
|
||||
tracing::debug!(" - transformed_height = ({} * {:.4}) = {:.2} -> {}", location.height, scale_y, location.height as f64 * scale_y, transformed_height);
|
||||
tracing::debug!(
|
||||
" - transformed_x = {} + ({} * {:.4}) = {} + {:.2} = {}",
|
||||
win_x,
|
||||
location.x,
|
||||
scale_x,
|
||||
win_x,
|
||||
location.x as f64 * scale_x,
|
||||
transformed_x
|
||||
);
|
||||
tracing::debug!(
|
||||
" - transformed_width = ({} * {:.4}) = {:.2} -> {}",
|
||||
location.width,
|
||||
scale_x,
|
||||
location.width as f64 * scale_x,
|
||||
transformed_width
|
||||
);
|
||||
tracing::debug!(
|
||||
" - transformed_height = ({} * {:.4}) = {:.2} -> {}",
|
||||
location.height,
|
||||
scale_y,
|
||||
location.height as f64 * scale_y,
|
||||
transformed_height
|
||||
);
|
||||
|
||||
tracing::debug!("Transformed location: screenshot=({},{}) {}x{} -> screen=({},{}) {}x{}",
|
||||
location.x, location.y, location.width, location.height,
|
||||
transformed_x, transformed_y, transformed_width, transformed_height);
|
||||
tracing::debug!(
|
||||
"Transformed location: screenshot=({},{}) {}x{} -> screen=({},{}) {}x{}",
|
||||
location.x,
|
||||
location.y,
|
||||
location.width,
|
||||
location.height,
|
||||
transformed_x,
|
||||
transformed_y,
|
||||
transformed_width,
|
||||
transformed_height
|
||||
);
|
||||
|
||||
TextLocation {
|
||||
text: location.text,
|
||||
|
||||
@@ -16,7 +16,7 @@ mod window_matching_tests {
|
||||
("Visual Studio Code", "VisualStudioCode", true),
|
||||
("Google Chrome", "Google Chrome", true),
|
||||
("Safari", "Safari", true),
|
||||
("iTerm", "iTerm2", true), // fuzzy match
|
||||
("iTerm", "iTerm2", true), // fuzzy match
|
||||
("Code", "Visual Studio Code", true), // fuzzy match
|
||||
];
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::{ComputerController, types::*};
|
||||
use crate::{types::*, ComputerController};
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use tesseract::Tesseract;
|
||||
@@ -61,7 +61,12 @@ impl ComputerController for WindowsController {
|
||||
anyhow::bail!("Windows implementation not yet available")
|
||||
}
|
||||
|
||||
async fn take_screenshot(&self, _path: &str, _region: Option<Rect>, _window_id: Option<&str>) -> Result<()> {
|
||||
async fn take_screenshot(
|
||||
&self,
|
||||
_path: &str,
|
||||
_region: Option<Rect>,
|
||||
_window_id: Option<&str>,
|
||||
) -> Result<()> {
|
||||
// Enforce that window_id must be provided
|
||||
if _window_id.is_none() {
|
||||
anyhow::bail!("window_id is required. You must specify which window to capture (e.g., 'Chrome', 'Terminal', 'Notepad'). Use list_windows to see available windows.");
|
||||
@@ -81,27 +86,32 @@ impl ComputerController for WindowsController {
|
||||
.output();
|
||||
|
||||
if tesseract_check.is_err() || !tesseract_check.as_ref().unwrap().status.success() {
|
||||
anyhow::bail!("Tesseract OCR is not installed on your system.\n\n\
|
||||
anyhow::bail!(
|
||||
"Tesseract OCR is not installed on your system.\n\n\
|
||||
To install tesseract on Windows:\n \
|
||||
1. Download the installer from: https://github.com/UB-Mannheim/tesseract/wiki\n \
|
||||
2. Run the installer and follow the instructions\n \
|
||||
3. Add tesseract to your PATH environment variable\n \
|
||||
4. Restart your terminal/command prompt\n\n\
|
||||
After installation, restart your terminal and try again.");
|
||||
After installation, restart your terminal and try again."
|
||||
);
|
||||
}
|
||||
|
||||
// Initialize Tesseract
|
||||
let tess = Tesseract::new(None, Some("eng"))
|
||||
.map_err(|e| {
|
||||
anyhow::anyhow!("Failed to initialize Tesseract: {}\n\n\
|
||||
let tess = Tesseract::new(None, Some("eng")).map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"Failed to initialize Tesseract: {}\n\n\
|
||||
This usually means:\n1. Tesseract is not properly installed\n\
|
||||
2. Language data files are missing\n\nTo fix:\n \
|
||||
1. Reinstall tesseract from https://github.com/UB-Mannheim/tesseract/wiki\n \
|
||||
2. Make sure to select 'Additional language data' during installation\n \
|
||||
3. Ensure tesseract is in your PATH", e)
|
||||
})?;
|
||||
3. Ensure tesseract is in your PATH",
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
let text = tess.set_image(_path)
|
||||
let text = tess
|
||||
.set_image(_path)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to load image '{}': {}", _path, e))?
|
||||
.get_text()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to extract text from image: {}", e))?;
|
||||
@@ -112,7 +122,12 @@ impl ComputerController for WindowsController {
|
||||
Ok(OCRResult {
|
||||
text,
|
||||
confidence,
|
||||
bounds: Rect { x: 0, y: 0, width: 0, height: 0 }, // Would need image dimensions
|
||||
bounds: Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
}, // Would need image dimensions
|
||||
})
|
||||
}
|
||||
|
||||
@@ -123,13 +138,15 @@ impl ComputerController for WindowsController {
|
||||
.output();
|
||||
|
||||
if tesseract_check.is_err() || !tesseract_check.as_ref().unwrap().status.success() {
|
||||
anyhow::bail!("Tesseract OCR is not installed on your system.\n\n\
|
||||
anyhow::bail!(
|
||||
"Tesseract OCR is not installed on your system.\n\n\
|
||||
To install tesseract on Windows:\n \
|
||||
1. Download the installer from: https://github.com/UB-Mannheim/tesseract/wiki\n \
|
||||
2. Run the installer and follow the instructions\n \
|
||||
3. Add tesseract to your PATH environment variable\n \
|
||||
4. Restart your terminal/command prompt\n\n\
|
||||
After installation, restart your terminal and try again.");
|
||||
After installation, restart your terminal and try again."
|
||||
);
|
||||
}
|
||||
|
||||
// Take full screen screenshot
|
||||
@@ -137,17 +154,20 @@ impl ComputerController for WindowsController {
|
||||
self.take_screenshot(&temp_path, None, None).await?;
|
||||
|
||||
// Use Tesseract to find text with bounding boxes
|
||||
let tess = Tesseract::new(None, Some("eng"))
|
||||
.map_err(|e| {
|
||||
anyhow::anyhow!("Failed to initialize Tesseract: {}\n\n\
|
||||
let tess = Tesseract::new(None, Some("eng")).map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"Failed to initialize Tesseract: {}\n\n\
|
||||
This usually means:\n1. Tesseract is not properly installed\n\
|
||||
2. Language data files are missing\n\nTo fix:\n \
|
||||
1. Reinstall tesseract from https://github.com/UB-Mannheim/tesseract/wiki\n \
|
||||
2. Make sure to select 'Additional language data' during installation\n \
|
||||
3. Ensure tesseract is in your PATH", e)
|
||||
})?;
|
||||
3. Ensure tesseract is in your PATH",
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
let full_text = tess.set_image(temp_path.as_str())
|
||||
let full_text = tess
|
||||
.set_image(temp_path.as_str())
|
||||
.map_err(|e| anyhow::anyhow!("Failed to load screenshot: {}", e))?
|
||||
.get_text()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to extract text from screen: {}", e))?;
|
||||
@@ -158,7 +178,9 @@ impl ComputerController for WindowsController {
|
||||
// Simple text search - full implementation would use get_component_images
|
||||
// to get bounding boxes for each word
|
||||
if full_text.contains(_text) {
|
||||
tracing::warn!("Text found but precise coordinates not available in simplified implementation");
|
||||
tracing::warn!(
|
||||
"Text found but precise coordinates not available in simplified implementation"
|
||||
);
|
||||
Ok(Some(Point { x: 0, y: 0 }))
|
||||
} else {
|
||||
Ok(None)
|
||||
|
||||
@@ -105,7 +105,13 @@ impl WebElement {
|
||||
|
||||
/// Find multiple child elements by CSS selector
|
||||
pub async fn find_elements(&mut self, selector: &str) -> Result<Vec<WebElement>> {
|
||||
let elems = self.inner.find_all(fantoccini::Locator::Css(selector)).await?;
|
||||
Ok(elems.into_iter().map(|inner| WebElement { inner }).collect())
|
||||
let elems = self
|
||||
.inner
|
||||
.find_all(fantoccini::Locator::Css(selector))
|
||||
.await?;
|
||||
Ok(elems
|
||||
.into_iter()
|
||||
.map(|inner| WebElement { inner })
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,10 @@ impl SafariDriver {
|
||||
let url = format!("http://localhost:{}", port);
|
||||
|
||||
let mut caps = serde_json::Map::new();
|
||||
caps.insert("browserName".to_string(), Value::String("safari".to_string()));
|
||||
caps.insert(
|
||||
"browserName".to_string(),
|
||||
Value::String("safari".to_string()),
|
||||
);
|
||||
|
||||
let client = ClientBuilder::native()
|
||||
.capabilities(caps)
|
||||
@@ -61,9 +64,7 @@ impl SafariDriver {
|
||||
/// Get all window handles
|
||||
pub async fn window_handles(&mut self) -> Result<Vec<String>> {
|
||||
let handles = self.client.windows().await?;
|
||||
Ok(handles.into_iter()
|
||||
.map(|h| h.into())
|
||||
.collect())
|
||||
Ok(handles.into_iter().map(|h| h.into()).collect())
|
||||
}
|
||||
|
||||
/// Switch to a window by handle
|
||||
@@ -109,7 +110,11 @@ impl SafariDriver {
|
||||
}
|
||||
|
||||
/// Wait for an element to appear (with timeout)
|
||||
pub async fn wait_for_element(&mut self, selector: &str, timeout: Duration) -> Result<WebElement> {
|
||||
pub async fn wait_for_element(
|
||||
&mut self,
|
||||
selector: &str,
|
||||
timeout: Duration,
|
||||
) -> Result<WebElement> {
|
||||
let start = std::time::Instant::now();
|
||||
let poll_interval = Duration::from_millis(100);
|
||||
|
||||
@@ -127,7 +132,11 @@ impl SafariDriver {
|
||||
}
|
||||
|
||||
/// Wait for an element to be visible (with timeout)
|
||||
pub async fn wait_for_visible(&mut self, selector: &str, timeout: Duration) -> Result<WebElement> {
|
||||
pub async fn wait_for_visible(
|
||||
&mut self,
|
||||
selector: &str,
|
||||
timeout: Duration,
|
||||
) -> Result<WebElement> {
|
||||
let start = std::time::Instant::now();
|
||||
let poll_interval = Duration::from_millis(100);
|
||||
|
||||
@@ -163,14 +172,26 @@ impl WebDriverController for SafariDriver {
|
||||
}
|
||||
|
||||
async fn find_element(&mut self, selector: &str) -> Result<WebElement> {
|
||||
let elem = self.client.find(fantoccini::Locator::Css(selector)).await
|
||||
.context(format!("Failed to find element with selector: {}", selector))?;
|
||||
let elem = self
|
||||
.client
|
||||
.find(fantoccini::Locator::Css(selector))
|
||||
.await
|
||||
.context(format!(
|
||||
"Failed to find element with selector: {}",
|
||||
selector
|
||||
))?;
|
||||
Ok(WebElement { inner: elem })
|
||||
}
|
||||
|
||||
async fn find_elements(&mut self, selector: &str) -> Result<Vec<WebElement>> {
|
||||
let elems = self.client.find_all(fantoccini::Locator::Css(selector)).await?;
|
||||
Ok(elems.into_iter().map(|inner| WebElement { inner }).collect())
|
||||
let elems = self
|
||||
.client
|
||||
.find_all(fantoccini::Locator::Css(selector))
|
||||
.await?;
|
||||
Ok(elems
|
||||
.into_iter()
|
||||
.map(|inner| WebElement { inner })
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn execute_script(&mut self, script: &str, args: Vec<Value>) -> Result<Value> {
|
||||
@@ -194,8 +215,7 @@ impl WebDriverController for SafariDriver {
|
||||
.context("Failed to create parent directories for screenshot")?;
|
||||
}
|
||||
|
||||
std::fs::write(path_str, screenshot_data)
|
||||
.context("Failed to write screenshot to file")?;
|
||||
std::fs::write(path_str, screenshot_data).context("Failed to write screenshot to file")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -7,11 +7,17 @@ async fn test_screenshot() {
|
||||
// Test that screenshot without window_id fails with appropriate error
|
||||
let path = "/tmp/test_screenshot.png";
|
||||
let result = controller.take_screenshot(path, None, None).await;
|
||||
assert!(result.is_err(), "Expected error when window_id is not provided");
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"Expected error when window_id is not provided"
|
||||
);
|
||||
|
||||
let error_msg = result.unwrap_err().to_string();
|
||||
assert!(error_msg.contains("window_id is required"),
|
||||
"Expected error message about window_id being required, got: {}", error_msg);
|
||||
assert!(
|
||||
error_msg.contains("window_id is required"),
|
||||
"Expected error message about window_id being required, got: {}",
|
||||
error_msg
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
+21
-25
@@ -1,5 +1,5 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -21,7 +21,7 @@ pub struct ProvidersConfig {
|
||||
pub databricks: Option<DatabricksConfig>,
|
||||
pub embedded: Option<EmbeddedConfig>,
|
||||
pub default_provider: String,
|
||||
pub coach: Option<String>, // Provider to use for coach in autonomous mode
|
||||
pub coach: Option<String>, // Provider to use for coach in autonomous mode
|
||||
pub player: Option<String>, // Provider to use for player in autonomous mode
|
||||
}
|
||||
|
||||
@@ -103,9 +103,7 @@ pub struct MacAxConfig {
|
||||
|
||||
impl Default for MacAxConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
}
|
||||
Self { enabled: false }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,11 +171,7 @@ impl Config {
|
||||
Path::new(path).exists()
|
||||
} else {
|
||||
// Check default locations
|
||||
let default_paths = [
|
||||
"./g3.toml",
|
||||
"~/.config/g3/config.toml",
|
||||
"~/.g3.toml",
|
||||
];
|
||||
let default_paths = ["./g3.toml", "~/.config/g3/config.toml", "~/.g3.toml"];
|
||||
|
||||
default_paths.iter().any(|path| {
|
||||
let expanded_path = shellexpand::tilde(path);
|
||||
@@ -205,7 +199,10 @@ impl Config {
|
||||
if let Err(e) = databricks_config.save(config_file.to_str().unwrap()) {
|
||||
eprintln!("Warning: Could not save default config: {}", e);
|
||||
} else {
|
||||
println!("Created default Databricks configuration at: {}", config_file.display());
|
||||
println!(
|
||||
"Created default Databricks configuration at: {}",
|
||||
config_file.display()
|
||||
);
|
||||
}
|
||||
|
||||
return Ok(databricks_config);
|
||||
@@ -224,11 +221,7 @@ impl Config {
|
||||
}
|
||||
} else {
|
||||
// Try to load from default locations
|
||||
let default_paths = [
|
||||
"./g3.toml",
|
||||
"~/.config/g3/config.toml",
|
||||
"~/.g3.toml",
|
||||
];
|
||||
let default_paths = ["./g3.toml", "~/.config/g3/config.toml", "~/.g3.toml"];
|
||||
|
||||
for path in &default_paths {
|
||||
let expanded_path = shellexpand::tilde(path);
|
||||
@@ -240,10 +233,7 @@ impl Config {
|
||||
}
|
||||
|
||||
// Override with environment variables
|
||||
settings = settings.add_source(
|
||||
config::Environment::with_prefix("G3")
|
||||
.separator("_")
|
||||
);
|
||||
settings = settings.add_source(config::Environment::with_prefix("G3").separator("_"));
|
||||
|
||||
let config = settings.build()?.try_deserialize()?;
|
||||
Ok(config)
|
||||
@@ -260,7 +250,7 @@ impl Config {
|
||||
embedded: Some(EmbeddedConfig {
|
||||
model_path: "~/.cache/g3/models/qwen2.5-7b-instruct-q3_k_m.gguf".to_string(),
|
||||
model_type: "qwen".to_string(),
|
||||
context_length: Some(32768), // Qwen2.5 supports 32k context
|
||||
context_length: Some(32768), // Qwen2.5 supports 32k context
|
||||
max_tokens: Some(2048),
|
||||
temperature: Some(0.1),
|
||||
gpu_layers: Some(32),
|
||||
@@ -345,8 +335,12 @@ impl Config {
|
||||
));
|
||||
}
|
||||
}
|
||||
_ => return Err(anyhow::anyhow!("Unknown provider: {}",
|
||||
config.providers.default_provider)),
|
||||
_ => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Unknown provider: {}",
|
||||
config.providers.default_provider
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,14 +349,16 @@ impl Config {
|
||||
|
||||
/// Get the provider to use for coach mode in autonomous execution
|
||||
pub fn get_coach_provider(&self) -> &str {
|
||||
self.providers.coach
|
||||
self.providers
|
||||
.coach
|
||||
.as_deref()
|
||||
.unwrap_or(&self.providers.default_provider)
|
||||
}
|
||||
|
||||
/// Get the provider to use for player mode in autonomous execution
|
||||
pub fn get_player_provider(&self) -> &str {
|
||||
self.providers.player
|
||||
self.providers
|
||||
.player
|
||||
.as_deref()
|
||||
.unwrap_or(&self.providers.default_provider)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#[cfg(test)]
|
||||
mod test_multiple_tool_calls {
|
||||
use g3_config::{Config, AgentConfig};
|
||||
use g3_config::{AgentConfig, Config};
|
||||
|
||||
#[test]
|
||||
fn test_config_has_multiple_tool_calls_field() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use sysinfo::{System, Pid};
|
||||
use sysinfo::{Pid, System};
|
||||
|
||||
fn main() {
|
||||
let mut sys = System::new_all();
|
||||
|
||||
@@ -8,8 +8,10 @@ fn main() {
|
||||
Ok(instances) => {
|
||||
println!("Found {} instances:", instances.len());
|
||||
for instance in instances {
|
||||
println!(" - PID: {}, Workspace: {:?}, Type: {:?}",
|
||||
instance.pid, instance.workspace, instance.instance_type);
|
||||
println!(
|
||||
" - PID: {}, Workspace: {:?}, Type: {:?}",
|
||||
instance.pid, instance.workspace, instance.instance_type
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use sysinfo::{System, Pid};
|
||||
use sysinfo::{Pid, System};
|
||||
|
||||
fn main() {
|
||||
let mut sys = System::new_all();
|
||||
|
||||
@@ -50,21 +50,24 @@ pub async fn restart_instance(
|
||||
let mut controller = controller.lock().await;
|
||||
|
||||
// Get stored launch params
|
||||
let params = controller.get_launch_params(pid)
|
||||
let params = controller
|
||||
.get_launch_params(pid)
|
||||
.ok_or(StatusCode::NOT_FOUND)?;
|
||||
|
||||
// Launch new instance with same parameters
|
||||
let new_pid = controller.launch_g3(
|
||||
params.workspace.to_str().unwrap(),
|
||||
¶ms.provider,
|
||||
¶ms.model,
|
||||
¶ms.prompt,
|
||||
params.autonomous,
|
||||
params.g3_binary_path.as_deref(),
|
||||
).map_err(|e| {
|
||||
error!("Failed to restart instance: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
let new_pid = controller
|
||||
.launch_g3(
|
||||
params.workspace.to_str().unwrap(),
|
||||
¶ms.provider,
|
||||
¶ms.model,
|
||||
¶ms.prompt,
|
||||
params.autonomous,
|
||||
params.g3_binary_path.as_deref(),
|
||||
)
|
||||
.map_err(|e| {
|
||||
error!("Failed to restart instance: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
let new_id = format!("{}_{}", new_pid, chrono::Utc::now().timestamp());
|
||||
|
||||
@@ -94,10 +97,13 @@ pub async fn launch_instance(
|
||||
// Check if file exists
|
||||
if !path.exists() {
|
||||
error!("G3 binary not found: {}", binary_path);
|
||||
return Err((StatusCode::BAD_REQUEST, Json(serde_json::json!({
|
||||
"error": "G3 binary not found",
|
||||
"message": format!("The specified g3 binary does not exist: {}", binary_path)
|
||||
}))));
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "G3 binary not found",
|
||||
"message": format!("The specified g3 binary does not exist: {}", binary_path)
|
||||
})),
|
||||
));
|
||||
}
|
||||
|
||||
// Check if file is executable (Unix only)
|
||||
@@ -107,20 +113,26 @@ pub async fn launch_instance(
|
||||
if let Ok(metadata) = std::fs::metadata(path) {
|
||||
if metadata.permissions().mode() & 0o111 == 0 {
|
||||
error!("G3 binary is not executable: {}", binary_path);
|
||||
return Err((StatusCode::BAD_REQUEST, Json(serde_json::json!({
|
||||
"error": "G3 binary is not executable",
|
||||
"message": format!("The specified g3 binary is not executable: {}", binary_path)
|
||||
}))));
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "G3 binary is not executable",
|
||||
"message": format!("The specified g3 binary is not executable: {}", binary_path)
|
||||
})),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let workspace = request.workspace.to_str().ok_or_else(|| {
|
||||
(StatusCode::BAD_REQUEST, Json(serde_json::json!({
|
||||
"error": "Invalid workspace path",
|
||||
"message": "The workspace path contains invalid characters"
|
||||
})))
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "Invalid workspace path",
|
||||
"message": "The workspace path contains invalid characters"
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
let autonomous = request.mode == LaunchMode::Ensemble;
|
||||
let g3_binary_path = request.g3_binary_path.as_deref();
|
||||
@@ -145,10 +157,13 @@ pub async fn launch_instance(
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to launch g3 instance: {}", e);
|
||||
Err((StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({
|
||||
"error": "Failed to launch instance",
|
||||
"message": format!("Error: {}", e)
|
||||
}))))
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "Failed to launch instance",
|
||||
"message": format!("Error: {}", e)
|
||||
})),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
use crate::logs::{LogParser, StatsAggregator};
|
||||
use crate::models::*;
|
||||
use crate::process::ProcessDetector;
|
||||
use axum::{extract::{Query, State}, http::StatusCode, Json};
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
@@ -69,7 +73,10 @@ fn get_instance_detail(instance: &Instance) -> anyhow::Result<InstanceDetail> {
|
||||
let log_entries = match LogParser::parse_logs(&instance.workspace) {
|
||||
Ok(entries) => entries,
|
||||
Err(e) => {
|
||||
warn!("Failed to parse logs for instance {}: {}. Instance may be newly started.", instance.id, e);
|
||||
warn!(
|
||||
"Failed to parse logs for instance {}: {}. Instance may be newly started.",
|
||||
instance.id, e
|
||||
);
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
@@ -85,7 +92,10 @@ fn get_instance_detail(instance: &Instance) -> anyhow::Result<InstanceDetail> {
|
||||
let git_status = match get_git_status(&instance.workspace) {
|
||||
Some(status) => Some(status),
|
||||
None => {
|
||||
debug!("No git status available for workspace: {:?}", instance.workspace);
|
||||
debug!(
|
||||
"No git status available for workspace: {:?}",
|
||||
instance.workspace
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
@@ -177,16 +187,10 @@ fn read_file_snippet(workspace: &std::path::Path, filename: &str) -> Option<Stri
|
||||
return None;
|
||||
}
|
||||
|
||||
fs::read_to_string(&path)
|
||||
.ok()
|
||||
.map(|content| {
|
||||
// Return first 10 lines
|
||||
content
|
||||
.lines()
|
||||
.take(10)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
})
|
||||
fs::read_to_string(&path).ok().map(|content| {
|
||||
// Return first 10 lines
|
||||
content.lines().take(10).collect::<Vec<_>>().join("\n")
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -202,8 +206,13 @@ pub async fn get_file_content(
|
||||
let mut detector = detector.lock().await;
|
||||
|
||||
// Find the instance
|
||||
let instances = detector.detect_instances().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let instance = instances.iter().find(|i| i.id == id).ok_or(StatusCode::NOT_FOUND)?;
|
||||
let instances = detector
|
||||
.detect_instances()
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let instance = instances
|
||||
.iter()
|
||||
.find(|i| i.id == id)
|
||||
.ok_or(StatusCode::NOT_FOUND)?;
|
||||
|
||||
// Read the full file
|
||||
let file_path = instance.workspace.join(&query.name);
|
||||
@@ -211,8 +220,8 @@ pub async fn get_file_content(
|
||||
return Err(StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
let content = std::fs::read_to_string(&file_path)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let content =
|
||||
std::fs::read_to_string(&file_path).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"name": query.name,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
pub mod instances;
|
||||
pub mod control;
|
||||
pub mod instances;
|
||||
pub mod logs;
|
||||
pub mod state;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use crate::launch::ConsoleState;
|
||||
use axum::{http::StatusCode, Json};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::PathBuf;
|
||||
use tracing::{error, info};
|
||||
|
||||
pub async fn get_state() -> Result<Json<ConsoleState>, StatusCode> {
|
||||
@@ -59,12 +59,14 @@ pub async fn browse_filesystem(
|
||||
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
|
||||
};
|
||||
|
||||
let current_path = path.canonicalize()
|
||||
let current_path = path
|
||||
.canonicalize()
|
||||
.map_err(|_| StatusCode::BAD_REQUEST)?
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
let parent_path = path.parent()
|
||||
let parent_path = path
|
||||
.parent()
|
||||
.and_then(|p| p.to_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
@@ -83,12 +85,10 @@ pub async fn browse_filesystem(
|
||||
}
|
||||
}
|
||||
|
||||
entries.sort_by(|a, b| {
|
||||
match (a.is_dir, b.is_dir) {
|
||||
(true, false) => std::cmp::Ordering::Less,
|
||||
(false, true) => std::cmp::Ordering::Greater,
|
||||
_ => a.name.cmp(&b.name),
|
||||
}
|
||||
entries.sort_by(|a, b| match (a.is_dir, b.is_dir) {
|
||||
(true, false) => std::cmp::Ordering::Less,
|
||||
(false, true) => std::cmp::Ordering::Greater,
|
||||
_ => a.name.cmp(&b.name),
|
||||
});
|
||||
|
||||
Ok(Json(BrowseResponse {
|
||||
|
||||
@@ -59,8 +59,6 @@ impl ConsoleState {
|
||||
fn config_path() -> PathBuf {
|
||||
// Use explicit ~/.config/g3/console.json path as per requirements
|
||||
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
|
||||
home.join(".config")
|
||||
.join("g3")
|
||||
.join("console.json")
|
||||
home.join(".config").join("g3").join("console.json")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
pub mod api;
|
||||
pub mod launch;
|
||||
pub mod logs;
|
||||
pub mod models;
|
||||
pub mod process;
|
||||
pub mod launch;
|
||||
|
||||
@@ -55,17 +55,21 @@ impl LogParser {
|
||||
if let Some(messages) = json.get("messages").and_then(|m| m.as_array()) {
|
||||
for msg in messages {
|
||||
entries.push(LogEntry {
|
||||
timestamp: msg.get("timestamp")
|
||||
timestamp: msg
|
||||
.get("timestamp")
|
||||
.and_then(|t| t.as_str())
|
||||
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
|
||||
.map(|dt| dt.with_timezone(&Utc)),
|
||||
role: msg.get("role")
|
||||
role: msg
|
||||
.get("role")
|
||||
.and_then(|r| r.as_str())
|
||||
.map(String::from),
|
||||
content: msg.get("content")
|
||||
content: msg
|
||||
.get("content")
|
||||
.and_then(|c| c.as_str())
|
||||
.map(String::from),
|
||||
tool_calls: msg.get("tool_calls")
|
||||
tool_calls: msg
|
||||
.get("tool_calls")
|
||||
.and_then(|tc| tc.as_array())
|
||||
.map(|arr| arr.clone()),
|
||||
raw: msg.clone(),
|
||||
@@ -78,13 +82,11 @@ impl LogParser {
|
||||
}
|
||||
|
||||
// Sort by timestamp
|
||||
entries.sort_by(|a, b| {
|
||||
match (&a.timestamp, &b.timestamp) {
|
||||
(Some(t1), Some(t2)) => t1.cmp(t2),
|
||||
(Some(_), None) => std::cmp::Ordering::Less,
|
||||
(None, Some(_)) => std::cmp::Ordering::Greater,
|
||||
(None, None) => std::cmp::Ordering::Equal,
|
||||
}
|
||||
entries.sort_by(|a, b| match (&a.timestamp, &b.timestamp) {
|
||||
(Some(t1), Some(t2)) => t1.cmp(t2),
|
||||
(Some(_), None) => std::cmp::Ordering::Less,
|
||||
(None, Some(_)) => std::cmp::Ordering::Greater,
|
||||
(None, None) => std::cmp::Ordering::Equal,
|
||||
});
|
||||
|
||||
Ok(entries)
|
||||
@@ -117,10 +119,12 @@ impl LogParser {
|
||||
if let Some(name) = call.get("name").and_then(|n| n.as_str()) {
|
||||
tool_calls.push(ToolCall {
|
||||
name: name.to_string(),
|
||||
parameters: call.get("parameters")
|
||||
parameters: call
|
||||
.get("parameters")
|
||||
.cloned()
|
||||
.unwrap_or(Value::Object(serde_json::Map::new())),
|
||||
result: call.get("result")
|
||||
result: call
|
||||
.get("result")
|
||||
.and_then(|r| r.as_str())
|
||||
.map(String::from),
|
||||
timestamp: entry.timestamp,
|
||||
@@ -193,7 +197,9 @@ impl StatsAggregator {
|
||||
entries
|
||||
.iter()
|
||||
.filter_map(|entry| {
|
||||
entry.raw.get("usage")
|
||||
entry
|
||||
.raw
|
||||
.get("usage")
|
||||
.and_then(|u| u.get("total_tokens"))
|
||||
.and_then(|t| t.as_u64())
|
||||
})
|
||||
@@ -213,7 +219,11 @@ impl StatsAggregator {
|
||||
.iter()
|
||||
.filter(|entry| {
|
||||
entry.raw.get("error").is_some()
|
||||
|| entry.content.as_ref().map(|c| c.to_lowercase().contains("error")).unwrap_or(false)
|
||||
|| entry
|
||||
.content
|
||||
.as_ref()
|
||||
.map(|c| c.to_lowercase().contains("error"))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.count() as u64
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use g3_console::api;
|
||||
use g3_console::process;
|
||||
use g3_console::launch;
|
||||
use g3_console::process;
|
||||
|
||||
use api::control::{kill_instance, launch_instance, restart_instance};
|
||||
use api::instances::{get_instance, get_file_content, list_instances};
|
||||
use api::instances::{get_file_content, get_instance, list_instances};
|
||||
use api::logs::get_instance_logs;
|
||||
use api::state::{get_state, save_state, browse_filesystem};
|
||||
use api::state::{browse_filesystem, get_state, save_state};
|
||||
use axum::{
|
||||
routing::{get, post},
|
||||
Router,
|
||||
@@ -39,9 +39,7 @@ struct Args {
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
// Initialize tracing
|
||||
tracing_subscriber::fmt()
|
||||
.with_max_level(Level::INFO)
|
||||
.init();
|
||||
tracing_subscriber::fmt().with_max_level(Level::INFO).init();
|
||||
|
||||
let args = Args::parse();
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Instance {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatMessage {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::os::unix::process::CommandExt;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use std::path::PathBuf;
|
||||
use sysinfo::{Pid, Signal, System, Process};
|
||||
use tracing::{debug, info};
|
||||
use crate::models::LaunchParams;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use std::collections::HashMap;
|
||||
use std::os::unix::process::CommandExt;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::Mutex;
|
||||
use sysinfo::{Pid, Process, Signal, System};
|
||||
use tracing::{debug, info};
|
||||
|
||||
pub struct ProcessController {
|
||||
system: System,
|
||||
@@ -114,11 +114,16 @@ impl ProcessController {
|
||||
let intermediate_pid = child.id();
|
||||
|
||||
// Wait for intermediate process (it will exit immediately after forking)
|
||||
child.wait().context("Failed to wait for intermediate process")?;
|
||||
child
|
||||
.wait()
|
||||
.context("Failed to wait for intermediate process")?;
|
||||
|
||||
// The actual g3 process is now running as orphan
|
||||
// We need to scan for it by matching workspace and recent start time
|
||||
info!("Scanning for newly launched g3 process in workspace: {}", workspace);
|
||||
info!(
|
||||
"Scanning for newly launched g3 process in workspace: {}",
|
||||
workspace
|
||||
);
|
||||
|
||||
// Wait even longer for the process to fully start and appear in process list
|
||||
std::thread::sleep(std::time::Duration::from_millis(2500));
|
||||
@@ -151,7 +156,8 @@ impl ProcessController {
|
||||
if has_workspace {
|
||||
// Check if it's recent (started within last 10 seconds)
|
||||
let now = std::time::SystemTime::now();
|
||||
let start_time = std::time::UNIX_EPOCH + std::time::Duration::from_secs(process.start_time());
|
||||
let start_time =
|
||||
std::time::UNIX_EPOCH + std::time::Duration::from_secs(process.start_time());
|
||||
if let Ok(duration) = now.duration_since(start_time) {
|
||||
if duration.as_secs() < 10 {
|
||||
found_pid = Some(pid.as_u32());
|
||||
@@ -283,7 +289,8 @@ impl ProcessController {
|
||||
}
|
||||
|
||||
// Only return params if we have the minimum required fields
|
||||
if let (Some(ws), Some(prov), Some(mdl), Some(prmt)) = (workspace, provider, model, prompt) {
|
||||
if let (Some(ws), Some(prov), Some(mdl), Some(prmt)) = (workspace, provider, model, prompt)
|
||||
{
|
||||
Some(LaunchParams {
|
||||
workspace: ws,
|
||||
provider: prov,
|
||||
|
||||
@@ -2,7 +2,7 @@ use crate::models::{ExecutionMethod, Instance, InstanceStatus, InstanceType};
|
||||
use anyhow::Result;
|
||||
use chrono::{DateTime, Utc};
|
||||
use std::path::PathBuf;
|
||||
use sysinfo::{System, Pid, Process};
|
||||
use sysinfo::{Pid, Process, System};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
pub struct ProcessDetector {
|
||||
@@ -41,12 +41,7 @@ impl ProcessDetector {
|
||||
Ok(instances)
|
||||
}
|
||||
|
||||
fn parse_g3_process(
|
||||
&self,
|
||||
pid: Pid,
|
||||
process: &Process,
|
||||
cmd: &[String],
|
||||
) -> Option<Instance> {
|
||||
fn parse_g3_process(&self, pid: Pid, process: &Process, cmd: &[String]) -> Option<Instance> {
|
||||
let cmd_str = cmd.join(" ");
|
||||
|
||||
// Exclude g3-console itself
|
||||
@@ -55,10 +50,16 @@ impl ProcessDetector {
|
||||
}
|
||||
|
||||
// Check if this is a g3 binary (more comprehensive check)
|
||||
let is_g3_binary = cmd.get(0).map(|s| {
|
||||
(s.ends_with("g3") || s.ends_with("/g3") || s.contains("/target/release/g3") || s.contains("/target/debug/g3"))
|
||||
&& !s.contains("g3-") // Exclude other g3-* binaries
|
||||
}).unwrap_or(false);
|
||||
let is_g3_binary = cmd
|
||||
.get(0)
|
||||
.map(|s| {
|
||||
(s.ends_with("g3")
|
||||
|| s.ends_with("/g3")
|
||||
|| s.contains("/target/release/g3")
|
||||
|| s.contains("/target/debug/g3"))
|
||||
&& !s.contains("g3-") // Exclude other g3-* binaries
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
// Check if this is cargo run with g3 (not g3-console or other variants)
|
||||
let is_cargo_run = cmd.get(0).map(|s| s.contains("cargo")).unwrap_or(false)
|
||||
@@ -97,8 +98,8 @@ impl ProcessDetector {
|
||||
let model = self.extract_flag_value(cmd, "--model");
|
||||
|
||||
// Get start time
|
||||
let start_time = DateTime::from_timestamp(process.start_time() as i64, 0)
|
||||
.unwrap_or_else(Utc::now);
|
||||
let start_time =
|
||||
DateTime::from_timestamp(process.start_time() as i64, 0).unwrap_or_else(Utc::now);
|
||||
|
||||
// Generate instance ID from PID and start time
|
||||
let id = format!("{}_{}", pid, start_time.timestamp());
|
||||
@@ -158,7 +159,10 @@ impl ProcessDetector {
|
||||
}
|
||||
|
||||
// Final fallback: use current directory of console
|
||||
warn!("Could not determine workspace for PID {}, using current directory", pid);
|
||||
warn!(
|
||||
"Could not determine workspace for PID {}, using current directory",
|
||||
pid
|
||||
);
|
||||
std::env::current_dir().ok()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
pub mod detector;
|
||||
pub mod controller;
|
||||
pub mod detector;
|
||||
|
||||
pub use detector::*;
|
||||
pub use controller::*;
|
||||
pub use detector::*;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Inspect tree-sitter AST structure for Rust code
|
||||
|
||||
use tree_sitter::{Parser, Language};
|
||||
use tree_sitter::{Language, Parser};
|
||||
|
||||
fn print_tree(node: tree_sitter::Node, source: &str, indent: usize) {
|
||||
let indent_str = " ".repeat(indent);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Inspect tree-sitter AST structure for Python code
|
||||
|
||||
use tree_sitter::{Parser, Language};
|
||||
use tree_sitter::{Language, Parser};
|
||||
|
||||
fn print_tree(node: tree_sitter::Node, source: &str, indent: usize) {
|
||||
let indent_str = " ".repeat(indent);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Test Python async query
|
||||
|
||||
use tree_sitter::{Parser, Query, QueryCursor, Language};
|
||||
use streaming_iterator::StreamingIterator;
|
||||
use tree_sitter::{Language, Parser, Query, QueryCursor};
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let source_code = r#"
|
||||
|
||||
@@ -3,8 +3,8 @@ use anyhow::{anyhow, Result};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use tree_sitter::{Language, Parser, Query, QueryCursor};
|
||||
use streaming_iterator::StreamingIterator;
|
||||
use tree_sitter::{Language, Parser, Query, QueryCursor};
|
||||
use walkdir::WalkDir;
|
||||
|
||||
pub struct TreeSitterSearcher {
|
||||
@@ -50,7 +50,8 @@ impl TreeSitterSearcher {
|
||||
|
||||
// Create separate parser for "js" alias
|
||||
let mut parser_js = Parser::new();
|
||||
parser_js.set_language(&language)
|
||||
parser_js
|
||||
.set_language(&language)
|
||||
.map_err(|e| anyhow!("Failed to set JavaScript language: {}", e))?;
|
||||
parsers.insert("js".to_string(), parser_js);
|
||||
languages.insert("javascript".to_string(), language.clone());
|
||||
@@ -68,7 +69,8 @@ impl TreeSitterSearcher {
|
||||
|
||||
// Create separate parser for "ts" alias
|
||||
let mut parser_ts = Parser::new();
|
||||
parser_ts.set_language(&language)
|
||||
parser_ts
|
||||
.set_language(&language)
|
||||
.map_err(|e| anyhow!("Failed to set TypeScript language: {}", e))?;
|
||||
parsers.insert("ts".to_string(), parser_ts);
|
||||
languages.insert("typescript".to_string(), language.clone());
|
||||
@@ -215,8 +217,8 @@ impl TreeSitterSearcher {
|
||||
.ok_or_else(|| anyhow!("Language not found: {}", spec.language))?;
|
||||
|
||||
// Parse query
|
||||
let query = Query::new(language, &spec.query)
|
||||
.map_err(|e| anyhow!("Invalid query: {}", e))?;
|
||||
let query =
|
||||
Query::new(language, &spec.query).map_err(|e| anyhow!("Invalid query: {}", e))?;
|
||||
|
||||
let mut matches = Vec::new();
|
||||
let mut files_searched = 0;
|
||||
@@ -255,11 +257,8 @@ impl TreeSitterSearcher {
|
||||
if let Ok(source_code) = fs::read_to_string(path) {
|
||||
if let Some(tree) = parser.parse(&source_code, None) {
|
||||
let mut cursor = QueryCursor::new();
|
||||
let mut query_matches = cursor.matches(
|
||||
&query,
|
||||
tree.root_node(),
|
||||
source_code.as_bytes(),
|
||||
);
|
||||
let mut query_matches =
|
||||
cursor.matches(&query, tree.root_node(), source_code.as_bytes());
|
||||
|
||||
query_matches.advance();
|
||||
while let Some(query_match) = query_matches.get() {
|
||||
|
||||
@@ -191,23 +191,36 @@ pub fn classify_error(error: &anyhow::Error) -> ErrorType {
|
||||
let error_str = error.to_string().to_lowercase();
|
||||
|
||||
// Check for recoverable error patterns
|
||||
if error_str.contains("rate limit") || error_str.contains("rate_limit") || error_str.contains("429") {
|
||||
if error_str.contains("rate limit")
|
||||
|| error_str.contains("rate_limit")
|
||||
|| error_str.contains("429")
|
||||
{
|
||||
return ErrorType::Recoverable(RecoverableError::RateLimit);
|
||||
}
|
||||
|
||||
if error_str.contains("network") || error_str.contains("connection") ||
|
||||
error_str.contains("dns") || error_str.contains("refused") {
|
||||
if error_str.contains("network")
|
||||
|| error_str.contains("connection")
|
||||
|| error_str.contains("dns")
|
||||
|| error_str.contains("refused")
|
||||
{
|
||||
return ErrorType::Recoverable(RecoverableError::NetworkError);
|
||||
}
|
||||
|
||||
if error_str.contains("500") || error_str.contains("502") ||
|
||||
error_str.contains("503") || error_str.contains("504") ||
|
||||
error_str.contains("server error") || error_str.contains("internal error") {
|
||||
if error_str.contains("500")
|
||||
|| error_str.contains("502")
|
||||
|| error_str.contains("503")
|
||||
|| error_str.contains("504")
|
||||
|| error_str.contains("server error")
|
||||
|| error_str.contains("internal error")
|
||||
{
|
||||
return ErrorType::Recoverable(RecoverableError::ServerError);
|
||||
}
|
||||
|
||||
if error_str.contains("busy") || error_str.contains("overloaded") ||
|
||||
error_str.contains("capacity") || error_str.contains("unavailable") {
|
||||
if error_str.contains("busy")
|
||||
|| error_str.contains("overloaded")
|
||||
|| error_str.contains("capacity")
|
||||
|| error_str.contains("unavailable")
|
||||
{
|
||||
return ErrorType::Recoverable(RecoverableError::ModelBusy);
|
||||
}
|
||||
|
||||
@@ -216,18 +229,24 @@ pub fn classify_error(error: &anyhow::Error) -> ErrorType {
|
||||
error_str.contains("timed out") ||
|
||||
error_str.contains("operation timed out") ||
|
||||
error_str.contains("request or response body error") || // Common timeout pattern
|
||||
error_str.contains("stream error") && error_str.contains("timed out") {
|
||||
error_str.contains("stream error") && error_str.contains("timed out")
|
||||
{
|
||||
return ErrorType::Recoverable(RecoverableError::Timeout);
|
||||
}
|
||||
|
||||
// Check for context length exceeded errors (HTTP 400 with specific messages)
|
||||
if (error_str.contains("400") || error_str.contains("bad request")) &&
|
||||
(error_str.contains("context length") || error_str.contains("prompt is too long") ||
|
||||
error_str.contains("maximum context length") || error_str.contains("context_length_exceeded")) {
|
||||
if (error_str.contains("400") || error_str.contains("bad request"))
|
||||
&& (error_str.contains("context length")
|
||||
|| error_str.contains("prompt is too long")
|
||||
|| error_str.contains("maximum context length")
|
||||
|| error_str.contains("context_length_exceeded"))
|
||||
{
|
||||
return ErrorType::Recoverable(RecoverableError::ContextLengthExceeded);
|
||||
}
|
||||
|
||||
if error_str.contains("token") && (error_str.contains("limit") || error_str.contains("exceeded")) {
|
||||
if error_str.contains("token")
|
||||
&& (error_str.contains("limit") || error_str.contains("exceeded"))
|
||||
{
|
||||
return ErrorType::Recoverable(RecoverableError::TokenLimit);
|
||||
}
|
||||
|
||||
@@ -243,7 +262,9 @@ fn calculate_autonomous_retry_delay(attempt: u32) -> Duration {
|
||||
// Distribute 6 retries over 10 minutes (600 seconds)
|
||||
// Base delays: 10s, 30s, 60s, 120s, 180s, 200s = 600s total
|
||||
let base_delays_ms = [10000, 30000, 60000, 120000, 180000, 200000];
|
||||
let base_delay = base_delays_ms.get(attempt.saturating_sub(1) as usize).unwrap_or(&200000);
|
||||
let base_delay = base_delays_ms
|
||||
.get(attempt.saturating_sub(1) as usize)
|
||||
.unwrap_or(&200000);
|
||||
|
||||
// Add jitter of ±30% to prevent thundering herd
|
||||
let jitter = (*base_delay as f64 * 0.3 * rng.gen::<f64>()) as u64;
|
||||
@@ -263,7 +284,11 @@ pub fn calculate_retry_delay(attempt: u32, is_autonomous: bool) -> Duration {
|
||||
}
|
||||
|
||||
use rand::Rng;
|
||||
let max_retry_delay_ms = if is_autonomous { AUTONOMOUS_MAX_RETRY_DELAY_MS } else { DEFAULT_MAX_RETRY_DELAY_MS };
|
||||
let max_retry_delay_ms = if is_autonomous {
|
||||
AUTONOMOUS_MAX_RETRY_DELAY_MS
|
||||
} else {
|
||||
DEFAULT_MAX_RETRY_DELAY_MS
|
||||
};
|
||||
|
||||
// Exponential backoff: delay = base * 2^attempt
|
||||
let base_delay = BASE_RETRY_DELAY_MS * (2_u64.pow(attempt.saturating_sub(1)));
|
||||
@@ -370,7 +395,11 @@ fn truncate_for_logging(s: &str, max_len: usize) -> String {
|
||||
truncate_at = max_len.min(s.len());
|
||||
}
|
||||
|
||||
format!("{}... (truncated, {} total bytes)", &s[..truncate_at], s.len())
|
||||
format!(
|
||||
"{}... (truncated, {} total bytes)",
|
||||
&s[..truncate_at],
|
||||
s.len()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -398,37 +427,64 @@ mod tests {
|
||||
fn test_error_classification() {
|
||||
// Rate limit errors
|
||||
let error = anyhow!("Rate limit exceeded");
|
||||
assert_eq!(classify_error(&error), ErrorType::Recoverable(RecoverableError::RateLimit));
|
||||
assert_eq!(
|
||||
classify_error(&error),
|
||||
ErrorType::Recoverable(RecoverableError::RateLimit)
|
||||
);
|
||||
|
||||
let error = anyhow!("HTTP 429 Too Many Requests");
|
||||
assert_eq!(classify_error(&error), ErrorType::Recoverable(RecoverableError::RateLimit));
|
||||
assert_eq!(
|
||||
classify_error(&error),
|
||||
ErrorType::Recoverable(RecoverableError::RateLimit)
|
||||
);
|
||||
|
||||
// Network errors
|
||||
let error = anyhow!("Network connection failed");
|
||||
assert_eq!(classify_error(&error), ErrorType::Recoverable(RecoverableError::NetworkError));
|
||||
assert_eq!(
|
||||
classify_error(&error),
|
||||
ErrorType::Recoverable(RecoverableError::NetworkError)
|
||||
);
|
||||
|
||||
// Server errors
|
||||
let error = anyhow!("HTTP 503 Service Unavailable");
|
||||
assert_eq!(classify_error(&error), ErrorType::Recoverable(RecoverableError::ServerError));
|
||||
assert_eq!(
|
||||
classify_error(&error),
|
||||
ErrorType::Recoverable(RecoverableError::ServerError)
|
||||
);
|
||||
|
||||
// Model busy
|
||||
let error = anyhow!("Model is busy, please try again");
|
||||
assert_eq!(classify_error(&error), ErrorType::Recoverable(RecoverableError::ModelBusy));
|
||||
assert_eq!(
|
||||
classify_error(&error),
|
||||
ErrorType::Recoverable(RecoverableError::ModelBusy)
|
||||
);
|
||||
|
||||
// Timeout
|
||||
let error = anyhow!("Request timed out");
|
||||
assert_eq!(classify_error(&error), ErrorType::Recoverable(RecoverableError::Timeout));
|
||||
assert_eq!(
|
||||
classify_error(&error),
|
||||
ErrorType::Recoverable(RecoverableError::Timeout)
|
||||
);
|
||||
|
||||
// Token limit
|
||||
let error = anyhow!("Token limit exceeded");
|
||||
assert_eq!(classify_error(&error), ErrorType::Recoverable(RecoverableError::TokenLimit));
|
||||
assert_eq!(
|
||||
classify_error(&error),
|
||||
ErrorType::Recoverable(RecoverableError::TokenLimit)
|
||||
);
|
||||
|
||||
// Context length exceeded
|
||||
let error = anyhow!("HTTP 400 Bad Request: context length exceeded");
|
||||
assert_eq!(classify_error(&error), ErrorType::Recoverable(RecoverableError::ContextLengthExceeded));
|
||||
assert_eq!(
|
||||
classify_error(&error),
|
||||
ErrorType::Recoverable(RecoverableError::ContextLengthExceeded)
|
||||
);
|
||||
|
||||
let error = anyhow!("Error 400: prompt is too long");
|
||||
assert_eq!(classify_error(&error), ErrorType::Recoverable(RecoverableError::ContextLengthExceeded));
|
||||
assert_eq!(
|
||||
classify_error(&error),
|
||||
ErrorType::Recoverable(RecoverableError::ContextLengthExceeded)
|
||||
);
|
||||
|
||||
// Non-recoverable
|
||||
let error = anyhow!("Invalid API key");
|
||||
|
||||
@@ -17,7 +17,7 @@ mod tests {
|
||||
"test prompt".to_string(),
|
||||
None,
|
||||
100,
|
||||
false, // quiet parameter
|
||||
false, // quiet parameter
|
||||
);
|
||||
|
||||
let result = retry_with_backoff(
|
||||
@@ -57,7 +57,7 @@ mod tests {
|
||||
"test prompt".to_string(),
|
||||
None,
|
||||
100,
|
||||
false, // quiet parameter
|
||||
false, // quiet parameter
|
||||
);
|
||||
|
||||
let result: Result<&str, _> = retry_with_backoff(
|
||||
@@ -91,7 +91,7 @@ mod tests {
|
||||
"test prompt".to_string(),
|
||||
None,
|
||||
100,
|
||||
false, // quiet parameter
|
||||
false, // quiet parameter
|
||||
);
|
||||
|
||||
let result: Result<&str, _> = retry_with_backoff(
|
||||
@@ -124,7 +124,7 @@ mod tests {
|
||||
long_prompt,
|
||||
None,
|
||||
100,
|
||||
false, // quiet parameter
|
||||
false, // quiet parameter
|
||||
);
|
||||
|
||||
// The prompt should be truncated to 1000 chars
|
||||
|
||||
@@ -29,7 +29,7 @@ struct FixedJsonToolState {
|
||||
brace_depth: i32,
|
||||
buffer: String,
|
||||
json_start_in_buffer: Option<usize>, // Position where confirmed JSON tool call starts
|
||||
content_returned_up_to: usize, // Track how much content we've already returned
|
||||
content_returned_up_to: usize, // Track how much content we've already returned
|
||||
potential_json_start: Option<usize>, // Where the potential JSON started
|
||||
}
|
||||
|
||||
|
||||
@@ -358,8 +358,8 @@ More text"#;
|
||||
// 2. Then the same complete JSON appears
|
||||
let chunks = vec![
|
||||
"Some text\n",
|
||||
r#"{"tool": "str_replace", "args": {"diff":"...","file_path":"./crates/g3-cli"#, // Truncated
|
||||
r#"{"tool": "str_replace", "args": {"diff":"...","file_path":"./crates/g3-cli/src/lib.rs"}}"#, // Complete
|
||||
r#"{"tool": "str_replace", "args": {"diff":"...","file_path":"./crates/g3-cli"#, // Truncated
|
||||
r#"{"tool": "str_replace", "args": {"diff":"...","file_path":"./crates/g3-cli/src/lib.rs"}}"#, // Complete
|
||||
"\nMore text",
|
||||
];
|
||||
|
||||
|
||||
+468
-196
File diff suppressed because it is too large
Load Diff
@@ -58,7 +58,10 @@ impl Project {
|
||||
}
|
||||
|
||||
/// Create a project for autonomous mode with requirements text override
|
||||
pub fn new_autonomous_with_requirements(workspace_dir: PathBuf, requirements_text: String) -> Result<Self> {
|
||||
pub fn new_autonomous_with_requirements(
|
||||
workspace_dir: PathBuf,
|
||||
requirements_text: String,
|
||||
) -> Result<Self> {
|
||||
let mut project = Self::new(workspace_dir.clone());
|
||||
project.autonomous = true;
|
||||
project.requirements_text = Some(requirements_text);
|
||||
|
||||
@@ -189,7 +189,7 @@ Do not explain what you're going to do - just do it by calling the tools.
|
||||
";
|
||||
|
||||
pub const SYSTEM_PROMPT_FOR_NATIVE_TOOL_USE: &'static str =
|
||||
concatcp!(SYSTEM_NATIVE_TOOL_CALLS, CODING_STYLE);
|
||||
concatcp!(SYSTEM_NATIVE_TOOL_CALLS, CODING_STYLE);
|
||||
|
||||
/// Generate system prompt based on whether multiple tool calls are allowed
|
||||
pub fn get_system_prompt_for_native(allow_multiple: bool) -> String {
|
||||
|
||||
@@ -67,7 +67,8 @@ impl TaskResult {
|
||||
let blocks: Vec<&str> = content_without_timing.split("\n\n").collect();
|
||||
|
||||
// Find the last non-empty block that isn't just whitespace
|
||||
blocks.iter()
|
||||
blocks
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|block| !block.trim().is_empty())
|
||||
.map(|block| block.trim().to_string())
|
||||
@@ -79,7 +80,8 @@ impl TaskResult {
|
||||
|
||||
/// Check if the response contains an approval (for autonomous mode)
|
||||
pub fn is_approved(&self) -> bool {
|
||||
self.extract_final_output().contains("IMPLEMENTATION_APPROVED")
|
||||
self.extract_final_output()
|
||||
.contains("IMPLEMENTATION_APPROVED")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,7 +93,8 @@ mod tests {
|
||||
fn test_extract_last_block() {
|
||||
// Test case 1: Response with timing info
|
||||
let context_window = ContextWindow::new(1000);
|
||||
let response_with_timing = "Some initial content\n\nFinal block content\n\n⏱️ 2.3s | 💭 1.2s".to_string();
|
||||
let response_with_timing =
|
||||
"Some initial content\n\nFinal block content\n\n⏱️ 2.3s | 💭 1.2s".to_string();
|
||||
let result = TaskResult::new(response_with_timing, context_window.clone());
|
||||
assert_eq!(result.extract_last_block(), "Final block content");
|
||||
|
||||
@@ -138,10 +141,14 @@ mod tests {
|
||||
// Test case 1: Response with final_output tool call
|
||||
let response_with_final_output = "Analyzing files...\n\nCalling final_output\n\nThis is the complete feedback\nwith multiple lines\nand important details\n\n⏱️ 2.3s".to_string();
|
||||
let result = TaskResult::new(response_with_final_output, context_window.clone());
|
||||
assert_eq!(result.extract_final_output(), "This is the complete feedback\nwith multiple lines\nand important details");
|
||||
assert_eq!(
|
||||
result.extract_final_output(),
|
||||
"This is the complete feedback\nwith multiple lines\nand important details"
|
||||
);
|
||||
|
||||
// Test case 2: Response with IMPLEMENTATION_APPROVED in final_output
|
||||
let response_approved = "Review complete\n\nfinal_output called\n\nIMPLEMENTATION_APPROVED".to_string();
|
||||
let response_approved =
|
||||
"Review complete\n\nfinal_output called\n\nIMPLEMENTATION_APPROVED".to_string();
|
||||
let result = TaskResult::new(response_approved, context_window.clone());
|
||||
assert_eq!(result.extract_final_output(), "IMPLEMENTATION_APPROVED");
|
||||
assert!(result.is_approved());
|
||||
|
||||
@@ -6,10 +6,14 @@ use std::sync::Arc;
|
||||
fn test_task_result_basic_functionality() {
|
||||
// Create a context window with some messages
|
||||
let mut context = ContextWindow::new(10000);
|
||||
context.add_message(Message::new(MessageRole::User, "Test message 1".to_string())
|
||||
);
|
||||
context.add_message(Message::new(MessageRole::Assistant, "Response 1".to_string())
|
||||
);
|
||||
context.add_message(Message::new(
|
||||
MessageRole::User,
|
||||
"Test message 1".to_string(),
|
||||
));
|
||||
context.add_message(Message::new(
|
||||
MessageRole::Assistant,
|
||||
"Response 1".to_string(),
|
||||
));
|
||||
|
||||
// Create a TaskResult
|
||||
let response = "This is the response\n\nFinal output block".to_string();
|
||||
@@ -70,7 +74,11 @@ fn test_is_approved_detection() {
|
||||
|
||||
for response in approved_responses {
|
||||
let result = TaskResult::new(response.to_string(), context.clone());
|
||||
assert!(result.is_approved(), "Failed to detect approval in: {}", response);
|
||||
assert!(
|
||||
result.is_approved(),
|
||||
"Failed to detect approval in: {}",
|
||||
response
|
||||
);
|
||||
}
|
||||
|
||||
// Test not approved cases
|
||||
@@ -84,7 +92,11 @@ fn test_is_approved_detection() {
|
||||
|
||||
for response in not_approved_responses {
|
||||
let result = TaskResult::new(response.to_string(), context.clone());
|
||||
assert!(!result.is_approved(), "Incorrectly detected approval in: {}", response);
|
||||
assert!(
|
||||
!result.is_approved(),
|
||||
"Incorrectly detected approval in: {}",
|
||||
response
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,7 +108,14 @@ fn test_context_window_preservation() {
|
||||
|
||||
// Add some messages
|
||||
for i in 0..5 {
|
||||
context.add_message(Message::new(if i % 2 == 0 { MessageRole::User } else { MessageRole::Assistant }, format!("Message {}", i)));
|
||||
context.add_message(Message::new(
|
||||
if i % 2 == 0 {
|
||||
MessageRole::User
|
||||
} else {
|
||||
MessageRole::Assistant
|
||||
},
|
||||
format!("Message {}", i),
|
||||
));
|
||||
}
|
||||
|
||||
// Create TaskResult
|
||||
@@ -109,10 +128,16 @@ fn test_context_window_preservation() {
|
||||
|
||||
// Verify messages are preserved correctly
|
||||
for i in 0..5 {
|
||||
let is_user = matches!(result.context_window.conversation_history[i].role, MessageRole::User);
|
||||
let is_user = matches!(
|
||||
result.context_window.conversation_history[i].role,
|
||||
MessageRole::User
|
||||
);
|
||||
let expected_is_user = i % 2 == 0;
|
||||
assert_eq!(is_user, expected_is_user, "Message {} has wrong role", i);
|
||||
assert_eq!(result.context_window.conversation_history[i].content, format!("Message {}", i));
|
||||
assert_eq!(
|
||||
result.context_window.conversation_history[i].content,
|
||||
format!("Message {}", i)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,7 +206,8 @@ fn test_edge_cases_and_special_characters() {
|
||||
assert_eq!(result.extract_last_block(), "✅ Final part with emojis 🎉");
|
||||
|
||||
// Test with code blocks
|
||||
let response_with_code = "Explanation\n\n```rust\nfn main() {}\n```\n\nFinal comment".to_string();
|
||||
let response_with_code =
|
||||
"Explanation\n\n```rust\nfn main() {}\n```\n\nFinal comment".to_string();
|
||||
let result = TaskResult::new(response_with_code, context.clone());
|
||||
assert_eq!(result.extract_last_block(), "Final comment");
|
||||
|
||||
@@ -203,7 +229,10 @@ fn test_large_response_handling() {
|
||||
large_response.push_str("This is the final block after 100 other blocks");
|
||||
|
||||
let result = TaskResult::new(large_response, context);
|
||||
assert_eq!(result.extract_last_block(), "This is the final block after 100 other blocks");
|
||||
assert_eq!(
|
||||
result.extract_last_block(),
|
||||
"This is the final block after 100 other blocks"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -237,4 +266,3 @@ fn test_concurrent_access() {
|
||||
handle.join().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -55,7 +55,9 @@ pub trait UiWriter: Send + Sync {
|
||||
|
||||
/// Returns true if this UI writer wants full, untruncated output
|
||||
/// Default is false (truncate for human readability)
|
||||
fn wants_full_output(&self) -> bool { false }
|
||||
fn wants_full_output(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Prompt the user for a yes/no confirmation
|
||||
fn prompt_user_yes_no(&self, message: &str) -> bool;
|
||||
@@ -86,7 +88,13 @@ impl UiWriter for NullUiWriter {
|
||||
fn print_agent_response(&self, _content: &str) {}
|
||||
fn notify_sse_received(&self) {}
|
||||
fn flush(&self) {}
|
||||
fn wants_full_output(&self) -> bool { false }
|
||||
fn prompt_user_yes_no(&self, _message: &str) -> bool { true }
|
||||
fn prompt_user_choice(&self, _message: &str, _options: &[&str]) -> usize { 0 }
|
||||
fn wants_full_output(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn prompt_user_yes_no(&self, _message: &str) -> bool {
|
||||
true
|
||||
}
|
||||
fn prompt_user_choice(&self, _message: &str, _options: &[&str]) -> usize {
|
||||
0
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,10 @@ pub async fn another_async(x: i32) -> Result<(), ()> {
|
||||
assert_eq!(response.searches.len(), 1);
|
||||
let search_result = &response.searches[0];
|
||||
assert_eq!(search_result.name, "find_async_functions");
|
||||
assert_eq!(search_result.match_count, 2, "Should find 2 async functions");
|
||||
assert_eq!(
|
||||
search_result.match_count, 2,
|
||||
"Should find 2 async functions"
|
||||
);
|
||||
assert!(search_result.error.is_none());
|
||||
|
||||
// Check that we found the right functions
|
||||
@@ -107,7 +110,10 @@ pub async fn another_async(x: i32) -> Result<(), ()> {
|
||||
assert_eq!(response.searches.len(), 1);
|
||||
let search_result = &response.searches[0];
|
||||
assert_eq!(search_result.name, "find_all_functions");
|
||||
assert_eq!(search_result.match_count, 3, "Should find 3 functions total");
|
||||
assert_eq!(
|
||||
search_result.match_count, 3,
|
||||
"Should find 3 functions total"
|
||||
);
|
||||
assert!(search_result.error.is_none());
|
||||
|
||||
// Check that we found all functions
|
||||
@@ -229,10 +235,16 @@ pub fn target_function() {
|
||||
|
||||
let context = match_result.context.as_ref().unwrap();
|
||||
assert!(context.contains("Line 2"), "Should include 2 lines before");
|
||||
assert!(context.contains("target_function"), "Should include the function");
|
||||
assert!(
|
||||
context.contains("target_function"),
|
||||
"Should include the function"
|
||||
);
|
||||
// Note: context_lines=2 means 2 lines before and after the match line (line 4)
|
||||
// So we get lines 2-6, which includes up to println but not the closing brace
|
||||
assert!(context.contains("println"), "Should include 2 lines after the match");
|
||||
assert!(
|
||||
context.contains("println"),
|
||||
"Should include 2 lines after the match"
|
||||
);
|
||||
|
||||
// Cleanup
|
||||
fs::remove_dir_all(&test_dir).ok();
|
||||
@@ -338,7 +350,10 @@ class MyClass:
|
||||
|
||||
assert_eq!(response.searches.len(), 1);
|
||||
let search_result = &response.searches[0];
|
||||
assert_eq!(search_result.match_count, 3, "Should find 3 functions in Python (2 regular + 1 async + 1 method)");
|
||||
assert_eq!(
|
||||
search_result.match_count, 3,
|
||||
"Should find 3 functions in Python (2 regular + 1 async + 1 method)"
|
||||
);
|
||||
|
||||
let function_names: Vec<String> = search_result
|
||||
.matches
|
||||
@@ -396,7 +411,10 @@ class MyClass {
|
||||
|
||||
assert_eq!(response.searches.len(), 1);
|
||||
let search_result = &response.searches[0];
|
||||
assert_eq!(search_result.match_count, 2, "Should find 2 functions in JavaScript");
|
||||
assert_eq!(
|
||||
search_result.match_count, 2,
|
||||
"Should find 2 functions in JavaScript"
|
||||
);
|
||||
|
||||
let function_names: Vec<String> = search_result
|
||||
.matches
|
||||
@@ -439,10 +457,15 @@ async fn test_go_search() {
|
||||
eprintln!("Go search result: {:?}", response.searches[0]);
|
||||
eprintln!("Match count: {}", response.searches[0].matches.len());
|
||||
eprintln!("Error: {:?}", response.searches[0].error);
|
||||
assert!(response.searches[0].matches.len() > 0, "No matches found for Go search");
|
||||
assert!(
|
||||
response.searches[0].matches.len() > 0,
|
||||
"No matches found for Go search"
|
||||
);
|
||||
|
||||
// Should find main and greet functions
|
||||
let names: Vec<&str> = response.searches[0].matches.iter()
|
||||
let names: Vec<&str> = response.searches[0]
|
||||
.matches
|
||||
.iter()
|
||||
.filter_map(|m| m.captures.get("name").map(|s| s.as_str()))
|
||||
.collect();
|
||||
assert!(names.contains(&"main"));
|
||||
@@ -476,7 +499,9 @@ async fn test_java_search() {
|
||||
assert!(response.searches[0].matches.len() > 0);
|
||||
|
||||
// Should find Example class
|
||||
let names: Vec<&str> = response.searches[0].matches.iter()
|
||||
let names: Vec<&str> = response.searches[0]
|
||||
.matches
|
||||
.iter()
|
||||
.filter_map(|m| m.captures.get("name").map(|s| s.as_str()))
|
||||
.collect();
|
||||
assert!(names.contains(&"Example"));
|
||||
@@ -509,7 +534,9 @@ async fn test_c_search() {
|
||||
assert!(response.searches[0].matches.len() > 0);
|
||||
|
||||
// Should find greet, add, and main functions
|
||||
let names: Vec<&str> = response.searches[0].matches.iter()
|
||||
let names: Vec<&str> = response.searches[0]
|
||||
.matches
|
||||
.iter()
|
||||
.filter_map(|m| m.captures.get("name").map(|s| s.as_str()))
|
||||
.collect();
|
||||
assert!(names.contains(&"greet"));
|
||||
@@ -544,7 +571,9 @@ async fn test_cpp_search() {
|
||||
assert!(response.searches[0].matches.len() > 0);
|
||||
|
||||
// Should find Person class
|
||||
let names: Vec<&str> = response.searches[0].matches.iter()
|
||||
let names: Vec<&str> = response.searches[0]
|
||||
.matches
|
||||
.iter()
|
||||
.filter_map(|m| m.captures.get("name").map(|s| s.as_str()))
|
||||
.collect();
|
||||
assert!(names.contains(&"Person"));
|
||||
@@ -570,7 +599,9 @@ async fn test_kotlin_search() {
|
||||
assert!(response.searches[0].matches.len() > 0);
|
||||
|
||||
// Should find Person class
|
||||
let names: Vec<&str> = response.searches[0].matches.iter()
|
||||
let names: Vec<&str> = response.searches[0]
|
||||
.matches
|
||||
.iter()
|
||||
.filter_map(|m| m.captures.get("name").map(|s| s.as_str()))
|
||||
.collect();
|
||||
assert!(names.contains(&"Person"));
|
||||
|
||||
@@ -63,10 +63,7 @@ fn test_thin_context_basic() {
|
||||
format!("Tool result: small result {}", i)
|
||||
};
|
||||
|
||||
context.add_message(Message::new(
|
||||
MessageRole::User,
|
||||
content,
|
||||
));
|
||||
context.add_message(Message::new(MessageRole::User, content));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,7 +74,11 @@ fn test_thin_context_basic() {
|
||||
println!("Thinning summary: {}", summary);
|
||||
|
||||
// Should have thinned at least 1 large tool result in the first third
|
||||
assert!(summary.contains("1 tool result"), "Summary was: {}", summary);
|
||||
assert!(
|
||||
summary.contains("1 tool result"),
|
||||
"Summary was: {}",
|
||||
summary
|
||||
);
|
||||
assert!(summary.contains("50%"));
|
||||
|
||||
// Check that the large tool results were replaced
|
||||
@@ -160,7 +161,11 @@ fn test_thin_str_replace_tool_calls() {
|
||||
));
|
||||
|
||||
// Add an assistant message with a str_replace tool call containing large diff
|
||||
let large_diff = format!("--- old\n{}\n+++ new\n{}", "-old line\n".repeat(100), "+new line\n".repeat(100));
|
||||
let large_diff = format!(
|
||||
"--- old\n{}\n+++ new\n{}",
|
||||
"-old line\n".repeat(100),
|
||||
"+new line\n".repeat(100)
|
||||
);
|
||||
let tool_call_json = format!(
|
||||
r#"{{"tool": "str_replace", "args": {{"file_path": "test.txt", "diff": "{}"}}}}"#,
|
||||
large_diff.replace('\n', "\\n")
|
||||
@@ -261,8 +266,11 @@ fn test_thin_context_only_affects_first_third() {
|
||||
if matches!(msg.role, MessageRole::User) && msg.content.starts_with("Tool result:") {
|
||||
// These should still be large (not thinned)
|
||||
if i % 2 == 1 {
|
||||
assert!(msg.content.len() > 1000,
|
||||
"Message at index {} should not have been thinned", i);
|
||||
assert!(
|
||||
msg.content.len() > 1000,
|
||||
"Message at index {} should not have been thinned",
|
||||
i
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,10 @@ fn test_todo_read_results_not_thinned() {
|
||||
let mut context = ContextWindow::new(10000);
|
||||
|
||||
// Add a todo_read tool call
|
||||
context.add_message(Message::new(MessageRole::Assistant, r#"{"tool": "todo_read", "args": {}}"#.to_string()));
|
||||
context.add_message(Message::new(
|
||||
MessageRole::Assistant,
|
||||
r#"{"tool": "todo_read", "args": {}}"#.to_string(),
|
||||
));
|
||||
|
||||
// Add a large TODO result (> 500 chars)
|
||||
let large_todo_result = format!(
|
||||
@@ -19,7 +22,10 @@ fn test_todo_read_results_not_thinned() {
|
||||
|
||||
// Add more messages to ensure we have enough for "first third" logic
|
||||
for i in 0..6 {
|
||||
context.add_message(Message::new(MessageRole::Assistant, format!("Response {}", i)))
|
||||
context.add_message(Message::new(
|
||||
MessageRole::Assistant,
|
||||
format!("Response {}", i),
|
||||
))
|
||||
}
|
||||
|
||||
// Trigger thinning at 50%
|
||||
@@ -56,7 +62,13 @@ fn test_todo_write_results_not_thinned() {
|
||||
|
||||
// Add a todo_write tool call
|
||||
let large_content = "- [ ] Task\n".repeat(100);
|
||||
context.add_message(Message::new(MessageRole::Assistant, format!(r#"{{"tool": "todo_write", "args": {{"content": "{}"}}}}"#, large_content)));
|
||||
context.add_message(Message::new(
|
||||
MessageRole::Assistant,
|
||||
format!(
|
||||
r#"{{"tool": "todo_write", "args": {{"content": "{}"}}}}"#,
|
||||
large_content
|
||||
),
|
||||
));
|
||||
|
||||
// Add a large TODO write result
|
||||
let large_todo_result = format!(
|
||||
@@ -67,7 +79,10 @@ fn test_todo_write_results_not_thinned() {
|
||||
|
||||
// Add more messages
|
||||
for i in 0..6 {
|
||||
context.add_message(Message::new(MessageRole::Assistant, format!("Response {}", i)))
|
||||
context.add_message(Message::new(
|
||||
MessageRole::Assistant,
|
||||
format!("Response {}", i),
|
||||
))
|
||||
}
|
||||
|
||||
// Trigger thinning at 50%
|
||||
@@ -101,7 +116,10 @@ fn test_non_todo_results_still_thinned() {
|
||||
let mut context = ContextWindow::new(10000);
|
||||
|
||||
// Add a non-TODO tool call (e.g., read_file)
|
||||
context.add_message(Message::new(MessageRole::Assistant, r#"{"tool": "read_file", "args": {"file_path": "test.txt"}}"#.to_string()));
|
||||
context.add_message(Message::new(
|
||||
MessageRole::Assistant,
|
||||
r#"{"tool": "read_file", "args": {"file_path": "test.txt"}}"#.to_string(),
|
||||
));
|
||||
|
||||
// Add a large read_file result (> 500 chars)
|
||||
let large_result = format!("Tool result: {}", "x".repeat(1500));
|
||||
@@ -109,7 +127,10 @@ fn test_non_todo_results_still_thinned() {
|
||||
|
||||
// Add more messages
|
||||
for i in 0..6 {
|
||||
context.add_message(Message::new(MessageRole::Assistant, format!("Response {}", i)))
|
||||
context.add_message(Message::new(
|
||||
MessageRole::Assistant,
|
||||
format!("Response {}", i),
|
||||
))
|
||||
}
|
||||
|
||||
// Trigger thinning at 50%
|
||||
@@ -145,18 +166,21 @@ fn test_todo_read_with_spaces_in_tool_name() {
|
||||
let mut context = ContextWindow::new(10000);
|
||||
|
||||
// Add a todo_read tool call with spaces (JSON formatting variation)
|
||||
context.add_message(Message::new(MessageRole::Assistant, r#"{"tool": "todo_read", "args": {}}"#.to_string()));
|
||||
context.add_message(Message::new(
|
||||
MessageRole::Assistant,
|
||||
r#"{"tool": "todo_read", "args": {}}"#.to_string(),
|
||||
));
|
||||
|
||||
// Add a large TODO result
|
||||
let large_todo_result = format!(
|
||||
"Tool result: 📝 TODO list:\n{}",
|
||||
"- [ ] Task\n".repeat(50)
|
||||
);
|
||||
let large_todo_result = format!("Tool result: 📝 TODO list:\n{}", "- [ ] Task\n".repeat(50));
|
||||
context.add_message(Message::new(MessageRole::User, large_todo_result.clone()));
|
||||
|
||||
// Add more messages
|
||||
for i in 0..6 {
|
||||
context.add_message(Message::new(MessageRole::Assistant, format!("Response {}", i)))
|
||||
context.add_message(Message::new(
|
||||
MessageRole::Assistant,
|
||||
format!("Response {}", i),
|
||||
))
|
||||
}
|
||||
|
||||
// Trigger thinning
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
use g3_core::Agent;
|
||||
use g3_core::ui_writer::NullUiWriter;
|
||||
use g3_core::Agent;
|
||||
use serial_test::serial;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::TempDir;
|
||||
|
||||
|
||||
/// Helper to create a test agent in a temporary directory
|
||||
async fn create_test_agent_in_dir(temp_dir: &TempDir) -> Agent<NullUiWriter> {
|
||||
// Change to temp directory
|
||||
@@ -46,7 +45,11 @@ async fn test_todo_write_creates_file() {
|
||||
|
||||
// Should report success
|
||||
assert!(result.contains("✅"), "Should report success: {}", result);
|
||||
assert!(result.contains("todo.g3.md"), "Should mention todo.g3.md: {}", result);
|
||||
assert!(
|
||||
result.contains("todo.g3.md"),
|
||||
"Should mention todo.g3.md: {}",
|
||||
result
|
||||
);
|
||||
|
||||
// File should now exist
|
||||
assert!(todo_path.exists(), "todo.g3.md should exist after write");
|
||||
@@ -79,9 +82,21 @@ async fn test_todo_read_from_file() {
|
||||
let result = agent.execute_tool(&tool_call).await.unwrap();
|
||||
|
||||
// Should contain the TODO content
|
||||
assert!(result.contains("📝 TODO list:"), "Should have TODO list header: {}", result);
|
||||
assert!(result.contains("First task"), "Should contain first task: {}", result);
|
||||
assert!(result.contains("Completed task"), "Should contain completed task: {}", result);
|
||||
assert!(
|
||||
result.contains("📝 TODO list:"),
|
||||
"Should have TODO list header: {}",
|
||||
result
|
||||
);
|
||||
assert!(
|
||||
result.contains("First task"),
|
||||
"Should contain first task: {}",
|
||||
result
|
||||
);
|
||||
assert!(
|
||||
result.contains("Completed task"),
|
||||
"Should contain completed task: {}",
|
||||
result
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -122,7 +137,10 @@ async fn test_todo_persistence_across_agents() {
|
||||
}
|
||||
|
||||
// Verify file exists
|
||||
assert!(todo_path.exists(), "todo.g3.md should persist after agent drops");
|
||||
assert!(
|
||||
todo_path.exists(),
|
||||
"todo.g3.md should persist after agent drops"
|
||||
);
|
||||
|
||||
// Agent 2: Read TODO (new agent instance)
|
||||
{
|
||||
@@ -134,8 +152,16 @@ async fn test_todo_persistence_across_agents() {
|
||||
let result = agent.execute_tool(&tool_call).await.unwrap();
|
||||
|
||||
// Should read the persisted content
|
||||
assert!(result.contains("Persistent task"), "Should read persisted task: {}", result);
|
||||
assert!(result.contains("Done task"), "Should read done task: {}", result);
|
||||
assert!(
|
||||
result.contains("Persistent task"),
|
||||
"Should read persisted task: {}",
|
||||
result
|
||||
);
|
||||
assert!(
|
||||
result.contains("Done task"),
|
||||
"Should read done task: {}",
|
||||
result
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,7 +205,10 @@ async fn test_todo_handles_large_content() {
|
||||
// Create a large TODO (but under the 50k limit)
|
||||
let mut large_content = String::from("# Large TODO\n\n");
|
||||
for i in 0..100 {
|
||||
large_content.push_str(&format!("- [ ] Task {} with a long description that exceeds normal line lengths\n", i));
|
||||
large_content.push_str(&format!(
|
||||
"- [ ] Task {} with a long description that exceeds normal line lengths\n",
|
||||
i
|
||||
));
|
||||
}
|
||||
|
||||
let tool_call = g3_core::ToolCall {
|
||||
@@ -190,7 +219,11 @@ async fn test_todo_handles_large_content() {
|
||||
};
|
||||
|
||||
let result = agent.execute_tool(&tool_call).await.unwrap();
|
||||
assert!(result.contains("✅"), "Should handle large content: {}", result);
|
||||
assert!(
|
||||
result.contains("✅"),
|
||||
"Should handle large content: {}",
|
||||
result
|
||||
);
|
||||
|
||||
// Verify file contains all content
|
||||
let file_content = fs::read_to_string(&todo_path).unwrap();
|
||||
@@ -217,8 +250,16 @@ async fn test_todo_respects_size_limit() {
|
||||
let result = agent.execute_tool(&tool_call).await.unwrap();
|
||||
|
||||
// Should reject content that's too large
|
||||
assert!(result.contains("❌"), "Should reject oversized content: {}", result);
|
||||
assert!(result.contains("too large"), "Should mention size limit: {}", result);
|
||||
assert!(
|
||||
result.contains("❌"),
|
||||
"Should reject oversized content: {}",
|
||||
result
|
||||
);
|
||||
assert!(
|
||||
result.contains("too large"),
|
||||
"Should mention size limit: {}",
|
||||
result
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -241,7 +282,11 @@ async fn test_todo_agent_initialization_loads_file() {
|
||||
};
|
||||
|
||||
let result = agent.execute_tool(&tool_call).await.unwrap();
|
||||
assert!(result.contains("Pre-existing task"), "Should load file on init: {}", result);
|
||||
assert!(
|
||||
result.contains("Pre-existing task"),
|
||||
"Should load file on init: {}",
|
||||
result
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -274,9 +319,17 @@ async fn test_todo_handles_unicode_content() {
|
||||
};
|
||||
|
||||
let result = agent.execute_tool(&read_call).await.unwrap();
|
||||
assert!(result.contains("日本語"), "Should preserve Japanese: {}", result);
|
||||
assert!(
|
||||
result.contains("日本語"),
|
||||
"Should preserve Japanese: {}",
|
||||
result
|
||||
);
|
||||
assert!(result.contains("🚀"), "Should preserve emoji: {}", result);
|
||||
assert!(result.contains("Ελληνικά"), "Should preserve Greek: {}", result);
|
||||
assert!(
|
||||
result.contains("Ελληνικά"),
|
||||
"Should preserve Greek: {}",
|
||||
result
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -327,5 +380,9 @@ async fn test_todo_whitespace_only_content() {
|
||||
let result = agent.execute_tool(&read_call).await.unwrap();
|
||||
|
||||
// Should report as empty (whitespace is trimmed)
|
||||
assert!(result.contains("empty"), "Whitespace-only should be empty: {}", result);
|
||||
assert!(
|
||||
result.contains("empty"),
|
||||
"Whitespace-only should be empty: {}",
|
||||
result
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,10 @@ fn test_token_accumulation() {
|
||||
total_tokens: 275,
|
||||
};
|
||||
window.update_usage_from_response(&usage2);
|
||||
assert_eq!(window.used_tokens, 425, "Second call should accumulate to 425 tokens");
|
||||
assert_eq!(
|
||||
window.used_tokens, 425,
|
||||
"Second call should accumulate to 425 tokens"
|
||||
);
|
||||
assert_eq!(window.cumulative_tokens, 425, "Cumulative should be 425");
|
||||
|
||||
// Third API call with SMALLER token count: 50 prompt + 25 completion = 75 total
|
||||
@@ -32,11 +35,17 @@ fn test_token_accumulation() {
|
||||
total_tokens: 75,
|
||||
};
|
||||
window.update_usage_from_response(&usage3);
|
||||
assert_eq!(window.used_tokens, 500, "Third call should accumulate to 500 tokens");
|
||||
assert_eq!(
|
||||
window.used_tokens, 500,
|
||||
"Third call should accumulate to 500 tokens"
|
||||
);
|
||||
assert_eq!(window.cumulative_tokens, 500, "Cumulative should be 500");
|
||||
|
||||
// Verify tokens never decrease
|
||||
assert!(window.used_tokens >= 425, "Token count should never decrease!");
|
||||
assert!(
|
||||
window.used_tokens >= 425,
|
||||
"Token count should never decrease!"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use g3_core::{Agent, ToolCall};
|
||||
use g3_core::ui_writer::UiWriter;
|
||||
use g3_config::Config;
|
||||
use g3_core::ui_writer::UiWriter;
|
||||
use g3_core::{Agent, ToolCall};
|
||||
use serial_test::serial;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tempfile::TempDir;
|
||||
use serial_test::serial;
|
||||
|
||||
// Mock UI Writer for testing
|
||||
#[derive(Clone)]
|
||||
@@ -47,7 +47,10 @@ impl UiWriter for MockUiWriter {
|
||||
}
|
||||
fn print_system_prompt(&self, _prompt: &str) {}
|
||||
fn print_context_status(&self, message: &str) {
|
||||
self.output.lock().unwrap().push(format!("STATUS: {}", message));
|
||||
self.output
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(format!("STATUS: {}", message));
|
||||
}
|
||||
fn print_context_thinning(&self, _message: &str) {}
|
||||
fn print_tool_header(&self, _tool_name: &str) {}
|
||||
@@ -61,13 +64,21 @@ impl UiWriter for MockUiWriter {
|
||||
fn print_agent_response(&self, _content: &str) {}
|
||||
fn notify_sse_received(&self) {}
|
||||
fn flush(&self) {}
|
||||
fn wants_full_output(&self) -> bool { false }
|
||||
fn wants_full_output(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn prompt_user_yes_no(&self, message: &str) -> bool {
|
||||
self.output.lock().unwrap().push(format!("PROMPT: {}", message));
|
||||
self.output
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(format!("PROMPT: {}", message));
|
||||
self.prompt_responses.lock().unwrap().pop().unwrap_or(true)
|
||||
}
|
||||
fn prompt_user_choice(&self, message: &str, options: &[&str]) -> usize {
|
||||
self.output.lock().unwrap().push(format!("CHOICE: {} Options: {:?}", message, options));
|
||||
self.output
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(format!("CHOICE: {} Options: {:?}", message, options));
|
||||
self.choice_responses.lock().unwrap().pop().unwrap_or(0)
|
||||
}
|
||||
}
|
||||
@@ -80,7 +91,10 @@ async fn test_todo_staleness_check_matching_sha() {
|
||||
std::env::set_current_dir(&temp_dir).unwrap();
|
||||
|
||||
let sha = "abc123hash";
|
||||
let content = format!("{{{{Based on the requirements file with SHA256: {}}}}}\n- [ ] Task 1", sha);
|
||||
let content = format!(
|
||||
"{{{{Based on the requirements file with SHA256: {}}}}}\n- [ ] Task 1",
|
||||
sha
|
||||
);
|
||||
std::fs::write(&todo_path, content).unwrap();
|
||||
|
||||
let mut config = Config::default();
|
||||
@@ -109,7 +123,10 @@ async fn test_todo_staleness_check_mismatch_sha_ignore() {
|
||||
|
||||
let sha_file = "old_sha";
|
||||
let sha_req = "new_sha";
|
||||
let content = format!("{{{{Based on the requirements file with SHA256: {}}}}}\n- [ ] Task 1", sha_file);
|
||||
let content = format!(
|
||||
"{{{{Based on the requirements file with SHA256: {}}}}}\n- [ ] Task 1",
|
||||
sha_file
|
||||
);
|
||||
std::fs::write(&todo_path, content).unwrap();
|
||||
|
||||
let mut config = Config::default();
|
||||
@@ -139,7 +156,10 @@ async fn test_todo_staleness_check_mismatch_sha_mark_stale() {
|
||||
|
||||
let sha_file = "old_sha";
|
||||
let sha_req = "new_sha";
|
||||
let content = format!("{{{{Based on the requirements file with SHA256: {}}}}}\n- [ ] Task 1", sha_file);
|
||||
let content = format!(
|
||||
"{{{{Based on the requirements file with SHA256: {}}}}}\n- [ ] Task 1",
|
||||
sha_file
|
||||
);
|
||||
std::fs::write(&todo_path, content).unwrap();
|
||||
|
||||
let mut config = Config::default();
|
||||
@@ -173,7 +193,10 @@ async fn test_todo_staleness_check_disabled() {
|
||||
|
||||
let sha_file = "old_sha";
|
||||
let sha_req = "new_sha";
|
||||
let content = format!("{{{{Based on the requirements file with SHA256: {}}}}}\n- [ ] Task 1", sha_file);
|
||||
let content = format!(
|
||||
"{{{{Based on the requirements file with SHA256: {}}}}}\n- [ ] Task 1",
|
||||
sha_file
|
||||
);
|
||||
std::fs::write(&todo_path, content).unwrap();
|
||||
|
||||
let mut config = Config::default();
|
||||
|
||||
@@ -43,12 +43,18 @@ impl FlockConfig {
|
||||
) -> Result<Self> {
|
||||
// Validate project directory
|
||||
if !project_dir.exists() {
|
||||
anyhow::bail!("Project directory does not exist: {}", project_dir.display());
|
||||
anyhow::bail!(
|
||||
"Project directory does not exist: {}",
|
||||
project_dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
// Check if it's a git repo
|
||||
if !project_dir.join(".git").exists() {
|
||||
anyhow::bail!("Project directory must be a git repository: {}", project_dir.display());
|
||||
anyhow::bail!(
|
||||
"Project directory must be a git repository: {}",
|
||||
project_dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
// Check for flock-requirements.md
|
||||
@@ -120,10 +126,16 @@ impl FlockMode {
|
||||
|
||||
/// Run flock mode
|
||||
pub async fn run(&mut self) -> Result<()> {
|
||||
info!("Starting flock mode with {} segments", self.config.num_segments);
|
||||
info!(
|
||||
"Starting flock mode with {} segments",
|
||||
self.config.num_segments
|
||||
);
|
||||
|
||||
// Step 1: Partition requirements
|
||||
println!("\n🧠 Step 1: Partitioning requirements into {} segments...", self.config.num_segments);
|
||||
println!(
|
||||
"\n🧠 Step 1: Partitioning requirements into {} segments...",
|
||||
self.config.num_segments
|
||||
);
|
||||
let partitions = self.partition_requirements().await?;
|
||||
|
||||
// Step 2: Create segment workspaces
|
||||
@@ -131,7 +143,10 @@ impl FlockMode {
|
||||
self.create_segment_workspaces(&partitions).await?;
|
||||
|
||||
// Step 3: Run segments in parallel
|
||||
println!("\n🚀 Step 3: Running {} segments in parallel...", self.config.num_segments);
|
||||
println!(
|
||||
"\n🚀 Step 3: Running {} segments in parallel...",
|
||||
self.config.num_segments
|
||||
);
|
||||
self.run_segments_parallel().await?;
|
||||
|
||||
// Step 4: Generate final report
|
||||
@@ -226,8 +241,8 @@ impl FlockMode {
|
||||
.context("Failed to extract partition JSON from agent output")?;
|
||||
|
||||
// Parse the partitions
|
||||
let partitions: Vec<serde_json::Value> = serde_json::from_str(&partitions_json)
|
||||
.context("Failed to parse partition JSON")?;
|
||||
let partitions: Vec<serde_json::Value> =
|
||||
serde_json::from_str(&partitions_json).context("Failed to parse partition JSON")?;
|
||||
|
||||
if partitions.len() != self.config.num_segments {
|
||||
warn!(
|
||||
@@ -241,9 +256,7 @@ impl FlockMode {
|
||||
let mut partition_texts = Vec::new();
|
||||
for (i, partition) in partitions.iter().enumerate() {
|
||||
let default_name = format!("module-{}", i + 1);
|
||||
let module_name = partition["module_name"]
|
||||
.as_str()
|
||||
.unwrap_or(&default_name);
|
||||
let module_name = partition["module_name"].as_str().unwrap_or(&default_name);
|
||||
let requirements = partition["requirements"]
|
||||
.as_str()
|
||||
.context("Missing requirements field in partition")?;
|
||||
@@ -312,7 +325,9 @@ impl FlockMode {
|
||||
}
|
||||
|
||||
if candidates.is_empty() {
|
||||
anyhow::bail!("Could not find any partition JSON markers with code fences in agent output");
|
||||
anyhow::bail!(
|
||||
"Could not find any partition JSON markers with code fences in agent output"
|
||||
);
|
||||
}
|
||||
|
||||
// Try to parse each candidate and return the first valid JSON
|
||||
@@ -320,11 +335,20 @@ impl FlockMode {
|
||||
for (i, candidate) in candidates.iter().enumerate() {
|
||||
match serde_json::from_str::<serde_json::Value>(candidate) {
|
||||
Ok(_) => {
|
||||
debug!("Successfully parsed JSON from candidate {} of {}", i + 1, candidates.len());
|
||||
debug!(
|
||||
"Successfully parsed JSON from candidate {} of {}",
|
||||
i + 1,
|
||||
candidates.len()
|
||||
);
|
||||
return Ok(candidate.clone());
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("Failed to parse candidate {} of {}: {}", i + 1, candidates.len(), e);
|
||||
debug!(
|
||||
"Failed to parse candidate {} of {}: {}",
|
||||
i + 1,
|
||||
candidates.len(),
|
||||
e
|
||||
);
|
||||
last_error = Some(e);
|
||||
}
|
||||
}
|
||||
@@ -349,7 +373,10 @@ impl FlockMode {
|
||||
|
||||
for (i, partition) in partitions.iter().enumerate() {
|
||||
let segment_id = i + 1;
|
||||
let segment_dir = self.config.flock_workspace.join(format!("segment-{}", segment_id));
|
||||
let segment_dir = self
|
||||
.config
|
||||
.flock_workspace
|
||||
.join(format!("segment-{}", segment_id));
|
||||
|
||||
println!(" Creating segment {} workspace...", segment_id);
|
||||
|
||||
@@ -360,10 +387,16 @@ impl FlockMode {
|
||||
|
||||
// Write segment-requirements.md
|
||||
let requirements_path = segment_dir.join("segment-requirements.md");
|
||||
std::fs::write(&requirements_path, partition)
|
||||
.context(format!("Failed to write requirements for segment {}", segment_id))?;
|
||||
std::fs::write(&requirements_path, partition).context(format!(
|
||||
"Failed to write requirements for segment {}",
|
||||
segment_id
|
||||
))?;
|
||||
|
||||
println!(" ✓ Segment {} workspace ready at {}", segment_id, segment_dir.display());
|
||||
println!(
|
||||
" ✓ Segment {} workspace ready at {}",
|
||||
segment_id,
|
||||
segment_dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -393,7 +426,10 @@ impl FlockMode {
|
||||
let mut handles = Vec::new();
|
||||
|
||||
for segment_id in 1..=self.config.num_segments {
|
||||
let segment_dir = self.config.flock_workspace.join(format!("segment-{}", segment_id));
|
||||
let segment_dir = self
|
||||
.config
|
||||
.flock_workspace
|
||||
.join(format!("segment-{}", segment_id));
|
||||
let max_turns = self.config.max_turns;
|
||||
let g3_binary = self.get_g3_binary()?;
|
||||
let status_file = self.get_status_file_path();
|
||||
@@ -444,10 +480,17 @@ impl FlockMode {
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
error!("Segment {} failed: {}", segment_id, e);
|
||||
let mut segment_status = self.status.segments.get(&segment_id).cloned()
|
||||
let mut segment_status = self
|
||||
.status
|
||||
.segments
|
||||
.get(&segment_id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| SegmentStatus {
|
||||
segment_id,
|
||||
workspace: self.config.flock_workspace.join(format!("segment-{}", segment_id)),
|
||||
workspace: self
|
||||
.config
|
||||
.flock_workspace
|
||||
.join(format!("segment-{}", segment_id)),
|
||||
state: SegmentState::Failed,
|
||||
started_at: Utc::now(),
|
||||
completed_at: Some(Utc::now()),
|
||||
@@ -468,10 +511,17 @@ impl FlockMode {
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Segment {} task panicked: {}", segment_id, e);
|
||||
let mut segment_status = self.status.segments.get(&segment_id).cloned()
|
||||
let mut segment_status = self
|
||||
.status
|
||||
.segments
|
||||
.get(&segment_id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| SegmentStatus {
|
||||
segment_id,
|
||||
workspace: self.config.flock_workspace.join(format!("segment-{}", segment_id)),
|
||||
workspace: self
|
||||
.config
|
||||
.flock_workspace
|
||||
.join(format!("segment-{}", segment_id)),
|
||||
state: SegmentState::Failed,
|
||||
started_at: Utc::now(),
|
||||
completed_at: Some(Utc::now()),
|
||||
@@ -527,7 +577,11 @@ async fn run_segment(
|
||||
status_file: PathBuf,
|
||||
session_id: String,
|
||||
) -> Result<SegmentStatus> {
|
||||
info!("Starting segment {} in {}", segment_id, segment_dir.display());
|
||||
info!(
|
||||
"Starting segment {} in {}",
|
||||
segment_id,
|
||||
segment_dir.display()
|
||||
);
|
||||
|
||||
let mut segment_status = SegmentStatus {
|
||||
segment_id,
|
||||
@@ -552,7 +606,9 @@ async fn run_segment(
|
||||
.arg("--max-turns")
|
||||
.arg(max_turns.to_string())
|
||||
.arg("--requirements")
|
||||
.arg(std::fs::read_to_string(segment_dir.join("segment-requirements.md"))?)
|
||||
.arg(std::fs::read_to_string(
|
||||
segment_dir.join("segment-requirements.md"),
|
||||
)?)
|
||||
.arg("--quiet") // Disable session logging for workers
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
@@ -615,7 +671,10 @@ async fn run_segment(
|
||||
}
|
||||
|
||||
// Wait for process to complete
|
||||
let status = child.wait().await.context("Failed to wait for g3 process")?;
|
||||
let status = child
|
||||
.wait()
|
||||
.await
|
||||
.context("Failed to wait for g3 process")?;
|
||||
|
||||
segment_status.completed_at = Some(Utc::now());
|
||||
|
||||
@@ -636,7 +695,9 @@ async fn run_segment(
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|s| s.to_str()) == Some("json") {
|
||||
if let Ok(log_content) = std::fs::read_to_string(&path) {
|
||||
if let Ok(log_json) = serde_json::from_str::<serde_json::Value>(&log_content) {
|
||||
if let Ok(log_json) =
|
||||
serde_json::from_str::<serde_json::Value>(&log_content)
|
||||
{
|
||||
// Extract token usage
|
||||
if let Some(context) = log_json.get("context_window") {
|
||||
if let Some(cumulative) = context.get("cumulative_tokens") {
|
||||
@@ -653,8 +714,7 @@ async fn run_segment(
|
||||
let tool_call_count = messages
|
||||
.iter()
|
||||
.filter(|msg| {
|
||||
msg.get("role")
|
||||
.and_then(|r| r.as_str())
|
||||
msg.get("role").and_then(|r| r.as_str())
|
||||
== Some("tool")
|
||||
})
|
||||
.count();
|
||||
@@ -685,12 +745,7 @@ fn update_status_file(
|
||||
FlockStatus::load_from_file(status_file)?
|
||||
} else {
|
||||
// This shouldn't happen, but handle it gracefully
|
||||
FlockStatus::new(
|
||||
session_id.to_string(),
|
||||
PathBuf::new(),
|
||||
PathBuf::new(),
|
||||
0,
|
||||
)
|
||||
FlockStatus::new(session_id.to_string(), PathBuf::new(), PathBuf::new(), 0)
|
||||
};
|
||||
|
||||
flock_status.update_segment(segment_status.segment_id, segment_status);
|
||||
@@ -896,8 +951,8 @@ The requirements have been partitioned into two logical, largely non-overlapping
|
||||
.expect("should extract valid JSON from output with multiple markers");
|
||||
|
||||
// Should be able to parse as JSON
|
||||
let parsed: serde_json::Value = serde_json::from_str(&extracted)
|
||||
.expect("extracted content should be valid JSON");
|
||||
let parsed: serde_json::Value =
|
||||
serde_json::from_str(&extracted).expect("extracted content should be valid JSON");
|
||||
|
||||
// Verify it's an array with 2 elements
|
||||
assert!(parsed.is_array());
|
||||
|
||||
@@ -185,7 +185,10 @@ impl FlockStatus {
|
||||
|
||||
report.push_str(&format!("\n\n🆔 Session ID: {}", self.session_id));
|
||||
report.push_str(&format!("\n📁 Project: {}", self.project_dir.display()));
|
||||
report.push_str(&format!("\n🗂️ Workspace: {}", self.flock_workspace.display()));
|
||||
report.push_str(&format!(
|
||||
"\n🗂️ Workspace: {}",
|
||||
self.flock_workspace.display()
|
||||
));
|
||||
report.push_str(&format!("\n🔢 Segments: {}", self.num_segments));
|
||||
|
||||
let duration = if let Some(completed) = self.completed_at {
|
||||
@@ -194,20 +197,41 @@ impl FlockStatus {
|
||||
Utc::now().signed_duration_since(self.started_at)
|
||||
};
|
||||
|
||||
report.push_str(&format!("\n⏱️ Duration: {:.2}s", duration.num_milliseconds() as f64 / 1000.0));
|
||||
report.push_str(&format!(
|
||||
"\n⏱️ Duration: {:.2}s",
|
||||
duration.num_milliseconds() as f64 / 1000.0
|
||||
));
|
||||
|
||||
// Segment status summary
|
||||
report.push_str(&format!("\n\n📈 Segment Status:"));
|
||||
report.push_str(&format!("\n • Completed: {}", self.count_by_state(SegmentState::Completed)));
|
||||
report.push_str(&format!("\n • Running: {}", self.count_by_state(SegmentState::Running)));
|
||||
report.push_str(&format!("\n • Failed: {}", self.count_by_state(SegmentState::Failed)));
|
||||
report.push_str(&format!("\n • Pending: {}", self.count_by_state(SegmentState::Pending)));
|
||||
report.push_str(&format!("\n • Cancelled: {}", self.count_by_state(SegmentState::Cancelled)));
|
||||
report.push_str(&format!(
|
||||
"\n • Completed: {}",
|
||||
self.count_by_state(SegmentState::Completed)
|
||||
));
|
||||
report.push_str(&format!(
|
||||
"\n • Running: {}",
|
||||
self.count_by_state(SegmentState::Running)
|
||||
));
|
||||
report.push_str(&format!(
|
||||
"\n • Failed: {}",
|
||||
self.count_by_state(SegmentState::Failed)
|
||||
));
|
||||
report.push_str(&format!(
|
||||
"\n • Pending: {}",
|
||||
self.count_by_state(SegmentState::Pending)
|
||||
));
|
||||
report.push_str(&format!(
|
||||
"\n • Cancelled: {}",
|
||||
self.count_by_state(SegmentState::Cancelled)
|
||||
));
|
||||
|
||||
// Metrics
|
||||
report.push_str(&format!("\n\n📊 Aggregate Metrics:"));
|
||||
report.push_str(&format!("\n • Total Tokens: {}", self.total_tokens));
|
||||
report.push_str(&format!("\n • Total Tool Calls: {}", self.total_tool_calls));
|
||||
report.push_str(&format!(
|
||||
"\n • Total Tool Calls: {}",
|
||||
self.total_tool_calls
|
||||
));
|
||||
report.push_str(&format!("\n • Total Errors: {}", self.total_errors));
|
||||
|
||||
// Per-segment details
|
||||
@@ -218,11 +242,17 @@ impl FlockStatus {
|
||||
for (id, segment) in segments {
|
||||
report.push_str(&format!("\n\n Segment {}:", id));
|
||||
report.push_str(&format!("\n Status: {}", segment.state));
|
||||
report.push_str(&format!("\n Workspace: {}", segment.workspace.display()));
|
||||
report.push_str(&format!(
|
||||
"\n Workspace: {}",
|
||||
segment.workspace.display()
|
||||
));
|
||||
report.push_str(&format!("\n Tokens: {}", segment.tokens_used));
|
||||
report.push_str(&format!("\n Tool Calls: {}", segment.tool_calls));
|
||||
report.push_str(&format!("\n Errors: {}", segment.errors));
|
||||
report.push_str(&format!("\n Turn: {}/{}", segment.current_turn, segment.max_turns));
|
||||
report.push_str(&format!(
|
||||
"\n Turn: {}/{}",
|
||||
segment.current_turn, segment.max_turns
|
||||
));
|
||||
|
||||
if let Some(ref msg) = segment.last_message {
|
||||
report.push_str(&format!("\n Last Message: {}", msg));
|
||||
|
||||
@@ -283,8 +283,7 @@ mod tests {
|
||||
assert!(json.contains("Completed"));
|
||||
|
||||
// Deserialize back
|
||||
let deserialized: FlockStatus =
|
||||
serde_json::from_str(&json).expect("Failed to deserialize");
|
||||
let deserialized: FlockStatus = serde_json::from_str(&json).expect("Failed to deserialize");
|
||||
assert_eq!(deserialized.session_id, "test-session");
|
||||
assert_eq!(deserialized.segments.len(), 1);
|
||||
assert_eq!(deserialized.total_tokens, 1000);
|
||||
|
||||
@@ -71,7 +71,7 @@ fn create_test_project(name: &str) -> TempDir {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flock_config_validation() {
|
||||
fn test_flock_config_validation() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let project_path = temp_dir.path().to_path_buf();
|
||||
let workspace_path = temp_dir.path().join("workspace");
|
||||
@@ -213,8 +213,7 @@ fn test_multiple_segment_clones() {
|
||||
assert!(segment2.exists());
|
||||
|
||||
// Modify segment 1
|
||||
fs::write(segment1.join("test.txt"), "segment 1")
|
||||
.expect("Failed to write to segment 1");
|
||||
fs::write(segment1.join("test.txt"), "segment 1").expect("Failed to write to segment 1");
|
||||
|
||||
// Verify segment 2 is unaffected
|
||||
assert!(!segment2.join("test.txt").exists());
|
||||
@@ -236,8 +235,11 @@ fn test_segment_requirements_creation() {
|
||||
|
||||
// Create segment-requirements.md (what flock mode does)
|
||||
let segment_requirements = "# Module A\n\nImplement module A functionality\n";
|
||||
fs::write(segment_dir.join("segment-requirements.md"), segment_requirements)
|
||||
.expect("Failed to write segment requirements");
|
||||
fs::write(
|
||||
segment_dir.join("segment-requirements.md"),
|
||||
segment_requirements,
|
||||
)
|
||||
.expect("Failed to write segment requirements");
|
||||
|
||||
// Verify it was created
|
||||
assert!(segment_dir.join("segment-requirements.md").exists());
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use anyhow::Result;
|
||||
use regex::Regex;
|
||||
use std::io::Write;
|
||||
use std::process::Command;
|
||||
use tempfile::NamedTempFile;
|
||||
use std::io::Write;
|
||||
use tracing::{info, debug, error};
|
||||
use tracing::{debug, error, info};
|
||||
|
||||
/// Expand tilde (~) in a path to the user's home directory
|
||||
fn expand_tilde(path: &str) -> String {
|
||||
@@ -35,17 +35,29 @@ impl CodeExecutor {
|
||||
|
||||
/// Extract code blocks from LLM response and execute them
|
||||
pub async fn execute_from_response(&self, response: &str) -> Result<String> {
|
||||
self.execute_from_response_with_options(response, true).await
|
||||
self.execute_from_response_with_options(response, true)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Extract code blocks from LLM response and execute them with UI options
|
||||
pub async fn execute_from_response_with_options(&self, response: &str, show_code: bool) -> Result<String> {
|
||||
debug!("CodeExecutor received response ({} chars): {}", response.len(), response);
|
||||
pub async fn execute_from_response_with_options(
|
||||
&self,
|
||||
response: &str,
|
||||
show_code: bool,
|
||||
) -> Result<String> {
|
||||
debug!(
|
||||
"CodeExecutor received response ({} chars): {}",
|
||||
response.len(),
|
||||
response
|
||||
);
|
||||
let code_blocks = self.extract_code_blocks(response)?;
|
||||
|
||||
if code_blocks.is_empty() {
|
||||
if show_code {
|
||||
return Ok(format!("⚠️ No executable code blocks found in response.\n\n{}", response));
|
||||
return Ok(format!(
|
||||
"⚠️ No executable code blocks found in response.\n\n{}",
|
||||
response
|
||||
));
|
||||
} else {
|
||||
return Ok("⚠️ No executable code found.".to_string());
|
||||
}
|
||||
@@ -108,12 +120,16 @@ impl CodeExecutor {
|
||||
// Pattern 1: Standard markdown format ```language\ncode```
|
||||
let markdown_re = Regex::new(r"(?s)```(\w+)?\n(.*?)```")?;
|
||||
for cap in markdown_re.captures_iter(text) {
|
||||
let language = cap.get(1)
|
||||
let language = cap
|
||||
.get(1)
|
||||
.map(|m| m.as_str().to_lowercase())
|
||||
.unwrap_or_else(|| "bash".to_string()); // Default to bash
|
||||
let code = cap.get(2).map(|m| m.as_str()).unwrap_or("").trim();
|
||||
|
||||
debug!("Found markdown code block - language: '{}', code: '{}'", language, code);
|
||||
debug!(
|
||||
"Found markdown code block - language: '{}', code: '{}'",
|
||||
language, code
|
||||
);
|
||||
|
||||
if !code.is_empty() {
|
||||
blocks.push((language, code.to_string()));
|
||||
@@ -131,7 +147,10 @@ impl CodeExecutor {
|
||||
let language = open_lang.to_lowercase();
|
||||
let code = cap.get(2).map(|m| m.as_str()).unwrap_or("").trim();
|
||||
|
||||
debug!("Found bracket code block - language: '{}', code: '{}'", language, code);
|
||||
debug!(
|
||||
"Found bracket code block - language: '{}', code: '{}'",
|
||||
language, code
|
||||
);
|
||||
|
||||
if !code.is_empty() {
|
||||
blocks.push((language, code.to_string()));
|
||||
@@ -163,9 +182,7 @@ impl CodeExecutor {
|
||||
temp_file.write_all(code.as_bytes())?;
|
||||
let temp_path = temp_file.path();
|
||||
|
||||
let output = Command::new("python3")
|
||||
.arg(temp_path)
|
||||
.output()?;
|
||||
let output = Command::new("python3").arg(temp_path).output()?;
|
||||
|
||||
Ok(ExecutionResult {
|
||||
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
|
||||
@@ -202,10 +219,7 @@ impl CodeExecutor {
|
||||
});
|
||||
}
|
||||
|
||||
let output = Command::new("bash")
|
||||
.arg("-c")
|
||||
.arg(code)
|
||||
.output()?;
|
||||
let output = Command::new("bash").arg("-c").arg(code).output()?;
|
||||
|
||||
Ok(ExecutionResult {
|
||||
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
|
||||
@@ -221,9 +235,7 @@ impl CodeExecutor {
|
||||
temp_file.write_all(code.as_bytes())?;
|
||||
let temp_path = temp_file.path();
|
||||
|
||||
let output = Command::new("node")
|
||||
.arg(temp_path)
|
||||
.output()?;
|
||||
let output = Command::new("node").arg(temp_path).output()?;
|
||||
|
||||
Ok(ExecutionResult {
|
||||
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
|
||||
@@ -251,9 +263,10 @@ impl CodeExecutor {
|
||||
pub async fn execute_bash_streaming<R: OutputReceiver>(
|
||||
&self,
|
||||
code: &str,
|
||||
receiver: &R
|
||||
receiver: &R,
|
||||
) -> Result<ExecutionResult> {
|
||||
self.execute_bash_streaming_in_dir(code, receiver, None).await
|
||||
self.execute_bash_streaming_in_dir(code, receiver, None)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Execute bash command with streaming output in a specific directory
|
||||
@@ -271,13 +284,25 @@ impl CodeExecutor {
|
||||
debug!("========== execute_bash_streaming_in_dir START ==========");
|
||||
debug!("Code to execute: {}", code);
|
||||
debug!("Working directory parameter: {:?}", working_dir);
|
||||
debug!("FULL DIAGNOSTIC: code='{}', working_dir={:?}", code, working_dir);
|
||||
debug!(
|
||||
"FULL DIAGNOSTIC: code='{}', working_dir={:?}",
|
||||
code, working_dir
|
||||
);
|
||||
|
||||
if let Some(dir) = working_dir {
|
||||
debug!("Working dir exists check: {}", std::path::Path::new(dir).exists());
|
||||
debug!("Working dir is_dir check: {}", std::path::Path::new(dir).is_dir());
|
||||
debug!(
|
||||
"Working dir exists check: {}",
|
||||
std::path::Path::new(dir).exists()
|
||||
);
|
||||
debug!(
|
||||
"Working dir is_dir check: {}",
|
||||
std::path::Path::new(dir).is_dir()
|
||||
);
|
||||
}
|
||||
debug!("Current process working directory: {:?}", std::env::current_dir());
|
||||
debug!(
|
||||
"Current process working directory: {:?}",
|
||||
std::env::current_dir()
|
||||
);
|
||||
|
||||
// Check if this is a detached/daemon command that should run independently
|
||||
// Look for patterns like: setsid, nohup with &, or explicit backgrounding with disown
|
||||
@@ -289,8 +314,7 @@ impl CodeExecutor {
|
||||
if is_detached {
|
||||
// For detached commands, just spawn and return immediately
|
||||
let mut cmd = TokioCommand::new("bash");
|
||||
cmd.arg("-c")
|
||||
.arg(code);
|
||||
cmd.arg("-c").arg(code);
|
||||
|
||||
// Set working directory if provided
|
||||
if let Some(dir) = working_dir {
|
||||
@@ -320,8 +344,14 @@ impl CodeExecutor {
|
||||
debug!("Setting current_dir on command to: {}", dir);
|
||||
let expanded_dir = expand_tilde(dir);
|
||||
debug!("Expanded working dir: {}", expanded_dir);
|
||||
debug!("Expanded dir exists: {}", std::path::Path::new(&expanded_dir).exists());
|
||||
debug!("Expanded dir is_dir: {}", std::path::Path::new(&expanded_dir).is_dir());
|
||||
debug!(
|
||||
"Expanded dir exists: {}",
|
||||
std::path::Path::new(&expanded_dir).exists()
|
||||
);
|
||||
debug!(
|
||||
"Expanded dir is_dir: {}",
|
||||
std::path::Path::new(&expanded_dir).is_dir()
|
||||
);
|
||||
cmd.current_dir(&expanded_dir);
|
||||
}
|
||||
|
||||
@@ -418,9 +448,7 @@ pub fn is_llvm_tools_installed() -> Result<bool> {
|
||||
|
||||
/// Check if cargo-llvm-cov is installed
|
||||
pub fn is_cargo_llvm_cov_installed() -> Result<bool> {
|
||||
let output = Command::new("cargo")
|
||||
.args(&["--list"])
|
||||
.output()?;
|
||||
let output = Command::new("cargo").args(&["--list"]).output()?;
|
||||
|
||||
let installed = String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
|
||||
@@ -291,7 +291,10 @@ pub fn explore_kotlin(path: &str) -> String {
|
||||
|
||||
// Build files
|
||||
report.push_str("--- Build Configuration ---\n");
|
||||
let build = run_command("cat build.gradle.kts 2>/dev/null | head -50 || cat build.gradle 2>/dev/null | head -50", path);
|
||||
let build = run_command(
|
||||
"cat build.gradle.kts 2>/dev/null | head -50 || cat build.gradle 2>/dev/null | head -50",
|
||||
path,
|
||||
);
|
||||
report.push_str(&build);
|
||||
report.push('\n');
|
||||
|
||||
|
||||
@@ -9,11 +9,11 @@ pub mod prompts;
|
||||
pub use code_explore::explore_codebase;
|
||||
|
||||
use anyhow::Result;
|
||||
use g3_providers::{CompletionRequest, LLMProvider, Message, MessageRole};
|
||||
use chrono::Local;
|
||||
use g3_providers::{CompletionRequest, LLMProvider, Message, MessageRole};
|
||||
use prompts::{DISCOVERY_REQUIREMENTS_PROMPT, DISCOVERY_SYSTEM_PROMPT};
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::Write;
|
||||
use prompts::{DISCOVERY_REQUIREMENTS_PROMPT, DISCOVERY_SYSTEM_PROMPT};
|
||||
|
||||
/// Type alias for a status callback function
|
||||
pub type StatusCallback = Box<dyn Fn(&str) + Send + Sync>;
|
||||
@@ -94,7 +94,10 @@ pub async fn get_initial_discovery_messages(
|
||||
// Step 5: Extract shell commands from the response
|
||||
let shell_commands = extract_shell_commands(&response.content);
|
||||
|
||||
status(&format!("📋 Extracted {} discovery commands", shell_commands.len()));
|
||||
status(&format!(
|
||||
"📋 Extracted {} discovery commands",
|
||||
shell_commands.len()
|
||||
));
|
||||
|
||||
// Write the discovery commands to logs directory
|
||||
write_discovery_commands(&shell_commands)?;
|
||||
|
||||
@@ -31,12 +31,14 @@ fn test_log_files_created() {
|
||||
|
||||
// Test writing discovery commands
|
||||
let commands_filename = format!("logs/discovery_commands_{}.log", timestamp);
|
||||
let test_commands = "# Discovery Commands\n# Generated by g3-planner\n\nls -la\ncat README.md\n";
|
||||
let test_commands =
|
||||
"# Discovery Commands\n# Generated by g3-planner\n\nls -la\ncat README.md\n";
|
||||
|
||||
fs::write(&commands_filename, test_commands).expect("Failed to write discovery commands");
|
||||
assert!(Path::new(&commands_filename).exists());
|
||||
|
||||
let content = fs::read_to_string(&commands_filename).expect("Failed to read discovery commands");
|
||||
let content =
|
||||
fs::read_to_string(&commands_filename).expect("Failed to read discovery commands");
|
||||
assert_eq!(content, test_commands);
|
||||
|
||||
// Clean up
|
||||
|
||||
@@ -188,12 +188,17 @@ impl AnthropicProvider {
|
||||
};
|
||||
|
||||
// Extract properties and required fields from the input schema
|
||||
if let Ok(schema_obj) = serde_json::from_value::<serde_json::Map<String, serde_json::Value>>(tool.input_schema.clone()) {
|
||||
if let Ok(schema_obj) = serde_json::from_value::<
|
||||
serde_json::Map<String, serde_json::Value>,
|
||||
>(tool.input_schema.clone())
|
||||
{
|
||||
if let Some(properties) = schema_obj.get("properties") {
|
||||
schema.properties = properties.clone();
|
||||
}
|
||||
if let Some(required) = schema_obj.get("required") {
|
||||
if let Ok(required_vec) = serde_json::from_value::<Vec<String>>(required.clone()) {
|
||||
if let Ok(required_vec) =
|
||||
serde_json::from_value::<Vec<String>>(required.clone())
|
||||
{
|
||||
schema.required = Some(required_vec);
|
||||
}
|
||||
}
|
||||
@@ -208,7 +213,10 @@ impl AnthropicProvider {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn convert_messages(&self, messages: &[Message]) -> Result<(Option<String>, Vec<AnthropicMessage>)> {
|
||||
fn convert_messages(
|
||||
&self,
|
||||
messages: &[Message],
|
||||
) -> Result<(Option<String>, Vec<AnthropicMessage>)> {
|
||||
let mut system_message = None;
|
||||
let mut anthropic_messages = Vec::new();
|
||||
|
||||
@@ -225,7 +233,9 @@ impl AnthropicProvider {
|
||||
role: "user".to_string(),
|
||||
content: vec![AnthropicContent::Text {
|
||||
text: message.content.clone(),
|
||||
cache_control: message.cache_control.as_ref()
|
||||
cache_control: message
|
||||
.cache_control
|
||||
.as_ref()
|
||||
.map(Self::convert_cache_control),
|
||||
}],
|
||||
});
|
||||
@@ -235,7 +245,9 @@ impl AnthropicProvider {
|
||||
role: "assistant".to_string(),
|
||||
content: vec![AnthropicContent::Text {
|
||||
text: message.content.clone(),
|
||||
cache_control: message.cache_control.as_ref()
|
||||
cache_control: message
|
||||
.cache_control
|
||||
.as_ref()
|
||||
.map(Self::convert_cache_control),
|
||||
}],
|
||||
});
|
||||
@@ -257,7 +269,9 @@ impl AnthropicProvider {
|
||||
let (system, anthropic_messages) = self.convert_messages(messages)?;
|
||||
|
||||
if anthropic_messages.is_empty() {
|
||||
return Err(anyhow!("At least one user or assistant message is required"));
|
||||
return Err(anyhow!(
|
||||
"At least one user or assistant message is required"
|
||||
));
|
||||
}
|
||||
|
||||
// Convert tools if provided
|
||||
@@ -312,7 +326,8 @@ impl AnthropicProvider {
|
||||
let valid_up_to = e.valid_up_to();
|
||||
if valid_up_to > 0 {
|
||||
// We have some valid UTF-8, extract it and keep the rest for next iteration
|
||||
let valid_bytes = byte_buffer.drain(..valid_up_to).collect::<Vec<_>>();
|
||||
let valid_bytes =
|
||||
byte_buffer.drain(..valid_up_to).collect::<Vec<_>>();
|
||||
std::str::from_utf8(&valid_bytes).unwrap().to_string()
|
||||
} else {
|
||||
// No valid UTF-8 at all, skip this chunk and continue
|
||||
@@ -346,7 +361,11 @@ impl AnthropicProvider {
|
||||
content: String::new(),
|
||||
finished: true,
|
||||
usage: accumulated_usage.clone(),
|
||||
tool_calls: if current_tool_calls.is_empty() { None } else { Some(current_tool_calls.clone()) },
|
||||
tool_calls: if current_tool_calls.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(current_tool_calls.clone())
|
||||
},
|
||||
};
|
||||
if tx.send(Ok(final_chunk)).await.is_err() {
|
||||
debug!("Receiver dropped, stopping stream");
|
||||
@@ -358,7 +377,10 @@ impl AnthropicProvider {
|
||||
|
||||
match serde_json::from_str::<AnthropicStreamEvent>(data) {
|
||||
Ok(event) => {
|
||||
debug!("Parsed event type: {}, event: {:?}", event.event_type, event);
|
||||
debug!(
|
||||
"Parsed event type: {}, event: {:?}",
|
||||
event.event_type, event
|
||||
);
|
||||
match event.event_type.as_str() {
|
||||
"message_start" => {
|
||||
// Extract usage data from message_start event
|
||||
@@ -367,17 +389,28 @@ impl AnthropicProvider {
|
||||
accumulated_usage = Some(Usage {
|
||||
prompt_tokens: usage.input_tokens,
|
||||
completion_tokens: usage.output_tokens,
|
||||
total_tokens: usage.input_tokens + usage.output_tokens,
|
||||
total_tokens: usage.input_tokens
|
||||
+ usage.output_tokens,
|
||||
});
|
||||
debug!("Captured usage from message_start: {:?}", accumulated_usage);
|
||||
debug!(
|
||||
"Captured usage from message_start: {:?}",
|
||||
accumulated_usage
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
"content_block_start" => {
|
||||
debug!("Received content_block_start event: {:?}", event);
|
||||
debug!(
|
||||
"Received content_block_start event: {:?}",
|
||||
event
|
||||
);
|
||||
if let Some(content_block) = event.content_block {
|
||||
match content_block {
|
||||
AnthropicContent::ToolUse { id, name, input } => {
|
||||
AnthropicContent::ToolUse {
|
||||
id,
|
||||
name,
|
||||
input,
|
||||
} => {
|
||||
debug!("Found tool use in content_block_start: id={}, name={}, input={:?}", id, name, input);
|
||||
|
||||
// For native tool calls, create the tool call immediately if we have complete args
|
||||
@@ -389,7 +422,12 @@ impl AnthropicProvider {
|
||||
};
|
||||
|
||||
// Check if we already have complete arguments
|
||||
if !input.is_null() && input != serde_json::Value::Object(serde_json::Map::new()) {
|
||||
if !input.is_null()
|
||||
&& input
|
||||
!= serde_json::Value::Object(
|
||||
serde_json::Map::new(),
|
||||
)
|
||||
{
|
||||
// We have complete arguments, send the tool call immediately
|
||||
debug!("Tool call has complete args, sending immediately: {:?}", tool_call);
|
||||
let chunk = CompletionChunk {
|
||||
@@ -410,7 +448,10 @@ impl AnthropicProvider {
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
debug!("Non-tool content block: {:?}", content_block);
|
||||
debug!(
|
||||
"Non-tool content block: {:?}",
|
||||
content_block
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -418,7 +459,11 @@ impl AnthropicProvider {
|
||||
"content_block_delta" => {
|
||||
if let Some(delta) = event.delta {
|
||||
if let Some(text) = delta.text {
|
||||
debug!("Sending text chunk of length {}: '{}'", text.len(), text);
|
||||
debug!(
|
||||
"Sending text chunk of length {}: '{}'",
|
||||
text.len(),
|
||||
text
|
||||
);
|
||||
let chunk = CompletionChunk {
|
||||
content: text,
|
||||
finished: false,
|
||||
@@ -432,25 +477,45 @@ impl AnthropicProvider {
|
||||
}
|
||||
// Handle partial JSON for tool calls
|
||||
if let Some(partial_json) = delta.partial_json {
|
||||
debug!("Received partial JSON: {}", partial_json);
|
||||
debug!(
|
||||
"Received partial JSON: {}",
|
||||
partial_json
|
||||
);
|
||||
partial_tool_json.push_str(&partial_json);
|
||||
debug!("Accumulated tool JSON: {}", partial_tool_json);
|
||||
debug!(
|
||||
"Accumulated tool JSON: {}",
|
||||
partial_tool_json
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
"content_block_stop" => {
|
||||
// Tool call block is complete - now parse the accumulated JSON
|
||||
if !current_tool_calls.is_empty() && !partial_tool_json.is_empty() {
|
||||
debug!("Parsing complete tool JSON: {}", partial_tool_json);
|
||||
if !current_tool_calls.is_empty()
|
||||
&& !partial_tool_json.is_empty()
|
||||
{
|
||||
debug!(
|
||||
"Parsing complete tool JSON: {}",
|
||||
partial_tool_json
|
||||
);
|
||||
|
||||
// Parse the accumulated JSON and update the last tool call
|
||||
if let Ok(parsed_args) = serde_json::from_str::<serde_json::Value>(&partial_tool_json) {
|
||||
if let Some(last_tool) = current_tool_calls.last_mut() {
|
||||
if let Ok(parsed_args) =
|
||||
serde_json::from_str::<serde_json::Value>(
|
||||
&partial_tool_json,
|
||||
)
|
||||
{
|
||||
if let Some(last_tool) =
|
||||
current_tool_calls.last_mut()
|
||||
{
|
||||
last_tool.args = parsed_args;
|
||||
debug!("Updated tool call with complete args: {:?}", last_tool);
|
||||
}
|
||||
} else {
|
||||
debug!("Failed to parse accumulated JSON: {}", partial_tool_json);
|
||||
debug!(
|
||||
"Failed to parse accumulated JSON: {}",
|
||||
partial_tool_json
|
||||
);
|
||||
}
|
||||
|
||||
// Clear the accumulator
|
||||
@@ -478,7 +543,11 @@ impl AnthropicProvider {
|
||||
content: String::new(),
|
||||
finished: true,
|
||||
usage: accumulated_usage.clone(),
|
||||
tool_calls: if current_tool_calls.is_empty() { None } else { Some(current_tool_calls.clone()) },
|
||||
tool_calls: if current_tool_calls.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(current_tool_calls.clone())
|
||||
},
|
||||
};
|
||||
if tx.send(Ok(final_chunk)).await.is_err() {
|
||||
debug!("Receiver dropped, stopping stream");
|
||||
@@ -490,7 +559,10 @@ impl AnthropicProvider {
|
||||
if let Some(error) = event.error {
|
||||
error!("Anthropic API error: {:?}", error);
|
||||
let _ = tx
|
||||
.send(Err(anyhow!("Anthropic API error: {:?}", error)))
|
||||
.send(Err(anyhow!(
|
||||
"Anthropic API error: {:?}",
|
||||
error
|
||||
)))
|
||||
.await;
|
||||
break; // Break to let stream exhaust naturally
|
||||
}
|
||||
@@ -524,7 +596,11 @@ impl AnthropicProvider {
|
||||
content: String::new(),
|
||||
finished: true,
|
||||
usage: accumulated_usage.clone(),
|
||||
tool_calls: if current_tool_calls.is_empty() { None } else { Some(current_tool_calls) },
|
||||
tool_calls: if current_tool_calls.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(current_tool_calls)
|
||||
},
|
||||
};
|
||||
let _ = tx.send(Ok(final_chunk)).await;
|
||||
accumulated_usage
|
||||
@@ -547,11 +623,13 @@ impl LLMProvider for AnthropicProvider {
|
||||
request.tools.as_deref(),
|
||||
false,
|
||||
max_tokens,
|
||||
temperature
|
||||
temperature,
|
||||
)?;
|
||||
|
||||
debug!("Sending request to Anthropic API: model={}, max_tokens={}, temperature={}",
|
||||
request_body.model, request_body.max_tokens, request_body.temperature);
|
||||
debug!(
|
||||
"Sending request to Anthropic API: model={}, max_tokens={}, temperature={}",
|
||||
request_body.model, request_body.max_tokens, request_body.temperature
|
||||
);
|
||||
|
||||
let response = self
|
||||
.create_request_builder(false)
|
||||
@@ -588,7 +666,8 @@ impl LLMProvider for AnthropicProvider {
|
||||
let usage = Usage {
|
||||
prompt_tokens: anthropic_response.usage.input_tokens,
|
||||
completion_tokens: anthropic_response.usage.output_tokens,
|
||||
total_tokens: anthropic_response.usage.input_tokens + anthropic_response.usage.output_tokens,
|
||||
total_tokens: anthropic_response.usage.input_tokens
|
||||
+ anthropic_response.usage.output_tokens,
|
||||
};
|
||||
|
||||
debug!(
|
||||
@@ -617,14 +696,20 @@ impl LLMProvider for AnthropicProvider {
|
||||
request.tools.as_deref(),
|
||||
true,
|
||||
max_tokens,
|
||||
temperature
|
||||
temperature,
|
||||
)?;
|
||||
|
||||
debug!("Sending streaming request to Anthropic API: model={}, max_tokens={}, temperature={}",
|
||||
request_body.model, request_body.max_tokens, request_body.temperature);
|
||||
debug!(
|
||||
"Sending streaming request to Anthropic API: model={}, max_tokens={}, temperature={}",
|
||||
request_body.model, request_body.max_tokens, request_body.temperature
|
||||
);
|
||||
|
||||
// Debug: Log the full request body
|
||||
debug!("Full request body: {}", serde_json::to_string_pretty(&request_body).unwrap_or_else(|_| "Failed to serialize".to_string()));
|
||||
debug!(
|
||||
"Full request body: {}",
|
||||
serde_json::to_string_pretty(&request_body)
|
||||
.unwrap_or_else(|_| "Failed to serialize".to_string())
|
||||
);
|
||||
|
||||
let response = self
|
||||
.create_request_builder(true)
|
||||
@@ -798,17 +883,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_message_conversion() {
|
||||
let provider = AnthropicProvider::new(
|
||||
"test-key".to_string(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
).unwrap();
|
||||
let provider =
|
||||
AnthropicProvider::new("test-key".to_string(), None, None, None, None, None).unwrap();
|
||||
|
||||
let messages = vec![
|
||||
Message::new(MessageRole::System, "You are a helpful assistant.".to_string()),
|
||||
Message::new(
|
||||
MessageRole::System,
|
||||
"You are a helpful assistant.".to_string(),
|
||||
),
|
||||
Message::new(MessageRole::User, "Hello!".to_string()),
|
||||
Message::new(MessageRole::Assistant, "Hi there!".to_string()),
|
||||
];
|
||||
@@ -830,7 +912,8 @@ mod tests {
|
||||
Some(0.5),
|
||||
None,
|
||||
None,
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let messages = vec![Message::new(MessageRole::User, "Test message".to_string())];
|
||||
|
||||
@@ -848,31 +931,23 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_tool_conversion() {
|
||||
let provider = AnthropicProvider::new(
|
||||
"test-key".to_string(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
).unwrap();
|
||||
let provider =
|
||||
AnthropicProvider::new("test-key".to_string(), None, None, None, None, None).unwrap();
|
||||
|
||||
let tools = vec![
|
||||
Tool {
|
||||
name: "get_weather".to_string(),
|
||||
description: "Get the current weather".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"location": {
|
||||
"type": "string",
|
||||
"description": "The city and state"
|
||||
}
|
||||
},
|
||||
"required": ["location"]
|
||||
}),
|
||||
},
|
||||
];
|
||||
let tools = vec![Tool {
|
||||
name: "get_weather".to_string(),
|
||||
description: "Get the current weather".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"location": {
|
||||
"type": "string",
|
||||
"description": "The city and state"
|
||||
}
|
||||
},
|
||||
"required": ["location"]
|
||||
}),
|
||||
}];
|
||||
|
||||
let anthropic_tools = provider.convert_tools(&tools);
|
||||
|
||||
@@ -881,19 +956,16 @@ mod tests {
|
||||
assert_eq!(anthropic_tools[0].description, "Get the current weather");
|
||||
assert_eq!(anthropic_tools[0].input_schema.schema_type, "object");
|
||||
assert!(anthropic_tools[0].input_schema.required.is_some());
|
||||
assert_eq!(anthropic_tools[0].input_schema.required.as_ref().unwrap()[0], "location");
|
||||
assert_eq!(
|
||||
anthropic_tools[0].input_schema.required.as_ref().unwrap()[0],
|
||||
"location"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_control_serialization() {
|
||||
let provider = AnthropicProvider::new(
|
||||
"test-key".to_string(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
).unwrap();
|
||||
let provider =
|
||||
AnthropicProvider::new("test-key".to_string(), None, None, None, None, None).unwrap();
|
||||
|
||||
// Test message WITHOUT cache_control
|
||||
let messages_without = vec![Message::new(MessageRole::User, "Hello".to_string())];
|
||||
@@ -904,8 +976,10 @@ mod tests {
|
||||
// Check if cache_control appears in the JSON
|
||||
if json_without.contains("cache_control") {
|
||||
println!("WARNING: JSON contains 'cache_control' field when not configured!");
|
||||
assert!(!json_without.contains("\"cache_control\":null"),
|
||||
"JSON should not contain 'cache_control: null'");
|
||||
assert!(
|
||||
!json_without.contains("\"cache_control\":null"),
|
||||
"JSON should not contain 'cache_control: null'"
|
||||
);
|
||||
}
|
||||
|
||||
// Test message WITH cache_control
|
||||
@@ -918,13 +992,19 @@ mod tests {
|
||||
let json_with = serde_json::to_string(&anthropic_messages_with).unwrap();
|
||||
|
||||
println!("Anthropic JSON with cache_control: {}", json_with);
|
||||
assert!(json_with.contains("cache_control"),
|
||||
"JSON should contain 'cache_control' field when configured");
|
||||
assert!(json_with.contains("ephemeral"),
|
||||
"JSON should contain 'ephemeral' type");
|
||||
assert!(
|
||||
json_with.contains("cache_control"),
|
||||
"JSON should contain 'cache_control' field when configured"
|
||||
);
|
||||
assert!(
|
||||
json_with.contains("ephemeral"),
|
||||
"JSON should contain 'ephemeral' type"
|
||||
);
|
||||
|
||||
// The key assertion: when cache_control is None, it should not appear in JSON
|
||||
assert!(!json_without.contains("cache_control") || !json_without.contains("null"),
|
||||
"JSON should not contain 'cache_control' field or null values when not configured");
|
||||
assert!(
|
||||
!json_without.contains("cache_control") || !json_without.contains("null"),
|
||||
"JSON should not contain 'cache_control' field or null values when not configured"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,7 +326,8 @@ impl DatabricksProvider {
|
||||
let valid_up_to = e.valid_up_to();
|
||||
if valid_up_to > 0 {
|
||||
// We have some valid UTF-8, extract it and keep the rest for next iteration
|
||||
let valid_bytes = byte_buffer.drain(..valid_up_to).collect::<Vec<_>>();
|
||||
let valid_bytes =
|
||||
byte_buffer.drain(..valid_up_to).collect::<Vec<_>>();
|
||||
std::str::from_utf8(&valid_bytes).unwrap().to_string()
|
||||
} else {
|
||||
// No valid UTF-8 at all, skip this chunk and continue
|
||||
@@ -610,8 +611,12 @@ impl DatabricksProvider {
|
||||
|
||||
// Log final state
|
||||
debug!("Stream ended after {} chunks", chunk_count);
|
||||
debug!("Final state: buffer_len={}, incomplete_data_line_len={}, byte_buffer_len={}",
|
||||
buffer.len(), incomplete_data_line.len(), byte_buffer.len());
|
||||
debug!(
|
||||
"Final state: buffer_len={}, incomplete_data_line_len={}, byte_buffer_len={}",
|
||||
buffer.len(),
|
||||
incomplete_data_line.len(),
|
||||
byte_buffer.len()
|
||||
);
|
||||
debug!("Accumulated tool calls: {}", current_tool_calls.len());
|
||||
|
||||
// If we have any remaining data in buffers, log it for debugging
|
||||
@@ -1181,7 +1186,10 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
let messages = vec![
|
||||
Message::new(MessageRole::System, "You are a helpful assistant.".to_string()),
|
||||
Message::new(
|
||||
MessageRole::System,
|
||||
"You are a helpful assistant.".to_string(),
|
||||
),
|
||||
Message::new(MessageRole::User, "Hello!".to_string()),
|
||||
Message::new(MessageRole::Assistant, "Hi there!".to_string()),
|
||||
];
|
||||
@@ -1306,8 +1314,10 @@ mod tests {
|
||||
let json_without = serde_json::to_string(&databricks_messages_without).unwrap();
|
||||
|
||||
println!("JSON without cache_control: {}", json_without);
|
||||
assert!(!json_without.contains("cache_control"),
|
||||
"JSON should not contain 'cache_control' field when not configured");
|
||||
assert!(
|
||||
!json_without.contains("cache_control"),
|
||||
"JSON should not contain 'cache_control' field when not configured"
|
||||
);
|
||||
|
||||
// Test message WITH cache_control - should still NOT include it (Databricks doesn't support it)
|
||||
let messages_with = vec![Message::with_cache_control(
|
||||
@@ -1319,8 +1329,10 @@ mod tests {
|
||||
let json_with = serde_json::to_string(&databricks_messages_with).unwrap();
|
||||
|
||||
println!("JSON with cache_control: {}", json_with);
|
||||
assert!(!json_with.contains("cache_control"),
|
||||
"JSON should NOT contain 'cache_control' field - Databricks doesn't support it");
|
||||
assert!(
|
||||
!json_with.contains("cache_control"),
|
||||
"JSON should NOT contain 'cache_control' field - Databricks doesn't support it"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1343,7 +1355,13 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(!claude_provider.supports_cache_control(), "Databricks should not support cache_control even for Claude models");
|
||||
assert!(!llama_provider.supports_cache_control(), "Databricks should not support cache_control for Llama models");
|
||||
assert!(
|
||||
!claude_provider.supports_cache_control(),
|
||||
"Databricks should not support cache_control even for Claude models"
|
||||
);
|
||||
assert!(
|
||||
!llama_provider.supports_cache_control(),
|
||||
"Databricks should not support cache_control for Llama models"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use anyhow::Result;
|
||||
use crate::{
|
||||
CompletionChunk, CompletionRequest, CompletionResponse, CompletionStream, LLMProvider, Message,
|
||||
MessageRole, Usage,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use llama_cpp::{
|
||||
standard_sampler::{SamplerStage, StandardSampler},
|
||||
LlamaModel, LlamaParams, LlamaSession, SessionParams,
|
||||
@@ -148,7 +148,10 @@ impl EmbeddedProvider {
|
||||
}
|
||||
|
||||
// If the last message was from user, add a space for the assistant's response
|
||||
if messages.last().is_some_and(|m| matches!(m.role, MessageRole::User)) {
|
||||
if messages
|
||||
.last()
|
||||
.is_some_and(|m| matches!(m.role, MessageRole::User))
|
||||
{
|
||||
formatted.push(' ');
|
||||
}
|
||||
|
||||
@@ -216,16 +219,25 @@ impl EmbeddedProvider {
|
||||
}
|
||||
Err(_) => {
|
||||
if attempt < 4 {
|
||||
debug!("Session busy, retrying in {}ms (attempt {}/5)", 100 * (attempt + 1), attempt + 1);
|
||||
std::thread::sleep(std::time::Duration::from_millis(100 * (attempt + 1) as u64));
|
||||
debug!(
|
||||
"Session busy, retrying in {}ms (attempt {}/5)",
|
||||
100 * (attempt + 1),
|
||||
attempt + 1
|
||||
);
|
||||
std::thread::sleep(std::time::Duration::from_millis(
|
||||
100 * (attempt + 1) as u64,
|
||||
));
|
||||
} else {
|
||||
return Err(anyhow::anyhow!("Model is busy after 5 attempts, please try again"));
|
||||
return Err(anyhow::anyhow!(
|
||||
"Model is busy after 5 attempts, please try again"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut session = session_guard.ok_or_else(|| anyhow::anyhow!("Failed to acquire session lock"))?;
|
||||
let mut session = session_guard
|
||||
.ok_or_else(|| anyhow::anyhow!("Failed to acquire session lock"))?;
|
||||
|
||||
debug!(
|
||||
"Starting inference with prompt length: {} chars, estimated {} tokens",
|
||||
@@ -350,18 +362,19 @@ impl EmbeddedProvider {
|
||||
|
||||
if model_name_lower.contains("qwen") {
|
||||
vec![
|
||||
"<|im_end|>", // Qwen ChatML format end token
|
||||
"<|endoftext|>", // Alternative end token
|
||||
"</s>", // Generic end of sequence
|
||||
"<|im_start|>", // Start of new message (shouldn't appear in response)
|
||||
"<|im_end|>", // Qwen ChatML format end token
|
||||
"<|endoftext|>", // Alternative end token
|
||||
"</s>", // Generic end of sequence
|
||||
"<|im_start|>", // Start of new message (shouldn't appear in response)
|
||||
]
|
||||
} else if model_name_lower.contains("codellama") || model_name_lower.contains("code-llama") {
|
||||
} else if model_name_lower.contains("codellama") || model_name_lower.contains("code-llama")
|
||||
{
|
||||
vec![
|
||||
"</s>", // End of sequence
|
||||
"[/INST]", // End of instruction
|
||||
"<</SYS>>", // End of system message
|
||||
"[INST]", // Start of new instruction (shouldn't appear in response)
|
||||
"<<SYS>>", // Start of system (shouldn't appear in response)
|
||||
"</s>", // End of sequence
|
||||
"[/INST]", // End of instruction
|
||||
"<</SYS>>", // End of system message
|
||||
"[INST]", // Start of new instruction (shouldn't appear in response)
|
||||
"<<SYS>>", // Start of system (shouldn't appear in response)
|
||||
]
|
||||
} else if model_name_lower.contains("llama") {
|
||||
vec![
|
||||
@@ -374,9 +387,9 @@ impl EmbeddedProvider {
|
||||
]
|
||||
} else if model_name_lower.contains("mistral") {
|
||||
vec![
|
||||
"</s>", // End of sequence
|
||||
"[/INST]", // End of instruction
|
||||
"<|im_end|>", // ChatML format
|
||||
"</s>", // End of sequence
|
||||
"[/INST]", // End of instruction
|
||||
"<|im_end|>", // ChatML format
|
||||
]
|
||||
} else if model_name_lower.contains("vicuna") || model_name_lower.contains("wizard") {
|
||||
vec![
|
||||
@@ -391,7 +404,7 @@ impl EmbeddedProvider {
|
||||
"### Instruction:", // Alpaca format
|
||||
"### Response:", // Alpaca format
|
||||
"### Input:", // Alpaca format
|
||||
"</s>", // End of sequence
|
||||
"</s>", // End of sequence
|
||||
]
|
||||
} else {
|
||||
// Generic/unknown model - use common stop sequences
|
||||
@@ -442,10 +455,11 @@ impl EmbeddedProvider {
|
||||
// Use curl with progress bar for download
|
||||
let output = Command::new("curl")
|
||||
.args([
|
||||
"-L", // Follow redirects
|
||||
"-#", // Show progress bar
|
||||
"-f", // Fail on HTTP errors
|
||||
"-o", model_path.to_str().unwrap(),
|
||||
"-L", // Follow redirects
|
||||
"-#", // Show progress bar
|
||||
"-f", // Fail on HTTP errors
|
||||
"-o",
|
||||
model_path.to_str().unwrap(),
|
||||
MODEL_URL,
|
||||
])
|
||||
.output()?;
|
||||
@@ -455,11 +469,15 @@ impl EmbeddedProvider {
|
||||
|
||||
// If curl is not available, provide alternative instructions
|
||||
if stderr.contains("command not found") || stderr.contains("not found") {
|
||||
error!("curl is not installed. Please install curl or manually download the model.");
|
||||
error!(
|
||||
"curl is not installed. Please install curl or manually download the model."
|
||||
);
|
||||
error!("Manual download instructions:");
|
||||
error!("1. Download from: {}", MODEL_URL);
|
||||
error!("2. Save to: {}", model_path.display());
|
||||
anyhow::bail!("curl not found - please install curl or download the model manually");
|
||||
anyhow::bail!(
|
||||
"curl not found - please install curl or download the model manually"
|
||||
);
|
||||
}
|
||||
|
||||
anyhow::bail!("Failed to download model: {}", stderr);
|
||||
@@ -469,11 +487,13 @@ impl EmbeddedProvider {
|
||||
let metadata = fs::metadata(model_path)?;
|
||||
let size_mb = metadata.len() / (1024 * 1024);
|
||||
|
||||
if size_mb < MODEL_SIZE_MB - 100 { // Allow some variance
|
||||
fs::remove_file(model_path).ok(); // Clean up partial download
|
||||
if size_mb < MODEL_SIZE_MB - 100 {
|
||||
// Allow some variance
|
||||
fs::remove_file(model_path).ok(); // Clean up partial download
|
||||
anyhow::bail!(
|
||||
"Downloaded file appears incomplete ({}MB vs expected ~{}MB). Please try again.",
|
||||
size_mb, MODEL_SIZE_MB
|
||||
size_mb,
|
||||
MODEL_SIZE_MB
|
||||
);
|
||||
}
|
||||
|
||||
@@ -541,10 +561,18 @@ impl LLMProvider for EmbeddedProvider {
|
||||
}
|
||||
Err(_) => {
|
||||
if attempt < 4 {
|
||||
debug!("Session busy, retrying in {}ms (attempt {}/5)", 100 * (attempt + 1), attempt + 1);
|
||||
std::thread::sleep(std::time::Duration::from_millis(100 * (attempt + 1) as u64));
|
||||
debug!(
|
||||
"Session busy, retrying in {}ms (attempt {}/5)",
|
||||
100 * (attempt + 1),
|
||||
attempt + 1
|
||||
);
|
||||
std::thread::sleep(std::time::Duration::from_millis(
|
||||
100 * (attempt + 1) as u64,
|
||||
));
|
||||
} else {
|
||||
let _ = tx.blocking_send(Err(anyhow::anyhow!("Model is busy after 5 attempts, please try again")));
|
||||
let _ = tx.blocking_send(Err(anyhow::anyhow!(
|
||||
"Model is busy after 5 attempts, please try again"
|
||||
)));
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -554,7 +582,8 @@ impl LLMProvider for EmbeddedProvider {
|
||||
let mut session = match session_guard {
|
||||
Some(ctx) => ctx,
|
||||
None => {
|
||||
let _ = tx.blocking_send(Err(anyhow::anyhow!("Failed to acquire session lock")));
|
||||
let _ =
|
||||
tx.blocking_send(Err(anyhow::anyhow!("Failed to acquire session lock")));
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -595,10 +624,26 @@ impl LLMProvider for EmbeddedProvider {
|
||||
vec!["<|im_end|>", "<|endoftext|>", "</s>", "<|im_start|>"]
|
||||
} else if prompt.contains("[INST]") || prompt.contains("<<SYS>>") {
|
||||
// Llama/CodeLlama format detected
|
||||
vec!["</s>", "[/INST]", "<</SYS>>", "[INST]", "<<SYS>>", "### Human:", "### Assistant:"]
|
||||
vec![
|
||||
"</s>",
|
||||
"[/INST]",
|
||||
"<</SYS>>",
|
||||
"[INST]",
|
||||
"<<SYS>>",
|
||||
"### Human:",
|
||||
"### Assistant:",
|
||||
]
|
||||
} else {
|
||||
// Generic format
|
||||
vec!["</s>", "<|endoftext|>", "<|im_end|>", "### Human:", "### Assistant:", "[/INST]", "<</SYS>>"]
|
||||
vec![
|
||||
"</s>",
|
||||
"<|endoftext|>",
|
||||
"<|im_end|>",
|
||||
"### Human:",
|
||||
"### Assistant:",
|
||||
"[/INST]",
|
||||
"<</SYS>>",
|
||||
]
|
||||
};
|
||||
|
||||
// Stream tokens with proper limits
|
||||
@@ -622,9 +667,9 @@ impl LLMProvider for EmbeddedProvider {
|
||||
if hit_stop {
|
||||
// Before stopping, check if there might be an incomplete tool call
|
||||
// Look for JSON tool call patterns that might be cut off by the stop sequence
|
||||
let has_potential_tool_call = accumulated_text.contains(r#"{"tool":"#) ||
|
||||
accumulated_text.contains(r#"{"{""tool"":"#) ||
|
||||
accumulated_text.contains(r#"{{""tool"":"#);
|
||||
let has_potential_tool_call = accumulated_text.contains(r#"{"tool":"#)
|
||||
|| accumulated_text.contains(r#"{"{""tool"":"#)
|
||||
|| accumulated_text.contains(r#"{{""tool"":"#);
|
||||
|
||||
if has_potential_tool_call {
|
||||
// Check if the tool call appears to be complete (has closing brace after the stop sequence)
|
||||
@@ -711,7 +756,8 @@ impl LLMProvider for EmbeddedProvider {
|
||||
|
||||
if might_be_stop {
|
||||
// Hold back tokens, but only for a limited buffer size
|
||||
if unsent_tokens.len() > 20 { // Don't hold back more than 20 characters
|
||||
if unsent_tokens.len() > 20 {
|
||||
// Don't hold back more than 20 characters
|
||||
// Send the oldest part and keep only the recent part that might be a stop sequence
|
||||
let to_send = &unsent_tokens[..unsent_tokens.len() - 10];
|
||||
if !to_send.is_empty() {
|
||||
@@ -755,7 +801,7 @@ impl LLMProvider for EmbeddedProvider {
|
||||
let final_chunk = CompletionChunk {
|
||||
content: String::new(),
|
||||
finished: true,
|
||||
usage: None, // Embedded models calculate usage differently
|
||||
usage: None, // Embedded models calculate usage differently
|
||||
tool_calls: None,
|
||||
};
|
||||
let _ = tx.blocking_send(Ok(final_chunk));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use anyhow::Result;
|
||||
use std::collections::HashMap;
|
||||
use rand::Rng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Trait for LLM providers
|
||||
#[async_trait::async_trait]
|
||||
@@ -60,15 +60,24 @@ pub enum CacheType {
|
||||
|
||||
impl CacheControl {
|
||||
pub fn ephemeral() -> Self {
|
||||
Self { cache_type: CacheType::Ephemeral, ttl: None }
|
||||
Self {
|
||||
cache_type: CacheType::Ephemeral,
|
||||
ttl: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn five_minute() -> Self {
|
||||
Self { cache_type: CacheType::Ephemeral, ttl: Some("5m".to_string()) }
|
||||
Self {
|
||||
cache_type: CacheType::Ephemeral,
|
||||
ttl: Some("5m".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn one_hour() -> Self {
|
||||
Self { cache_type: CacheType::Ephemeral, ttl: Some("1h".to_string()) }
|
||||
Self {
|
||||
cache_type: CacheType::Ephemeral,
|
||||
ttl: Some("1h".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +85,7 @@ impl CacheControl {
|
||||
pub struct Message {
|
||||
pub role: MessageRole,
|
||||
pub content: String,
|
||||
#[serde(skip)]
|
||||
pub id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_control: Option<CacheControl>,
|
||||
@@ -110,7 +120,7 @@ pub struct CompletionChunk {
|
||||
pub content: String,
|
||||
pub finished: bool,
|
||||
pub tool_calls: Option<Vec<ToolCall>>,
|
||||
pub usage: Option<Usage>, // Add usage tracking for streaming
|
||||
pub usage: Option<Usage>, // Add usage tracking for streaming
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -168,7 +178,11 @@ impl Message {
|
||||
}
|
||||
|
||||
/// Create a new message with cache control
|
||||
pub fn with_cache_control(role: MessageRole, content: String, cache_control: CacheControl) -> Self {
|
||||
pub fn with_cache_control(
|
||||
role: MessageRole,
|
||||
content: String,
|
||||
cache_control: CacheControl,
|
||||
) -> Self {
|
||||
Self {
|
||||
role,
|
||||
content,
|
||||
@@ -182,7 +196,7 @@ impl Message {
|
||||
role: MessageRole,
|
||||
content: String,
|
||||
cache_control: CacheControl,
|
||||
provider: &dyn LLMProvider
|
||||
provider: &dyn LLMProvider,
|
||||
) -> Self {
|
||||
if !provider.supports_cache_control() {
|
||||
tracing::warn!(
|
||||
@@ -257,8 +271,10 @@ mod tests {
|
||||
let json = serde_json::to_string(&msg).unwrap();
|
||||
|
||||
println!("Message JSON without cache_control: {}", json);
|
||||
assert!(!json.contains("cache_control"),
|
||||
"JSON should not contain 'cache_control' field when not configured");
|
||||
assert!(
|
||||
!json.contains("cache_control"),
|
||||
"JSON should not contain 'cache_control' field when not configured"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -271,14 +287,22 @@ mod tests {
|
||||
let json = serde_json::to_string(&msg).unwrap();
|
||||
|
||||
println!("Message JSON with cache_control: {}", json);
|
||||
assert!(json.contains("cache_control"),
|
||||
"JSON should contain 'cache_control' field when configured");
|
||||
assert!(json.contains("ephemeral"),
|
||||
"JSON should contain 'ephemeral' value");
|
||||
assert!(json.contains("\"type\":"),
|
||||
"JSON should contain 'type' field in cache_control");
|
||||
assert!(!json.contains("null"),
|
||||
"JSON should not contain null values");
|
||||
assert!(
|
||||
json.contains("cache_control"),
|
||||
"JSON should contain 'cache_control' field when configured"
|
||||
);
|
||||
assert!(
|
||||
json.contains("ephemeral"),
|
||||
"JSON should contain 'ephemeral' value"
|
||||
);
|
||||
assert!(
|
||||
json.contains("\"type\":"),
|
||||
"JSON should contain 'type' field in cache_control"
|
||||
);
|
||||
assert!(
|
||||
!json.contains("null"),
|
||||
"JSON should not contain null values"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -291,9 +315,18 @@ mod tests {
|
||||
let json = serde_json::to_string(&msg).unwrap();
|
||||
|
||||
println!("Message JSON with 5-minute cache_control: {}", json);
|
||||
assert!(json.contains("cache_control"), "JSON should contain 'cache_control' field");
|
||||
assert!(json.contains("ephemeral"), "JSON should contain 'ephemeral' type");
|
||||
assert!(json.contains("\"ttl\":\"5m\""), "JSON should contain ttl field with 5m value");
|
||||
assert!(
|
||||
json.contains("cache_control"),
|
||||
"JSON should contain 'cache_control' field"
|
||||
);
|
||||
assert!(
|
||||
json.contains("ephemeral"),
|
||||
"JSON should contain 'ephemeral' type"
|
||||
);
|
||||
assert!(
|
||||
json.contains("\"ttl\":\"5m\""),
|
||||
"JSON should contain ttl field with 5m value"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -306,9 +339,18 @@ mod tests {
|
||||
let json = serde_json::to_string(&msg).unwrap();
|
||||
|
||||
println!("Message JSON with 1-hour cache_control: {}", json);
|
||||
assert!(json.contains("cache_control"), "JSON should contain 'cache_control' field");
|
||||
assert!(json.contains("ephemeral"), "JSON should contain 'ephemeral' type");
|
||||
assert!(json.contains("\"ttl\":\"1h\""), "JSON should contain ttl field with 1h value");
|
||||
assert!(
|
||||
json.contains("cache_control"),
|
||||
"JSON should contain 'cache_control' field"
|
||||
);
|
||||
assert!(
|
||||
json.contains("ephemeral"),
|
||||
"JSON should contain 'ephemeral' type"
|
||||
);
|
||||
assert!(
|
||||
json.contains("\"ttl\":\"1h\""),
|
||||
"JSON should contain ttl field with 1h value"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -324,12 +366,17 @@ mod tests {
|
||||
|
||||
// Check timestamp part is 6 digits
|
||||
assert_eq!(parts[0].len(), 6, "Timestamp should be 6 digits (HHMMSS)");
|
||||
assert!(parts[0].chars().all(|c| c.is_ascii_digit()), "Timestamp should be all digits");
|
||||
assert!(
|
||||
parts[0].chars().all(|c| c.is_ascii_digit()),
|
||||
"Timestamp should be all digits"
|
||||
);
|
||||
|
||||
// Check random part is 3 alpha characters
|
||||
assert_eq!(parts[1].len(), 3, "Random part should be 3 characters");
|
||||
assert!(parts[1].chars().all(|c| c.is_ascii_alphabetic()),
|
||||
"Random part should be all alphabetic characters");
|
||||
assert!(
|
||||
parts[1].chars().all(|c| c.is_ascii_alphabetic()),
|
||||
"Random part should be all alphabetic characters"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -348,7 +395,10 @@ mod tests {
|
||||
let json = serde_json::to_string(&msg).unwrap();
|
||||
|
||||
println!("Message JSON: {}", json);
|
||||
assert!(!json.contains("\"id\""), "JSON should not contain 'id' field");
|
||||
assert!(
|
||||
!json.contains("\"id\""),
|
||||
"JSON should not contain 'id' field"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -359,7 +409,13 @@ mod tests {
|
||||
CacheControl::ephemeral(),
|
||||
);
|
||||
|
||||
assert!(!msg.id.is_empty(), "Message with cache control should have an ID");
|
||||
assert!(msg.id.contains('-'), "Message ID should contain hyphen separator");
|
||||
assert!(
|
||||
!msg.id.is_empty(),
|
||||
"Message with cache control should have an ID"
|
||||
);
|
||||
assert!(
|
||||
msg.id.contains('-'),
|
||||
"Message ID should contain hyphen separator"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ use tokio_stream::wrappers::ReceiverStream;
|
||||
use tracing::{debug, error};
|
||||
|
||||
use crate::{
|
||||
CompletionChunk, CompletionRequest, CompletionResponse, CompletionStream, LLMProvider,
|
||||
Message, MessageRole, Tool, ToolCall, Usage,
|
||||
CompletionChunk, CompletionRequest, CompletionResponse, CompletionStream, LLMProvider, Message,
|
||||
MessageRole, Tool, ToolCall, Usage,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -138,7 +138,8 @@ impl OpenAIProvider {
|
||||
debug!("Received stream completion marker");
|
||||
|
||||
// Send final chunk with accumulated content and tool calls
|
||||
if !accumulated_content.is_empty() || !current_tool_calls.is_empty() {
|
||||
if !accumulated_content.is_empty() || !current_tool_calls.is_empty()
|
||||
{
|
||||
let tool_calls = if current_tool_calls.is_empty() {
|
||||
None
|
||||
} else {
|
||||
@@ -188,8 +189,9 @@ impl OpenAIProvider {
|
||||
if let Some(index) = delta_tool_call.index {
|
||||
// Ensure we have enough tool calls in our vector
|
||||
while current_tool_calls.len() <= index {
|
||||
current_tool_calls
|
||||
.push(OpenAIStreamingToolCall::default());
|
||||
current_tool_calls.push(
|
||||
OpenAIStreamingToolCall::default(),
|
||||
);
|
||||
}
|
||||
|
||||
let tool_call = &mut current_tool_calls[index];
|
||||
@@ -198,11 +200,14 @@ impl OpenAIProvider {
|
||||
tool_call.id = Some(id.clone());
|
||||
}
|
||||
|
||||
if let Some(function) = &delta_tool_call.function {
|
||||
if let Some(function) =
|
||||
&delta_tool_call.function
|
||||
{
|
||||
if let Some(name) = &function.name {
|
||||
tool_call.name = Some(name.clone());
|
||||
}
|
||||
if let Some(arguments) = &function.arguments {
|
||||
if let Some(arguments) = &function.arguments
|
||||
{
|
||||
tool_call.arguments.push_str(arguments);
|
||||
}
|
||||
}
|
||||
@@ -291,7 +296,11 @@ impl LLMProvider for OpenAIProvider {
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
return Err(anyhow::anyhow!("OpenAI API error {}: {}", status, error_text));
|
||||
return Err(anyhow::anyhow!(
|
||||
"OpenAI API error {}: {}",
|
||||
status,
|
||||
error_text
|
||||
));
|
||||
}
|
||||
|
||||
let openai_response: OpenAIResponse = response.json().await?;
|
||||
@@ -334,7 +343,10 @@ impl LLMProvider for OpenAIProvider {
|
||||
request.temperature,
|
||||
);
|
||||
|
||||
debug!("Sending streaming request to OpenAI API: model={}", self.model);
|
||||
debug!(
|
||||
"Sending streaming request to OpenAI API: model={}",
|
||||
self.model
|
||||
);
|
||||
|
||||
let response = self
|
||||
.client
|
||||
@@ -350,7 +362,11 @@ impl LLMProvider for OpenAIProvider {
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
return Err(anyhow::anyhow!("OpenAI API error {}: {}", status, error_text));
|
||||
return Err(anyhow::anyhow!(
|
||||
"OpenAI API error {}: {}",
|
||||
status,
|
||||
error_text
|
||||
));
|
||||
}
|
||||
|
||||
let stream = response.bytes_stream();
|
||||
|
||||
@@ -24,14 +24,20 @@ fn test_no_wrong_serialization_format() {
|
||||
println!("Ephemeral message JSON: {}", json);
|
||||
|
||||
// Should NOT contain the wrong format
|
||||
assert!(!json.contains("system.0.cache_control"),
|
||||
"JSON should not contain 'system.0.cache_control' path");
|
||||
assert!(!json.contains("cache_control.ephemeral"),
|
||||
"JSON should not contain 'cache_control.ephemeral' path");
|
||||
assert!(
|
||||
!json.contains("system.0.cache_control"),
|
||||
"JSON should not contain 'system.0.cache_control' path"
|
||||
);
|
||||
assert!(
|
||||
!json.contains("cache_control.ephemeral"),
|
||||
"JSON should not contain 'cache_control.ephemeral' path"
|
||||
);
|
||||
|
||||
// Should contain the correct format
|
||||
assert!(json.contains(r#""cache_control":{"type":"ephemeral"}"#),
|
||||
"JSON should contain correct cache_control format");
|
||||
assert!(
|
||||
json.contains(r#""cache_control":{"type":"ephemeral"}"#),
|
||||
"JSON should contain correct cache_control format"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -46,16 +52,24 @@ fn test_five_minute_no_wrong_format() {
|
||||
println!("5-minute message JSON: {}", json);
|
||||
|
||||
// Should NOT contain the wrong format
|
||||
assert!(!json.contains("system.0.cache_control"),
|
||||
"JSON should not contain 'system.0.cache_control' path");
|
||||
assert!(!json.contains("cache_control.ephemeral.ttl"),
|
||||
"JSON should not contain 'cache_control.ephemeral.ttl' path");
|
||||
assert!(
|
||||
!json.contains("system.0.cache_control"),
|
||||
"JSON should not contain 'system.0.cache_control' path"
|
||||
);
|
||||
assert!(
|
||||
!json.contains("cache_control.ephemeral.ttl"),
|
||||
"JSON should not contain 'cache_control.ephemeral.ttl' path"
|
||||
);
|
||||
|
||||
// Should contain the correct format with ttl as a direct field
|
||||
assert!(json.contains(r#""type":"ephemeral""#),
|
||||
"JSON should contain type field");
|
||||
assert!(json.contains(r#""ttl":"5m""#),
|
||||
"JSON should contain ttl field with value 5m");
|
||||
assert!(
|
||||
json.contains(r#""type":"ephemeral""#),
|
||||
"JSON should contain type field"
|
||||
);
|
||||
assert!(
|
||||
json.contains(r#""ttl":"5m""#),
|
||||
"JSON should contain ttl field with value 5m"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -70,16 +84,24 @@ fn test_one_hour_no_wrong_format() {
|
||||
println!("1-hour message JSON: {}", json);
|
||||
|
||||
// Should NOT contain the wrong format
|
||||
assert!(!json.contains("system.0.cache_control"),
|
||||
"JSON should not contain 'system.0.cache_control' path");
|
||||
assert!(!json.contains("cache_control.ephemeral.ttl"),
|
||||
"JSON should not contain 'cache_control.ephemeral.ttl' path");
|
||||
assert!(
|
||||
!json.contains("system.0.cache_control"),
|
||||
"JSON should not contain 'system.0.cache_control' path"
|
||||
);
|
||||
assert!(
|
||||
!json.contains("cache_control.ephemeral.ttl"),
|
||||
"JSON should not contain 'cache_control.ephemeral.ttl' path"
|
||||
);
|
||||
|
||||
// Should contain the correct format with ttl as a direct field
|
||||
assert!(json.contains(r#""type":"ephemeral""#),
|
||||
"JSON should contain type field");
|
||||
assert!(json.contains(r#""ttl":"1h""#),
|
||||
"JSON should contain ttl field with value 1h");
|
||||
assert!(
|
||||
json.contains(r#""type":"ephemeral""#),
|
||||
"JSON should contain type field"
|
||||
);
|
||||
assert!(
|
||||
json.contains(r#""ttl":"1h""#),
|
||||
"JSON should contain ttl field with value 1h"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -90,12 +112,19 @@ fn test_cache_control_structure_is_flat() {
|
||||
let cache_control = CacheControl::five_minute();
|
||||
let json_value = serde_json::to_value(&cache_control).unwrap();
|
||||
|
||||
println!("Cache control as JSON value: {}", serde_json::to_string_pretty(&json_value).unwrap());
|
||||
println!(
|
||||
"Cache control as JSON value: {}",
|
||||
serde_json::to_string_pretty(&json_value).unwrap()
|
||||
);
|
||||
|
||||
let obj = json_value.as_object().expect("Should be an object");
|
||||
|
||||
// Should have exactly 2 keys at the top level
|
||||
assert_eq!(obj.len(), 2, "Cache control should have exactly 2 top-level fields");
|
||||
assert_eq!(
|
||||
obj.len(),
|
||||
2,
|
||||
"Cache control should have exactly 2 top-level fields"
|
||||
);
|
||||
|
||||
// Both 'type' and 'ttl' should be at the same level
|
||||
assert!(obj.contains_key("type"), "Should have 'type' field");
|
||||
@@ -113,18 +142,28 @@ fn test_ephemeral_cache_control_structure() {
|
||||
let cache_control = CacheControl::ephemeral();
|
||||
let json_value = serde_json::to_value(&cache_control).unwrap();
|
||||
|
||||
println!("Ephemeral cache control as JSON value: {}", serde_json::to_string_pretty(&json_value).unwrap());
|
||||
println!(
|
||||
"Ephemeral cache control as JSON value: {}",
|
||||
serde_json::to_string_pretty(&json_value).unwrap()
|
||||
);
|
||||
|
||||
let obj = json_value.as_object().expect("Should be an object");
|
||||
|
||||
// Should have exactly 1 key (only 'type', no 'ttl')
|
||||
assert_eq!(obj.len(), 1, "Ephemeral cache control should have exactly 1 top-level field");
|
||||
assert_eq!(
|
||||
obj.len(),
|
||||
1,
|
||||
"Ephemeral cache control should have exactly 1 top-level field"
|
||||
);
|
||||
|
||||
// Should have 'type' field
|
||||
assert!(obj.contains_key("type"), "Should have 'type' field");
|
||||
|
||||
// Should NOT have 'ttl' field
|
||||
assert!(!obj.contains_key("ttl"), "Ephemeral should not have 'ttl' field");
|
||||
assert!(
|
||||
!obj.contains_key("ttl"),
|
||||
"Ephemeral should not have 'ttl' field"
|
||||
);
|
||||
|
||||
// 'type' should be a string with value "ephemeral"
|
||||
assert_eq!(obj["type"].as_str().unwrap(), "ephemeral");
|
||||
|
||||
@@ -11,11 +11,17 @@ fn test_ephemeral_cache_control_serialization() {
|
||||
let cache_control = CacheControl::ephemeral();
|
||||
let json = serde_json::to_value(&cache_control).unwrap();
|
||||
|
||||
println!("Ephemeral cache_control JSON: {}", serde_json::to_string(&json).unwrap());
|
||||
println!(
|
||||
"Ephemeral cache_control JSON: {}",
|
||||
serde_json::to_string(&json).unwrap()
|
||||
);
|
||||
|
||||
assert_eq!(json, json!({
|
||||
"type": "ephemeral"
|
||||
}));
|
||||
assert_eq!(
|
||||
json,
|
||||
json!({
|
||||
"type": "ephemeral"
|
||||
})
|
||||
);
|
||||
|
||||
// Verify no ttl field is present
|
||||
assert!(!json.as_object().unwrap().contains_key("ttl"));
|
||||
@@ -26,12 +32,18 @@ fn test_five_minute_cache_control_serialization() {
|
||||
let cache_control = CacheControl::five_minute();
|
||||
let json = serde_json::to_value(&cache_control).unwrap();
|
||||
|
||||
println!("5-minute cache_control JSON: {}", serde_json::to_string(&json).unwrap());
|
||||
println!(
|
||||
"5-minute cache_control JSON: {}",
|
||||
serde_json::to_string(&json).unwrap()
|
||||
);
|
||||
|
||||
assert_eq!(json, json!({
|
||||
"type": "ephemeral",
|
||||
"ttl": "5m"
|
||||
}));
|
||||
assert_eq!(
|
||||
json,
|
||||
json!({
|
||||
"type": "ephemeral",
|
||||
"ttl": "5m"
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -39,12 +51,18 @@ fn test_one_hour_cache_control_serialization() {
|
||||
let cache_control = CacheControl::one_hour();
|
||||
let json = serde_json::to_value(&cache_control).unwrap();
|
||||
|
||||
println!("1-hour cache_control JSON: {}", serde_json::to_string(&json).unwrap());
|
||||
println!(
|
||||
"1-hour cache_control JSON: {}",
|
||||
serde_json::to_string(&json).unwrap()
|
||||
);
|
||||
|
||||
assert_eq!(json, json!({
|
||||
"type": "ephemeral",
|
||||
"ttl": "1h"
|
||||
}));
|
||||
assert_eq!(
|
||||
json,
|
||||
json!({
|
||||
"type": "ephemeral",
|
||||
"ttl": "1h"
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -56,9 +74,14 @@ fn test_message_with_ephemeral_cache_control() {
|
||||
);
|
||||
|
||||
let json = serde_json::to_value(&msg).unwrap();
|
||||
println!("Message with ephemeral cache_control: {}", serde_json::to_string(&json).unwrap());
|
||||
println!(
|
||||
"Message with ephemeral cache_control: {}",
|
||||
serde_json::to_string(&json).unwrap()
|
||||
);
|
||||
|
||||
let cache_control = json.get("cache_control").expect("cache_control field should exist");
|
||||
let cache_control = json
|
||||
.get("cache_control")
|
||||
.expect("cache_control field should exist");
|
||||
assert_eq!(cache_control.get("type").unwrap(), "ephemeral");
|
||||
assert!(!cache_control.as_object().unwrap().contains_key("ttl"));
|
||||
}
|
||||
@@ -72,9 +95,14 @@ fn test_message_with_five_minute_cache_control() {
|
||||
);
|
||||
|
||||
let json = serde_json::to_value(&msg).unwrap();
|
||||
println!("Message with 5-minute cache_control: {}", serde_json::to_string(&json).unwrap());
|
||||
println!(
|
||||
"Message with 5-minute cache_control: {}",
|
||||
serde_json::to_string(&json).unwrap()
|
||||
);
|
||||
|
||||
let cache_control = json.get("cache_control").expect("cache_control field should exist");
|
||||
let cache_control = json
|
||||
.get("cache_control")
|
||||
.expect("cache_control field should exist");
|
||||
assert_eq!(cache_control.get("type").unwrap(), "ephemeral");
|
||||
assert_eq!(cache_control.get("ttl").unwrap(), "5m");
|
||||
}
|
||||
@@ -88,9 +116,14 @@ fn test_message_with_one_hour_cache_control() {
|
||||
);
|
||||
|
||||
let json = serde_json::to_value(&msg).unwrap();
|
||||
println!("Message with 1-hour cache_control: {}", serde_json::to_string(&json).unwrap());
|
||||
println!(
|
||||
"Message with 1-hour cache_control: {}",
|
||||
serde_json::to_string(&json).unwrap()
|
||||
);
|
||||
|
||||
let cache_control = json.get("cache_control").expect("cache_control field should exist");
|
||||
let cache_control = json
|
||||
.get("cache_control")
|
||||
.expect("cache_control field should exist");
|
||||
assert_eq!(cache_control.get("type").unwrap(), "ephemeral");
|
||||
assert_eq!(cache_control.get("ttl").unwrap(), "1h");
|
||||
}
|
||||
@@ -100,7 +133,10 @@ fn test_message_without_cache_control() {
|
||||
let msg = Message::new(MessageRole::User, "Hello".to_string());
|
||||
|
||||
let json = serde_json::to_value(&msg).unwrap();
|
||||
println!("Message without cache_control: {}", serde_json::to_string(&json).unwrap());
|
||||
println!(
|
||||
"Message without cache_control: {}",
|
||||
serde_json::to_string(&json).unwrap()
|
||||
);
|
||||
|
||||
// cache_control field should not be present when not set
|
||||
assert!(!json.as_object().unwrap().contains_key("cache_control"));
|
||||
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Hacky script for viewing context window
|
||||
|
||||
if [[ -n "$G3_WORKSPACE" ]]; then
|
||||
TARGET_DIR="$G3_WORKSPACE/logs"
|
||||
else
|
||||
TARGET_DIR="$HOME/tmp/workspace/logs"
|
||||
fi
|
||||
|
||||
if [[ ! -d "$TARGET_DIR" ]]; then
|
||||
echo "Error: Directory '$TARGET_DIR' does not exist."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$TARGET_DIR" || exit 1
|
||||
|
||||
NAME="$TARGET_DIR/current_context_window"
|
||||
|
||||
echo "Monitoring directory '$NAME' for current context window, (waits for first update)"
|
||||
|
||||
|
||||
L=$(stat -f %m $NAME); while sleep 0.5; do N=$(stat -f %m $NAME); if [ "$N" != "$L" ]; then clear; cat $NAME; L=$N; fi; done
|
||||
Reference in New Issue
Block a user