Persist direct-provider tool calls, results, display details, and turn identities so saved conversations rebuild individual panes. Keep restored runs dormant until explicitly resumed and bind queued shell commands to their actual execution blocks.
1081 lines
48 KiB
Rust
1081 lines
48 KiB
Rust
//! Conversions from MAA API types to application types.
|
|
use std::collections::HashMap;
|
|
use std::time::Duration;
|
|
|
|
use ai::agent::action::{LifecycleEventType as StartAgentLifecycleEventType, ReadSkillRequest};
|
|
use ai::agent::action_result::StartAgentVersion;
|
|
use ai::agent::convert::ToolToAIAgentActionError;
|
|
use ai::agent::UnknownCitationTypeError;
|
|
use ai::skills::{
|
|
skill_reference_from_api_skill_ref, skill_reference_from_read_skill_ref, SkillPathOrigin,
|
|
};
|
|
use api::ask_user_question::question::QuestionType;
|
|
use galaxy_core::channel::ChannelState;
|
|
use warp_multi_agent_api as api;
|
|
|
|
use crate::ai::agent::api::convert_conversation::{
|
|
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::task::TaskId;
|
|
use crate::ai::agent::todos::AIAgentTodoList;
|
|
use crate::ai::agent::util::parse_markdown_into_text_and_code_sections;
|
|
use crate::ai::agent::{
|
|
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};
|
|
|
|
impl TryFrom<api::Attachment> for AIAgentAttachment {
|
|
type Error = anyhow::Error;
|
|
|
|
fn try_from(attachment: api::Attachment) -> Result<Self, Self::Error> {
|
|
match attachment.value {
|
|
Some(api::attachment::Value::FilePathReference(fpr)) => {
|
|
Ok(AIAgentAttachment::FilePathReference {
|
|
file_id: String::new(),
|
|
file_name: fpr
|
|
.file_path
|
|
.rsplit('/')
|
|
.next()
|
|
.unwrap_or(&fpr.file_path)
|
|
.to_string(),
|
|
file_path: fpr.file_path,
|
|
})
|
|
}
|
|
_ => anyhow::bail!("Unsupported attachment type for conversion"),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn convert_read_skill(
|
|
read_skill: api::message::tool_call::ReadSkill,
|
|
skill_path_origin: &SkillPathOrigin,
|
|
) -> Result<AIAgentActionType, ToolToAIAgentActionError> {
|
|
let Some(reference) = read_skill.skill_reference else {
|
|
return Err(ToolToAIAgentActionError::MissingSkillReference);
|
|
};
|
|
let skill = skill_reference_from_read_skill_ref(reference, skill_path_origin)
|
|
.map_err(|_| ToolToAIAgentActionError::MissingSkillReference)?;
|
|
Ok(AIAgentActionType::ReadSkill(ReadSkillRequest { skill }))
|
|
}
|
|
|
|
/// Converts proto UserQueryMode to the internal UserQueryMode type
|
|
pub(crate) fn convert_user_query_mode(mode: Option<&api::UserQueryMode>) -> UserQueryMode {
|
|
let Some(mode) = mode else {
|
|
return UserQueryMode::default();
|
|
};
|
|
|
|
match &mode.r#type {
|
|
Some(api::user_query_mode::Type::Plan(_)) => UserQueryMode::Plan,
|
|
Some(api::user_query_mode::Type::Orchestrate(_)) => UserQueryMode::Orchestrate,
|
|
None => UserQueryMode::Normal,
|
|
}
|
|
}
|
|
|
|
fn convert_start_agent_lifecycle_event_type(
|
|
event_type: i32,
|
|
) -> Option<StartAgentLifecycleEventType> {
|
|
let event_type = StartAgentLifecycleEventType::try_from(event_type).ok()?;
|
|
(event_type != StartAgentLifecycleEventType::Unspecified).then_some(event_type)
|
|
}
|
|
|
|
fn convert_start_agent_v2_harness_type(
|
|
harness: Option<api::start_agent_v2::execution_mode::Harness>,
|
|
) -> Option<String> {
|
|
harness
|
|
.map(|harness| harness.r#type)
|
|
.filter(|harness_type| !harness_type.trim().is_empty())
|
|
}
|
|
|
|
/// Maps the proto `Harness` oneof to a client-side string identifier
|
|
/// (e.g. "oz", "claude"). Returns `None` for an unset variant.
|
|
pub(crate) fn convert_run_agents_harness(harness: Option<&api::Harness>) -> Option<String> {
|
|
let variant = harness?.variant.as_ref()?;
|
|
Some(
|
|
match variant {
|
|
api::harness::Variant::Oz(_) => "oz",
|
|
api::harness::Variant::ClaudeCode(_) => "claude",
|
|
api::harness::Variant::OpenCode(_) => "opencode",
|
|
api::harness::Variant::Gemini(_) => "gemini",
|
|
api::harness::Variant::Codex(_) => "codex",
|
|
}
|
|
.to_string(),
|
|
)
|
|
}
|
|
|
|
fn convert_start_agent_execution_mode(
|
|
execution_mode: Option<api::start_agent::ExecutionMode>,
|
|
) -> StartAgentExecutionMode {
|
|
match execution_mode.and_then(|execution_mode| execution_mode.mode) {
|
|
Some(api::start_agent::execution_mode::Mode::Remote(remote)) => {
|
|
StartAgentExecutionMode::remote_with_defaults(remote.environment_id)
|
|
}
|
|
Some(api::start_agent::execution_mode::Mode::Local(_)) | None => {
|
|
StartAgentExecutionMode::local_with_defaults()
|
|
}
|
|
}
|
|
}
|
|
|
|
fn convert_run_agents_execution_mode(
|
|
execution_mode: Option<api::run_agents::ExecutionMode>,
|
|
) -> RunAgentsExecutionMode {
|
|
match execution_mode {
|
|
Some(api::run_agents::ExecutionMode::Remote(remote)) => RunAgentsExecutionMode::Remote {
|
|
environment_id: remote.environment_id,
|
|
worker_host: remote.worker_host,
|
|
computer_use_enabled: remote.computer_use_enabled,
|
|
},
|
|
Some(api::run_agents::ExecutionMode::Local(_)) | None => RunAgentsExecutionMode::Local,
|
|
}
|
|
}
|
|
|
|
fn convert_run_agents(
|
|
run_agents: api::RunAgents,
|
|
skill_path_origin: &SkillPathOrigin,
|
|
) -> AIAgentActionType {
|
|
let api::RunAgents {
|
|
summary,
|
|
base_prompt,
|
|
skills,
|
|
model_id,
|
|
harness,
|
|
agent_run_configs,
|
|
execution_mode,
|
|
plan_id,
|
|
} = run_agents;
|
|
AIAgentActionType::RunAgents(RunAgentsRequest {
|
|
summary,
|
|
base_prompt,
|
|
skills: skills
|
|
.into_iter()
|
|
.filter_map(|skill| skill_reference_from_api_skill_ref(skill, skill_path_origin))
|
|
.collect(),
|
|
model_id,
|
|
harness_type: convert_run_agents_harness(harness.as_ref()).unwrap_or_default(),
|
|
execution_mode: convert_run_agents_execution_mode(execution_mode),
|
|
agent_run_configs: agent_run_configs
|
|
.into_iter()
|
|
.map(|config| RunAgentsAgentRunConfig {
|
|
name: config.name,
|
|
prompt: config.prompt,
|
|
title: config.title,
|
|
model_id: String::new(),
|
|
})
|
|
.collect(),
|
|
plan_id,
|
|
// Auth secret is a client-side dispatch concern populated by the
|
|
// confirmation card from `CloudAgentSettings.last_selected_auth_secret`
|
|
// before Accept. The proto does not carry it.
|
|
harness_auth_secret_name: None,
|
|
})
|
|
}
|
|
|
|
fn convert_start_agent_v2_execution_mode(
|
|
execution_mode: Option<api::start_agent_v2::ExecutionMode>,
|
|
skill_path_origin: &SkillPathOrigin,
|
|
) -> StartAgentExecutionMode {
|
|
match execution_mode.and_then(|execution_mode| execution_mode.mode) {
|
|
Some(api::start_agent_v2::execution_mode::Mode::Remote(remote)) => {
|
|
StartAgentExecutionMode::Remote {
|
|
environment_id: remote.environment_id,
|
|
skill_references: remote
|
|
.skills
|
|
.into_iter()
|
|
.filter_map(|skill| {
|
|
skill_reference_from_api_skill_ref(skill, skill_path_origin)
|
|
})
|
|
.collect(),
|
|
model_id: remote.model_id,
|
|
computer_use_enabled: remote.computer_use_enabled,
|
|
worker_host: remote.worker_host,
|
|
harness_type: convert_start_agent_v2_harness_type(remote.harness)
|
|
.unwrap_or_default(),
|
|
title: remote.title,
|
|
// Auth secret is plumbed client-side via `RunAgentsRequest`;
|
|
// StartAgentV2 from the server never carries it.
|
|
auth_secret_name: None,
|
|
}
|
|
}
|
|
Some(api::start_agent_v2::execution_mode::Mode::Local(local)) => {
|
|
convert_start_agent_v2_harness_type(local.harness)
|
|
.map(StartAgentExecutionMode::local_harness)
|
|
.unwrap_or_else(StartAgentExecutionMode::local_with_defaults)
|
|
}
|
|
None => StartAgentExecutionMode::local_with_defaults(),
|
|
}
|
|
}
|
|
|
|
/// Unexpected errors when trying to convert an [`api::Message`] to an [`AIAgentOutputMessage`].
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum MessageToAIAgentOutputMessageError {
|
|
#[error("Invalid saved provider tool: {0}")]
|
|
SavedProviderTool(String),
|
|
#[error("Missing expected message")]
|
|
MissingMessage,
|
|
#[error("Error converting tool to action: {0:?}")]
|
|
ToolError(#[from] ToolToAIAgentActionError),
|
|
#[error("Error converting citation: {0:?}")]
|
|
CitationError(#[from] UnknownCitationTypeError),
|
|
}
|
|
|
|
/// Successful result when trying to convert an [`api::message::ToolCall`] to an [`AIAgentAction`].
|
|
#[allow(clippy::large_enum_variant)]
|
|
pub enum MaybeAIAgentOutputMessage {
|
|
/// There is a mapping to a client output message.
|
|
Message(AIAgentOutputMessage),
|
|
/// We tried to parse a message that we don't care about.
|
|
NoClientRepresentation,
|
|
}
|
|
|
|
/// Successful result when trying to convert an [`api::message::ToolCall`] to an [`AIAgentAction`].
|
|
#[allow(clippy::large_enum_variant)]
|
|
enum MaybeAIAgentAction {
|
|
/// There is a mapping to a client action.
|
|
Action(AIAgentAction),
|
|
Subagent(SubagentCall),
|
|
/// We tried to parse a tool call that we don't care about.
|
|
NoClientRepresentation,
|
|
}
|
|
|
|
pub struct ConversionParams<'a> {
|
|
pub task_id: &'a TaskId,
|
|
pub current_todo_list: Option<&'a AIAgentTodoList>,
|
|
pub active_code_review: Option<&'a CodeReview>,
|
|
pub skill_path_origin: &'a SkillPathOrigin,
|
|
}
|
|
|
|
/// Trait for converting an [`api::Message`] to an [`AIAgentOutputMessage`].
|
|
pub trait ConvertAPIMessageToClientOutputMessage {
|
|
fn to_client_output_message(
|
|
self,
|
|
params: ConversionParams,
|
|
) -> Result<MaybeAIAgentOutputMessage, MessageToAIAgentOutputMessageError>;
|
|
}
|
|
|
|
impl ConvertAPIMessageToClientOutputMessage for api::Message {
|
|
fn to_client_output_message(
|
|
self,
|
|
params: ConversionParams,
|
|
) -> Result<MaybeAIAgentOutputMessage, MessageToAIAgentOutputMessageError> {
|
|
if let Some(action) =
|
|
crate::ai::agent::provider_history::restore_tool(&self.server_message_data)
|
|
{
|
|
return Ok(MaybeAIAgentOutputMessage::Message(
|
|
AIAgentOutputMessage::action(
|
|
MessageId::new(self.id),
|
|
action.map_err(MessageToAIAgentOutputMessageError::SavedProviderTool)?,
|
|
),
|
|
));
|
|
}
|
|
let Some(message) = self.message else {
|
|
// In shared-session streams we can receive skeleton placeholder task messages without payloads.
|
|
// Treat them as having no client representation rather than erroring and aborting ingestion entirely.
|
|
return Ok(MaybeAIAgentOutputMessage::NoClientRepresentation);
|
|
};
|
|
|
|
let citations = self
|
|
.citations
|
|
.iter()
|
|
.map(|citation| (*citation).clone().try_into())
|
|
.collect::<Result<Vec<AIAgentCitation>, UnknownCitationTypeError>>()?;
|
|
|
|
match message {
|
|
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
|
|
.map(|d| Duration::from_secs(d.seconds as u64));
|
|
Ok(MaybeAIAgentOutputMessage::Message(
|
|
AIAgentOutputMessage::reasoning(
|
|
MessageId::new(self.id),
|
|
reasoning.into(),
|
|
duration,
|
|
),
|
|
))
|
|
}
|
|
api::message::Message::ToolCall(tool_call) => match tool_call.to_action(params)? {
|
|
MaybeAIAgentAction::Action(action) => Ok(MaybeAIAgentOutputMessage::Message(
|
|
AIAgentOutputMessage::action(MessageId::new(self.id), action)
|
|
.with_citations(citations),
|
|
)),
|
|
MaybeAIAgentAction::Subagent(subagent) => Ok(MaybeAIAgentOutputMessage::Message(
|
|
AIAgentOutputMessage::subagent(MessageId::new(self.id), subagent)
|
|
.with_citations(citations),
|
|
)),
|
|
MaybeAIAgentAction::NoClientRepresentation => {
|
|
Ok(MaybeAIAgentOutputMessage::NoClientRepresentation)
|
|
}
|
|
},
|
|
api::message::Message::WebSearch(web_search) => {
|
|
let status = match &web_search.status {
|
|
Some(api::message::web_search::Status {
|
|
r#type: Some(api::message::web_search::status::Type::Searching(searching)),
|
|
}) => WebSearchStatus::Searching {
|
|
query: if searching.query.is_empty() {
|
|
None
|
|
} else {
|
|
Some(searching.query.clone())
|
|
},
|
|
},
|
|
Some(api::message::web_search::Status {
|
|
r#type: Some(api::message::web_search::status::Type::Success(success)),
|
|
}) => WebSearchStatus::Success {
|
|
query: success.query.clone(),
|
|
pages: success
|
|
.pages
|
|
.iter()
|
|
.map(|p| (p.url.clone(), p.title.clone()))
|
|
.collect(),
|
|
},
|
|
Some(api::message::web_search::Status {
|
|
r#type: Some(api::message::web_search::status::Type::Error(_)),
|
|
}) => {
|
|
// Error type doesn't have a query field currently, use empty string
|
|
WebSearchStatus::Error {
|
|
query: String::new(),
|
|
}
|
|
}
|
|
_ => {
|
|
// Unknown or missing status
|
|
return Ok(MaybeAIAgentOutputMessage::NoClientRepresentation);
|
|
}
|
|
};
|
|
|
|
Ok(MaybeAIAgentOutputMessage::Message(
|
|
AIAgentOutputMessage::web_search(MessageId::new(self.id), status)
|
|
.with_citations(citations),
|
|
))
|
|
}
|
|
api::message::Message::WebFetch(web_fetch) => {
|
|
let status = match &web_fetch.status {
|
|
Some(api::message::web_fetch::Status {
|
|
r#type: Some(api::message::web_fetch::status::Type::Fetching(fetching)),
|
|
}) => WebFetchStatus::Fetching {
|
|
urls: fetching.urls.clone(),
|
|
},
|
|
Some(api::message::web_fetch::Status {
|
|
r#type: Some(api::message::web_fetch::status::Type::Success(success)),
|
|
}) => WebFetchStatus::Success {
|
|
pages: success
|
|
.pages
|
|
.iter()
|
|
.map(|p| (p.url.clone(), p.title.clone(), p.success))
|
|
.collect(),
|
|
},
|
|
Some(api::message::web_fetch::Status {
|
|
r#type: Some(api::message::web_fetch::status::Type::Error(_)),
|
|
}) => WebFetchStatus::Error,
|
|
_ => {
|
|
// Unknown or missing status
|
|
return Ok(MaybeAIAgentOutputMessage::NoClientRepresentation);
|
|
}
|
|
};
|
|
|
|
Ok(MaybeAIAgentOutputMessage::Message(
|
|
AIAgentOutputMessage::web_fetch(MessageId::new(self.id), status)
|
|
.with_citations(citations),
|
|
))
|
|
}
|
|
api::message::Message::ModelUsed(_) => {
|
|
Ok(MaybeAIAgentOutputMessage::NoClientRepresentation)
|
|
}
|
|
api::message::Message::UpdateTodos(update_todos) => {
|
|
if let Some(operation) = update_todos.operation {
|
|
match operation {
|
|
api::message::update_todos::Operation::CreateTodoList(create_todo_list) => {
|
|
Ok(MaybeAIAgentOutputMessage::Message(
|
|
AIAgentOutputMessage::todo_operation(
|
|
MessageId::new(self.id),
|
|
TodoOperation::UpdateTodos {
|
|
todos: create_todo_list
|
|
.initial_todos
|
|
.into_iter()
|
|
.map(Into::into)
|
|
.collect(),
|
|
},
|
|
)
|
|
.with_citations(citations),
|
|
))
|
|
}
|
|
api::message::update_todos::Operation::UpdatePendingTodos(
|
|
update_pending_todos,
|
|
) => Ok(MaybeAIAgentOutputMessage::Message(
|
|
AIAgentOutputMessage::todo_operation(
|
|
MessageId::new(self.id),
|
|
TodoOperation::UpdateTodos {
|
|
todos: params
|
|
.current_todo_list
|
|
.iter()
|
|
.flat_map(|list| list.completed_items().iter().cloned())
|
|
.chain(
|
|
update_pending_todos
|
|
.updated_pending_todos
|
|
.into_iter()
|
|
.map(Into::into),
|
|
)
|
|
.collect(),
|
|
},
|
|
)
|
|
.with_citations(citations),
|
|
)),
|
|
api::message::update_todos::Operation::MarkTodosCompleted(
|
|
mark_todos_completed,
|
|
) => {
|
|
if mark_todos_completed.todo_ids.is_empty() {
|
|
Ok(MaybeAIAgentOutputMessage::NoClientRepresentation)
|
|
} else {
|
|
// This is a mark as completed operation
|
|
Ok(MaybeAIAgentOutputMessage::Message(
|
|
AIAgentOutputMessage::todo_operation(
|
|
MessageId::new(self.id),
|
|
TodoOperation::MarkAsCompleted {
|
|
completed_todos: mark_todos_completed
|
|
.todo_ids
|
|
.into_iter()
|
|
.filter_map(|todo_id| {
|
|
params.current_todo_list.and_then(|todo_list| {
|
|
todo_list
|
|
.completed_items()
|
|
.iter()
|
|
.find(|item| {
|
|
item.id.as_ref() == todo_id.as_str()
|
|
})
|
|
.cloned()
|
|
})
|
|
})
|
|
.collect(),
|
|
},
|
|
)
|
|
.with_citations(citations),
|
|
))
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
Ok(MaybeAIAgentOutputMessage::NoClientRepresentation)
|
|
}
|
|
}
|
|
api::message::Message::Summarization(summarization) => {
|
|
let duration = summarization
|
|
.finished_duration
|
|
.map(|d| Duration::from_secs(d.seconds as u64));
|
|
let (text, summarization_type, token_count) = match summarization.summary_type {
|
|
Some(api::message::summarization::SummaryType::ConversationSummary(
|
|
conv_summary,
|
|
)) => {
|
|
let token_count = if conv_summary.token_count > 0 {
|
|
Some(conv_summary.token_count as u32)
|
|
} else {
|
|
None
|
|
};
|
|
let text = if !conv_summary.summary.is_empty() {
|
|
AIAgentText {
|
|
sections: parse_markdown_into_text_and_code_sections(
|
|
&conv_summary.summary,
|
|
),
|
|
}
|
|
} else {
|
|
AIAgentText { sections: vec![] }
|
|
};
|
|
(text, SummarizationType::ConversationSummary, token_count)
|
|
}
|
|
Some(api::message::summarization::SummaryType::ToolCallResultSummary(_)) => (
|
|
AIAgentText { sections: vec![] },
|
|
SummarizationType::ToolCallResultSummary,
|
|
None,
|
|
),
|
|
None => {
|
|
// Default to ConversationSummary if not specified
|
|
(
|
|
AIAgentText { sections: vec![] },
|
|
SummarizationType::ConversationSummary,
|
|
None,
|
|
)
|
|
}
|
|
};
|
|
Ok(MaybeAIAgentOutputMessage::Message(
|
|
AIAgentOutputMessage::summarization(
|
|
MessageId::new(self.id),
|
|
text,
|
|
duration,
|
|
summarization_type,
|
|
token_count,
|
|
),
|
|
))
|
|
}
|
|
api::message::Message::UpdateReviewComments(update_comments) => {
|
|
if let Some(operation) = update_comments.operation {
|
|
match operation {
|
|
api::message::update_review_comments::Operation::AddressReviewComments(
|
|
address_comments,
|
|
) => {
|
|
if let Some(current_comments) = params.active_code_review {
|
|
let addressed_comments = current_comments
|
|
.addressed_comments
|
|
.iter()
|
|
.filter(|comment| {
|
|
address_comments
|
|
.comment_ids
|
|
.iter()
|
|
.any(|id| id == &comment.id.to_string())
|
|
})
|
|
.cloned()
|
|
.collect();
|
|
Ok(MaybeAIAgentOutputMessage::Message(
|
|
AIAgentOutputMessage::comments_addressed(
|
|
MessageId::new(self.id),
|
|
addressed_comments,
|
|
)
|
|
.with_citations(citations),
|
|
))
|
|
} else {
|
|
Ok(MaybeAIAgentOutputMessage::NoClientRepresentation)
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
Ok(MaybeAIAgentOutputMessage::NoClientRepresentation)
|
|
}
|
|
}
|
|
api::message::Message::DebugOutput(debug_output) => {
|
|
if ChannelState::enable_debug_features() {
|
|
Ok(MaybeAIAgentOutputMessage::Message(
|
|
AIAgentOutputMessage::debug_output(
|
|
MessageId::new(self.id),
|
|
debug_output.text,
|
|
),
|
|
))
|
|
} else {
|
|
Ok(MaybeAIAgentOutputMessage::NoClientRepresentation)
|
|
}
|
|
}
|
|
api::message::Message::ArtifactEvent(artifact_event) => match artifact_event.event {
|
|
Some(api::message::artifact_event::Event::Created(artifact_created)) => {
|
|
match artifact_created.artifact {
|
|
Some(
|
|
api::message::artifact_event::artifact_created::Artifact::PullRequest(
|
|
pr,
|
|
),
|
|
) => Ok(MaybeAIAgentOutputMessage::Message(
|
|
AIAgentOutputMessage::artifact_created(
|
|
MessageId::new(self.id),
|
|
ArtifactCreatedData::PullRequest {
|
|
url: pr.url,
|
|
branch: pr.branch,
|
|
},
|
|
)
|
|
.with_citations(citations),
|
|
)),
|
|
Some(
|
|
api::message::artifact_event::artifact_created::Artifact::Screenshot(
|
|
screenshot,
|
|
),
|
|
) => Ok(MaybeAIAgentOutputMessage::Message(
|
|
AIAgentOutputMessage::artifact_created(
|
|
MessageId::new(self.id),
|
|
ArtifactCreatedData::Screenshot {
|
|
artifact_uid: screenshot.artifact_uid,
|
|
mime_type: screenshot.mime_type,
|
|
description: if screenshot.description.is_empty() {
|
|
None
|
|
} else {
|
|
Some(screenshot.description)
|
|
},
|
|
},
|
|
)
|
|
.with_citations(citations),
|
|
)),
|
|
Some(api::message::artifact_event::artifact_created::Artifact::File(
|
|
file,
|
|
)) => Ok(MaybeAIAgentOutputMessage::Message(
|
|
AIAgentOutputMessage::artifact_created(
|
|
MessageId::new(self.id),
|
|
ArtifactCreatedData::File {
|
|
artifact_uid: file.artifact_uid,
|
|
filename: sanitized_basename(&file.filepath)
|
|
.unwrap_or_else(|| file.filepath.clone()),
|
|
filepath: file.filepath,
|
|
mime_type: file.mime_type,
|
|
description: if file.description.is_empty() {
|
|
None
|
|
} else {
|
|
Some(file.description)
|
|
},
|
|
size_bytes: file.size_bytes,
|
|
},
|
|
)
|
|
.with_citations(citations),
|
|
)),
|
|
None => Ok(MaybeAIAgentOutputMessage::NoClientRepresentation),
|
|
}
|
|
}
|
|
Some(api::message::artifact_event::Event::ForkArtifacts(_)) | None => {
|
|
Ok(MaybeAIAgentOutputMessage::NoClientRepresentation)
|
|
}
|
|
},
|
|
api::message::Message::MessagesReceivedFromAgents(messages_received_from_agents) => {
|
|
let messages = messages_received_from_agents
|
|
.messages
|
|
.into_iter()
|
|
.map(|msg| crate::ai::agent::ReceivedMessageDisplay {
|
|
message_id: msg.message_id,
|
|
sender_agent_id: msg.sender_agent_id,
|
|
addresses: msg.addresses,
|
|
subject: msg.subject,
|
|
message_body: msg.message_body,
|
|
})
|
|
.collect();
|
|
Ok(MaybeAIAgentOutputMessage::Message(
|
|
AIAgentOutputMessage::messages_received_from_agents(
|
|
MessageId::new(self.id),
|
|
messages,
|
|
)
|
|
.with_citations(citations),
|
|
))
|
|
}
|
|
api::message::Message::EventsFromAgents(events) => {
|
|
let event_ids = events
|
|
.agent_events
|
|
.iter()
|
|
.map(|e| e.event_id.clone())
|
|
.collect();
|
|
Ok(MaybeAIAgentOutputMessage::Message(
|
|
AIAgentOutputMessage::events_from_agents(MessageId::new(self.id), event_ids)
|
|
.with_citations(citations),
|
|
))
|
|
}
|
|
// These messages don't indicate an error but they don't translate to a client-side output message.
|
|
api::message::Message::UserQuery(_)
|
|
| api::message::Message::SystemQuery(_)
|
|
| api::message::Message::ToolCallResult(_)
|
|
| api::message::Message::CodeReview(_)
|
|
| api::message::Message::ServerEvent(_)
|
|
| api::message::Message::InvokeSkill(_)
|
|
| api::message::Message::PassiveSuggestionResult(_)
|
|
// Stage 2 plan-card config snapshot: hydrated separately by the
|
|
// plan card's `AIDocumentModel` subscription, not via the
|
|
// exchange/output stream. No client output message representation.
|
|
| api::message::Message::OrchestrationConfigSnapshot(_) => {
|
|
Ok(MaybeAIAgentOutputMessage::NoClientRepresentation)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<api::message::AgentOutput> for AIAgentText {
|
|
fn from(value: api::message::AgentOutput) -> Self {
|
|
AIAgentText {
|
|
sections: parse_markdown_into_text_and_code_sections(value.text.as_str()),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<api::message::AgentReasoning> for AIAgentText {
|
|
fn from(value: api::message::AgentReasoning) -> Self {
|
|
AIAgentText {
|
|
sections: parse_markdown_into_text_and_code_sections(value.reasoning.as_str()),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Trait for converting an [`api::Message`] to an [`AIAgentOutputMessage`].
|
|
trait ConvertAPIToolCallToAIAgentAction {
|
|
fn to_action(
|
|
self,
|
|
params: ConversionParams,
|
|
) -> Result<MaybeAIAgentAction, ToolToAIAgentActionError>;
|
|
}
|
|
|
|
/// Tries to convert an [`api::message::ToolCall`] to an [`AIAgentAction`].
|
|
///
|
|
/// A [`Result::Error`] indicates an unexpected problem, while [`Ok(None)`]
|
|
/// indicates a tool call that we aren't expected to parse.
|
|
impl ConvertAPIToolCallToAIAgentAction for api::message::ToolCall {
|
|
fn to_action(
|
|
self,
|
|
params: ConversionParams,
|
|
) -> Result<MaybeAIAgentAction, ToolToAIAgentActionError> {
|
|
let Some(tool) = self.tool else {
|
|
return Err(ToolToAIAgentActionError::MissingTool);
|
|
};
|
|
|
|
// Detect notebook tool name encoded in tool_call_id prefix.
|
|
let (effective_tool_call_id, tool_name) =
|
|
if let Some(stripped) = self.tool_call_id.strip_prefix("notebook::") {
|
|
(stripped.to_string(), Some("notebook".to_string()))
|
|
} else {
|
|
(self.tool_call_id.clone(), None)
|
|
};
|
|
|
|
let create_standard_action = |action: AIAgentActionType| {
|
|
Ok(MaybeAIAgentAction::Action(AIAgentAction {
|
|
id: effective_tool_call_id.clone().into(),
|
|
task_id: params.task_id.clone(),
|
|
action,
|
|
requires_result: true,
|
|
tool_name: tool_name.clone(),
|
|
}))
|
|
};
|
|
|
|
match tool {
|
|
api::message::tool_call::Tool::RunShellCommand(run_shell_command) => {
|
|
create_standard_action(run_shell_command.into())
|
|
}
|
|
api::message::tool_call::Tool::WriteToLongRunningShellCommand(
|
|
write_to_long_running_shell_command,
|
|
) => create_standard_action(write_to_long_running_shell_command.into()),
|
|
api::message::tool_call::Tool::ReadFiles(read_files) => {
|
|
create_standard_action(read_files.into())
|
|
}
|
|
api::message::tool_call::Tool::UploadFileArtifact(upload_file_artifact) => {
|
|
create_standard_action(upload_file_artifact.try_into()?)
|
|
}
|
|
api::message::tool_call::Tool::SearchCodebase(search_codebase) => {
|
|
create_standard_action(search_codebase.into())
|
|
}
|
|
api::message::tool_call::Tool::Grep(grep) => create_standard_action(grep.into()),
|
|
#[allow(deprecated)]
|
|
api::message::tool_call::Tool::FileGlob(glob) => create_standard_action(glob.into()),
|
|
api::message::tool_call::Tool::FileGlobV2(glob) => create_standard_action(glob.into()),
|
|
api::message::tool_call::Tool::ApplyFileDiffs(apply_file_diffs) => {
|
|
create_standard_action(apply_file_diffs.into())
|
|
}
|
|
api::message::tool_call::Tool::ReadMcpResource(read_mcp_resource) => {
|
|
create_standard_action(read_mcp_resource.into())
|
|
}
|
|
api::message::tool_call::Tool::CallMcpTool(call_mcp_tool) => {
|
|
match call_mcp_tool.try_into() {
|
|
Ok(call_mcp_tool_action) => create_standard_action(call_mcp_tool_action),
|
|
Err(error) => Err(error),
|
|
}
|
|
}
|
|
api::message::tool_call::Tool::SuggestNewConversation(suggest_new_conversation) => {
|
|
create_standard_action(suggest_new_conversation.into())
|
|
}
|
|
api::message::tool_call::Tool::SuggestPrompt(suggest_prompt) => {
|
|
match suggest_prompt.try_into() {
|
|
Ok(suggest_prompt_action) => create_standard_action(suggest_prompt_action),
|
|
Err(_) => Ok(MaybeAIAgentAction::NoClientRepresentation),
|
|
}
|
|
}
|
|
api::message::tool_call::Tool::OpenCodeReview(_) => {
|
|
create_standard_action(AIAgentActionType::OpenCodeReview)
|
|
}
|
|
api::message::tool_call::Tool::InitProject(_) => {
|
|
create_standard_action(AIAgentActionType::InitProject)
|
|
}
|
|
api::message::tool_call::Tool::ReadDocuments(read_documents) => {
|
|
create_standard_action(read_documents.into())
|
|
}
|
|
api::message::tool_call::Tool::EditDocuments(edit_documents) => {
|
|
create_standard_action(edit_documents.into())
|
|
}
|
|
api::message::tool_call::Tool::CreateDocuments(create_documents) => {
|
|
create_standard_action(create_documents.into())
|
|
}
|
|
api::message::tool_call::Tool::ReadShellCommandOutput(read_shell_command_output) => {
|
|
create_standard_action(read_shell_command_output.into())
|
|
}
|
|
api::message::tool_call::Tool::TransferShellCommandControlToUser(
|
|
transfer_shell_command_control_to_user,
|
|
) => create_standard_action(transfer_shell_command_control_to_user.into()),
|
|
api::message::tool_call::Tool::UseComputer(use_computer) => {
|
|
create_standard_action(use_computer.try_into()?)
|
|
}
|
|
api::message::tool_call::Tool::RequestComputerUse(request_computer_use) => {
|
|
create_standard_action(request_computer_use.into())
|
|
}
|
|
api::message::tool_call::Tool::Subagent(subagent) => {
|
|
use api::message::tool_call::subagent::conversation_search_metadata::Target;
|
|
use api::message::tool_call::subagent::Metadata;
|
|
let subagent_type = match subagent.metadata {
|
|
Some(Metadata::Cli(_)) => SubagentType::Cli,
|
|
Some(Metadata::Research(_)) => SubagentType::Research,
|
|
Some(Metadata::Advice(_)) => SubagentType::Advice,
|
|
Some(Metadata::ComputerUse(_)) => SubagentType::ComputerUse,
|
|
Some(Metadata::Summarization(_)) => SubagentType::Summarization,
|
|
Some(Metadata::ConversationSearch(cs_meta)) => {
|
|
let query = if cs_meta.query.is_empty() {
|
|
None
|
|
} else {
|
|
Some(cs_meta.query)
|
|
};
|
|
let (conversation_id, agent_run_id) = match cs_meta.target {
|
|
Some(Target::ConversationId(conversation_id))
|
|
if !conversation_id.is_empty() =>
|
|
{
|
|
(Some(conversation_id), None)
|
|
}
|
|
Some(Target::AgentRunId(agent_run_id)) if !agent_run_id.is_empty() => {
|
|
(None, Some(agent_run_id))
|
|
}
|
|
Some(Target::ConversationId(_))
|
|
| Some(Target::AgentRunId(_))
|
|
| None => (None, None),
|
|
};
|
|
SubagentType::ConversationSearch {
|
|
query,
|
|
conversation_id,
|
|
agent_run_id,
|
|
}
|
|
}
|
|
Some(Metadata::WarpDocumentationSearch(_)) => {
|
|
SubagentType::WarpDocumentationSearch
|
|
}
|
|
None => SubagentType::Unknown,
|
|
};
|
|
Ok(MaybeAIAgentAction::Subagent(SubagentCall {
|
|
task_id: subagent.task_id,
|
|
subagent_type,
|
|
}))
|
|
}
|
|
api::message::tool_call::Tool::StartAgent(start_agent) => {
|
|
create_standard_action(AIAgentActionType::StartAgent {
|
|
version: StartAgentVersion::V1,
|
|
name: start_agent.name,
|
|
prompt: start_agent.prompt,
|
|
execution_mode: convert_start_agent_execution_mode(start_agent.execution_mode),
|
|
lifecycle_subscription: start_agent.lifecycle_subscription.map(
|
|
|subscription| {
|
|
subscription
|
|
.event_types
|
|
.into_iter()
|
|
.filter_map(convert_start_agent_lifecycle_event_type)
|
|
.collect()
|
|
},
|
|
),
|
|
})
|
|
}
|
|
api::message::tool_call::Tool::StartAgentV2(start_agent) => {
|
|
create_standard_action(AIAgentActionType::StartAgent {
|
|
version: StartAgentVersion::V2,
|
|
name: start_agent.name,
|
|
prompt: start_agent.prompt,
|
|
execution_mode: convert_start_agent_v2_execution_mode(
|
|
start_agent.execution_mode,
|
|
params.skill_path_origin,
|
|
),
|
|
lifecycle_subscription: start_agent.lifecycle_subscription.map(
|
|
|subscription| {
|
|
subscription
|
|
.event_types
|
|
.into_iter()
|
|
.filter_map(convert_start_agent_lifecycle_event_type)
|
|
.collect()
|
|
},
|
|
),
|
|
})
|
|
}
|
|
api::message::tool_call::Tool::RunAgents(orchestrate) => {
|
|
create_standard_action(convert_run_agents(orchestrate, params.skill_path_origin))
|
|
}
|
|
api::message::tool_call::Tool::SendMessageToAgent(send_message) => {
|
|
create_standard_action(AIAgentActionType::SendMessageToAgent {
|
|
addresses: send_message.addresses,
|
|
subject: send_message.subject,
|
|
message: send_message.message,
|
|
})
|
|
}
|
|
api::message::tool_call::Tool::InsertReviewComments(insert_review_comments) => {
|
|
create_standard_action(insert_review_comments.into())
|
|
}
|
|
api::message::tool_call::Tool::ReadSkill(read_skill) => {
|
|
create_standard_action(convert_read_skill(read_skill, params.skill_path_origin)?)
|
|
}
|
|
api::message::tool_call::Tool::FetchConversation(fetch_conversation) => {
|
|
create_standard_action(fetch_conversation.into())
|
|
}
|
|
api::message::tool_call::Tool::AskUserQuestion(ask) => {
|
|
let questions = ask
|
|
.questions
|
|
.into_iter()
|
|
.filter_map(convert_api_question)
|
|
.collect();
|
|
create_standard_action(AIAgentActionType::AskUserQuestion { questions })
|
|
}
|
|
// Clients do not need to know how to parse server tool-calls but receiving
|
|
// them is not an error.
|
|
api::message::tool_call::Tool::Server(_) => {
|
|
Ok(MaybeAIAgentAction::NoClientRepresentation)
|
|
}
|
|
api::message::tool_call::Tool::WaitForEvents(payload) => {
|
|
create_standard_action(AIAgentActionType::WaitForEvents {
|
|
tool_call_id: self.tool_call_id.clone(),
|
|
idle_timeout_seconds: payload.idle_timeout_seconds,
|
|
})
|
|
}
|
|
_ => Err(ToolToAIAgentActionError::UnexpectedTool),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<api::Suggestions> for Suggestions {
|
|
fn from(api_suggestions: api::Suggestions) -> Self {
|
|
Self {
|
|
rules: api_suggestions
|
|
.rules
|
|
.into_iter()
|
|
.map(|rule| SuggestedRule {
|
|
name: rule.name,
|
|
content: rule.content,
|
|
logging_id: rule.logging_id.into(),
|
|
})
|
|
.collect(),
|
|
agent_mode_workflows: api_suggestions
|
|
.workflows
|
|
.into_iter()
|
|
.map(|workflow| SuggestedAgentModeWorkflow {
|
|
name: workflow.name,
|
|
prompt: workflow.prompt,
|
|
logging_id: workflow.logging_id.into(),
|
|
})
|
|
.collect(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<api::TodoItem> for AIAgentTodo {
|
|
fn from(value: api::TodoItem) -> Self {
|
|
AIAgentTodo {
|
|
id: value.id.into(),
|
|
title: value.title,
|
|
description: value.description,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Reconstruct user inputs from the provided server messages
|
|
/// (for use in shared agent exchanges where the input was not provided in this session)
|
|
pub fn user_inputs_from_messages(messages: &[api::Message]) -> Vec<AIAgentInput> {
|
|
let mut inputs = Vec::new();
|
|
let mut document_versions: HashMap<AIDocumentId, AIDocumentVersion> = HashMap::new();
|
|
for m in messages {
|
|
let Some(inner) = &m.message else { continue };
|
|
match inner {
|
|
api::message::Message::UserQuery(uq) => {
|
|
if is_internal_command_completion_assessment(m) {
|
|
continue;
|
|
}
|
|
let context = convert_input_context(uq.context.as_ref());
|
|
let referenced_attachments = uq
|
|
.referenced_attachments
|
|
.iter()
|
|
.filter_map(|(key, attachment)| {
|
|
AIAgentAttachment::try_from(attachment.clone())
|
|
.ok()
|
|
.map(|a| (key.clone(), a))
|
|
})
|
|
.collect();
|
|
inputs.push(AIAgentInput::UserQuery {
|
|
query: uq.query.clone(),
|
|
context,
|
|
static_query_type: None,
|
|
referenced_attachments,
|
|
user_query_mode: convert_user_query_mode(uq.mode.as_ref()),
|
|
running_command: None,
|
|
intended_agent: Some(uq.intended_agent()),
|
|
});
|
|
}
|
|
api::message::Message::SystemQuery(sq) => {
|
|
let ctx = convert_input_context(sq.context.as_ref());
|
|
if let Some(t) = &sq.r#type {
|
|
// These system queries appear as user inputs in ai blocks.
|
|
match t {
|
|
api::message::system_query::Type::CreateNewProject(p) => {
|
|
inputs.push(AIAgentInput::CreateNewProject {
|
|
query: p.query.clone(),
|
|
context: ctx,
|
|
});
|
|
}
|
|
api::message::system_query::Type::CloneRepository(p) => {
|
|
inputs.push(AIAgentInput::CloneRepository {
|
|
clone_repo_url: CloneRepositoryURL::new(p.url.clone()),
|
|
context: ctx,
|
|
});
|
|
}
|
|
api::message::system_query::Type::AutoCodeDiff(p) => {
|
|
inputs.push(AIAgentInput::AutoCodeDiffQuery {
|
|
query: p.query.clone(),
|
|
context: ctx,
|
|
});
|
|
}
|
|
api::message::system_query::Type::FetchReviewComments(fetch) => {
|
|
inputs.push(AIAgentInput::FetchReviewComments {
|
|
repo_path: fetch.repo_path.clone(),
|
|
context: ctx,
|
|
});
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
api::message::Message::ToolCallResult(tcr) => {
|
|
let task_id = TaskId::new(m.task_id.clone());
|
|
if let Some(input) = convert_tool_call_result_to_input(
|
|
&task_id,
|
|
tcr,
|
|
&HashMap::new(),
|
|
&mut document_versions,
|
|
) {
|
|
inputs.push(input);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
inputs
|
|
}
|
|
|
|
fn convert_api_question(
|
|
q: api::ask_user_question::Question,
|
|
) -> Option<ai::agent::action::AskUserQuestionItem> {
|
|
let Some(QuestionType::MultipleChoice(mc)) = q.question_type else {
|
|
return None;
|
|
};
|
|
|
|
// Server sends -1 when there is no recommendation.
|
|
let recommended_idx = usize::try_from(mc.recommended_option_index)
|
|
.ok()
|
|
.filter(|idx| *idx < mc.options.len());
|
|
let options = mc
|
|
.options
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, opt)| ai::agent::action::AskUserQuestionOption {
|
|
label: opt.label.clone(),
|
|
recommended: recommended_idx == Some(i),
|
|
})
|
|
.collect();
|
|
Some(ai::agent::action::AskUserQuestionItem {
|
|
question_id: q.question_id.clone(),
|
|
question: q.question,
|
|
question_type: ai::agent::action::AskUserQuestionType::MultipleChoice {
|
|
is_multiselect: mc.is_multiselect,
|
|
options,
|
|
supports_other: mc.supports_other,
|
|
},
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "convert_from_tests.rs"]
|
|
mod tests;
|