mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
feat(tui/auto-drive): route user prompts through coordinator
This commit is contained in:
@@ -747,6 +747,10 @@ pub struct AutoDriveSettings {
|
||||
#[serde(default = "default_true")]
|
||||
pub observer_enabled: bool,
|
||||
|
||||
/// Enable coordinator routing of user prompts during Auto Drive turns.
|
||||
#[serde(default = "default_true")]
|
||||
pub coordinator_routing: bool,
|
||||
|
||||
#[serde(default)]
|
||||
pub continue_mode: AutoDriveContinueMode,
|
||||
}
|
||||
@@ -759,6 +763,7 @@ impl Default for AutoDriveSettings {
|
||||
qa_automation_enabled: true,
|
||||
cross_check_enabled: true,
|
||||
observer_enabled: true,
|
||||
coordinator_routing: true,
|
||||
continue_mode: AutoDriveContinueMode::TenSeconds,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1835,6 +1835,14 @@ impl App<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
AppEvent::AutoCoordinatorUserReply {
|
||||
user_response,
|
||||
cli_command,
|
||||
} => {
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
widget.auto_handle_user_reply(user_response, cli_command);
|
||||
}
|
||||
}
|
||||
AppEvent::AutoCoordinatorThinking { delta, summary_index } => {
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
widget.auto_handle_thinking(delta, summary_index);
|
||||
|
||||
@@ -258,6 +258,10 @@ pub(crate) enum AppEvent {
|
||||
agents: Vec<AutoTurnAgentsAction>,
|
||||
transcript: Vec<ResponseItem>,
|
||||
},
|
||||
AutoCoordinatorUserReply {
|
||||
user_response: Option<String>,
|
||||
cli_command: Option<String>,
|
||||
},
|
||||
AutoCoordinatorThinking {
|
||||
delta: String,
|
||||
summary_index: Option<u32>,
|
||||
|
||||
@@ -58,6 +58,8 @@ use code_protocol::num_format::format_with_separators;
|
||||
pub(crate) mod auto_coordinator;
|
||||
mod auto_drive_history;
|
||||
mod auto_observer;
|
||||
mod coordinator_router;
|
||||
mod coordinator_user_schema;
|
||||
mod qa_orchestrator;
|
||||
#[cfg(feature = "dev-faults")]
|
||||
mod faults;
|
||||
@@ -95,6 +97,7 @@ use self::agent_install::{
|
||||
wrap_command,
|
||||
};
|
||||
use self::auto_drive_history::AutoDriveHistory;
|
||||
use self::coordinator_router::{CoordinatorContext, CoordinatorRouterResponse};
|
||||
use self::auto_coordinator::{
|
||||
start_auto_coordinator,
|
||||
AutoCoordinatorCommand,
|
||||
@@ -7452,6 +7455,45 @@ impl ChatWidget<'_> {
|
||||
true
|
||||
}
|
||||
|
||||
fn try_coordinator_route(
|
||||
&mut self,
|
||||
original_text: &str,
|
||||
) -> Option<CoordinatorRouterResponse> {
|
||||
let trimmed = original_text.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if !self.auto_state.active {
|
||||
return None;
|
||||
}
|
||||
if !self.config.tui.auto_drive.coordinator_routing {
|
||||
return None;
|
||||
}
|
||||
if trimmed.starts_with('/') {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut updates = Vec::new();
|
||||
if let Some(summary) = self.auto_state.last_decision_summary.clone() {
|
||||
if !summary.trim().is_empty() {
|
||||
updates.push(summary);
|
||||
}
|
||||
}
|
||||
if let Some(current) = self.auto_state.current_summary.clone() {
|
||||
if !current.trim().is_empty() && updates.iter().all(|existing| existing != ¤t) {
|
||||
updates.push(current);
|
||||
}
|
||||
}
|
||||
|
||||
let context = CoordinatorContext::new(self.auto_state.pending_agent_actions.len(), updates);
|
||||
let response = coordinator_router::route_user_message(trimmed, &context);
|
||||
if response.user_response.is_some() || response.cli_command.is_some() {
|
||||
Some(response)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn submit_user_message(&mut self, user_message: UserMessage) {
|
||||
if self.layout.scroll_offset > 0 {
|
||||
layout_scroll::to_bottom(self);
|
||||
@@ -7503,6 +7545,53 @@ impl ChatWidget<'_> {
|
||||
return;
|
||||
}
|
||||
let original_text = message.display_text.clone();
|
||||
|
||||
let mut submitted_cli = false;
|
||||
if !message.suppress_persistence
|
||||
&& !original_text.trim().starts_with('/')
|
||||
&& self.auto_state.active
|
||||
&& self.config.tui.auto_drive.coordinator_routing
|
||||
{
|
||||
let mut conversation = self.current_auto_history();
|
||||
if let Some(user_item) = Self::auto_drive_make_user_message(original_text.clone()) {
|
||||
conversation.push(user_item.clone());
|
||||
if self.auto_send_user_prompt_to_coordinator(original_text.clone(), conversation) {
|
||||
self.finalize_sent_user_message(message);
|
||||
self.consume_pending_prompt_for_ui_only_turn();
|
||||
self.auto_history.append_raw(std::slice::from_ref(&user_item));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !message.suppress_persistence {
|
||||
if let Some(mut routed) = self.try_coordinator_route(&original_text) {
|
||||
self.finalize_sent_user_message(message);
|
||||
self.consume_pending_prompt_for_ui_only_turn();
|
||||
let _ = self.rebuild_auto_history();
|
||||
|
||||
if let Some(notice_text) = routed.user_response.take() {
|
||||
let mut lines = Vec::with_capacity(2);
|
||||
lines.push("AUTO DRIVE RESPONSE".to_string());
|
||||
lines.push(notice_text);
|
||||
self.history_push_plain_paragraphs(PlainMessageKind::Notice, lines);
|
||||
}
|
||||
|
||||
if let Some(cli_command) = routed.cli_command {
|
||||
let mut synthetic: UserMessage = cli_command.into();
|
||||
synthetic.suppress_persistence = true;
|
||||
self.submit_user_message(synthetic);
|
||||
submitted_cli = true;
|
||||
}
|
||||
|
||||
if !submitted_cli {
|
||||
self.auto_send_conversation_force();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let only_text_items = message
|
||||
.ordered_items
|
||||
.iter()
|
||||
@@ -13193,6 +13282,71 @@ fi\n\
|
||||
}
|
||||
}
|
||||
|
||||
fn auto_send_conversation_force(&mut self) {
|
||||
if !self.auto_state.active {
|
||||
return;
|
||||
}
|
||||
let conversation = self.current_auto_history();
|
||||
self.observer_send_delta(&conversation);
|
||||
let Some(handle) = self.auto_handle.as_ref() else {
|
||||
return;
|
||||
};
|
||||
if handle
|
||||
.send(AutoCoordinatorCommand::UpdateConversation(conversation))
|
||||
.is_err()
|
||||
{
|
||||
self.auto_stop(Some("Coordinator stopped unexpectedly.".to_string()));
|
||||
} else {
|
||||
self.auto_state.waiting_for_response = true;
|
||||
self.auto_state.coordinator_waiting = true;
|
||||
self.auto_state.current_summary = None;
|
||||
self.auto_state.current_progress_past = None;
|
||||
self.auto_state.current_progress_current = None;
|
||||
self.auto_state.current_cli_prompt = None;
|
||||
self.auto_state.current_cli_context = None;
|
||||
self.auto_state.last_broadcast_summary = None;
|
||||
self.auto_state.current_summary_index = None;
|
||||
self.auto_state.current_display_line = None;
|
||||
self.auto_state.current_display_is_summary = false;
|
||||
self.auto_state.current_reasoning_title = None;
|
||||
self.auto_state.placeholder_phrase =
|
||||
Some(auto_drive_strings::next_auto_drive_phrase().to_string());
|
||||
self.auto_state.thinking_prefix_stripped = false;
|
||||
self.auto_rebuild_live_ring();
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
fn auto_send_user_prompt_to_coordinator(
|
||||
&mut self,
|
||||
prompt: String,
|
||||
conversation: Vec<ResponseItem>,
|
||||
) -> bool {
|
||||
let Some(handle) = self.auto_handle.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
let command = AutoCoordinatorCommand::HandleUserPrompt {
|
||||
_prompt: prompt,
|
||||
conversation,
|
||||
};
|
||||
match handle.send(command) {
|
||||
Ok(()) => {
|
||||
self.auto_state.waiting_for_response = true;
|
||||
self.auto_state.coordinator_waiting = true;
|
||||
self.auto_state.awaiting_submission = false;
|
||||
self.auto_state.placeholder_phrase =
|
||||
Some(auto_drive_strings::next_auto_drive_phrase().to_string());
|
||||
self.auto_rebuild_live_ring();
|
||||
self.request_redraw();
|
||||
true
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!("failed to dispatch user prompt to coordinator: {err}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn observer_send_delta(&mut self, conversation: &[ResponseItem]) -> usize {
|
||||
let current_len = conversation.len();
|
||||
let last = self.observer_history.last_sent_index.min(current_len);
|
||||
@@ -13477,6 +13631,41 @@ fi\n\
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn auto_handle_user_reply(
|
||||
&mut self,
|
||||
user_response: Option<String>,
|
||||
cli_command: Option<String>,
|
||||
) {
|
||||
if let Some(text) = user_response.clone() {
|
||||
if let Some(item) = Self::auto_drive_make_assistant_message(text.clone()) {
|
||||
self.auto_history
|
||||
.append_raw(std::slice::from_ref(&item));
|
||||
}
|
||||
let mut lines = Vec::with_capacity(2);
|
||||
lines.push("AUTO DRIVE RESPONSE".to_string());
|
||||
lines.push(text);
|
||||
self.history_push_plain_paragraphs(PlainMessageKind::Notice, lines);
|
||||
}
|
||||
|
||||
if let Some(command) = cli_command {
|
||||
if command.trim_start().starts_with('/') {
|
||||
self.app_event_tx
|
||||
.send(AppEvent::DispatchCommand(SlashCommand::Auto, command));
|
||||
} else {
|
||||
let mut message: UserMessage = command.into();
|
||||
message.suppress_persistence = true;
|
||||
self.submit_user_message(message);
|
||||
}
|
||||
} else {
|
||||
self.auto_state.waiting_for_response = false;
|
||||
self.auto_state.coordinator_waiting = false;
|
||||
self.auto_state.placeholder_phrase = None;
|
||||
}
|
||||
|
||||
self.auto_rebuild_live_ring();
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
fn schedule_auto_cli_prompt(&mut self, prompt_text: String) {
|
||||
self.auto_state.current_cli_prompt = Some(prompt_text);
|
||||
self.auto_state.awaiting_submission = true;
|
||||
@@ -14309,7 +14498,7 @@ fi\n\
|
||||
}
|
||||
self.bottom_pane.update_status_text(String::new());
|
||||
self.bottom_pane.set_task_running(false);
|
||||
use crate::chatwidget::message::UserMessage;
|
||||
use crate::chatwidget::message::UserMessage;
|
||||
let mut message: UserMessage = full_prompt.into();
|
||||
message.suppress_persistence = true;
|
||||
self.submit_user_message(message);
|
||||
@@ -14432,10 +14621,7 @@ fi\n\
|
||||
if !self.auto_state.subagents_enabled {
|
||||
return requested_write.unwrap_or(false);
|
||||
}
|
||||
match requested_write {
|
||||
Some(flag) => flag,
|
||||
None => true,
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn auto_stop(&mut self, message: Option<String>) {
|
||||
@@ -21973,6 +22159,7 @@ mod tests {
|
||||
CAPTURE_AUTO_TURN_COMMIT_STUB,
|
||||
GIT_DIFF_NAME_ONLY_BETWEEN_STUB,
|
||||
};
|
||||
use crate::chatwidget::message::UserMessage;
|
||||
use crate::chatwidget::smoke_helpers::ChatWidgetHarness;
|
||||
use crate::history_cell::{self, ExploreAggregationCell, HistoryCellType};
|
||||
use crate::chatwidget::auto_coordinator::{
|
||||
@@ -22541,6 +22728,7 @@ mod tests {
|
||||
|
||||
chat.auto_state.active = true;
|
||||
chat.auto_state.review_enabled = true;
|
||||
chat.config.sandbox_policy = SandboxPolicy::DangerFullAccess;
|
||||
|
||||
chat.auto_handle_decision(
|
||||
AutoCoordinatorStatus::Continue,
|
||||
@@ -22589,6 +22777,112 @@ mod tests {
|
||||
assert!(write_notice_present);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coordinator_router_emits_notice_for_status_question() {
|
||||
let mut harness = ChatWidgetHarness::new();
|
||||
{
|
||||
let chat = harness.chat();
|
||||
chat.auto_state.active = true;
|
||||
chat.config.tui.auto_drive.coordinator_routing = true;
|
||||
chat.config.sandbox_policy = SandboxPolicy::DangerFullAccess;
|
||||
}
|
||||
|
||||
let baseline_notice_count = {
|
||||
let chat = harness.chat();
|
||||
chat.history_cells
|
||||
.iter()
|
||||
.filter(|cell| matches!(cell.kind(), HistoryCellType::Notice))
|
||||
.count()
|
||||
};
|
||||
|
||||
{
|
||||
let chat = harness.chat();
|
||||
chat.auto_handle_user_reply(
|
||||
Some("Two active agents reporting steady progress.".to_string()),
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
let notice_count = {
|
||||
let chat = harness.chat();
|
||||
chat.history_cells
|
||||
.iter()
|
||||
.filter(|cell| matches!(cell.kind(), HistoryCellType::Notice))
|
||||
.count()
|
||||
};
|
||||
assert!(notice_count > baseline_notice_count);
|
||||
|
||||
let header_span = {
|
||||
let chat = harness.chat();
|
||||
let notice_cell = chat
|
||||
.history_cells
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|cell| matches!(cell.kind(), HistoryCellType::Notice))
|
||||
.expect("notice cell");
|
||||
let lines = notice_cell.display_lines_trimmed();
|
||||
assert!(!lines.is_empty());
|
||||
lines
|
||||
.first()
|
||||
.and_then(|line| line.spans.first())
|
||||
.map(|span| span.content.to_string())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
assert_eq!(header_span, "AUTO DRIVE RESPONSE");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coordinator_router_injects_cli_for_plan_requests() {
|
||||
let mut harness = ChatWidgetHarness::new();
|
||||
{
|
||||
let chat = harness.chat();
|
||||
chat.auto_state.active = true;
|
||||
chat.config.tui.auto_drive.coordinator_routing = true;
|
||||
chat.config.sandbox_policy = SandboxPolicy::DangerFullAccess;
|
||||
}
|
||||
|
||||
harness.drain_events();
|
||||
|
||||
{
|
||||
let chat = harness.chat();
|
||||
chat.auto_handle_user_reply(None, Some("/plan".to_string()));
|
||||
}
|
||||
|
||||
let events = harness.drain_events();
|
||||
let (command, payload) = events
|
||||
.iter()
|
||||
.find_map(|event| match event {
|
||||
AppEvent::DispatchCommand(cmd, payload) => Some((cmd, payload.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.expect("dispatch for /plan");
|
||||
assert_eq!(*command, SlashCommand::Auto);
|
||||
assert!(payload.contains("/plan"), "payload={payload}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coordinator_router_bypasses_slash_commands() {
|
||||
let mut harness = ChatWidgetHarness::new();
|
||||
{
|
||||
let chat = harness.chat();
|
||||
chat.auto_state.active = true;
|
||||
chat.config.tui.auto_drive.coordinator_routing = true;
|
||||
}
|
||||
|
||||
harness.drain_events();
|
||||
{
|
||||
let chat = harness.chat();
|
||||
chat.submit_user_message(UserMessage::from("/status".to_string()));
|
||||
}
|
||||
|
||||
let events = harness.drain_events();
|
||||
assert!(
|
||||
events.iter().any(|event| matches!(event, AppEvent::DispatchCommand(_, _))
|
||||
|| matches!(event, AppEvent::CodexOp(_))),
|
||||
"slash command should follow existing dispatch path"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observer_history_filters_noise_cells() {
|
||||
use crate::history::state::ExecStatus;
|
||||
@@ -22664,7 +22958,7 @@ mod tests {
|
||||
.history_cells
|
||||
.iter()
|
||||
.flat_map(|cell| cell.display_lines_trimmed())
|
||||
.flat_map(|line| line.spans.iter())
|
||||
.flat_map(|line| line.spans.clone())
|
||||
.filter(|span| span.content.contains("Observer guidance: Tighten up the summary"))
|
||||
.count();
|
||||
assert_eq!(guidance_hits, 1);
|
||||
@@ -22757,6 +23051,9 @@ mod tests {
|
||||
chat.auto_on_assistant_final();
|
||||
assert!(chat.auto_state.waiting_for_review);
|
||||
|
||||
let descriptor_snapshot = chat.pending_turn_descriptor.clone();
|
||||
chat.auto_handle_post_turn_review(turn_config.clone(), descriptor_snapshot.as_ref());
|
||||
|
||||
chat.handle_code_event(Event {
|
||||
id: "turn".to_string(),
|
||||
event_seq: 42,
|
||||
|
||||
@@ -20,6 +20,7 @@ use futures::StreamExt;
|
||||
use reqwest::StatusCode;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{self, json, Value};
|
||||
use super::coordinator_user_schema::{user_turn_schema, parse_user_turn_reply};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{debug, warn};
|
||||
use uuid::Uuid;
|
||||
@@ -63,7 +64,7 @@ const MAX_DECISION_RECOVERY_ATTEMPTS: u32 = 3;
|
||||
struct AutoCoordinatorCancelled;
|
||||
|
||||
pub(super) const MODEL_SLUG: &str = "gpt-5";
|
||||
const SCHEMA_NAME: &str = "auto_coordinator_flow";
|
||||
const USER_TURN_SCHEMA_NAME: &str = "auto_coordinator_user_turn";
|
||||
pub(super) const CROSS_CHECK_RESTART_BANNER: &str = "Cross-check restart with minimal context";
|
||||
const COORDINATOR_PROMPT_PATH: &str = "code-rs/core/prompt_coordinator.md";
|
||||
|
||||
@@ -90,6 +91,10 @@ impl AutoCoordinatorHandle {
|
||||
#[derive(Debug)]
|
||||
pub(super) enum AutoCoordinatorCommand {
|
||||
UpdateConversation(Vec<ResponseItem>),
|
||||
HandleUserPrompt {
|
||||
_prompt: String,
|
||||
conversation: Vec<ResponseItem>,
|
||||
},
|
||||
ObserverResult(ObserverOutcome),
|
||||
ObserverThinking {
|
||||
mode: ObserverMode,
|
||||
@@ -1020,6 +1025,40 @@ fn run_auto_loop(
|
||||
}
|
||||
|
||||
match cmd_rx.recv() {
|
||||
Ok(AutoCoordinatorCommand::HandleUserPrompt { _prompt, conversation }) => {
|
||||
let developer_intro = compose_developer_intro(&base_developer_intro, &observer_guidance);
|
||||
let mut updated_conversation = conversation.clone();
|
||||
let schema = user_turn_schema();
|
||||
match request_user_turn_decision(
|
||||
&runtime,
|
||||
client.as_ref(),
|
||||
developer_intro.as_str(),
|
||||
&primary_goal_message,
|
||||
&schema,
|
||||
updated_conversation.clone(),
|
||||
auto_instructions.as_deref(),
|
||||
&app_event_tx,
|
||||
&cancel_token,
|
||||
) {
|
||||
Ok((user_response, cli_command)) => {
|
||||
if let Some(response_text) = user_response.clone() {
|
||||
updated_conversation.push(make_message("assistant", response_text.clone()));
|
||||
}
|
||||
pending_conversation = Some(updated_conversation);
|
||||
app_event_tx.send(AppEvent::AutoCoordinatorUserReply {
|
||||
user_response,
|
||||
cli_command,
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!("failed to handle coordinator user prompt: {err:#}");
|
||||
app_event_tx.send(AppEvent::AutoCoordinatorUserReply {
|
||||
user_response: Some(format!("Coordinator error: {err}")),
|
||||
cli_command: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(AutoCoordinatorCommand::UpdateConversation(conv)) => {
|
||||
requests_completed = requests_completed.saturating_add(1);
|
||||
consecutive_decision_failures = 0;
|
||||
@@ -1598,6 +1637,32 @@ fn request_decision(
|
||||
}
|
||||
}
|
||||
|
||||
fn request_user_turn_decision(
|
||||
runtime: &tokio::runtime::Runtime,
|
||||
client: &ModelClient,
|
||||
developer_intro: &str,
|
||||
primary_goal: &str,
|
||||
schema: &Value,
|
||||
conversation: Vec<ResponseItem>,
|
||||
auto_instructions: Option<&str>,
|
||||
app_event_tx: &AppEventSender,
|
||||
cancel_token: &CancellationToken,
|
||||
) -> Result<(Option<String>, Option<String>)> {
|
||||
let (raw, _response_items) = request_decision(
|
||||
runtime,
|
||||
client,
|
||||
developer_intro,
|
||||
primary_goal,
|
||||
schema,
|
||||
&conversation,
|
||||
auto_instructions,
|
||||
app_event_tx,
|
||||
cancel_token,
|
||||
)?;
|
||||
let (user_response, cli_command) = parse_user_turn_reply(&raw)?;
|
||||
Ok((user_response, cli_command))
|
||||
}
|
||||
|
||||
fn request_decision_with_model(
|
||||
runtime: &tokio::runtime::Runtime,
|
||||
client: &ModelClient,
|
||||
@@ -1624,7 +1689,7 @@ fn request_decision_with_model(
|
||||
retry_with_backoff(
|
||||
|| {
|
||||
let instructions = auto_instructions.clone();
|
||||
let prompt = build_prompt_for_model(
|
||||
let prompt = build_user_turn_prompt(
|
||||
&developer_intro,
|
||||
&primary_goal,
|
||||
&schema,
|
||||
@@ -1643,16 +1708,20 @@ fn request_decision_with_model(
|
||||
let mut out = String::new();
|
||||
let mut response_items: Vec<ResponseItem> = Vec::new();
|
||||
let mut reasoning_delta_accumulator = String::new();
|
||||
let mut saw_output_text_delta = false;
|
||||
while let Some(ev) = stream.next().await {
|
||||
match ev {
|
||||
Ok(ResponseEvent::OutputTextDelta { delta, .. }) => {
|
||||
out.push_str(&delta);
|
||||
saw_output_text_delta = true;
|
||||
}
|
||||
Ok(ResponseEvent::OutputItemDone { item, .. }) => {
|
||||
if let ResponseItem::Message { content, .. } = &item {
|
||||
for c in content {
|
||||
if let ContentItem::OutputText { text } = c {
|
||||
out.push_str(text);
|
||||
if !saw_output_text_delta {
|
||||
for c in content {
|
||||
if let ContentItem::OutputText { text } = c {
|
||||
out.push_str(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1660,6 +1729,7 @@ fn request_decision_with_model(
|
||||
reasoning_delta_accumulator.clear();
|
||||
}
|
||||
response_items.push(item);
|
||||
saw_output_text_delta = false;
|
||||
}
|
||||
Ok(ResponseEvent::ReasoningSummaryDelta {
|
||||
delta,
|
||||
@@ -1764,11 +1834,11 @@ fn request_decision_with_model(
|
||||
}
|
||||
}
|
||||
|
||||
fn build_prompt_for_model(
|
||||
fn build_user_turn_prompt(
|
||||
developer_intro: &str,
|
||||
primary_goal: &str,
|
||||
schema: &Value,
|
||||
conversation: &[ResponseItem],
|
||||
conversation: &Vec<ResponseItem>,
|
||||
model_slug: &str,
|
||||
auto_instructions: Option<&str>,
|
||||
) -> Prompt {
|
||||
@@ -1791,7 +1861,7 @@ fn build_prompt_for_model(
|
||||
prompt.input.extend(conversation.iter().cloned());
|
||||
prompt.text_format = Some(TextFormat {
|
||||
r#type: "json_schema".to_string(),
|
||||
name: Some(SCHEMA_NAME.to_string()),
|
||||
name: Some(USER_TURN_SCHEMA_NAME.to_string()),
|
||||
strict: Some(true),
|
||||
schema: Some(schema.clone()),
|
||||
});
|
||||
@@ -2125,7 +2195,10 @@ fn push_unique_guidance(guidance: &mut Vec<String>, message: &str) {
|
||||
if trimmed.is_empty() {
|
||||
return;
|
||||
}
|
||||
if guidance.iter().any(|existing| existing == trimmed) {
|
||||
if guidance
|
||||
.iter()
|
||||
.any(|existing| existing.eq_ignore_ascii_case(trimmed))
|
||||
{
|
||||
return;
|
||||
}
|
||||
guidance.push(trimmed.to_string());
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
//! Lightweight, keyword-based router for Auto Drive user prompts.
|
||||
//!
|
||||
//! This module offers a tiny heuristic bridge so the TUI can hand user
|
||||
//! questions to the Auto Drive coordinator before they are sent to the CLI.
|
||||
//! The real coordinator integration can later replace these heuristics
|
||||
//! without touching call sites.
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct CoordinatorRouterResponse {
|
||||
pub user_response: Option<String>,
|
||||
pub cli_command: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct CoordinatorContext {
|
||||
pub active_agents: usize,
|
||||
pub recent_updates: Vec<String>,
|
||||
}
|
||||
|
||||
impl CoordinatorContext {
|
||||
pub fn new(active_agents: usize, recent_updates: Vec<String>) -> Self {
|
||||
Self {
|
||||
active_agents,
|
||||
recent_updates,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn latest_update(&self) -> Option<&str> {
|
||||
self.recent_updates.last().map(String::as_str)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn route_user_message(msg: &str, ctx: &CoordinatorContext) -> CoordinatorRouterResponse {
|
||||
let normalized = msg.trim().to_ascii_lowercase();
|
||||
|
||||
if normalized.is_empty() {
|
||||
return CoordinatorRouterResponse::default();
|
||||
}
|
||||
|
||||
if contains_any(&normalized, &STATUS_PHRASES) {
|
||||
return status_response(ctx);
|
||||
}
|
||||
|
||||
if contains_any(&normalized, &PLAN_PHRASES) {
|
||||
return plan_response();
|
||||
}
|
||||
|
||||
if contains_any(&normalized, &STOP_PHRASES) {
|
||||
return stop_response();
|
||||
}
|
||||
|
||||
CoordinatorRouterResponse::default()
|
||||
}
|
||||
|
||||
const STATUS_PHRASES: [&str; 5] = [
|
||||
"what work has been done",
|
||||
"what have you done",
|
||||
"status update",
|
||||
"progress update",
|
||||
"current status",
|
||||
];
|
||||
|
||||
const PLAN_PHRASES: [&str; 4] = [
|
||||
"start more agents",
|
||||
"spin up more agents",
|
||||
"launch more agents",
|
||||
"create a plan",
|
||||
];
|
||||
|
||||
const STOP_PHRASES: [&str; 3] = [
|
||||
"stop all agents",
|
||||
"halt agents",
|
||||
"cancel the plan",
|
||||
];
|
||||
|
||||
fn contains_any(message: &str, phrases: &[&str]) -> bool {
|
||||
phrases.iter().any(|phrase| message.contains(phrase))
|
||||
}
|
||||
|
||||
fn status_response(ctx: &CoordinatorContext) -> CoordinatorRouterResponse {
|
||||
let mut summary = format!(
|
||||
"We currently have {} active agent{}",
|
||||
ctx.active_agents,
|
||||
if ctx.active_agents == 1 { "" } else { "s" }
|
||||
);
|
||||
|
||||
if let Some(update) = ctx.latest_update() {
|
||||
summary.push_str("; most recently: ");
|
||||
summary.push_str(update);
|
||||
} else {
|
||||
summary.push('.');
|
||||
}
|
||||
|
||||
CoordinatorRouterResponse {
|
||||
user_response: Some(summary),
|
||||
cli_command: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn plan_response() -> CoordinatorRouterResponse {
|
||||
CoordinatorRouterResponse {
|
||||
user_response: Some("Starting a fresh plan via the planner.".to_string()),
|
||||
cli_command: Some("/plan".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn stop_response() -> CoordinatorRouterResponse {
|
||||
CoordinatorRouterResponse {
|
||||
user_response: Some("Stopping all active automation.".to_string()),
|
||||
cli_command: Some("/stop".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn routes_status_queries() {
|
||||
let ctx = CoordinatorContext::new(
|
||||
2,
|
||||
vec!["Finished lint pass on the CLI crate".to_string()],
|
||||
);
|
||||
|
||||
let response = route_user_message(
|
||||
"Can you tell me what work has been done so far?",
|
||||
&ctx,
|
||||
);
|
||||
|
||||
let user_response = response.user_response.expect("expected a status message");
|
||||
assert!(user_response.contains("2 active agents"));
|
||||
assert!(user_response.contains("Finished lint pass on the CLI crate"));
|
||||
assert!(response.cli_command.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn routes_plan_requests() {
|
||||
let ctx = CoordinatorContext::default();
|
||||
let response = route_user_message("Please start more agents to handle this.", &ctx);
|
||||
|
||||
assert_eq!(response.cli_command.as_deref(), Some("/plan"));
|
||||
assert!(response
|
||||
.user_response
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.contains("Starting a fresh plan"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_default_for_unmatched_input() {
|
||||
let ctx = CoordinatorContext::default();
|
||||
let response = route_user_message("Hello there!", &ctx);
|
||||
|
||||
assert!(response.user_response.is_none());
|
||||
assert!(response.cli_command.is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
//! JSON schema helper for coordinator user-turn responses.
|
||||
|
||||
use anyhow::Context;
|
||||
use serde_json::Value;
|
||||
|
||||
pub(crate) fn user_turn_schema() -> Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"user_response": {
|
||||
"type": ["string", "null"],
|
||||
"maxLength": 400,
|
||||
"description": "Short message to respond the USER immediately."
|
||||
},
|
||||
"cli_command": {
|
||||
"type": ["string", "null"],
|
||||
"maxLength": 400,
|
||||
"description": "Shell command to execute in the CLI this turn. Use null when no CLI action is required."
|
||||
}
|
||||
},
|
||||
"required": ["user_response", "cli_command"]
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn parse_user_turn_reply(raw: &str) -> anyhow::Result<(Option<String>, Option<String>)> {
|
||||
let value: Value = serde_json::from_str(raw)
|
||||
.context("parsing coordinator user turn JSON")?;
|
||||
let obj = value
|
||||
.as_object()
|
||||
.ok_or_else(|| anyhow::anyhow!("coordinator response was not a JSON object"))?;
|
||||
|
||||
let extract = |name: &str| -> anyhow::Result<Option<String>> {
|
||||
let field = obj
|
||||
.get(name)
|
||||
.ok_or_else(|| anyhow::anyhow!("coordinator response missing required field '{name}'"))?;
|
||||
if field.is_null() {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(text) = field.as_str() else {
|
||||
return Err(anyhow::anyhow!("coordinator field '{name}' must be string or null"));
|
||||
};
|
||||
let trimmed = text.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
if trimmed.chars().count() > 400 {
|
||||
return Err(anyhow::anyhow!("coordinator field '{name}' exceeded 400 characters"));
|
||||
}
|
||||
Ok(Some(trimmed.to_string()))
|
||||
};
|
||||
|
||||
Ok((extract("user_response")?, extract("cli_command")?))
|
||||
}
|
||||
Reference in New Issue
Block a user