This commit is contained in:
2026-08-05 00:56:55 -05:00
parent b0ad07f6f2
commit c321e17708
44 changed files with 2005 additions and 598 deletions
+18 -9
View File
@@ -21,11 +21,12 @@ use crate::ai::agent::task::TaskId;
use crate::ai::agent::todos::AIAgentTodoList;
use crate::ai::agent::util::parse_markdown_into_text_and_code_sections;
use crate::ai::agent::{
AIAgentAction, AIAgentActionType, AIAgentAttachment, AIAgentCitation, AIAgentInput,
AIAgentOutputMessage, AIAgentText, AIAgentTodo, ArtifactCreatedData, CloneRepositoryURL,
MessageId, RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest,
StartAgentExecutionMode, SubagentCall, SubagentType, SuggestedAgentModeWorkflow, SuggestedRule,
Suggestions, SummarizationType, TodoOperation, UserQueryMode, WebFetchStatus, WebSearchStatus,
runtime_activity, AIAgentAction, AIAgentActionType, AIAgentAttachment, AIAgentCitation,
AIAgentInput, AIAgentOutputMessage, AIAgentText, AIAgentTodo, ArtifactCreatedData,
CloneRepositoryURL, MessageId, RunAgentsAgentRunConfig, RunAgentsExecutionMode,
RunAgentsRequest, StartAgentExecutionMode, SubagentCall, SubagentType,
SuggestedAgentModeWorkflow, SuggestedRule, Suggestions, SummarizationType, TodoOperation,
UserQueryMode, WebFetchStatus, WebSearchStatus,
};
use crate::ai::artifact_download::sanitized_basename;
use crate::ai::document::ai_document_model::{AIDocumentId, AIDocumentVersion};
@@ -272,10 +273,18 @@ impl ConvertAPIMessageToClientOutputMessage for api::Message {
.collect::<Result<Vec<AIAgentCitation>, UnknownCitationTypeError>>()?;
match message {
api::message::Message::AgentOutput(output) => Ok(MaybeAIAgentOutputMessage::Message(
AIAgentOutputMessage::text(MessageId::new(self.id), output.into())
.with_citations(citations),
)),
api::message::Message::AgentOutput(output) => {
let message = if let Some(activity) =
runtime_activity::decode(&self.server_message_data)
{
AIAgentOutputMessage::runtime_activity(MessageId::new(self.id), activity)
} else {
AIAgentOutputMessage::text(MessageId::new(self.id), output.into())
};
Ok(MaybeAIAgentOutputMessage::Message(
message.with_citations(citations),
))
}
api::message::Message::AgentReasoning(reasoning) => {
let duration = reasoning
.finished_duration
+45 -1
View File
@@ -2,6 +2,7 @@ use std::path::PathBuf;
use ai::agent::action::AskUserQuestionType;
use ai::skills::{SkillPathOrigin, SkillReference};
use galaxy_agent_core::{RuntimeActivity, RuntimeActivityStatus};
use warp_multi_agent_api as api;
use warp_util::local_or_remote_path::LocalOrRemotePath;
@@ -11,7 +12,8 @@ use super::{
};
use crate::ai::agent::task::TaskId;
use crate::ai::agent::{
AIAgentActionType, AIAgentOutputMessageType, LifecycleEventType, StartAgentExecutionMode,
runtime_activity, AIAgentActionType, AIAgentOutputMessageType, LifecycleEventType,
StartAgentExecutionMode,
};
fn start_agent_tool_call_message(
@@ -665,3 +667,45 @@ fn transfer_control_tool_call_converts_to_action_message() {
}
}
}
#[test]
fn structured_runtime_activity_converts_to_display_only_output() {
let activity = RuntimeActivity {
id: "tool-1".to_owned(),
title: "List directories".to_owned(),
status: Some(RuntimeActivityStatus::Completed),
output: Some("payments\nrecords_v2".to_owned()),
};
let task_id = TaskId::new("task".to_owned());
let message = api::Message {
fetched_memories: Vec::new(),
id: "message".to_owned(),
task_id: "task".to_owned(),
server_message_data: runtime_activity::encode(&activity).expect("metadata"),
citations: Vec::new(),
message: Some(api::message::Message::AgentOutput(
api::message::AgentOutput {
text: "fallback text".to_owned(),
},
)),
request_id: "request".to_owned(),
timestamp: None,
};
let converted = message
.to_client_output_message(ConversionParams {
task_id: &task_id,
current_todo_list: None,
active_code_review: None,
skill_path_origin: &SkillPathOrigin::Local,
})
.expect("conversion");
let MaybeAIAgentOutputMessage::Message(output) = converted else {
panic!("expected display output");
};
assert_eq!(
output.message,
AIAgentOutputMessageType::RuntimeActivity(activity)
);
}
+1 -1
View File
@@ -244,7 +244,7 @@ pub async fn generate_multi_agent_output(
let err = Arc::new(crate::server::server_api::AIApiError::Stream {
stream_type: "none",
source: anyhow::anyhow!(
"No AI provider configured. Enable Bedrock or OpenAI/LiteLLM in settings."
"No AI runtime configured. Enable an agent runtime or model provider in settings."
),
});
let (tx, rx) = async_channel::unbounded();
+21
View File
@@ -705,6 +705,27 @@ impl AIConversation {
&self.agent_backend
}
/// Updates the backend of a conversation that has not produced agent output.
///
/// Provider failures without output are safe to retry through a newly enabled runtime. Once
/// any exchange has produced output, the backend remains stable so provider-native and
/// ACP-owned histories are never mixed.
pub(crate) fn set_agent_backend_if_no_output(&mut self, agent_backend: AgentBackend) -> bool {
let can_change_backend = self.all_exchanges().iter().all(|exchange| {
matches!(
&exchange.output_status,
AIAgentOutputStatus::Finished {
finished_output: FinishedAIAgentOutput::Error { output: None, .. }
}
)
});
if !can_change_backend && self.agent_backend != agent_backend {
return false;
}
self.agent_backend = agent_backend;
true
}
/// Records a resumable ACP session ID.
///
/// Returns `false` when called for a native provider conversation.
+9
View File
@@ -106,6 +106,15 @@ fn restored_conversation_with_queries(queries: &[&str]) -> AIConversation {
.unwrap()
}
#[test]
fn agent_backend_does_not_change_after_successful_output() {
let mut conversation = restored_conversation_with_queries(&["Review this repository"]);
assert!(!conversation
.set_agent_backend_if_no_output(AgentBackend::Acp(AcpConversationData::default())));
assert_eq!(conversation.agent_backend(), &AgentBackend::Provider);
}
#[test]
fn latest_user_query_returns_latest_non_empty_user_query() {
let conversation =
+27
View File
@@ -7,6 +7,7 @@ pub(crate) mod comment;
pub(crate) mod icons;
pub(crate) mod linearization;
pub(crate) mod redaction;
pub(crate) mod runtime_activity;
pub(crate) mod task;
mod task_store;
pub(super) mod telemetry;
@@ -27,6 +28,7 @@ use ai::skills::ParsedSkill;
use chrono::{DateTime, Local, TimeDelta};
use comment::ReviewComment;
use derivative::Derivative;
use galaxy_agent_core::RuntimeActivity;
use galaxy_core::channel::ChannelState;
use galaxy_core::features::FeatureFlag;
use markdown_parser::{parse_markdown, FormattedTable, FormattedText, FormattedTextInline};
@@ -619,6 +621,13 @@ impl AIAgentOutput {
}
}
}
AIAgentOutputMessageType::RuntimeActivity(activity) => {
result.push(activity.title.clone());
if let Some(output) = &activity.output {
result.push(output.clone());
}
last_was_action = true;
}
AIAgentOutputMessageType::TodoOperation(operation) => {
result.push(format!("{operation}"));
last_was_action = false;
@@ -1805,6 +1814,10 @@ pub enum AIAgentOutputMessageType {
token_count: Option<u32>,
},
Subagent(SubagentCall),
/// Display-only activity executed and owned by an external agent runtime.
/// Unlike [`AIAgentOutputMessageType::Action`], Galaxy must never dispatch
/// this activity through its action executor.
RuntimeActivity(RuntimeActivity),
Action(AIAgentAction),
TodoOperation(TodoOperation),
WebSearch(WebSearchStatus),
@@ -1972,6 +1985,12 @@ impl Display for AIAgentOutputMessage {
}
}
AIAgentOutputMessageType::Action(action) => write!(f, "Action: {action}")?,
AIAgentOutputMessageType::RuntimeActivity(activity) => {
write!(f, "Runtime activity: {}", activity.title)?;
if let Some(output) = &activity.output {
write!(f, "\n{output}")?;
}
}
AIAgentOutputMessageType::TodoOperation(todo) => write!(f, "Todo: {todo}")?,
AIAgentOutputMessageType::Subagent(subagent) => write!(f, "Subagent: {subagent}")?,
AIAgentOutputMessageType::WebSearch(status) => match status {
@@ -2044,6 +2063,14 @@ impl AIAgentOutputMessage {
}
}
pub fn runtime_activity(id: MessageId, activity: RuntimeActivity) -> Self {
Self {
id,
message: AIAgentOutputMessageType::RuntimeActivity(activity),
citations: vec![],
}
}
pub fn text(id: MessageId, text: AIAgentText) -> Self {
Self {
id,
+12
View File
@@ -0,0 +1,12 @@
use galaxy_agent_core::RuntimeActivity;
const SERVER_MESSAGE_DATA_PREFIX: &str = "galaxy:runtime-activity:v1:";
pub(crate) fn encode(activity: &RuntimeActivity) -> Result<String, serde_json::Error> {
serde_json::to_string(activity).map(|json| format!("{SERVER_MESSAGE_DATA_PREFIX}{json}"))
}
pub(crate) fn decode(server_message_data: &str) -> Option<RuntimeActivity> {
let json = server_message_data.strip_prefix(SERVER_MESSAGE_DATA_PREFIX)?;
serde_json::from_str(json).ok()
}