Replace local input model and improve shell command feedback

This commit is contained in:
2026-09-23 15:38:10 -05:00
parent 35ff43de7f
commit 3bfd7c0a54
33 changed files with 499 additions and 2700 deletions
@@ -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,
+14 -139
View File
@@ -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
}
+5 -1
View File
@@ -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": {
+2
View File
@@ -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;
+65 -63
View File
@@ -1,71 +1,73 @@
use std::sync::Arc;
//! Deterministic input detection using the shell's existing completion inventory and history.
//! Never executes the input or invokes a model.
use galaxy_completer::completer::CompletionContext;
use galaxy_completer::parsers::simple::top_level_command;
use galaxy_completer::ParsedTokensSnapshot;
use galaxy_core::command::ExitCode;
use galaxy_util::path::EscapeChar;
use input_classifier::InputType;
use galaxyui::{Entity, ModelContext, SingletonEntity};
use input_classifier::{HeuristicClassifier, InputClassifier};
#[cfg(any(
feature = "nld_classifier_v1",
feature = "nld_classifier_v2",
feature = "nld_classifier_v3"
))]
use input_classifier::{OnnxClassifier, OnnxModel};
pub struct InputClassifierModel {
pub classifier: Arc<dyn InputClassifier>,
/// Unknown outcomes and interrupted/failed input are not evidence that a command exists.
/// Do not use `was_successful`: it also accepts interruptions for terminal UI purposes.
pub(crate) fn is_verified_history_command(exit_code: Option<ExitCode>) -> bool {
exit_code.is_some_and(|code| code.value() == 0)
}
impl InputClassifierModel {
pub fn new(_ctx: &mut ModelContext<Self>) -> Self {
#[cfg(feature = "nld_classifier_v1")]
{
match OnnxClassifier::new(OnnxModel::BertTinyV1) {
Ok(classifier) => {
log::info!("Loaded onnx classifier bert_tiny_v1.onnx");
return Self {
classifier: Arc::new(classifier),
};
}
Err(e) => log::warn!("Failed to load onnx classifier bert_tiny_v1.onnx: {e:#}"),
}
}
#[cfg(feature = "nld_classifier_v2")]
{
match OnnxClassifier::new(OnnxModel::BertTinyV2) {
Ok(classifier) => {
log::info!("Loaded onnx classifier bert_tiny_v2.onnx");
return Self {
classifier: Arc::new(classifier),
};
}
Err(e) => log::warn!("Failed to load onnx classifier bert_tiny_v2.onnx: {e:#}"),
}
}
#[cfg(feature = "nld_classifier_v3")]
{
match OnnxClassifier::new(OnnxModel::BertTinyV3) {
Ok(classifier) => {
log::info!("Loaded onnx classifier bert_tiny_v3.onnx");
return Self {
classifier: Arc::new(classifier),
};
}
Err(e) => log::warn!("Failed to load onnx classifier bert_tiny_v3.onnx: {e:#}"),
}
}
Self {
classifier: Arc::new(HeuristicClassifier),
pub(crate) fn detect_input_type(
input: &ParsedTokensSnapshot,
context: &impl CompletionContext,
history: &[String],
current: InputType,
) -> InputType {
let escape = context.escape_char();
let Some(command) = top_level_command(&input.buffer_text, escape) else {
return current;
};
let matches = |candidate: &str| {
if matches!(escape, EscapeChar::Backtick) {
candidate.eq_ignore_ascii_case(&command)
} else {
candidate == command
}
};
let known = context.top_level_commands().any(matches)
|| context.aliases().any(|(name, _)| matches(name))
|| context
.functions()
.is_some_and(|names| names.iter().any(|name| matches(name)))
|| context
.builtins()
.is_some_and(|names| names.iter().any(|name| matches(name)))
|| context
.abbreviations()
.is_some_and(|names| names.keys().any(|name| matches(name)))
|| history
.iter()
.filter_map(|entry| top_level_command(entry, escape))
.any(|name| matches(&name));
// Explicit paths and shell constructs indicate command intent even while incomplete.
if known
|| command.starts_with(['/', '~'])
|| command.starts_with("./")
|| command.starts_with("../")
|| matches!(
command.as_str(),
"if" | "for" | "while" | "until" | "case" | "function" | "begin" | "{"
)
{
return InputType::Shell;
}
pub fn classifier(&self) -> Arc<dyn InputClassifier> {
self.classifier.clone()
if !input.buffer_text.contains(char::is_whitespace)
&& context
.top_level_commands()
.chain(context.aliases().map(|(name, _)| name))
.any(|name| name.starts_with(&command))
{
return current;
}
InputType::AI
}
impl Entity for InputClassifierModel {
type Event = ();
}
impl SingletonEntity for InputClassifierModel {}
#[cfg(test)]
#[path = "input_classifier_tests.rs"]
mod tests;
+86
View File
@@ -0,0 +1,86 @@
use galaxy_completer::completer::testing::FakeCompletionContext;
use galaxy_completer::signatures::testing::create_test_command_registry;
use galaxy_completer::ParsedTokensSnapshot;
use input_classifier::InputType;
use super::detect_input_type;
#[test]
fn detection_uses_command_inventory_and_history_without_running_input() {
let context = FakeCompletionContext::new(create_test_command_registry([]))
.with_top_level_commands(["git", "yarn"])
.with_aliases([("gs".into(), "git status".to_string())].into())
.with_functions(["deploy_app".into()].into())
.with_builtins(["cd".into()].into());
let history = vec!["custom_tool --old-argument".to_string()];
for command in [
"git pull origin CI",
"yarn workspace app tsc",
"gs",
"deploy_app",
"cd /tmp",
"custom_tool --new-argument",
"FOO=bar git status",
"./script/check",
] {
let input = ParsedTokensSnapshot {
buffer_text: command.to_string(),
parsed_tokens: vec![],
};
assert_eq!(
detect_input_type(&input, &context, &history, InputType::AI),
InputType::Shell,
"{command}"
);
}
for prompt in ["hello, there!", "please fix the build", "gti status"] {
let input = ParsedTokensSnapshot {
buffer_text: prompt.to_string(),
parsed_tokens: vec![],
};
assert_eq!(
detect_input_type(&input, &context, &history, InputType::Shell),
InputType::AI,
"{prompt}"
);
}
}
#[test]
fn command_prefix_preserves_current_mode() {
let context = FakeCompletionContext::new(create_test_command_registry([]))
.with_top_level_commands(["git"]);
let input = ParsedTokensSnapshot {
buffer_text: "gi".to_string(),
parsed_tokens: vec![],
};
for current in [InputType::AI, InputType::Shell] {
assert_eq!(detect_input_type(&input, &context, &[], current), current);
}
}
#[test]
fn unverified_greeting_history_does_not_turn_a_prompt_into_a_command() {
let context = FakeCompletionContext::new(create_test_command_registry([]));
let records = [
("Hello, there!", None),
("Hello, there!", Some(127.into())),
("Hello, there!", Some(1.into())),
("Hello, there!", Some(130.into())),
("custom_tool --previous", Some(0.into())),
];
let history = records
.into_iter()
.filter(|(_, exit_code)| super::is_verified_history_command(*exit_code))
.map(|(command, _)| command.to_string())
.collect::<Vec<_>>();
assert_eq!(history, vec!["custom_tool --previous"]);
let greeting = ParsedTokensSnapshot {
buffer_text: "Hello, there!".to_string(),
parsed_tokens: vec![],
};
assert_eq!(
detect_input_type(&greeting, &context, &history, InputType::Shell),
InputType::AI
);
}
-4
View File
@@ -70,8 +70,6 @@ mod interval_timer;
mod linear;
#[cfg(feature = "local_fs")]
mod local_control;
#[cfg(not(target_family = "wasm"))]
mod local_inference;
mod local_object_repository;
#[cfg(any(target_os = "macos", target_os = "windows"))]
mod login_item;
@@ -2292,8 +2290,6 @@ pub(crate) fn initialize_app(
});
ctx.add_singleton_model(move |_| persistence_writer);
ctx.add_singleton_model(input_classifier::InputClassifierModel::new);
ctx.add_singleton_model(move |_| IgnoredSuggestionsModel::new(persisted_ignored_suggestions));
// Subscribe WorkflowAliases to the UpdateManager so that it can be notified when objects are
-148
View File
@@ -1,148 +0,0 @@
//! One lazy local classifier worker shared by all terminal panes.
use std::sync::{mpsc, OnceLock};
use std::time::Duration;
use futures::channel::oneshot;
use input_classifier::InputType;
use instant::Instant;
use local_inference::{
CancellationToken, Device, InferenceEngine, InputCategory, InputClassificationInput,
InputClassificationTask,
};
struct Request {
input: InputClassificationInput,
cancel: CancellationToken,
reply: oneshot::Sender<Option<InputType>>,
}
struct CancelOnDrop(CancellationToken);
impl Drop for CancelOnDrop {
fn drop(&mut self) {
self.0.cancel();
}
}
static WORKER: OnceLock<Option<mpsc::SyncSender<Request>>> = OnceLock::new();
fn start_worker() -> Option<mpsc::SyncSender<Request>> {
// Bound queued work across panes. Superseded requests are skipped by the worker.
let (sender, receiver) = mpsc::sync_channel::<Request>(16);
let result = std::thread::Builder::new()
.name("local-input-classifier".to_string())
.spawn(move || {
let runtime = match tokio::runtime::Builder::new_current_thread().enable_all().build() {
Ok(runtime) => runtime,
Err(error) => {
log::warn!("Could not initialize local classifier runtime: {error}");
return;
}
};
let mut engine = None;
let mut retry_after = None;
let mut cached: Option<(InputClassificationInput, InputType)> = None;
for request in receiver {
if request.cancel.is_cancelled() || request.reply.is_canceled() {
continue;
}
if let Some((input, result)) = &cached {
if input == &request.input {
log::info!("Local input classification: category={result:?}, cached=true");
let _ = request.reply.send(Some(*result));
continue;
}
}
if engine.is_none() {
if retry_after.is_some_and(|deadline| Instant::now() < deadline) {
let _ = request.reply.send(None);
continue;
}
match runtime.block_on(InferenceEngine::new(Device::best_available())) {
Ok(loaded) => engine = Some(loaded),
Err(error) => {
log::warn!("Local input classifier unavailable; preserving input mode: {error:#}");
retry_after = Some(Instant::now() + Duration::from_secs(60));
let _ = request.reply.send(None);
continue;
}
}
}
if request.cancel.is_cancelled() || request.reply.is_canceled() {
continue;
}
let started = Instant::now();
let result = runtime.block_on(InputClassificationTask.run_cancellable(
engine.as_ref().expect("engine was initialized"),
request.input.clone(),
&request.cancel,
));
let classification = match result {
Ok(result) => {
let input_type = match result.category {
InputCategory::Shell => InputType::Shell,
InputCategory::AgentPrompt => InputType::AI,
};
log::info!("Local input classification: category={input_type:?}, label_probability={:.3}, elapsed_ms={}", result.confidence, started.elapsed().as_millis());
cached = Some((request.input, input_type));
Some(input_type)
}
Err(error) => {
if !request.cancel.is_cancelled() {
log::warn!("Local classifier returned no decision; preserving input mode: {error:#}");
}
None
}
};
let _ = request.reply.send(classification);
}
});
match result {
Ok(_) => Some(sender),
Err(error) => {
log::warn!("Could not start local classifier: {error}");
None
}
}
}
pub(crate) async fn classify(
user_input: String,
recent_commands: &[String],
is_follow_up: bool,
) -> Option<InputType> {
// Do not classify a truncated script or document as though it were the full input.
if user_input.trim().is_empty() || user_input.len() > 2048 {
return None;
}
let sender = WORKER.get_or_init(start_worker).as_ref()?;
let cancel = CancellationToken::new();
let _cancel_on_drop = CancelOnDrop(cancel.clone());
let (reply, receiver) = oneshot::channel();
sender
.try_send(Request {
input: InputClassificationInput {
user_input,
recent_commands: recent_commands
.iter()
.rev()
.take(3)
.map(|command| command.chars().take(120).collect())
.collect(),
is_follow_up,
},
cancel,
reply,
})
.ok()?;
let timeout = galaxyui::r#async::Timer::after(Duration::from_secs(2));
match futures::future::select(receiver, Box::pin(timeout)).await {
futures::future::Either::Left((result, _)) => result.ok().flatten(),
futures::future::Either::Right(_) => {
log::info!(
"Local input classification timed out after 2 seconds; preserving input mode"
);
None
}
}
}
-15
View File
@@ -1235,17 +1235,6 @@ define_settings_group!(AISettings, settings: [
toml_path: "agents.warp_agent.input.ai_command_denylist",
description: "Commands to exclude from AI natural language autodetection.",
}
// Use SmolLM2-135M for input classification instead of legacy heuristics.
// The model is downloaded once and inference stays on the device.
use_local_model: UseLocalModel {
type: bool,
default: true,
supported_platforms: SupportedPlatforms::DESKTOP,
sync_to_cloud: SyncToCloud::Never,
private: false,
toml_path: "agents.warp_agent.input.use_local_model",
description: "Use a locally downloaded AI model to distinguish commands from agent messages instead of legacy heuristics.",
},
// This field should not be referenced directly to lookup intelligent autosuggestion enablement
// -- use the `is_intelligent_autosuggestions_enabled()` getter.
intelligent_autosuggestions_enabled_internal: IntelligentAutosuggestionsEnabled {
@@ -2516,10 +2505,6 @@ impl AISettings {
self.is_active_ai_enabled(app) && *self.code_suggestions_enabled_internal
}
pub fn should_use_local_model(&self) -> bool {
*self.use_local_model
}
pub fn is_natural_language_autosuggestions_enabled(&self, app: &galaxyui::AppContext) -> bool {
self.is_active_ai_enabled(app) && *self.natural_language_autosuggestions_enabled_internal
}
+2 -2
View File
@@ -150,7 +150,7 @@ pub struct TerminalGridSnapshotElement {
impl TerminalGridSnapshotElement {
#[allow(clippy::too_many_arguments)]
pub fn new(
grid: &GridHandler,
grid: GridHandler,
row_count: usize,
colors: color::List,
override_colors: color::OverrideList,
@@ -181,7 +181,7 @@ impl TerminalGridSnapshotElement {
.with_rows_and_columns(row_count, grid.columns());
Self {
grid: grid.clone(),
grid,
row_count,
colors,
override_colors,
@@ -715,6 +715,27 @@ impl<T: EventLoopSender> PtyController<T> {
model.active_block_id().clone()
};
self.is_user_command_executing = true;
#[cfg(not(target_family = "wasm"))]
if let CommandExecutionSource::AI { metadata }
| CommandExecutionSource::SharedSession {
ai_metadata: Some(metadata),
..
} = &event.source
{
crate::ai::remote_logging::log_model_event(
ctx,
crate::ai::remote_logging::RemoteLogRecord {
level: crate::ai::remote_logging::RemoteLogLevel::Info,
message: "Agent command dispatched to PTY".to_string(),
context: serde_json::json!({
"event": "shell_command_pty_dispatched",
"conversation_id": metadata.conversation_id().to_string(),
"action_id": metadata.requested_command_action_id().map(|id| id.to_string()),
"block_id": block_id.to_string(),
}),
},
);
}
ctx.emit(PtyControllerEvent::CommandStarted { event, block_id });
}