Preserve completed command assessments

This commit is contained in:
2026-08-13 16:41:19 -05:00
parent 934cddc063
commit 5737f8342c
29 changed files with 864 additions and 94 deletions
+1
View File
@@ -129,6 +129,7 @@ Key invariants:
- Progressive summary (if present) is prepended to the messages array as a user/assistant pair in `translator.rs` - Progressive summary (if present) is prepended to the messages array as a user/assistant pair in `translator.rs`
- Loop prevention guardrail in `controller.rs` detects repeated tool failures (3+ identical) and injects corrective instructions - Loop prevention guardrail in `controller.rs` detects repeated tool failures (3+ identical) and injects corrective instructions
- Direct-provider long-running shell follow-ups create unlinked CLI tasks under the root task with an empty subagent tool-call ID; `TaskStore` linearization must include their exchanges chronologically even though no parent `Subagent` output references them - Direct-provider long-running shell follow-ups create unlinked CLI tasks under the root task with an empty subagent tool-call ID; `TaskStore` linearization must include their exchanges chronologically even though no parent `Subagent` output references them
- Direct-provider completed-command assessments are hidden, tool-free root-task turns; CLI monitor exchanges remain on the retained CLI task, while the root assessment output must survive CLI-task deactivation and restoration and its hidden input must remain available to future provider context
- Orchestrated child conversations are leaf workers by default: nested `RunAgents` and legacy `StartAgent` calls must be rejected before autonomous or permission bypasses, and child requests must not advertise delegation tools - Orchestrated child conversations are leaf workers by default: nested `RunAgents` and legacy `StartAgent` calls must be rejected before autonomous or permission bypasses, and child requests must not advertise delegation tools
### Platform Setup ### Platform Setup
+19
View File
@@ -153,6 +153,25 @@ fn append_hidden_input(
)); ));
} }
AIAgentInput::UserQuery { .. } | AIAgentInput::CreateNewProject { .. } => {} AIAgentInput::UserQuery { .. } | AIAgentInput::CreateNewProject { .. } => {}
AIAgentInput::CommandCompletionAssessment {
prompt,
completed_command,
..
} => {
hidden_context.push(format!(
"A monitored command has completed.\n\
Command: {}\n\
Galaxy block_id: {}\n\
Final output:\n{}\n\n{}",
completed_command.command,
completed_command.block_id,
tail_chars(
&completed_command.grid_contents,
MAX_RUNNING_COMMAND_OUTPUT_CHARS
),
prompt,
));
}
AIAgentInput::AutoCodeDiffQuery { query, .. } => { AIAgentInput::AutoCodeDiffQuery { query, .. } => {
hidden_context.push(format!( hidden_context.push(format!(
"Galaxy system request: create a code diff.\n{query}" "Galaxy system request: create a code diff.\n{query}"
+40
View File
@@ -117,6 +117,46 @@ fn hidden_system_requests_still_reach_the_agent_without_a_user_bubble() {
assert!(text.contains("hidden_from_transcript")); assert!(text.contains("hidden_from_transcript"));
} }
#[test]
fn completed_command_assessment_uses_hidden_context_without_monitor_guidance() {
let block_id = BlockId::from("completed-session-42".to_owned());
let mut params = RequestParams::new_for_test();
params.input = vec![AIAgentInput::CommandCompletionAssessment {
prompt: "Report whether the command succeeded.".to_owned(),
context: Arc::from([AIAgentContext::SelectedText("root context".to_owned())]),
completed_command: RunningCommand {
command: "script/run-soak-test".to_owned(),
block_id: block_id.clone(),
grid_contents: "completed successfully".to_owned(),
cursor: String::new(),
requested_command_id: None,
is_alt_screen_active: false,
},
}];
let prompt = prompt_content(
&params,
GalaxyTerminalTools {
status: true,
interrupt: true,
},
)
.expect("prompt");
let text = prompt_text(&prompt);
assert!(text.starts_with("Handle the Galaxy system request"));
assert!(text.contains("hidden_from_transcript"));
assert!(text.contains("A monitored command has completed."));
assert!(text.contains("script/run-soak-test"));
assert!(text.contains(block_id.as_str()));
assert!(text.contains("Final output:\ncompleted successfully"));
assert!(text.contains("Report whether the command succeeded."));
assert!(text.contains("Selected text:\nroot context"));
assert!(!text.contains("galaxy_terminal_status"));
assert!(!text.contains("galaxy_terminal_interrupt"));
assert!(!text.contains("running_for_ms"));
}
#[test] #[test]
fn running_command_identity_and_output_are_sent_as_hidden_context() { fn running_command_identity_and_output_are_sent_as_hidden_context() {
let block_id = BlockId::from("session-42".to_owned()); let block_id = BlockId::from("session-42".to_owned());
+15
View File
@@ -37,6 +37,21 @@ use crate::settings::AISettings;
use crate::terminal::safe_mode_settings::get_secret_obfuscation_mode; use crate::terminal::safe_mode_settings::get_secret_obfuscation_mode;
use crate::workspaces::user_workspaces::UserWorkspaces; use crate::workspaces::user_workspaces::UserWorkspaces;
const INTERNAL_COMMAND_COMPLETION_ASSESSMENT_MESSAGE_DATA: &str =
"galaxy:internal-command-completion-assessment:v1";
pub(crate) fn mark_internal_command_completion_assessment(
message: &mut warp_multi_agent_api::Message,
) {
message.server_message_data = INTERNAL_COMMAND_COMPLETION_ASSESSMENT_MESSAGE_DATA.to_string();
}
pub(crate) fn is_internal_command_completion_assessment(
message: &warp_multi_agent_api::Message,
) -> bool {
message.server_message_data == INTERNAL_COMMAND_COMPLETION_ASSESSMENT_MESSAGE_DATA
}
/// Unique, server-generated conversation-scoped token to be roundtripped to the API when sending /// Unique, server-generated conversation-scoped token to be roundtripped to the API when sending
/// requests that follow-up within a given conversation. /// requests that follow-up within a given conversation.
#[derive(Serialize, Debug, Clone, PartialEq, Eq, Hash)] #[derive(Serialize, Debug, Clone, PartialEq, Eq, Hash)]
+10 -5
View File
@@ -23,6 +23,7 @@ use crate::ai::agent::api::convert_from::{
convert_user_query_mode, ConversionParams, ConvertAPIMessageToClientOutputMessage, convert_user_query_mode, ConversionParams, ConvertAPIMessageToClientOutputMessage,
MaybeAIAgentOutputMessage, MaybeAIAgentOutputMessage,
}; };
use crate::ai::agent::api::is_internal_command_completion_assessment;
use crate::ai::agent::conversation::{ use crate::ai::agent::conversation::{
update_todo_list_from_todo_op, AIConversation, AIConversationId, ServerAIConversationMetadata, update_todo_list_from_todo_op, AIConversation, AIConversationId, ServerAIConversationMetadata,
}; };
@@ -389,17 +390,21 @@ impl ConvertToExchanges for &api::Task {
let added_message_as_exchange_input = match message { let added_message_as_exchange_input = match message {
api::message::Message::UserQuery(user_query) => { api::message::Message::UserQuery(user_query) => {
// Add user query as input if is_internal_command_completion_assessment(api_message) {
current_inputs.push(AIAgentInput::UserQuery { false
} else {
// Add user query as input
current_inputs.push(AIAgentInput::UserQuery {
query: user_query.query.clone(), query: user_query.query.clone(),
context: convert_input_context(user_query.context.as_ref()), context: convert_input_context(user_query.context.as_ref()),
static_query_type: None, static_query_type: None,
referenced_attachments: HashMap::new(), referenced_attachments: HashMap::new(),
user_query_mode: convert_user_query_mode(user_query.mode.as_ref()), user_query_mode: convert_user_query_mode(user_query.mode.as_ref()),
running_command: None, running_command: None,
intended_agent: Some(user_query.intended_agent()), intended_agent: Some(user_query.intended_agent()),
}); });
true true
}
} }
api::message::Message::SystemQuery(query) => { api::message::Message::SystemQuery(query) => {
let Some(query_type) = &query.r#type else { let Some(query_type) = &query.r#type else {
@@ -4,7 +4,7 @@ use chrono::Utc;
use warp_multi_agent_api as api; use warp_multi_agent_api as api;
use crate::ai::agent::api::convert_conversation::*; use crate::ai::agent::api::convert_conversation::*;
use crate::ai::agent::api::ServerConversationToken; use crate::ai::agent::api::{mark_internal_command_completion_assessment, ServerConversationToken};
use crate::ai::agent::conversation::{ use crate::ai::agent::conversation::{
AIAgentHarness, AIConversationId, ServerAIConversationMetadata, AIAgentHarness, AIConversationId, ServerAIConversationMetadata,
}; };
@@ -2129,6 +2129,61 @@ fn test_create_then_edit_then_create_version_tracking() {
); );
} }
#[test]
fn test_internal_command_completion_assessment_restores_output_without_visible_input() {
let assessment_text =
"[Completed command: cargo test]\n[Final terminal output:\ntest result: ok\n]";
let mut hidden_assessment = api::Message {
id: "msg_assessment".to_string(),
task_id: "task1".to_string(),
request_id: "req1".to_string(),
message: Some(api::message::Message::UserQuery(api::message::UserQuery {
query: assessment_text.to_string(),
..Default::default()
})),
..Default::default()
};
mark_internal_command_completion_assessment(&mut hidden_assessment);
let provider_history =
crate::ai::bedrock::request_translator::convert_proto_message(&hidden_assessment)
.expect("hidden assessment should remain in provider history");
assert_eq!(
provider_history.role,
crate::ai::provider::types::MessageRole::User
);
assert!(matches!(
provider_history.content,
crate::ai::provider::types::MessageContent::Text(text) if text == assessment_text
));
let task = api::Task {
id: "task1".to_string(),
messages: vec![
hidden_assessment,
api::Message {
id: "msg_output".to_string(),
task_id: "task1".to_string(),
request_id: "req1".to_string(),
message: Some(api::message::Message::AgentOutput(
api::message::AgentOutput {
text: "The command completed successfully.".to_string(),
},
)),
..Default::default()
},
],
..Default::default()
};
let exchanges = task.into_exchanges();
assert_eq!(exchanges.len(), 1);
assert!(exchanges[0].input.is_empty());
assert_eq!(
exchanges[0].format_output_for_copy(None),
"The command completed successfully."
);
}
/// Verify that a `SystemQuery::HandoffRehydration` message does not produce /// Verify that a `SystemQuery::HandoffRehydration` message does not produce
/// a displayed input when restoring a conversation. It must be treated as /// a displayed input when restoring a conversation. It must be treated as
/// hidden, so the exchange should have zero user-visible inputs. /// hidden, so the exchange should have zero user-visible inputs.
+4
View File
@@ -16,6 +16,7 @@ use warp_multi_agent_api as api;
use crate::ai::agent::api::convert_conversation::{ use crate::ai::agent::api::convert_conversation::{
convert_input_context, convert_tool_call_result_to_input, convert_input_context, convert_tool_call_result_to_input,
}; };
use crate::ai::agent::api::is_internal_command_completion_assessment;
use crate::ai::agent::comment::CodeReview; use crate::ai::agent::comment::CodeReview;
use crate::ai::agent::task::TaskId; use crate::ai::agent::task::TaskId;
use crate::ai::agent::todos::AIAgentTodoList; use crate::ai::agent::todos::AIAgentTodoList;
@@ -957,6 +958,9 @@ pub fn user_inputs_from_messages(messages: &[api::Message]) -> Vec<AIAgentInput>
let Some(inner) = &m.message else { continue }; let Some(inner) = &m.message else { continue };
match inner { match inner {
api::message::Message::UserQuery(uq) => { api::message::Message::UserQuery(uq) => {
if is_internal_command_completion_assessment(m) {
continue;
}
let context = convert_input_context(uq.context.as_ref()); let context = convert_input_context(uq.context.as_ref());
let referenced_attachments = uq let referenced_attachments = uq
.referenced_attachments .referenced_attachments
+35 -4
View File
@@ -7,13 +7,14 @@ use warp_multi_agent_api as api;
use warp_util::local_or_remote_path::LocalOrRemotePath; use warp_util::local_or_remote_path::LocalOrRemotePath;
use super::{ use super::{
convert_api_question, ConversionParams, ConvertAPIMessageToClientOutputMessage, convert_api_question, user_inputs_from_messages, ConversionParams,
MaybeAIAgentOutputMessage, ConvertAPIMessageToClientOutputMessage, MaybeAIAgentOutputMessage,
}; };
use crate::ai::agent::api::mark_internal_command_completion_assessment;
use crate::ai::agent::task::TaskId; use crate::ai::agent::task::TaskId;
use crate::ai::agent::{ use crate::ai::agent::{
runtime_activity, AIAgentActionType, AIAgentOutputMessageType, LifecycleEventType, runtime_activity, AIAgentActionType, AIAgentInput, AIAgentOutputMessageType,
StartAgentExecutionMode, LifecycleEventType, StartAgentExecutionMode,
}; };
fn start_agent_tool_call_message( fn start_agent_tool_call_message(
@@ -617,6 +618,36 @@ fn converts_local_start_agent_v2_with_harness_type() {
assert_eq!(lifecycle_subscription, None); assert_eq!(lifecycle_subscription, None);
} }
#[test]
fn internal_command_completion_assessment_is_not_restored_as_shared_user_input() {
let mut hidden_assessment = api::Message {
id: "hidden-assessment".to_string(),
task_id: "task".to_string(),
message: Some(api::message::Message::UserQuery(api::message::UserQuery {
query: "[Completed command: cargo test]".to_string(),
..Default::default()
})),
..Default::default()
};
mark_internal_command_completion_assessment(&mut hidden_assessment);
let visible_query = api::Message {
id: "visible-query".to_string(),
task_id: "task".to_string(),
message: Some(api::message::Message::UserQuery(api::message::UserQuery {
query: "What changed?".to_string(),
..Default::default()
})),
..Default::default()
};
let inputs = user_inputs_from_messages(&[hidden_assessment, visible_query]);
assert_eq!(inputs.len(), 1);
assert!(matches!(
&inputs[0],
AIAgentInput::UserQuery { query, .. } if query == "What changed?"
));
}
#[test] #[test]
fn transfer_control_tool_call_converts_to_action_message() { fn transfer_control_tool_call_converts_to_action_message() {
let task_id = TaskId::new("task".to_string()); let task_id = TaskId::new("task".to_string());
+37
View File
@@ -348,6 +348,43 @@ fn convert_input_to_user_input(
} }
)) ))
} }
AIAgentInput::CommandCompletionAssessment {
prompt,
completed_command:
RunningCommand {
command,
block_id,
grid_contents: output,
cursor,
requested_command_id,
is_alt_screen_active,
},
..
} => Ok(
api::request::input::user_inputs::user_input::Input::CliAgentUserQuery(
api::request::input::CliAgentUserQuery {
user_query: Some(api::request::input::UserQuery {
query: prompt,
referenced_attachments: Default::default(),
mode: Some(UserQueryMode::Normal.into()),
intended_agent: api::AgentType::Primary.into(),
}),
running_command: Some(api::RunningShellCommand {
command,
snapshot: Some(api::LongRunningShellCommandSnapshot {
output,
cursor,
command_id: block_id.as_str().to_owned(),
is_alt_screen_active,
is_preempted: false,
}),
}),
run_shell_command_tool_call_id: requested_command_id
.map(|id| id.to_string())
.unwrap_or_default(),
},
),
),
AIAgentInput::ActionResult { result, .. } => result.try_into(), AIAgentInput::ActionResult { result, .. } => result.try_into(),
AIAgentInput::MessagesReceivedFromAgents { messages } => Ok( AIAgentInput::MessagesReceivedFromAgents { messages } => Ok(
api::request::input::user_inputs::user_input::Input::MessagesReceivedFromAgents( api::request::input::user_inputs::user_input::Input::MessagesReceivedFromAgents(
+82 -2
View File
@@ -1,11 +1,13 @@
use std::sync::Arc;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use galaxy_core::command::ExitCode; use galaxy_core::command::ExitCode;
use warp_multi_agent_api as api; use warp_multi_agent_api as api;
use crate::ai::agent::task::TaskId; use crate::ai::agent::task::TaskId;
use crate::ai::agent::{ use crate::ai::agent::{
AIAgentActionResult, AIAgentActionResultType, AIAgentContext, ImageContext, AIAgentActionResult, AIAgentActionResultType, AIAgentContext, AIAgentInput, ImageContext,
TransferShellCommandControlToUserResult, RunningCommand, TransferShellCommandControlToUserResult, UserQueryMode,
}; };
use crate::terminal::model::block::BlockId; use crate::terminal::model::block::BlockId;
@@ -132,6 +134,84 @@ fn git_context_deserializes_legacy_string_pull_request_number() {
assert_eq!(pull_request.number, 42); assert_eq!(pull_request.number, 42);
} }
#[test]
fn command_completion_assessment_converts_to_primary_cli_query() {
let block_id = BlockId::from("completed-block".to_string());
let converted = super::convert_input(vec![AIAgentInput::CommandCompletionAssessment {
prompt: "Summarize whether the command succeeded.".to_string(),
context: Arc::from([AIAgentContext::SelectedText("root context".to_string())]),
completed_command: RunningCommand {
command: "cargo test -p galaxy".to_string(),
block_id: block_id.clone(),
grid_contents: "test result: ok".to_string(),
cursor: "cursor".to_string(),
requested_command_id: Some("run-call".to_string().into()),
is_alt_screen_active: true,
},
}])
.unwrap();
let Some(api::request::input::Type::UserInputs(inputs)) = converted.r#type else {
panic!("expected user inputs");
};
let Some(api::request::input::user_inputs::user_input::Input::CliAgentUserQuery(query)) =
inputs.inputs[0].input.as_ref()
else {
panic!("expected CLI agent query");
};
let user_query = query
.user_query
.as_ref()
.expect("expected assessment prompt");
assert_eq!(user_query.query, "Summarize whether the command succeeded.");
assert_eq!(user_query.intended_agent(), api::AgentType::Primary);
let command = query
.running_command
.as_ref()
.expect("expected completed command");
assert_eq!(command.command, "cargo test -p galaxy");
let snapshot = command.snapshot.as_ref().expect("expected final snapshot");
assert_eq!(snapshot.command_id, block_id.as_str());
assert_eq!(snapshot.output, "test result: ok");
assert_eq!(snapshot.cursor, "cursor");
assert!(snapshot.is_alt_screen_active);
assert_eq!(query.run_shell_command_tool_call_id, "run-call");
let active = super::convert_input(vec![AIAgentInput::UserQuery {
query: "Keep monitoring.".to_string(),
context: Arc::from([]),
static_query_type: None,
referenced_attachments: Default::default(),
user_query_mode: UserQueryMode::Normal,
running_command: Some(RunningCommand {
command: "cargo test -p galaxy".to_string(),
block_id,
grid_contents: "still running".to_string(),
cursor: String::new(),
requested_command_id: None,
is_alt_screen_active: false,
}),
intended_agent: None,
}])
.unwrap();
let Some(api::request::input::Type::UserInputs(inputs)) = active.r#type else {
panic!("expected active user inputs");
};
let Some(api::request::input::user_inputs::user_input::Input::CliAgentUserQuery(query)) =
inputs.inputs[0].input.as_ref()
else {
panic!("expected active CLI agent query");
};
assert_eq!(
query
.user_query
.as_ref()
.expect("expected active monitor prompt")
.intended_agent(),
api::AgentType::Cli
);
}
#[test] #[test]
fn transfer_control_snapshot_result_converts_to_tool_call_result_input() { fn transfer_control_snapshot_result_converts_to_tool_call_result_input() {
let block_id = BlockId::default(); let block_id = BlockId::default();
+7 -1
View File
@@ -7,7 +7,7 @@ use warp_multi_agent_api as api;
use super::convert_to::convert_input; use super::convert_to::convert_input;
use super::{ConvertToAPITypeError, RequestParams, ResponseStream, StreamEvent}; use super::{ConvertToAPITypeError, RequestParams, ResponseStream, StreamEvent};
use crate::ai::agent::redaction; use crate::ai::agent::{redaction, AIAgentInput};
use crate::ai::openai::translator as openai_translator; use crate::ai::openai::translator as openai_translator;
use crate::ai::provider::ProviderConfig; use crate::ai::provider::ProviderConfig;
use crate::server::server_api::AIApiError; use crate::server::server_api::AIApiError;
@@ -97,6 +97,10 @@ pub async fn generate_multi_agent_output(
); );
} }
let emit_user_query_message = !params
.input
.iter()
.any(|input| matches!(input, AIAgentInput::CommandCompletionAssessment { .. }));
let mut request = api::Request { let mut request = api::Request {
task_context: Some(api::request::TaskContext { task_context: Some(api::request::TaskContext {
tasks: params.tasks, tasks: params.tasks,
@@ -189,6 +193,7 @@ pub async fn generate_multi_agent_output(
progressive_summary: params.progressive_summary.clone(), progressive_summary: params.progressive_summary.clone(),
messages_sent: params.messages_sent.clone(), messages_sent: params.messages_sent.clone(),
global_rules: params.global_rules.clone(), global_rules: params.global_rules.clone(),
emit_user_query_message,
}; };
match openai_translator::execute(translator_request, &mut request).await { match openai_translator::execute(translator_request, &mut request).await {
@@ -223,6 +228,7 @@ pub async fn generate_multi_agent_output(
bedrock_progressive_summary: params.progressive_summary.clone(), bedrock_progressive_summary: params.progressive_summary.clone(),
bedrock_messages_sent: params.messages_sent.clone(), bedrock_messages_sent: params.messages_sent.clone(),
global_rules: params.global_rules.clone(), global_rules: params.global_rules.clone(),
emit_user_query_message,
}; };
match crate::ai::bedrock::translator::execute(translator_request, &mut request).await { match crate::ai::bedrock::translator::execute(translator_request, &mut request).await {
+15 -2
View File
@@ -2705,6 +2705,13 @@ pub enum AIAgentInput {
intended_agent: Option<AgentType>, intended_agent: Option<AgentType>,
}, },
/// A hidden system turn that asks for the final assessment of a completed command.
CommandCompletionAssessment {
prompt: String,
context: Arc<[AIAgentContext]>,
completed_command: RunningCommand,
},
AutoCodeDiffQuery { AutoCodeDiffQuery {
query: String, query: String,
context: Arc<[AIAgentContext]>, context: Arc<[AIAgentContext]>,
@@ -2869,6 +2876,9 @@ impl Display for AIAgentInput {
Self::UserQuery { .. } => { Self::UserQuery { .. } => {
write!(f, "UserQuery: {}", self.display_query().unwrap_or_default()) write!(f, "UserQuery: {}", self.display_query().unwrap_or_default())
} }
Self::CommandCompletionAssessment { .. } => {
write!(f, "CommandCompletionAssessment")
}
Self::AutoCodeDiffQuery { query, .. } => { Self::AutoCodeDiffQuery { query, .. } => {
write!(f, "AutoCodeDiffQuery: {query}") write!(f, "AutoCodeDiffQuery: {query}")
} }
@@ -2957,7 +2967,8 @@ impl AIAgentInput {
suggestion: PassiveSuggestionResultType::Prompt { prompt }, suggestion: PassiveSuggestionResultType::Prompt { prompt },
.. ..
} => Some(prompt.clone()), } => Some(prompt.clone()),
Self::AutoCodeDiffQuery { .. } Self::CommandCompletionAssessment { .. }
| Self::AutoCodeDiffQuery { .. }
| Self::ActionResult { .. } | Self::ActionResult { .. }
| Self::TriggerPassiveSuggestion { .. } | Self::TriggerPassiveSuggestion { .. }
| Self::ResumeConversation { .. } | Self::ResumeConversation { .. }
@@ -3048,6 +3059,7 @@ impl AIAgentInput {
pub fn context(&self) -> Option<&[AIAgentContext]> { pub fn context(&self) -> Option<&[AIAgentContext]> {
match self { match self {
Self::UserQuery { context, .. } Self::UserQuery { context, .. }
| Self::CommandCompletionAssessment { context, .. }
| Self::ActionResult { context, .. } | Self::ActionResult { context, .. }
| Self::AutoCodeDiffQuery { context, .. } | Self::AutoCodeDiffQuery { context, .. }
| Self::ResumeConversation { context, .. } | Self::ResumeConversation { context, .. }
@@ -3081,7 +3093,8 @@ impl AIAgentInput {
Some(res) Some(res)
} }
Self::TriggerPassiveSuggestion { attachments, .. } => Some(attachments.clone()), Self::TriggerPassiveSuggestion { attachments, .. } => Some(attachments.clone()),
Self::ActionResult { .. } Self::CommandCompletionAssessment { .. }
| Self::ActionResult { .. }
| Self::AutoCodeDiffQuery { .. } | Self::AutoCodeDiffQuery { .. }
| Self::ResumeConversation { .. } | Self::ResumeConversation { .. }
| Self::InitProjectRules { .. } | Self::InitProjectRules { .. }
+52 -2
View File
@@ -1,17 +1,22 @@
use std::collections::HashSet;
use std::ops::Range; use std::ops::Range;
use std::sync::Arc; use std::sync::Arc;
use anyhow::anyhow; use anyhow::anyhow;
use chrono::Local;
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine}; use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
use warp_multi_agent_api::{FileContent, FileContentLineRange}; use warp_multi_agent_api::{FileContent, FileContentLineRange};
use crate::ai::agent::{ use crate::ai::agent::{
AIAgentContext, AIAgentOutput, AIAgentOutputMessage, AIAgentOutputMessageType, AIAgentText, AIAgentContext, AIAgentExchange, AIAgentExchangeId, AIAgentInput, AIAgentOutput,
AIAgentOutputMessage, AIAgentOutputMessageType, AIAgentOutputStatus, AIAgentText,
AIAgentTextSection, AgentOutputImage, AgentOutputImageLayout, AgentOutputMermaidDiagram, AIAgentTextSection, AgentOutputImage, AgentOutputImageLayout, AgentOutputMermaidDiagram,
AnyFileContent, FileContext, FormattedTextWrapper, MessageId, ProgrammingLanguage, AnyFileContent, FileContext, FormattedTextWrapper, MessageId, ProgrammingLanguage,
RenderableAIError, TransientNetworkErrorKind, RenderableAIError, RunningCommand, TransientNetworkErrorKind,
}; };
use crate::ai::llms::LLMId;
use crate::server::server_api::AIApiError; use crate::server::server_api::AIApiError;
use crate::terminal::model::block::BlockId;
use crate::terminal::shell::ShellType; use crate::terminal::shell::ShellType;
fn to_range(range: Range<u32>) -> Option<FileContentLineRange> { fn to_range(range: Range<u32>) -> Option<FileContentLineRange> {
@@ -21,6 +26,51 @@ fn to_range(range: Range<u32>) -> Option<FileContentLineRange> {
}) })
} }
#[test]
fn command_completion_assessment_stays_hidden_from_user_transcript() {
let context: Arc<[AIAgentContext]> =
Arc::from([AIAgentContext::SelectedText("relevant context".to_string())]);
let input = AIAgentInput::CommandCompletionAssessment {
prompt: "Report the final result.".to_string(),
context: context.clone(),
completed_command: RunningCommand {
command: "cargo test -p galaxy".to_string(),
block_id: BlockId::from("completed-command".to_string()),
grid_contents: "test result: ok".to_string(),
cursor: String::new(),
requested_command_id: None,
is_alt_screen_active: false,
},
};
assert_eq!(input.display_query(), None);
assert!(!input.is_user_query());
assert!(!input.is_passive_request());
assert_eq!(input.context(), Some(context.as_ref()));
assert_eq!(input.attachments(), None);
let now = Local::now();
let exchange = AIAgentExchange {
id: AIAgentExchangeId::new(),
input: vec![input],
output_status: AIAgentOutputStatus::Streaming { output: None },
added_message_ids: HashSet::new(),
start_time: now,
finish_time: None,
time_to_first_token_ms: None,
working_directory: None,
model_id: LLMId::from("test-model"),
request_cost: None,
coding_model_id: LLMId::from("test-model"),
cli_agent_model_id: LLMId::from("test-model"),
computer_use_model_id: LLMId::from("test-model"),
response_initiator: None,
};
assert_eq!(exchange.format_input_for_copy(), "");
assert_eq!(exchange.format_for_copy(None), "");
assert!(!exchange.has_user_query());
}
#[test] #[test]
fn formatted_text_wrapper_shares_arc_across_calls() { fn formatted_text_wrapper_shares_arc_across_calls() {
let text = FormattedText::new([FormattedTextLine::Line(vec![ let text = FormattedText::new([FormattedTextLine::Line(vec![
+11
View File
@@ -47,6 +47,17 @@ pub(crate) fn redact_inputs(inputs: &mut [AIAgentInput]) {
redact_secrets(&mut running_command.cursor); redact_secrets(&mut running_command.cursor);
} }
} }
AIAgentInput::CommandCompletionAssessment {
prompt,
context,
completed_command,
} => {
redact_secrets(prompt);
redact_context(Arc::make_mut(context));
redact_secrets(&mut completed_command.command);
redact_secrets(&mut completed_command.grid_contents);
redact_secrets(&mut completed_command.cursor);
}
AIAgentInput::AutoCodeDiffQuery { query, context, .. } => { AIAgentInput::AutoCodeDiffQuery { query, context, .. } => {
redact_secrets(query); redact_secrets(query);
redact_context(Arc::make_mut(context)); redact_context(Arc::make_mut(context));
+2
View File
@@ -26,6 +26,7 @@ pub mod text {
pub fn format_input<W: Write>(input: &AIAgentInput, w: &mut W) -> io::Result<()> { pub fn format_input<W: Write>(input: &AIAgentInput, w: &mut W) -> io::Result<()> {
match input { match input {
AIAgentInput::UserQuery { .. } AIAgentInput::UserQuery { .. }
| AIAgentInput::CommandCompletionAssessment { .. }
| AIAgentInput::AutoCodeDiffQuery { .. } | AIAgentInput::AutoCodeDiffQuery { .. }
| AIAgentInput::CreateNewProject { .. } | AIAgentInput::CreateNewProject { .. }
| AIAgentInput::CloneRepository { .. } | AIAgentInput::CloneRepository { .. }
@@ -785,6 +786,7 @@ pub mod json {
match input { match input {
// Do not include the user query, since it's already provided as input to the agent. // Do not include the user query, since it's already provided as input to the agent.
AIAgentInput::UserQuery { .. } AIAgentInput::UserQuery { .. }
| AIAgentInput::CommandCompletionAssessment { .. }
| AIAgentInput::AutoCodeDiffQuery { .. } | AIAgentInput::AutoCodeDiffQuery { .. }
| AIAgentInput::CreateNewProject { .. } | AIAgentInput::CreateNewProject { .. }
| AIAgentInput::CloneRepository { .. } | AIAgentInput::CloneRepository { .. }
+86 -48
View File
@@ -7,6 +7,7 @@ use warp_multi_agent_api as api;
use super::convert::{ use super::convert::{
ContentPart, ConversationMessage, MessageContent, MessageRole, ToolDefinition, ContentPart, ConversationMessage, MessageContent, MessageRole, ToolDefinition,
}; };
use crate::ai::agent::api::mark_internal_command_completion_assessment;
/// Command-monitor turns must wake often enough to react to steering and user-specified deadlines. /// Command-monitor turns must wake often enough to react to steering and user-specified deadlines.
/// ///
@@ -91,32 +92,11 @@ pub fn extract_new_input_messages(request: &api::Request) -> Vec<ConversationMes
) => { ) => {
if let Some(user_query) = &cli_query.user_query { if let Some(user_query) = &cli_query.user_query {
if !user_query.query.is_empty() { if !user_query.query.is_empty() {
let query_text =
if let Some(running_cmd) = &cli_query.running_command {
let mut context =
format!("[Running command: {}]\n", running_cmd.command);
if let Some(snapshot) = &running_cmd.snapshot {
if !snapshot.command_id.is_empty() {
context.push_str(&format!(
"[Command ID: {}]\n",
snapshot.command_id
));
}
if !snapshot.output.is_empty() {
context.push_str(&format!(
"[Terminal output:\n{}\n]\n",
snapshot.output
));
}
}
context.push_str(&user_query.query);
context
} else {
user_query.query.clone()
};
user_queries.push(ConversationMessage { user_queries.push(ConversationMessage {
role: MessageRole::User, role: MessageRole::User,
content: MessageContent::Text(query_text), content: MessageContent::Text(cli_query_text(
cli_query, user_query,
)),
}); });
} }
} }
@@ -565,24 +545,7 @@ fn extract_input_messages(request: &api::Request) -> Vec<api::Message> {
) => { ) => {
if let Some(user_query) = &cli_query.user_query { if let Some(user_query) = &cli_query.user_query {
if !user_query.query.is_empty() { if !user_query.query.is_empty() {
let query_text = let mut message = api::Message {
if let Some(running_cmd) = &cli_query.running_command {
let mut context =
format!("[Running command: {}]\n", running_cmd.command);
if let Some(snapshot) = &running_cmd.snapshot {
if !snapshot.output.is_empty() {
context.push_str(&format!(
"[Terminal output:\n{}\n]\n",
snapshot.output
));
}
}
context.push_str(&user_query.query);
context
} else {
user_query.query.clone()
};
results.push(api::Message {
id: uuid::Uuid::new_v4().to_string(), id: uuid::Uuid::new_v4().to_string(),
task_id: task_id.clone(), task_id: task_id.clone(),
request_id: String::new(), request_id: String::new(),
@@ -592,11 +555,15 @@ fn extract_input_messages(request: &api::Request) -> Vec<api::Message> {
fetched_memories: vec![], fetched_memories: vec![],
message: Some(api::message::Message::UserQuery( message: Some(api::message::Message::UserQuery(
api::message::UserQuery { api::message::UserQuery {
query: query_text, query: cli_query_text(cli_query, user_query),
..Default::default() ..Default::default()
}, },
)), )),
}); };
if cli_query_is_completed_assessment(cli_query) {
mark_internal_command_completion_assessment(&mut message);
}
results.push(message);
} }
} }
} }
@@ -1235,12 +1202,57 @@ fn collect_tool_result_ids(content: &MessageContent, ids: &mut std::collections:
} }
} }
fn cli_query_is_completed_assessment(cli_query: &api::request::input::CliAgentUserQuery) -> bool {
cli_query
.user_query
.as_ref()
.is_some_and(|query| query.intended_agent() == api::AgentType::Primary)
}
fn cli_query_text(
cli_query: &api::request::input::CliAgentUserQuery,
user_query: &api::request::input::UserQuery,
) -> String {
let Some(command) = &cli_query.running_command else {
return user_query.query.clone();
};
let completed = cli_query_is_completed_assessment(cli_query);
let mut context = format!(
"[{}: {}]\n",
if completed {
"Completed command"
} else {
"Running command"
},
command.command
);
if let Some(snapshot) = &command.snapshot {
if !snapshot.command_id.is_empty() {
context.push_str(&format!("[Command ID: {}]\n", snapshot.command_id));
}
if !snapshot.output.is_empty() {
context.push_str(&format!(
"[{}:\n{}\n]\n",
if completed {
"Final terminal output"
} else {
"Terminal output"
},
snapshot.output
));
}
}
context.push_str(&user_query.query);
context
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum AgentMode { enum AgentMode {
Normal, Normal,
Plan, Plan,
Orchestrate, Orchestrate,
Cli, Cli,
CompletedCommandAssessment,
} }
fn request_agent_mode(request: &api::Request) -> AgentMode { fn request_agent_mode(request: &api::Request) -> AgentMode {
@@ -1252,6 +1264,17 @@ fn request_agent_mode(request: &api::Request) -> AgentMode {
return AgentMode::Normal; return AgentMode::Normal;
}; };
if user_inputs.inputs.iter().any(|user_input| {
matches!(
&user_input.input,
Some(api::request::input::user_inputs::user_input::Input::CliAgentUserQuery(
cli_query
)) if cli_query_is_completed_assessment(cli_query)
)
}) {
return AgentMode::CompletedCommandAssessment;
}
let mut mode = AgentMode::Normal; let mut mode = AgentMode::Normal;
for user_input in &user_inputs.inputs { for user_input in &user_inputs.inputs {
match &user_input.input { match &user_input.input {
@@ -1511,6 +1534,15 @@ pub fn extract_system_prompt(
clear reason.\n\n", clear reason.\n\n",
); );
} }
AgentMode::CompletedCommandAssessment => {
prompt.push_str("## Completed Command Assessment\n");
prompt.push_str(
"The monitored command has finished. Use its command, command ID, final terminal \
output, and the assessment instruction in the latest hidden input to provide the \
final user-facing outcome. Do not continue polling, request more terminal output, \
or call tools.\n\n",
);
}
} }
prompt.push_str("## Available Tools\n"); prompt.push_str("## Available Tools\n");
@@ -1559,6 +1591,10 @@ pub fn extract_system_prompt(
} }
pub fn extract_tools(request: &api::Request) -> Vec<ToolDefinition> { pub fn extract_tools(request: &api::Request) -> Vec<ToolDefinition> {
if request_agent_mode(request) == AgentMode::CompletedCommandAssessment {
return Vec::new();
}
let mut tools = default_tool_definitions(); let mut tools = default_tool_definitions();
let mut seen_names: std::collections::HashSet<String> = let mut seen_names: std::collections::HashSet<String> =
tools.iter().map(|t| t.name.clone()).collect(); tools.iter().map(|t| t.name.clone()).collect();
@@ -1620,10 +1656,12 @@ pub fn extract_tools(request: &api::Request) -> Vec<ToolDefinition> {
fn supported_tool_types(request: &api::Request) -> Option<HashSet<api::ToolType>> { fn supported_tool_types(request: &api::Request) -> Option<HashSet<api::ToolType>> {
let settings = request.settings.as_ref()?; let settings = request.settings.as_ref()?;
let raw_tools = if request_agent_mode(request) == AgentMode::Cli { let raw_tools = match request_agent_mode(request) {
&settings.supported_cli_agent_tools AgentMode::Cli => &settings.supported_cli_agent_tools,
} else { AgentMode::Normal
&settings.supported_tools | AgentMode::Plan
| AgentMode::Orchestrate
| AgentMode::CompletedCommandAssessment => &settings.supported_tools,
}; };
Some( Some(
raw_tools raw_tools
@@ -5,6 +5,7 @@ use super::{
convert_proto_message_for_test, extract_new_input_messages, extract_system_prompt, convert_proto_message_for_test, extract_new_input_messages, extract_system_prompt,
extract_tools, inject_input_messages_into_task, sanitize_messages_for_bedrock, extract_tools, inject_input_messages_into_task, sanitize_messages_for_bedrock,
}; };
use crate::ai::agent::api::is_internal_command_completion_assessment;
use crate::ai::bedrock::convert::{ContentPart, ConversationMessage, MessageContent, MessageRole}; use crate::ai::bedrock::convert::{ContentPart, ConversationMessage, MessageContent, MessageRole};
#[test] #[test]
@@ -237,6 +238,117 @@ fn plan_mode_prompt_prohibits_mutation() {
assert!(prompt.contains("do not edit files")); assert!(prompt.contains("do not edit files"));
} }
fn completed_command_request() -> api::Request {
api::Request {
task_context: Some(api::request::TaskContext {
tasks: vec![api::Task {
id: "root-task".to_string(),
..Default::default()
}],
}),
input: Some(api::request::Input {
r#type: Some(api::request::input::Type::UserInputs(
api::request::input::UserInputs {
inputs: vec![api::request::input::user_inputs::UserInput {
input: Some(
api::request::input::user_inputs::user_input::Input::CliAgentUserQuery(
api::request::input::CliAgentUserQuery {
user_query: Some(api::request::input::UserQuery {
query: "Report the final outcome.".to_string(),
intended_agent: api::AgentType::Primary.into(),
..Default::default()
}),
running_command: Some(api::RunningShellCommand {
command: "cargo test -p galaxy".to_string(),
snapshot: Some(api::LongRunningShellCommandSnapshot {
command_id: "completed-block-123".to_string(),
output: "test result: ok".to_string(),
cursor: "cursor".to_string(),
..Default::default()
}),
}),
..Default::default()
},
),
),
}],
},
)),
..Default::default()
}),
settings: Some(api::request::Settings {
supported_tools: vec![
api::ToolType::RunShellCommand.into(),
api::ToolType::ReadFiles.into(),
api::ToolType::CallMcpTool.into(),
],
supported_cli_agent_tools: vec![api::ToolType::ReadShellCommandOutput.into()],
..Default::default()
}),
mcp_context: Some(api::request::McpContext {
servers: vec![api::request::mcp_context::McpServer {
id: "server-id".to_string(),
name: "test-server".to_string(),
description: String::new(),
resources: Vec::new(),
tools: vec![api::request::mcp_context::McpTool {
name: "echo".to_string(),
description: "Echo input".to_string(),
input_schema: None,
}],
}],
..Default::default()
}),
..Default::default()
}
}
#[test]
fn completed_command_assessment_is_tool_free_and_persists_hidden_provider_history() {
let mut request = completed_command_request();
let messages = extract_new_input_messages(&request);
assert_eq!(messages.len(), 1);
assert!(matches!(
&messages[0].content,
MessageContent::Text(text)
if text.contains("[Completed command: cargo test -p galaxy]")
&& text.contains("[Command ID: completed-block-123]")
&& text.contains("[Final terminal output:\ntest result: ok")
&& text.contains("Report the final outcome.")
));
let prompt = extract_system_prompt(&request, &[]).expect("system prompt");
assert!(prompt.contains("## Completed Command Assessment"));
assert!(prompt.contains("No tools are available for this request"));
assert!(!prompt.contains("## Running Command Monitor"));
assert!(!prompt.contains("next assistant output MUST be a tool call"));
assert!(extract_tools(&request).is_empty());
inject_input_messages_into_task(&mut request);
let persisted = &request.task_context.as_ref().expect("task context").tasks[0].messages;
assert_eq!(persisted.len(), 1);
assert!(is_internal_command_completion_assessment(&persisted[0]));
assert!(matches!(
persisted[0].message.as_ref(),
Some(api::message::Message::UserQuery(query))
if query.query.contains("[Completed command: cargo test -p galaxy]")
&& query.query.contains("[Command ID: completed-block-123]")
&& query.query.contains("[Final terminal output:\ntest result: ok")
&& query.query.contains("Report the final outcome.")
));
let restored = convert_proto_message_for_test(&persisted[0])
.expect("hidden assessment should remain in provider history");
assert_eq!(restored.role, MessageRole::User);
assert!(matches!(
restored.content,
MessageContent::Text(text)
if text.contains("[Completed command: cargo test -p galaxy]")
&& text.contains("Report the final outcome.")
));
}
#[test] #[test]
fn running_command_turn_gets_monitor_prompt_and_cli_tools() { fn running_command_turn_gets_monitor_prompt_and_cli_tools() {
let request = api::Request { let request = api::Request {
+6 -1
View File
@@ -21,6 +21,8 @@ pub struct TranslatorRequest {
pub bedrock_messages_sent: Arc<Mutex<Vec<ConversationMessage>>>, pub bedrock_messages_sent: Arc<Mutex<Vec<ConversationMessage>>>,
/// Global rules (name, content) from the local CloudModel. /// Global rules (name, content) from the local CloudModel.
pub global_rules: Vec<(String, String)>, pub global_rules: Vec<(String, String)>,
/// Whether the native input should be emitted as a transcript-visible user query.
pub emit_user_query_message: bool,
} }
pub async fn execute( pub async fn execute(
@@ -131,7 +133,10 @@ pub async fn execute(
); );
} }
let user_query_text = request_translator::extract_user_query_text(request); let user_query_text = params
.emit_user_query_message
.then(|| request_translator::extract_user_query_text(request))
.flatten();
let stream = bedrock let stream = bedrock
.converse_stream( .converse_stream(
+2 -3
View File
@@ -400,10 +400,10 @@ impl CLISubagentController {
} }
fn advance_completed_subagent(&mut self, block_id: &BlockId, ctx: &mut ModelContext<Self>) { fn advance_completed_subagent(&mut self, block_id: &BlockId, ctx: &mut ModelContext<Self>) {
let Some((task_id, completion)) = self let Some(completion) = self
.active_subagents_by_block .active_subagents_by_block
.get(block_id) .get(block_id)
.and_then(|state| Some((state.task_id.clone()?, state.completion.as_ref()?.clone()))) .and_then(|state| state.completion.as_ref().cloned())
else { else {
return; return;
}; };
@@ -433,7 +433,6 @@ impl CLISubagentController {
let sent = self.controller.update(ctx, |controller, ctx| { let sent = self.controller.update(ctx, |controller, ctx| {
controller.send_command_completion_assessment( controller.send_command_completion_assessment(
completion.conversation_id, completion.conversation_id,
task_id,
completion.prompt, completion.prompt,
completion.completed_command, completion.completed_command,
ctx, ctx,
+1
View File
@@ -1375,6 +1375,7 @@ impl AIAgentInput {
app, app,
)), )),
AIAgentInput::UserQuery { .. } AIAgentInput::UserQuery { .. }
| AIAgentInput::CommandCompletionAssessment { .. }
| AIAgentInput::AutoCodeDiffQuery { .. } | AIAgentInput::AutoCodeDiffQuery { .. }
| AIAgentInput::ResumeConversation { .. } | AIAgentInput::ResumeConversation { .. }
| AIAgentInput::InitProjectRules { .. } | AIAgentInput::InitProjectRules { .. }
@@ -3687,6 +3687,7 @@ pub(super) fn query_prefix_highlight_len(
match input { match input {
AIAgentInput::InvokeSkill { skill, .. } => Some(1 + skill.name.len()), AIAgentInput::InvokeSkill { skill, .. } => Some(1 + skill.name.len()),
AIAgentInput::UserQuery { .. } AIAgentInput::UserQuery { .. }
| AIAgentInput::CommandCompletionAssessment { .. }
| AIAgentInput::AutoCodeDiffQuery { .. } | AIAgentInput::AutoCodeDiffQuery { .. }
| AIAgentInput::ResumeConversation { .. } | AIAgentInput::ResumeConversation { .. }
| AIAgentInput::InitProjectRules { .. } | AIAgentInput::InitProjectRules { .. }
+17 -12
View File
@@ -1597,7 +1597,7 @@ impl BlocklistAIController {
); );
} }
/// Sends one non-preemptive final assessment to a completed CLI-monitor task. /// Sends one non-preemptive final assessment to the root task after a CLI monitor completes.
/// ///
/// This deliberately bypasses `send_query`: command completion must not cancel /// This deliberately bypasses `send_query`: command completion must not cancel
/// another conversation, drain unrelated action results, or replace a request /// another conversation, drain unrelated action results, or replace a request
@@ -1605,8 +1605,7 @@ impl BlocklistAIController {
pub fn send_command_completion_assessment( pub fn send_command_completion_assessment(
&mut self, &mut self,
conversation_id: AIConversationId, conversation_id: AIConversationId,
task_id: TaskId, prompt: String,
query: String,
completed_command: RunningCommand, completed_command: RunningCommand,
ctx: &mut ModelContext<Self>, ctx: &mut ModelContext<Self>,
) -> bool { ) -> bool {
@@ -1621,6 +1620,16 @@ impl BlocklistAIController {
return false; return false;
} }
let Some(root_task_id) = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&conversation_id)
.map(|conversation| conversation.get_root_task_id().clone())
else {
log::warn!(
"Cannot send command completion assessment for missing conversation \
{conversation_id:?}"
);
return false;
};
let context = input_context_for_request( let context = input_context_for_request(
false, false,
self.context_model.as_ref(ctx), self.context_model.as_ref(ctx),
@@ -1629,16 +1638,12 @@ impl BlocklistAIController {
ctx, ctx,
); );
let request_input = RequestInput::for_task( let request_input = RequestInput::for_task(
vec![AIAgentInput::UserQuery { vec![AIAgentInput::CommandCompletionAssessment {
query, prompt,
context, context,
static_query_type: None, completed_command,
referenced_attachments: HashMap::new(),
user_query_mode: UserQueryMode::Normal,
running_command: Some(completed_command),
intended_agent: None,
}], }],
task_id, root_task_id,
&self.active_session, &self.active_session,
self.get_current_response_initiator(), self.get_current_response_initiator(),
conversation_id, conversation_id,
@@ -4553,7 +4558,7 @@ impl BlocklistAIController {
.as_ref() .as_ref()
.map(|stream_cancellation| format!("{:?}", stream_cancellation.reason)), .map(|stream_cancellation| format!("{:?}", stream_cancellation.reason)),
"queued_tools": remote_action_summaries(&actions_to_queue), "queued_tools": remote_action_summaries(&actions_to_queue),
"proposed_tools": remote_action_summaries(&proposed_actions), "proposed_tools": remote_action_summaries(proposed_actions),
}), }),
}, },
); );
@@ -376,6 +376,7 @@ impl ResponseStream {
} }
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
#[allow(clippy::too_many_arguments)]
fn log_llm_request_started( fn log_llm_request_started(
ctx: &mut ModelContext<Self>, ctx: &mut ModelContext<Self>,
stream_id: &ResponseStreamId, stream_id: &ResponseStreamId,
+123 -2
View File
@@ -21,8 +21,10 @@ use crate::ai::agent::conversation::{
ServerAIConversationMetadata, ServerAIConversationMetadata,
}; };
use crate::ai::agent::{ use crate::ai::agent::{
AIAgentExchange, AIAgentExchangeId, AIAgentInput, AIAgentOutputStatus, FinishedAIAgentOutput, AIAgentExchange, AIAgentExchangeId, AIAgentInput, AIAgentOutput, AIAgentOutputMessage,
RenderableAIError, Shared, TransientNetworkErrorKind, UserQueryMode, AIAgentOutputMessageType, AIAgentOutputStatus, AIAgentText, AIAgentTextSection,
AgentOutputText, FinishedAIAgentOutput, MessageId, RenderableAIError, RunningCommand, Shared,
TransientNetworkErrorKind, UserQueryMode,
}; };
use crate::ai::ambient_agents::{ use crate::ai::ambient_agents::{
conversation_output_status_from_conversation, AmbientAgentTaskId, AmbientConversationStatus, conversation_output_status_from_conversation, AmbientAgentTaskId, AmbientConversationStatus,
@@ -270,6 +272,125 @@ fn deactivating_cli_subtask_clears_activity_without_deleting_task() {
}); });
} }
#[test]
fn completed_command_assessment_survives_cli_subtask_deactivation_on_root() {
App::test((), |mut app| async move {
initialize_history_persistence_for_tests(&mut app);
let terminal_view_id = EntityId::new();
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let block_id = BlockId::new();
let assessment_output = "The command completed successfully.";
history_model.update(&mut app, |model, ctx| {
let conversation_id =
model.start_new_conversation(terminal_view_id, false, false, false, ctx);
let cli_task_id = model
.create_cli_subagent_task_for_conversation(
block_id.clone(),
conversation_id,
terminal_view_id,
ctx,
)
.expect("CLI subtask should be created");
let monitor_exchange =
create_exchange_with_query("Check the command status.", Local::now(), None);
let monitor_exchange_id = monitor_exchange.id;
let conversation = model
.conversation_mut(&conversation_id)
.expect("conversation should exist");
conversation
.append_task_exchange_for_test(
&cli_task_id,
monitor_exchange,
terminal_view_id,
ctx,
)
.expect("monitor exchange should be appended to the CLI task");
let now = Local::now();
let assessment_exchange = AIAgentExchange {
id: AIAgentExchangeId::new(),
input: vec![AIAgentInput::CommandCompletionAssessment {
prompt: "Assess the completed command.".to_string(),
context: Arc::from([]),
completed_command: RunningCommand {
command: "cargo test -p galaxy".to_string(),
block_id: block_id.clone(),
grid_contents: "test result: ok".to_string(),
cursor: String::new(),
requested_command_id: None,
is_alt_screen_active: false,
},
}],
output_status: AIAgentOutputStatus::Finished {
finished_output: FinishedAIAgentOutput::Success {
output: Shared::new(AIAgentOutput {
messages: vec![AIAgentOutputMessage {
id: MessageId::new("assessment-output".to_string()),
message: AIAgentOutputMessageType::Text(AIAgentText {
sections: vec![AIAgentTextSection::PlainText {
text: AgentOutputText::from(assessment_output.to_string()),
}],
}),
citations: vec![],
}],
..Default::default()
}),
},
},
added_message_ids: HashSet::new(),
start_time: now,
finish_time: Some(now),
time_to_first_token_ms: None,
working_directory: None,
model_id: LLMId::from("test-model"),
request_cost: None,
coding_model_id: LLMId::from("test-coding-model"),
cli_agent_model_id: LLMId::from("test-cli-agent-model"),
computer_use_model_id: LLMId::from("test-computer-use-model"),
response_initiator: None,
};
let assessment_exchange_id = assessment_exchange.id;
model
.conversation_mut(&conversation_id)
.expect("conversation should exist")
.append_root_exchange_for_test(assessment_exchange);
model
.deactivate_cli_subagent_task_for_conversation(&block_id, conversation_id)
.expect("CLI subtask should deactivate");
let conversation = model
.conversation(&conversation_id)
.expect("conversation should still exist");
assert!(!conversation.has_active_subagent());
let cli_task = conversation
.get_task(&cli_task_id)
.expect("CLI task should be retained after deactivation");
assert_eq!(cli_task.exchanges_len(), 1);
assert_eq!(
cli_task.last_exchange().map(|exchange| exchange.id),
Some(monitor_exchange_id)
);
let root_exchange = conversation
.latest_visible_exchange()
.expect("root assessment output should remain visible");
assert_eq!(root_exchange.id, assessment_exchange_id);
assert!(matches!(
root_exchange.input.as_slice(),
[AIAgentInput::CommandCompletionAssessment { .. }]
));
assert!(root_exchange.input[0].display_query().is_none());
assert_eq!(
root_exchange.format_output_for_copy(None),
assessment_output
);
});
});
}
#[test] #[test]
fn monitoring_a_different_block_preserves_completed_cli_task_history() { fn monitoring_a_different_block_preserves_completed_cli_task_history() {
App::test((), |mut app| async move { App::test((), |mut app| async move {
+2 -1
View File
@@ -77,7 +77,8 @@ impl TryFrom<&AIAgentInput> for PersistedAIInputType {
AIAgentInput::PassiveSuggestionResult { suggestion: PassiveSuggestionResultType::CodeDiff { .. }, .. } => Err(anyhow!( AIAgentInput::PassiveSuggestionResult { suggestion: PassiveSuggestionResultType::CodeDiff { .. }, .. } => Err(anyhow!(
"PassiveSuggestionResult::CodeDiff is not persisted as a query." "PassiveSuggestionResult::CodeDiff is not persisted as a query."
)), )),
AIAgentInput::ActionResult { .. } AIAgentInput::CommandCompletionAssessment { .. }
| AIAgentInput::ActionResult { .. }
| AIAgentInput::ResumeConversation { .. } | AIAgentInput::ResumeConversation { .. }
| AIAgentInput::InitProjectRules { .. } | AIAgentInput::InitProjectRules { .. }
| AIAgentInput::CreateEnvironment { .. } | AIAgentInput::CreateEnvironment { .. }
+6 -1
View File
@@ -24,6 +24,8 @@ pub struct TranslatorRequest {
pub messages_sent: Arc<Mutex<Vec<ConversationMessage>>>, pub messages_sent: Arc<Mutex<Vec<ConversationMessage>>>,
/// Global rules (name, content) from the local CloudModel. /// Global rules (name, content) from the local CloudModel.
pub global_rules: Vec<(String, String)>, pub global_rules: Vec<(String, String)>,
/// Whether the native input should be emitted as a transcript-visible user query.
pub emit_user_query_message: bool,
} }
pub(crate) struct PreparedTurn { pub(crate) struct PreparedTurn {
@@ -102,7 +104,10 @@ pub(crate) fn prepare_turn(params: &TranslatorRequest, request: &mut api::Reques
PreparedTurn { PreparedTurn {
task_id, task_id,
needs_create_task, needs_create_task,
user_query: request_translator::extract_user_query_text(request), user_query: params
.emit_user_query_message
.then(|| request_translator::extract_user_query_text(request))
.flatten(),
messages, messages,
system_prompt: request_translator::extract_system_prompt(request, &params.global_rules), system_prompt: request_translator::extract_system_prompt(request, &params.global_rules),
tools, tools,
+46 -9
View File
@@ -110,18 +110,29 @@ fn prepare_rig_turn_for_provider(
let mode = request_mode(&input); let mode = request_mode(&input);
let available_tools = match mode { let available_tools = match mode {
RigRequestMode::Cli => supported_cli_agent_tools, RigRequestMode::Cli => supported_cli_agent_tools,
RigRequestMode::CompletedCommandAssessment => Vec::new(),
RigRequestMode::Normal | RigRequestMode::Plan | RigRequestMode::Orchestrate => { RigRequestMode::Normal | RigRequestMode::Plan | RigRequestMode::Orchestrate => {
supported_tools supported_tools
} }
}; };
let (mut tools, mcp_tool_aliases) = tool_definitions(&available_tools, mcp_context.as_ref()); let (mut tools, mut mcp_tool_aliases) =
if matches!(mode, RigRequestMode::Cli) { tool_definitions(&available_tools, mcp_context.as_ref());
// History recall cannot advance a running command and is handled inline by the Rig match mode {
// adapter (without producing a client action that can trigger another turn). Keeping it RigRequestMode::Cli => {
// in the CLI tool list lets the model spend its entire monitor turn recalling the prior // History recall cannot advance a running command and is handled inline by the Rig
// snapshot instead of scheduling `read_shell_command_output`, so make polling the only // adapter (without producing a client action that can trigger another turn). Keeping it
// way to inspect the active command here. // in the CLI tool list lets the model spend its entire monitor turn recalling the prior
tools.retain(|tool| tool.name != "recall_tool_history"); // snapshot instead of scheduling `read_shell_command_output`, so make polling the only
// way to inspect the active command here.
tools.retain(|tool| tool.name != "recall_tool_history");
}
RigRequestMode::CompletedCommandAssessment => {
// The caller deliberately disables tools for the final assessment. The inline history
// tool is added independently of supported tool types, so remove it explicitly too.
tools.clear();
mcp_tool_aliases.clear();
}
RigRequestMode::Normal | RigRequestMode::Plan | RigRequestMode::Orchestrate => {}
} }
let system_prompt = build_system_prompt(&input, &tools, &global_rules, mode); let system_prompt = build_system_prompt(&input, &tools, &global_rules, mode);
@@ -243,6 +254,20 @@ fn input_message(input: AIAgentInput) -> Option<ConversationMessage> {
}; };
(text, image_parts(&context)) (text, image_parts(&context))
} }
AIAgentInput::CommandCompletionAssessment {
prompt,
context,
completed_command,
} => (
format!(
"[Completed command: {}]\n[Command ID: {}]\n[Final terminal output:\n{}\n]\n{}",
completed_command.command,
completed_command.block_id,
completed_command.grid_contents,
prompt
),
image_parts(&context),
),
AIAgentInput::ActionResult { .. } => return None, AIAgentInput::ActionResult { .. } => return None,
AIAgentInput::AutoCodeDiffQuery { query, .. } => (query, Vec::new()), AIAgentInput::AutoCodeDiffQuery { query, .. } => (query, Vec::new()),
AIAgentInput::ResumeConversation { .. } => ( AIAgentInput::ResumeConversation { .. } => (
@@ -389,7 +414,8 @@ fn input_user_query(input: &AIAgentInput) -> Option<String> {
match input { match input {
AIAgentInput::UserQuery { query, .. } => Some(query.clone()), AIAgentInput::UserQuery { query, .. } => Some(query.clone()),
AIAgentInput::InvokeSkill { skill, .. } => Some(format!("/{}", skill.name)), AIAgentInput::InvokeSkill { skill, .. } => Some(format!("/{}", skill.name)),
AIAgentInput::AutoCodeDiffQuery { .. } AIAgentInput::CommandCompletionAssessment { .. }
| AIAgentInput::AutoCodeDiffQuery { .. }
| AIAgentInput::ResumeConversation { .. } | AIAgentInput::ResumeConversation { .. }
| AIAgentInput::InitProjectRules { .. } | AIAgentInput::InitProjectRules { .. }
| AIAgentInput::CreateEnvironment { .. } | AIAgentInput::CreateEnvironment { .. }
@@ -414,9 +440,17 @@ enum RigRequestMode {
Plan, Plan,
Orchestrate, Orchestrate,
Cli, Cli,
CompletedCommandAssessment,
} }
fn request_mode(inputs: &[AIAgentInput]) -> RigRequestMode { fn request_mode(inputs: &[AIAgentInput]) -> RigRequestMode {
if inputs
.iter()
.any(|input| matches!(input, AIAgentInput::CommandCompletionAssessment { .. }))
{
return RigRequestMode::CompletedCommandAssessment;
}
for input in inputs { for input in inputs {
// A direct-provider follow-up carries an LRC snapshot as an action result rather than // A direct-provider follow-up carries an LRC snapshot as an action result rather than
// as a user query with `running_command`. Treat that result as a CLI-monitor turn so the // as a user query with `running_command`. Treat that result as a CLI-monitor turn so the
@@ -747,6 +781,9 @@ fn build_system_prompt(
RigRequestMode::Cli => prompt.push_str( RigRequestMode::Cli => prompt.push_str(
"## Running Command Monitor\nThis turn concerns a running or just-finished shell command. Act as its dedicated monitor while still following the user's steering messages. Use the command ID from the tool result for every read/write operation. If the result says the command finished, report its outcome and stop polling. If it says the command is still running, the next assistant output MUST be a tool call: use `read_shell_command_output` with a short delay, or use `interrupt_shell_command` immediately when the user's explicit stop condition is met. Do not end a still-running monitor turn with prose, a status message, or a request for the user to say continue. Never choose a poll interval that crosses a user-specified deadline or stop condition. After an interrupt, poll briefly to verify the outcome. Never start a duplicate command merely to check its state, and never report completion while a result says it is still running.\n\n", "## Running Command Monitor\nThis turn concerns a running or just-finished shell command. Act as its dedicated monitor while still following the user's steering messages. Use the command ID from the tool result for every read/write operation. If the result says the command finished, report its outcome and stop polling. If it says the command is still running, the next assistant output MUST be a tool call: use `read_shell_command_output` with a short delay, or use `interrupt_shell_command` immediately when the user's explicit stop condition is met. Do not end a still-running monitor turn with prose, a status message, or a request for the user to say continue. Never choose a poll interval that crosses a user-specified deadline or stop condition. After an interrupt, poll briefly to verify the outcome. Never start a duplicate command merely to check its state, and never report completion while a result says it is still running.\n\n",
), ),
RigRequestMode::CompletedCommandAssessment => prompt.push_str(
"## Completed Command Assessment\nThe monitored command has finished. Use its command, command ID, final terminal output, and the assessment instruction in the latest hidden input to provide the final user-facing outcome. Do not continue polling, request more terminal output, or call tools.\n\n",
),
} }
prompt.push_str("## Available Tools\n"); prompt.push_str("## Available Tools\n");
if tools.is_empty() { if tools.is_empty() {
+71
View File
@@ -248,6 +248,77 @@ fn rig_prompt_requires_follow_through_without_manual_continue_prompts() {
assert!(prompt.contains("After each tool result, choose and perform the next necessary step")); assert!(prompt.contains("After each tool result, choose and perform the next necessary step"));
} }
#[test]
#[allow(deprecated)]
fn completed_command_assessment_uses_root_history_without_tools_or_monitor_instructions() {
let block_id: galaxy_terminal::model::BlockId = "completed-lrc-test".to_string().into();
let mcp_tool = serde_json::from_value(serde_json::json!({
"name": "echo",
"description": "Echo input",
"inputSchema": {"type": "object"}
}))
.unwrap();
let mut params = RequestParams::new_for_test();
params.root_task_id = Some("root-task".to_string());
params.message_history = vec![galaxy_agent_core::ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text("Prior root conversation".to_string()),
}];
params.mcp_context = Some(MCPContext {
resources: Vec::new(),
tools: Vec::new(),
servers: vec![MCPServer {
id: "11111111-1111-4111-8111-111111111111".to_string(),
name: "Echo".to_string(),
description: String::new(),
resources: Vec::new(),
tools: vec![mcp_tool],
}],
});
params.input = vec![AIAgentInput::CommandCompletionAssessment {
prompt: "Report the final result to the user.".to_string(),
context: Arc::from([]),
completed_command: crate::ai::agent::RunningCommand {
command: "bash loop.sh".to_string(),
block_id,
grid_contents: "All 42 checks passed.".to_string(),
cursor: String::new(),
requested_command_id: None,
is_alt_screen_active: false,
},
}];
let prepared = prepare_rig_turn(
&config(),
params,
vec![ToolType::RunShellCommand, ToolType::CallMcpTool],
vec![ToolType::ReadShellCommandOutput],
);
let prompt = prepared.request.system_prompt.expect("system prompt");
assert_eq!(prepared.task_id, "root-task");
assert_eq!(prepared.user_query, None);
assert!(prepared.request.tools.is_empty());
assert!(prepared.mcp_tool_aliases.is_empty());
assert!(prompt.contains("## Completed Command Assessment"));
assert!(prompt.contains("No tools are available"));
assert!(!prompt.contains("## Running Command Monitor"));
assert!(!prompt.contains("next assistant output MUST be a tool call"));
assert!(prepared.persistent_messages.iter().any(|message| matches!(
&message.content,
MessageContent::Text(text) if text == "Prior root conversation"
)));
assert!(prepared.persistent_messages.iter().any(|message| matches!(
&message.content,
MessageContent::Text(text)
if text.contains("[Completed command: bash loop.sh]")
&& text.contains("[Command ID: completed-lrc-test]")
&& text.contains("[Final terminal output:\nAll 42 checks passed.")
&& text.contains("Report the final result to the user.")
)));
assert_eq!(prepared.request.messages, prepared.persistent_messages);
}
#[test] #[test]
fn lrc_snapshot_follow_up_uses_the_cli_monitor_prompt_and_tools() { fn lrc_snapshot_follow_up_uses_the_cli_monitor_prompt_and_tools() {
let block_id: galaxy_terminal::model::BlockId = "precmd-lrc-test".to_string().into(); let block_id: galaxy_terminal::model::BlockId = "precmd-lrc-test".to_string().into();
+4
View File
@@ -1021,6 +1021,7 @@ pub enum InputUXChangeOrigin {
#[derive(Clone, Debug, Serialize)] #[derive(Clone, Debug, Serialize)]
pub enum AIAgentInput { pub enum AIAgentInput {
UserQuery { query: String }, UserQuery { query: String },
CommandCompletionAssessment,
AutoCodeDiffQuery { query: String }, AutoCodeDiffQuery { query: String },
ResumeConversation, ResumeConversation,
InitProjectRules { display_query: Option<String> }, InitProjectRules { display_query: Option<String> },
@@ -1044,6 +1045,9 @@ impl From<FullAIAgentInput> for AIAgentInput {
fn from(input: FullAIAgentInput) -> Self { fn from(input: FullAIAgentInput) -> Self {
match input { match input {
FullAIAgentInput::UserQuery { query, .. } => Self::UserQuery { query }, FullAIAgentInput::UserQuery { query, .. } => Self::UserQuery { query },
FullAIAgentInput::CommandCompletionAssessment { .. } => {
Self::CommandCompletionAssessment
}
FullAIAgentInput::AutoCodeDiffQuery { query, .. } => Self::AutoCodeDiffQuery { query }, FullAIAgentInput::AutoCodeDiffQuery { query, .. } => Self::AutoCodeDiffQuery { query },
FullAIAgentInput::ResumeConversation { .. } => Self::ResumeConversation, FullAIAgentInput::ResumeConversation { .. } => Self::ResumeConversation,
FullAIAgentInput::InitProjectRules { display_query, .. } => { FullAIAgentInput::InitProjectRules { display_query, .. } => {