feat: Externalize research tool as embedded skill
Replaces the built-in research/research_status tools with a portable skill-based approach: - Add embedded skills infrastructure (skills compiled into binary) - Add repo-local skills/ directory support (highest priority) - Create research skill with SKILL.md and g3-research shell script - Script extraction to .g3/bin/ with version tracking - Filesystem-based handoff via .g3/research/<id>/status.json - Remove PendingResearchManager and all research tool code - Update system prompt to reference skill instead of tool Benefits: - No special tool infrastructure needed (just shell + read_file) - Context-efficient (reports stay on disk until needed) - Crash-resilient (state persisted to filesystem) - Portable (skill can be overridden per-workspace) Breaking change: research tool calls now return a deprecation message pointing to the research skill.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
//! Interactive command handlers for G3 CLI.
|
||||
//!
|
||||
//! Handles `/` commands in interactive mode (help, compact, research, etc.).
|
||||
//! Handles `/` commands in interactive mode (help, compact, etc.).
|
||||
|
||||
use anyhow::Result;
|
||||
use rustyline::Editor;
|
||||
@@ -25,33 +25,6 @@ pub enum CommandResult {
|
||||
EnterPlanMode,
|
||||
}
|
||||
|
||||
// --- Research command helpers ---
|
||||
|
||||
fn format_research_task_summary(task: &g3_core::pending_research::ResearchTask) -> String {
|
||||
let status_emoji = match task.status {
|
||||
g3_core::pending_research::ResearchStatus::Pending => "🔄",
|
||||
g3_core::pending_research::ResearchStatus::Complete => "✅",
|
||||
g3_core::pending_research::ResearchStatus::Failed => "❌",
|
||||
};
|
||||
let injected = if task.injected { " (injected)" } else { "" };
|
||||
let query_preview = if task.query.len() > 60 {
|
||||
format!("{}...", task.query.chars().take(57).collect::<String>())
|
||||
} else {
|
||||
task.query.clone()
|
||||
};
|
||||
format!(
|
||||
" {} `{}` - {} ({}){}\n Query: {}",
|
||||
status_emoji, task.id, task.status, task.elapsed_display(), injected, query_preview
|
||||
)
|
||||
}
|
||||
|
||||
fn format_research_report_header(task: &g3_core::pending_research::ResearchTask) -> String {
|
||||
format!(
|
||||
"📋 Research Report: `{}`\n\nQuery: {}\n\nStatus: {} | Elapsed: {}\n\n{}",
|
||||
task.id, task.query, task.status, task.elapsed_display(), "─".repeat(60)
|
||||
)
|
||||
}
|
||||
|
||||
/// Handle a control command. Returns true if the command was handled and the loop should continue.
|
||||
pub async fn handle_command<W: UiWriter>(
|
||||
input: &str,
|
||||
@@ -74,9 +47,6 @@ pub async fn handle_command<W: UiWriter>(
|
||||
output.print(" /fragments - List dehydrated context fragments (ACD)");
|
||||
output.print(" /rehydrate - Restore a dehydrated fragment by ID");
|
||||
output.print(" /resume - List and switch to a previous session");
|
||||
output.print(" /research - List pending/completed research tasks");
|
||||
output.print(" /research <id> - View a specific research report");
|
||||
output.print(" /research latest - View the most recent research report");
|
||||
output.print(" /project <path> - Load a project from the given absolute path");
|
||||
output.print(" /unproject - Unload the current project and reset context");
|
||||
output.print(" /dump - Dump entire context window to file for debugging");
|
||||
@@ -170,56 +140,6 @@ pub async fn handle_command<W: UiWriter>(
|
||||
}
|
||||
Ok(CommandResult::Handled)
|
||||
}
|
||||
cmd if cmd == "/research" || cmd.starts_with("/research ") => {
|
||||
let manager = agent.get_pending_research_manager();
|
||||
let arg = cmd.strip_prefix("/research").unwrap_or("").trim();
|
||||
|
||||
if arg.is_empty() {
|
||||
let all_tasks = manager.list_all();
|
||||
if all_tasks.is_empty() {
|
||||
output.print("📋 No research tasks (pending or completed).");
|
||||
} else {
|
||||
output.print(&format!("📋 Research Tasks ({} total):\n", all_tasks.len()));
|
||||
for task in all_tasks {
|
||||
output.print(&format_research_task_summary(&task));
|
||||
output.print("");
|
||||
}
|
||||
}
|
||||
} else if arg == "latest" {
|
||||
let all_tasks = manager.list_all();
|
||||
let latest = all_tasks.iter()
|
||||
.filter(|t| t.status != g3_core::pending_research::ResearchStatus::Pending)
|
||||
.min_by_key(|t| t.started_at.elapsed());
|
||||
|
||||
match latest {
|
||||
Some(task) => {
|
||||
output.print(&format_research_report_header(task));
|
||||
output.print(task.result.as_deref().unwrap_or("(No report content available)"));
|
||||
}
|
||||
None => {
|
||||
output.print("📋 No completed research tasks yet.");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match manager.get(&arg.to_string()) {
|
||||
Some(task) => {
|
||||
output.print(&format_research_report_header(&task));
|
||||
let content = if let Some(ref result) = task.result {
|
||||
result.as_str()
|
||||
} else if task.status == g3_core::pending_research::ResearchStatus::Pending {
|
||||
"(Research still in progress...)"
|
||||
} else {
|
||||
"(No report content available)"
|
||||
};
|
||||
output.print(content);
|
||||
}
|
||||
None => {
|
||||
output.print(&format!("❓ No research task found with id: `{}`", arg));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(CommandResult::Handled)
|
||||
}
|
||||
cmd if cmd.starts_with("/run") => {
|
||||
let parts: Vec<&str> = cmd.splitn(2, ' ').collect();
|
||||
if parts.len() < 2 || parts[1].trim().is_empty() {
|
||||
|
||||
@@ -6,10 +6,7 @@ use rustyline::error::ReadlineError;
|
||||
use rustyline::{Config, Editor};
|
||||
use crate::completion::G3Helper;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use tracing::{debug, error};
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use g3_core::ui_writer::UiWriter;
|
||||
use g3_core::Agent;
|
||||
@@ -134,56 +131,6 @@ async fn execute_user_input<W: UiWriter>(
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn a background task to handle research completion notifications.
|
||||
///
|
||||
/// This task listens for research completions and prints status messages in real-time.
|
||||
/// When g3 is idle (waiting for input), it reprints the prompt after the notification.
|
||||
/// When g3 is busy (processing), it just prints the notification (interleaving is fine).
|
||||
///
|
||||
/// Returns a handle to the spawned task and an `is_busy` flag that should be set
|
||||
/// to true while the agent is processing and false when waiting for input.
|
||||
fn spawn_research_notification_handler(
|
||||
mut rx: broadcast::Receiver<g3_core::ResearchCompletionNotification>,
|
||||
is_busy: Arc<AtomicBool>,
|
||||
prompt: Arc<std::sync::RwLock<String>>,
|
||||
) -> tokio::task::JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(notification) => {
|
||||
use std::io::Write;
|
||||
|
||||
let succeeded = notification.status == g3_core::ResearchStatus::Complete;
|
||||
|
||||
// Print the completion notification
|
||||
// If we're idle (at prompt), we need to print on a new line first
|
||||
let busy = is_busy.load(Ordering::SeqCst);
|
||||
if !busy {
|
||||
// Clear the current line (prompt) and move to start
|
||||
print!("\r\x1b[K");
|
||||
}
|
||||
|
||||
G3Status::research_complete(1, succeeded);
|
||||
|
||||
// If we're idle, reprint the prompt
|
||||
if !busy {
|
||||
let prompt_str = prompt.read().unwrap().clone();
|
||||
print!("{}", prompt_str);
|
||||
let _ = std::io::stdout().flush();
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => {
|
||||
// Channel closed, exit the task
|
||||
break;
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(_)) => {
|
||||
// Missed some messages, continue
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Run interactive mode with console output.
|
||||
/// If `agent_name` is Some, we're in agent+chat mode: skip session resume/verbose welcome,
|
||||
@@ -264,16 +211,6 @@ pub async fn run_interactive<W: UiWriter>(
|
||||
let _ = rl.load_history(history_path);
|
||||
}
|
||||
|
||||
// Enable research completion notifications for real-time updates
|
||||
let research_rx = agent.enable_research_notifications();
|
||||
let is_busy = Arc::new(AtomicBool::new(false));
|
||||
let current_prompt = Arc::new(std::sync::RwLock::new(String::new()));
|
||||
let _notification_handle = spawn_research_notification_handler(
|
||||
research_rx,
|
||||
is_busy.clone(),
|
||||
current_prompt.clone(),
|
||||
);
|
||||
|
||||
// Track multiline input
|
||||
let mut multiline_buffer = String::new();
|
||||
let mut in_multiline = false;
|
||||
@@ -299,20 +236,8 @@ pub async fn run_interactive<W: UiWriter>(
|
||||
// Display context window progress bar before each prompt
|
||||
display_context_progress(&agent, &output);
|
||||
|
||||
// Check for completed research and inject into context
|
||||
// This happens before prompting the user for input
|
||||
let injected_count = agent.inject_completed_research();
|
||||
if injected_count > 0 {
|
||||
println!("📋 {} research result(s) ready - injected into context", injected_count);
|
||||
println!();
|
||||
}
|
||||
|
||||
// Build prompt
|
||||
let prompt = build_prompt(in_multiline, in_plan_mode, agent_name, &active_project);
|
||||
|
||||
// Update the shared prompt for the notification handler
|
||||
*current_prompt.write().unwrap() = prompt.clone();
|
||||
is_busy.store(false, Ordering::SeqCst);
|
||||
|
||||
let readline = rl.readline(&prompt);
|
||||
match readline {
|
||||
@@ -350,11 +275,9 @@ pub async fn run_interactive<W: UiWriter>(
|
||||
// Reprint input with formatting
|
||||
reprint_formatted_input(&input, &prompt);
|
||||
|
||||
is_busy.store(true, Ordering::SeqCst);
|
||||
execute_user_input(
|
||||
&mut agent, &input, show_prompt, show_code, &output, from_agent_mode
|
||||
).await;
|
||||
is_busy.store(false, Ordering::SeqCst);
|
||||
} else {
|
||||
// Single line input
|
||||
let input = line.trim().to_string();
|
||||
@@ -367,9 +290,7 @@ pub async fn run_interactive<W: UiWriter>(
|
||||
// Reprint input with formatting
|
||||
reprint_formatted_input(&input, &prompt);
|
||||
|
||||
is_busy.store(true, Ordering::SeqCst);
|
||||
let (approved, result) = execute_plan_approve_directly(&mut agent, &output).await;
|
||||
is_busy.store(false, Ordering::SeqCst);
|
||||
|
||||
if approved {
|
||||
// Exit plan mode on successful approval
|
||||
@@ -398,9 +319,7 @@ pub async fn run_interactive<W: UiWriter>(
|
||||
|
||||
// Check for control commands
|
||||
if input.starts_with('/') {
|
||||
is_busy.store(true, Ordering::SeqCst);
|
||||
let result = handle_command(&input, &mut agent, workspace_path, &output, &mut active_project, &mut rl, show_prompt, show_code).await?;
|
||||
is_busy.store(false, Ordering::SeqCst);
|
||||
|
||||
match result {
|
||||
CommandResult::Handled => {
|
||||
@@ -417,11 +336,9 @@ pub async fn run_interactive<W: UiWriter>(
|
||||
// Reprint input with formatting
|
||||
reprint_formatted_input(&input, &prompt);
|
||||
|
||||
is_busy.store(true, Ordering::SeqCst);
|
||||
execute_user_input(
|
||||
&mut agent, &input, show_prompt, show_code, &output, from_agent_mode
|
||||
).await;
|
||||
is_busy.store(false, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
Err(ReadlineError::Interrupted) => {
|
||||
|
||||
@@ -820,13 +820,6 @@ impl UiWriter for ConsoleUiWriter {
|
||||
}
|
||||
}
|
||||
|
||||
// Add blank line before footer for research tool (its output is a full report)
|
||||
if let Some(tool_name) = self.current_tool_name.lock().unwrap().as_ref() {
|
||||
if tool_name == "research" {
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we're in shell compact mode - append timing to the output line
|
||||
let is_shell = *self.is_shell_compact.lock().unwrap();
|
||||
if is_shell {
|
||||
|
||||
Reference in New Issue
Block a user