feat: async research tool - runs in background, returns immediately

The research tool now spawns the scout agent in a background tokio task
and returns immediately with a research_id placeholder. This allows the
agent to continue working while research runs (30-120 seconds).

Key changes:
- New PendingResearchManager for tracking async research tasks
- research tool returns immediately with placeholder containing research_id
- research_status tool to check progress of pending research
- Auto-injection of completed research at natural break points:
  - Start of each tool iteration (before LLM call)
  - Before prompting user in interactive mode
- /research CLI command to list all research tasks
- Updated system prompt to explain async behavior

The agent can:
- Continue with other work while research runs
- Check status with research_status tool
- Yield turn to user if results are critical before continuing
This commit is contained in:
Dhanji R. Prasanna
2026-01-30 13:00:02 +11:00
parent 2e21502357
commit 5ab1598e03
11 changed files with 797 additions and 81 deletions

View File

@@ -38,6 +38,7 @@ 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(" /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");
@@ -130,6 +131,42 @@ pub async fn handle_command<W: UiWriter>(
}
Ok(true)
}
"/research" => {
let manager = agent.get_pending_research_manager();
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 {
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_marker = if task.injected { " (injected)" } else { "" };
output.print(&format!(
" {} `{}` - {} ({}){}\n Query: {}",
status_emoji,
task.id,
task.status,
task.elapsed_display(),
injected_marker,
if task.query.len() > 60 {
format!("{}...", &task.query.chars().take(57).collect::<String>())
} else {
task.query.clone()
}
));
output.print("");
}
}
Ok(true)
}
cmd if cmd.starts_with("/run") => {
let parts: Vec<&str> = cmd.splitn(2, ' ').collect();
if parts.len() < 2 || parts[1].trim().is_empty() {