Replace local input model and improve shell command feedback
This commit is contained in:
@@ -1010,6 +1010,17 @@ impl BlocklistAIActionExecutor {
|
||||
.async_executing_actions
|
||||
.remove(conversation_id, &action_id)
|
||||
else {
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
crate::ai::remote_logging::log_model_event(ctx, crate::ai::remote_logging::RemoteLogRecord {
|
||||
level: crate::ai::remote_logging::RemoteLogLevel::Info,
|
||||
message: "Async tool result discarded after action removal".to_string(),
|
||||
context: serde_json::json!({
|
||||
"event": "tool_result_discarded",
|
||||
"conversation_id": conversation_id.to_string(),
|
||||
"action_id": action_id.to_string(),
|
||||
"reason": "action_no_longer_running",
|
||||
}),
|
||||
});
|
||||
log::warn!("try_to_execute_action: async action completed but not found in executing map, conversation_id={conversation_id}, action_id={action_id:?}");
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -77,8 +77,64 @@ impl ShellCommandExecutor {
|
||||
&mut self,
|
||||
_: ModelHandle<ModelEventDispatcher>,
|
||||
event: &ModelEvent,
|
||||
_ctx: &mut ModelContext<Self>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
if let ModelEvent::Exit { reason } = event {
|
||||
let context = {
|
||||
let model = self.terminal_model.lock();
|
||||
let block = model.block_list().active_block();
|
||||
serde_json::json!({
|
||||
"event": "shell_terminal_exited",
|
||||
"reason": format!("{reason:?}"),
|
||||
"block_id": block.id().to_string(),
|
||||
"conversation_id": block.ai_conversation_id().map(|id| id.to_string()),
|
||||
"action_id": block.requested_command_action_id().map(|id| id.to_string()),
|
||||
"completion_waiter_groups": self.block_finished_senders.len(),
|
||||
"refresh_waiter_groups": self.force_refresh_senders.len(),
|
||||
})
|
||||
};
|
||||
crate::ai::remote_logging::log_model_event(
|
||||
ctx,
|
||||
crate::ai::remote_logging::RemoteLogRecord {
|
||||
level: crate::ai::remote_logging::RemoteLogLevel::Info,
|
||||
message: "Shell terminal exited".to_string(),
|
||||
context,
|
||||
},
|
||||
);
|
||||
}
|
||||
// Log definitive terminal completion independently of tool-result delivery.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
if let ModelEvent::BlockCompleted(completed) = event {
|
||||
let context = {
|
||||
let model = self.terminal_model.lock();
|
||||
model
|
||||
.block_list()
|
||||
.block_with_id(&completed.block_id)
|
||||
.and_then(|block| {
|
||||
block.requested_command_action_id().map(|action_id| serde_json::json!({
|
||||
"event": "shell_command_terminal_completed",
|
||||
"conversation_id": block.ai_conversation_id().map(|id| id.to_string()),
|
||||
"action_id": action_id.to_string(),
|
||||
"block_id": block.id().to_string(),
|
||||
"exit_code": block.exit_code().value(),
|
||||
"start_ts": block.start_ts(),
|
||||
"completed_ts": block.completed_ts(),
|
||||
"output_rows": block.output_grid().len_displayed(),
|
||||
}))
|
||||
})
|
||||
};
|
||||
if let Some(context) = context {
|
||||
crate::ai::remote_logging::log_model_event(
|
||||
ctx,
|
||||
crate::ai::remote_logging::RemoteLogRecord {
|
||||
level: crate::ai::remote_logging::RemoteLogLevel::Info,
|
||||
message: "Shell command terminal completed".to_string(),
|
||||
context,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
// Precmd provides fresh CWD metadata, while BlockCompleted is definitive completion
|
||||
// evidence for shells that never deliver a subsequent precmd.
|
||||
if matches!(
|
||||
@@ -217,6 +273,8 @@ impl ShellCommandExecutor {
|
||||
|
||||
// Determine the action we want to take based on the input.
|
||||
let action_id = input.action.id.clone();
|
||||
let diagnostic_action_id = action_id.clone();
|
||||
let conversation_id = input.conversation_id;
|
||||
|
||||
let handle = ctx.handle();
|
||||
match &input.action.action {
|
||||
@@ -278,6 +336,12 @@ impl ShellCommandExecutor {
|
||||
// Remove the senders from the maps.
|
||||
if let Some(handle) = handle.upgrade(ctx) {
|
||||
handle.update(ctx, |me, ctx| {
|
||||
log_shell_wait_result(
|
||||
conversation_id,
|
||||
&diagnostic_action_id,
|
||||
&result,
|
||||
ctx,
|
||||
);
|
||||
me.prune_closed_senders(&block_selector);
|
||||
if matches!(
|
||||
result,
|
||||
@@ -313,16 +377,17 @@ impl ShellCommandExecutor {
|
||||
let exit_code = block.exit_code();
|
||||
let start_ts = block.start_ts().cloned();
|
||||
let completed_ts = block.completed_ts().cloned();
|
||||
let result = ActionResult::CommandFinished {
|
||||
block_id: block.id().clone(),
|
||||
output,
|
||||
exit_code,
|
||||
start_ts,
|
||||
completed_ts,
|
||||
};
|
||||
drop(model);
|
||||
log_shell_wait_result(conversation_id, &action_id, &result, ctx);
|
||||
return ActionExecution::Sync(
|
||||
AIAgentActionResultType::WriteToLongRunningShellCommand(
|
||||
WriteToLongRunningShellCommandResult::CommandFinished {
|
||||
block_id: block.id().clone(),
|
||||
output,
|
||||
exit_code,
|
||||
start_ts,
|
||||
completed_ts,
|
||||
},
|
||||
),
|
||||
action_result_for_write_to_long_running_shell_command(result),
|
||||
);
|
||||
}
|
||||
// Drop immutable borrow.
|
||||
@@ -348,7 +413,13 @@ impl ShellCommandExecutor {
|
||||
move |result, ctx| {
|
||||
// Remove the senders from the maps.
|
||||
if let Some(handle) = handle.upgrade(ctx) {
|
||||
handle.update(ctx, |me, _| {
|
||||
handle.update(ctx, |me, ctx| {
|
||||
log_shell_wait_result(
|
||||
conversation_id,
|
||||
&diagnostic_action_id,
|
||||
&result,
|
||||
ctx,
|
||||
);
|
||||
me.prune_closed_senders(&block_selector);
|
||||
});
|
||||
}
|
||||
@@ -369,15 +440,17 @@ impl ShellCommandExecutor {
|
||||
let exit_code = block.exit_code();
|
||||
let start_ts = block.start_ts().cloned();
|
||||
let completed_ts = block.completed_ts().cloned();
|
||||
return ActionExecution::Sync(AIAgentActionResultType::ReadShellCommandOutput(
|
||||
ReadShellCommandOutputResult::CommandFinished {
|
||||
command,
|
||||
block_id: block_id.clone(),
|
||||
output,
|
||||
exit_code,
|
||||
start_ts,
|
||||
completed_ts,
|
||||
},
|
||||
let result = ActionResult::CommandFinished {
|
||||
block_id: block_id.clone(),
|
||||
output,
|
||||
exit_code,
|
||||
start_ts,
|
||||
completed_ts,
|
||||
};
|
||||
drop(model);
|
||||
log_shell_wait_result(conversation_id, &action_id, &result, ctx);
|
||||
return ActionExecution::Sync(action_result_for_read_shell_command_output(
|
||||
command, result,
|
||||
));
|
||||
}
|
||||
let command = block.command_with_secrets_unobfuscated(false);
|
||||
@@ -389,7 +462,13 @@ impl ShellCommandExecutor {
|
||||
move |result, ctx| {
|
||||
// Remove the senders from the maps.
|
||||
if let Some(handle) = handle.upgrade(ctx) {
|
||||
handle.update(ctx, |me, _| {
|
||||
handle.update(ctx, |me, ctx| {
|
||||
log_shell_wait_result(
|
||||
conversation_id,
|
||||
&diagnostic_action_id,
|
||||
&result,
|
||||
ctx,
|
||||
);
|
||||
me.prune_closed_senders(&block_selector);
|
||||
});
|
||||
}
|
||||
@@ -487,6 +566,7 @@ impl ShellCommandExecutor {
|
||||
cursor: CURSOR_MARKER,
|
||||
is_alt_screen_active: model.is_alt_screen_active(),
|
||||
is_preempted: false,
|
||||
wake_reason: "control_handed_back",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -501,7 +581,13 @@ impl ShellCommandExecutor {
|
||||
ActionExecution::new_async(transfer_future, move |result, ctx| {
|
||||
// Clean up.
|
||||
if let Some(handle) = handle.upgrade(ctx) {
|
||||
handle.update(ctx, |me, _| {
|
||||
handle.update(ctx, |me, ctx| {
|
||||
log_shell_wait_result(
|
||||
conversation_id,
|
||||
&diagnostic_action_id,
|
||||
&result,
|
||||
ctx,
|
||||
);
|
||||
me.prune_closed_senders(&block_selector);
|
||||
me.control_handback_sender = None;
|
||||
});
|
||||
@@ -640,6 +726,11 @@ impl ShellCommandExecutor {
|
||||
cursor: CURSOR_MARKER,
|
||||
is_alt_screen_active: selected_block_owns_alt_screen,
|
||||
is_preempted,
|
||||
wake_reason: match wake_reason {
|
||||
WakeReason::BlockFinished => "block_finished",
|
||||
WakeReason::Timeout => "timeout",
|
||||
WakeReason::ForceRefresh => "force_refresh",
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -704,29 +795,29 @@ impl ShellCommandExecutor {
|
||||
let terminal_model = self.terminal_model.lock();
|
||||
// Find every pending poll whose selector resolves to this block. Multiple provider polls
|
||||
// may legitimately wait on the same command and must be refreshed together.
|
||||
let matching_selector = self
|
||||
let matching_selectors = self
|
||||
.force_refresh_senders
|
||||
.keys()
|
||||
.find(|selector| {
|
||||
.filter(|selector| {
|
||||
selector.get_block(&terminal_model).is_some_and(|block| {
|
||||
block.id() == block_id
|
||||
&& block.is_active_and_long_running()
|
||||
&& !block.finished()
|
||||
})
|
||||
})
|
||||
.cloned();
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
drop(terminal_model);
|
||||
|
||||
if let Some(selector) = matching_selector {
|
||||
let mut refreshed = false;
|
||||
for selector in matching_selectors {
|
||||
if let Some(senders) = self.force_refresh_senders.remove(&selector) {
|
||||
let mut refreshed = false;
|
||||
for sender in senders {
|
||||
refreshed |= sender.send(()).is_ok();
|
||||
}
|
||||
return refreshed;
|
||||
}
|
||||
}
|
||||
false
|
||||
refreshed
|
||||
}
|
||||
|
||||
pub(super) fn preprocess_action(
|
||||
@@ -738,6 +829,57 @@ impl ShellCommandExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
fn log_shell_wait_result(
|
||||
conversation_id: crate::AIConversationId,
|
||||
action_id: &AIAgentActionId,
|
||||
result: &ActionResult,
|
||||
ctx: &mut ModelContext<ShellCommandExecutor>,
|
||||
) {
|
||||
crate::ai::remote_logging::log_model_event(
|
||||
ctx,
|
||||
crate::ai::remote_logging::RemoteLogRecord {
|
||||
level: crate::ai::remote_logging::RemoteLogLevel::Info,
|
||||
message: "Shell command wait resolved".to_string(),
|
||||
context: serde_json::json!({
|
||||
"event": "shell_command_wait_resolved",
|
||||
"conversation_id": conversation_id.to_string(),
|
||||
"action_id": action_id.to_string(),
|
||||
"result": shell_result_diagnostics(result),
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn shell_result_diagnostics(result: &ActionResult) -> serde_json::Value {
|
||||
match result {
|
||||
ActionResult::CommandFinished {
|
||||
block_id,
|
||||
output,
|
||||
exit_code,
|
||||
start_ts,
|
||||
completed_ts,
|
||||
} => serde_json::json!({
|
||||
"state": "finished", "block_id": block_id.to_string(),
|
||||
"exit_code": exit_code.value(), "output_bytes": output.len(),
|
||||
"start_ts": start_ts, "completed_ts": completed_ts,
|
||||
}),
|
||||
ActionResult::LongRunningCommandSnapshot {
|
||||
block_id,
|
||||
grid_contents,
|
||||
is_alt_screen_active,
|
||||
is_preempted,
|
||||
wake_reason,
|
||||
..
|
||||
} => serde_json::json!({
|
||||
"state": "running", "block_id": block_id.to_string(),
|
||||
"snapshot_bytes": grid_contents.len(), "alt_screen": is_alt_screen_active,
|
||||
"preempted": is_preempted, "wake_reason": wake_reason,
|
||||
}),
|
||||
ActionResult::Cancelled => serde_json::json!({"state": "cancelled"}),
|
||||
ActionResult::BlockNotFound => serde_json::json!({"state": "block_not_found"}),
|
||||
}
|
||||
}
|
||||
|
||||
fn command_for_execution(
|
||||
command: &str,
|
||||
shell_type: Option<ShellType>,
|
||||
@@ -863,6 +1005,7 @@ fn action_result_for_write_to_long_running_shell_command(
|
||||
cursor,
|
||||
is_alt_screen_active,
|
||||
is_preempted,
|
||||
..
|
||||
} => AIAgentActionResultType::WriteToLongRunningShellCommand(
|
||||
WriteToLongRunningShellCommandResult::Snapshot {
|
||||
block_id,
|
||||
@@ -909,6 +1052,7 @@ fn action_result_for_read_shell_command_output(
|
||||
cursor,
|
||||
is_alt_screen_active,
|
||||
is_preempted,
|
||||
..
|
||||
} => AIAgentActionResultType::ReadShellCommandOutput(
|
||||
ReadShellCommandOutputResult::LongRunningCommandSnapshot {
|
||||
command,
|
||||
@@ -954,6 +1098,7 @@ fn action_result_for_transfer_shell_command_control_to_user(
|
||||
cursor,
|
||||
is_alt_screen_active,
|
||||
is_preempted,
|
||||
..
|
||||
} => AIAgentActionResultType::TransferShellCommandControlToUser(
|
||||
TransferShellCommandControlToUserResult::Snapshot {
|
||||
block_id,
|
||||
@@ -1020,6 +1165,7 @@ enum ActionResult {
|
||||
cursor: &'static str,
|
||||
is_alt_screen_active: bool,
|
||||
is_preempted: bool,
|
||||
wake_reason: &'static str,
|
||||
},
|
||||
Cancelled,
|
||||
BlockNotFound,
|
||||
|
||||
@@ -23,6 +23,7 @@ use crate::terminal::model::session::Sessions;
|
||||
use crate::terminal::model::terminal_model::{BlockIndex, TerminalModel};
|
||||
use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher};
|
||||
use crate::terminal::shell::ShellType;
|
||||
use crate::test_util::settings::initialize_settings_for_tests;
|
||||
use crate::AIConversationId;
|
||||
|
||||
#[test]
|
||||
@@ -178,16 +179,43 @@ fn force_refresh_block_reports_and_resolves_matching_poll() {
|
||||
)
|
||||
});
|
||||
|
||||
let action_id = AIAgentActionId::from("refresh-command".to_string());
|
||||
terminal_model
|
||||
.lock()
|
||||
.block_list_mut()
|
||||
.active_block_mut()
|
||||
.set_agent_interaction_mode_for_requested_command(
|
||||
action_id.clone(),
|
||||
None,
|
||||
AIConversationId::new(),
|
||||
);
|
||||
let (tx, mut rx) = oneshot::channel();
|
||||
let (action_tx, mut action_rx) = oneshot::channel();
|
||||
let (second_tx, mut second_rx) = oneshot::channel();
|
||||
let (unrelated_tx, mut unrelated_rx) = oneshot::channel();
|
||||
let unrelated_selector = BlockSelector::Id(BlockId::new());
|
||||
executor.update(&mut app, |executor, _| {
|
||||
executor
|
||||
.force_refresh_senders
|
||||
.insert(BlockSelector::Id(block_id.clone()), vec![tx]);
|
||||
executor.force_refresh_senders.insert(
|
||||
BlockSelector::RequestedCommandId(action_id),
|
||||
vec![action_tx, second_tx],
|
||||
);
|
||||
executor
|
||||
.force_refresh_senders
|
||||
.insert(unrelated_selector.clone(), vec![unrelated_tx]);
|
||||
assert!(executor.force_refresh_block(&block_id));
|
||||
assert!(executor
|
||||
.force_refresh_senders
|
||||
.contains_key(&unrelated_selector));
|
||||
assert!(!executor.force_refresh_block(&block_id));
|
||||
});
|
||||
|
||||
assert!(matches!(rx.try_recv(), Ok(Some(()))));
|
||||
assert!(matches!(action_rx.try_recv(), Ok(Some(()))));
|
||||
assert!(matches!(second_rx.try_recv(), Ok(Some(()))));
|
||||
assert!(matches!(unrelated_rx.try_recv(), Ok(None)));
|
||||
|
||||
let (tx, _rx) = oneshot::channel();
|
||||
executor.update(&mut app, |executor, _| {
|
||||
@@ -205,6 +233,7 @@ fn force_refresh_block_reports_and_resolves_matching_poll() {
|
||||
#[test]
|
||||
fn requested_command_waiter_survives_early_metadata_and_resolves_after_association() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_settings_for_tests(&mut app);
|
||||
let terminal_view_id = EntityId::new();
|
||||
let sessions = app.add_model(|_| Sessions::new_for_test());
|
||||
let (_model_events_tx, model_events_rx) = unbounded();
|
||||
@@ -393,3 +422,20 @@ fn force_refresh_wakes_on_completion_poll_with_preempted_snapshot() {
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_diagnostics_report_snapshot_reason_without_output_contents() {
|
||||
let result = ActionResult::LongRunningCommandSnapshot {
|
||||
block_id: BlockId::new(),
|
||||
grid_contents: "private terminal output".to_string(),
|
||||
cursor: "cursor",
|
||||
is_alt_screen_active: false,
|
||||
is_preempted: true,
|
||||
wake_reason: "force_refresh",
|
||||
};
|
||||
let metadata = super::shell_result_diagnostics(&result);
|
||||
assert_eq!(metadata["state"], "running");
|
||||
assert_eq!(metadata["wake_reason"], "force_refresh");
|
||||
assert_eq!(metadata["snapshot_bytes"], "private terminal output".len());
|
||||
assert!(!metadata.to_string().contains("private terminal output"));
|
||||
}
|
||||
|
||||
@@ -698,6 +698,19 @@ impl CLISubagentController {
|
||||
let block_id = block_id.clone();
|
||||
let refreshed =
|
||||
executor_handle.update(ctx, |executor, _| executor.force_refresh_block(&block_id));
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
crate::ai::remote_logging::log_model_event(
|
||||
ctx,
|
||||
crate::ai::remote_logging::RemoteLogRecord {
|
||||
level: crate::ai::remote_logging::RemoteLogLevel::Info,
|
||||
message: "Shell command refresh requested".to_string(),
|
||||
context: serde_json::json!({
|
||||
"event": "shell_command_refresh_requested",
|
||||
"block_id": block_id.to_string(),
|
||||
"refreshed": refreshed,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if refreshed {
|
||||
self.active_subagents_by_block.entry(block_id).or_default();
|
||||
}
|
||||
|
||||
@@ -1419,7 +1419,7 @@ impl RequestedCommandView {
|
||||
}
|
||||
Some(AIActionStatus::RunningAsync) if self.action_type.is_requested_command() => {
|
||||
config = config.with_interaction_mode(InteractionMode::ManuallyExpandable(
|
||||
self.get_expansion_config(requested_command_block, app),
|
||||
self.get_expansion_config(app),
|
||||
));
|
||||
}
|
||||
Some(AIActionStatus::Finished(result)) => {
|
||||
@@ -1447,7 +1447,7 @@ impl RequestedCommandView {
|
||||
|
||||
if should_be_expandable {
|
||||
config = config.with_interaction_mode(InteractionMode::ManuallyExpandable(
|
||||
self.get_expansion_config(requested_command_block, app),
|
||||
self.get_expansion_config(app),
|
||||
));
|
||||
} else {
|
||||
// Commands cancelled before execution should be right clickable only.
|
||||
@@ -1471,7 +1471,7 @@ impl RequestedCommandView {
|
||||
// for this action, the command ran and the header should be expandable.
|
||||
if requested_command_block.is_some_and(|block| block.finished()) {
|
||||
config = config.with_interaction_mode(InteractionMode::ManuallyExpandable(
|
||||
self.get_expansion_config(requested_command_block, app),
|
||||
self.get_expansion_config(app),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1487,11 +1487,7 @@ impl RequestedCommandView {
|
||||
}
|
||||
}
|
||||
|
||||
fn get_expansion_config(
|
||||
&self,
|
||||
requested_command_block: Option<&Block>,
|
||||
app: &AppContext,
|
||||
) -> ExpandedConfig {
|
||||
fn get_expansion_config(&self, app: &AppContext) -> ExpandedConfig {
|
||||
let command_text_for_callback = self.command_text().to_string();
|
||||
let mut expansion_config =
|
||||
ExpandedConfig::new(self.is_header_expanded, self.header_mouse_state.clone())
|
||||
@@ -1499,16 +1495,11 @@ impl RequestedCommandView {
|
||||
ctx.dispatch_typed_action(AIBlockAction::StoreRightClickedCommand {
|
||||
command: command_text_for_callback.clone(),
|
||||
});
|
||||
})
|
||||
.with_toggle_callback(|ctx| {
|
||||
ctx.dispatch_typed_action(RequestedCommandViewAction::ToggleExpanded);
|
||||
});
|
||||
|
||||
let is_active_agent_monitored_command = requested_command_block
|
||||
.is_some_and(|block| block.is_agent_monitoring() && !block.finished());
|
||||
if !is_active_agent_monitored_command {
|
||||
expansion_config = expansion_config.with_toggle_callback(|ctx| {
|
||||
ctx.dispatch_typed_action(RequestedCommandViewAction::ToggleExpanded);
|
||||
});
|
||||
}
|
||||
|
||||
if InputModeSettings::as_ref(app).is_pinned_to_top()
|
||||
&& self.action_type.is_requested_command()
|
||||
{
|
||||
@@ -1723,7 +1714,7 @@ impl View for RequestedCommandView {
|
||||
command_output_grid
|
||||
{
|
||||
let mut output_grid = TerminalGridSnapshotElement::new(
|
||||
&grid,
|
||||
grid,
|
||||
row_count,
|
||||
colors,
|
||||
override_colors,
|
||||
|
||||
@@ -11,7 +11,6 @@ use futures::stream::AbortHandle;
|
||||
use galaxy_completer::completer::CompletionContext;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxyui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
|
||||
use input_classifier::util::{is_agent_follow_up_input, is_one_off_natural_language_word};
|
||||
pub use input_classifier::{InputClassifierDecisionSource, InputType};
|
||||
use instant::Instant;
|
||||
use parking_lot::FairMutex;
|
||||
@@ -90,20 +89,15 @@ use super::agent_view::AgentViewEntryOrigin;
|
||||
use super::context_model::BlocklistAIContextModel;
|
||||
use super::telemetry_banner::should_collect_ai_ugc_telemetry;
|
||||
use super::{ConversationSelectionEvent, ConversationSelectionHandle};
|
||||
use crate::input_classifier::InputClassifierModel;
|
||||
use crate::settings::{AISettings, AISettingsChangedEvent, InputBoxType, InputSettings};
|
||||
use crate::terminal::cli_agent_sessions::{
|
||||
CLIAgentInputState, CLIAgentSessionsModel, CLIAgentSessionsModelEvent,
|
||||
};
|
||||
use crate::terminal::input::decorations::ParsedTokensSnapshot;
|
||||
use crate::terminal::model::rich_content::RichContentType;
|
||||
use crate::terminal::model::session::SessionId;
|
||||
use crate::terminal::{History, TerminalModel};
|
||||
use crate::{report_if_error, send_telemetry_from_ctx, PrivacySettings, TelemetryEvent};
|
||||
|
||||
/// Cutoff score for deciding an user input matches a history command entry.
|
||||
const HISTORY_ENTRY_MATCH_CUTOFF: f32 = 0.9;
|
||||
|
||||
/// Duration to temporarily disable autodetection during operations like history selection.
|
||||
const AUTODETECTION_DISABLE_DURATION_MS: u64 = 250;
|
||||
|
||||
@@ -696,7 +690,7 @@ impl BlocklistAIInputModel {
|
||||
/// nothing.
|
||||
///
|
||||
/// When `session_id` is `Some`, history matching is performed. The `completion_context`
|
||||
/// is always used for alias expansion (callers without a live session should pass an
|
||||
/// supplies the shell command inventory (callers without a live session should pass an
|
||||
/// `EmptyCompletionContext`).
|
||||
pub fn detect_and_set_input_type<C: CompletionContext + Clone + Send + 'static>(
|
||||
&mut self,
|
||||
@@ -760,10 +754,7 @@ impl BlocklistAIInputModel {
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|entry| {
|
||||
if entry
|
||||
.exit_code
|
||||
.is_some_and(|code| code.was_command_not_found())
|
||||
{
|
||||
if !crate::input_classifier::is_verified_history_command(entry.exit_code) {
|
||||
return None;
|
||||
}
|
||||
Some(entry.command.to_string())
|
||||
@@ -771,105 +762,27 @@ impl BlocklistAIInputModel {
|
||||
.collect::<Vec<String>>()
|
||||
});
|
||||
|
||||
let buffer_cloned = input.buffer_text.clone();
|
||||
let other_buffer_cloned = buffer_cloned.clone();
|
||||
let other_buffer_cloned = input.buffer_text.clone();
|
||||
let current_input_type = self.input_type();
|
||||
|
||||
let is_udi_enabled = InputSettings::as_ref(ctx).is_universal_developer_input_enabled(ctx);
|
||||
|
||||
// Determine if the input is a follow-up to an AI block.
|
||||
let is_agent_follow_up = {
|
||||
let model = self.model.lock();
|
||||
let block_list = model.block_list();
|
||||
let block_index = block_list.last_non_hidden_block_by_index();
|
||||
match block_list.last_non_hidden_rich_content_block_after_block(block_index) {
|
||||
Some((_, content)) => content.content_type == Some(RichContentType::AIBlock),
|
||||
_ => false,
|
||||
}
|
||||
};
|
||||
|
||||
let classifier = InputClassifierModel::as_ref(ctx).classifier();
|
||||
let generation = self.autodetection_generation;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
let use_local_model = AISettings::as_ref(ctx).should_use_local_model();
|
||||
let handle = ctx
|
||||
.spawn(
|
||||
async move {
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
if use_local_model {
|
||||
// A cancelled debounce never starts inference. The local worker
|
||||
// also cancels obsolete requests already queued or generating.
|
||||
warpui::r#async::Timer::after(std::time::Duration::from_millis(100)).await;
|
||||
let result = crate::local_inference::classify(
|
||||
buffer_cloned.to_string(),
|
||||
history_entries.as_deref().unwrap_or_default(),
|
||||
is_agent_follow_up,
|
||||
)
|
||||
.await;
|
||||
return match result {
|
||||
Some(input_type) => (
|
||||
input_type,
|
||||
InputClassifierDecisionSource::LocalLanguageModel.into(),
|
||||
),
|
||||
None => (
|
||||
current_input_type,
|
||||
InputClassifierDecisionSource::InputClassifierFallbackCurrentInput
|
||||
.into(),
|
||||
),
|
||||
};
|
||||
}
|
||||
// First check if the token is a natural language word, if current input type is AI
|
||||
if matches!(current_input_type, InputType::AI)
|
||||
&& is_one_off_natural_language_word(first_token_str.to_lowercase().as_str())
|
||||
{
|
||||
return (
|
||||
InputType::AI,
|
||||
InputClassifierDecisionSource::NaturalLanguageOneOffAllowlist.into(),
|
||||
);
|
||||
}
|
||||
|
||||
// If this is clearly intended to be a follow-up to an AI block, classify it as AI.
|
||||
if is_agent_follow_up
|
||||
&& is_agent_follow_up_input(&buffer_cloned.trim().to_lowercase())
|
||||
{
|
||||
return (
|
||||
InputType::AI,
|
||||
InputTypeAutoDetectionSource::NaturalLanguageAgentFollowUpAllowList,
|
||||
);
|
||||
}
|
||||
|
||||
// If we have history entries (i.e., a live session), check for
|
||||
// close matches to short-circuit as shell input.
|
||||
if let Some(history_entries) = history_entries {
|
||||
if has_any_close_matches(
|
||||
&buffer_cloned,
|
||||
history_entries.iter().map(AsRef::as_ref),
|
||||
HISTORY_ENTRY_MATCH_CUTOFF,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return (InputType::Shell, InputTypeAutoDetectionSource::HistoryMatch);
|
||||
}
|
||||
}
|
||||
|
||||
// Yield so that an attempt to abort the classification is handled. We do this periodically
|
||||
// so that we can skip doing additional expensive work if the classification is aborted.
|
||||
futures_lite::future::yield_now().await;
|
||||
|
||||
let input =
|
||||
galaxy_completer::util::expand_aliases(input, &completion_context).await;
|
||||
|
||||
futures_lite::future::yield_now().await;
|
||||
|
||||
let context = input_classifier::Context {
|
||||
// Coalesce keystrokes; aborting this task prevents obsolete decisions.
|
||||
warpui::r#async::Timer::after(std::time::Duration::from_millis(100)).await;
|
||||
let input_type = crate::input_classifier::detect_input_type(
|
||||
&input,
|
||||
&completion_context,
|
||||
history_entries.as_deref().unwrap_or_default(),
|
||||
current_input_type,
|
||||
is_agent_follow_up,
|
||||
};
|
||||
let classification =
|
||||
classifier.detect_input_type(input.clone(), &context).await;
|
||||
|
||||
futures_lite::future::yield_now().await;
|
||||
(classification.input_type, classification.source.into())
|
||||
);
|
||||
(
|
||||
input_type,
|
||||
InputClassifierDecisionSource::ShellCommandLookup.into(),
|
||||
)
|
||||
},
|
||||
move |me, (new_input_type, decision_source), ctx| {
|
||||
// In theory, we shouldn't need to check this, as we only run autodetection if the input
|
||||
@@ -958,41 +871,3 @@ impl BlocklistAIInputEvent {
|
||||
impl Entity for BlocklistAIInputModel {
|
||||
type Event = BlocklistAIInputEvent;
|
||||
}
|
||||
|
||||
/// Returns whether the set of possibilities contains any close matches
|
||||
/// to the provided word, using the given similarity threshold.
|
||||
///
|
||||
/// Adapted from [`difflib::get_close_matches`], but an async function with
|
||||
/// periodic yields such that the operation can be aborted if necessary. Also,
|
||||
/// unlike the original function, this returns as soon as it finds any match
|
||||
/// above the threshold, instead of finding _all_ matches above the threshold
|
||||
/// and returning the top N matches.
|
||||
async fn has_any_close_matches<'a>(
|
||||
word: &str,
|
||||
possibilities: impl Iterator<Item = &'a str>,
|
||||
cutoff: f32,
|
||||
) -> bool {
|
||||
const BATCH_SIZE: usize = 50;
|
||||
|
||||
if !(0.0..=1.0).contains(&cutoff) {
|
||||
panic!("Cutoff must be greater than 0.0 and lower than 1.0");
|
||||
}
|
||||
let mut matcher = difflib::sequencematcher::SequenceMatcher::new("", word);
|
||||
for (idx, i) in possibilities.enumerate() {
|
||||
// Periodically, yield to the executor so this task can be aborted if
|
||||
// requested.
|
||||
if idx % BATCH_SIZE == 0 {
|
||||
futures_lite::future::yield_now().await;
|
||||
}
|
||||
|
||||
matcher.set_first_seq(i);
|
||||
// The fast ratio computations produce an upper bound on the value of
|
||||
// ratio, so if a faster check fails, the slower checks are guaranteed
|
||||
// to also fail.
|
||||
if matcher.real_quick_ratio() >= cutoff && matcher.ratio() >= cutoff {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
@@ -1463,6 +1463,7 @@ pub fn extract_system_prompt(
|
||||
}
|
||||
}
|
||||
|
||||
prompt.push_str(SHELL_EXECUTION_GUIDANCE);
|
||||
prompt.push_str("## Operating Contract\n");
|
||||
prompt.push_str(
|
||||
"- If the user asks for a change, carry it through inspection, implementation, and \
|
||||
@@ -1714,11 +1715,14 @@ pub(crate) fn tool_name_is_supported(name: &str, supported: &HashSet<api::ToolTy
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared by direct-provider prompts and compatibility/background request prompts.
|
||||
pub(crate) const SHELL_EXECUTION_GUIDANCE: &str = "## Shell Execution\nCommands use a persistent terminal. Never exit, log out of, replace with exec, or kill that shell or Galaxy. Do not append `exit $status` to a command to report its result; let the command's exit status be returned naturally. If a script needs exit, run it in an explicit child shell such as `sh -c '...'`. Stop a running agent command with interrupt_shell_command, not broad kill/pkill commands.\nRun builds, tests, installs, and workspace checks as separate shell tool calls so each has its own output and exit status. Keep stdout and stderr live: do not hide them in subprocess capture_output/PIPE, redirect everything to a file, or buffer a whole validation suite and print only its final tail. If a log file is needed, stream through tee while preserving the original command's exit status. Use non-interactive, non-watch modes and disable pagers. For intentionally quiet commands, explain what is running before starting; silence alone is not evidence of a hang. If repeated snapshots show no progress, investigate whether the command is waiting for input, watching, or buffering; do not blindly poll forever or start duplicate commands. Interrupt and correct an unintended watcher or blocked command before retrying.\n\n";
|
||||
|
||||
pub fn default_tool_definitions() -> Vec<ToolDefinition> {
|
||||
vec![
|
||||
ToolDefinition {
|
||||
name: "run_shell_command".to_string(),
|
||||
description: "Execute a shell command in the user's terminal and return its output. Use for builds, tests, git, package managers, and other shell work. Commands run in this conversation's persistent shell and environment, starting in the supplied working directory. Successful cd commands affect subsequent calls in this shell; subshell directory changes do not. Avoid prefixing commands with cd to the directory you are already in, and use pwd only when the current location is uncertain. Set wait_until_complete=false for commands that might run longer than a few seconds; Galaxy will return a command_id that can be monitored with read_shell_command_output.".to_string(),
|
||||
description: "Execute a shell command in the user's terminal and return its output. Use for builds, tests, git, package managers, and other shell work. Commands run in this conversation's persistent shell and environment, starting in the supplied working directory. Successful cd commands affect subsequent calls in this shell; subshell directory changes do not. Avoid prefixing commands with cd to the directory you are already in, and use pwd only when the current location is uncertain. Never exit, log out of, replace, or kill this persistent shell. Run each build/test/install separately and keep its output live; avoid buffered subprocess wrappers and redirecting all output to files. Set wait_until_complete=false for commands that might run longer than a few seconds; Galaxy will return a command_id that can be monitored with read_shell_command_output.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -20,6 +20,7 @@ use crate::ai::openai::client::OpenAIClientConfig;
|
||||
use crate::ai::openai::request_translator::sanitize_messages_for_openai;
|
||||
use crate::ai::provider::request_translator::{
|
||||
default_tool_definitions, sanitize_messages_for_bedrock, tool_name_is_supported,
|
||||
SHELL_EXECUTION_GUIDANCE,
|
||||
};
|
||||
use crate::ai::provider::types::flatten_tool_history_for_no_tools_turn;
|
||||
|
||||
@@ -774,6 +775,7 @@ fn build_system_prompt(
|
||||
prompt.push_str(
|
||||
"## Shell Working Directory\nShell commands run in this conversation's persistent terminal session. The supplied working directory is the shell's location when the request was prepared. Start there without a redundant `cd` or `pwd`. Track successful directory changes across tool calls; do not assume later commands reset to the project root. Use `cd` only when the next command needs a different directory, and use `pwd` only when the location is missing or uncertain. A `cd` inside a subshell or separate script does not change the parent shell's directory. Each sub-agent has its own terminal; never assume it shares another agent's directory changes.\n\n",
|
||||
);
|
||||
prompt.push_str(SHELL_EXECUTION_GUIDANCE);
|
||||
let contexts = inputs.iter().filter_map(AIAgentInput::context).flatten();
|
||||
let mut environment = Vec::new();
|
||||
let mut request_time = None;
|
||||
|
||||
Reference in New Issue
Block a user