Migrate Rig tool flow to domain runtime
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
mod provider;
|
||||
mod rig;
|
||||
mod rig_request;
|
||||
|
||||
pub(crate) use provider::ProviderRuntime;
|
||||
pub(crate) use rig::rig_openai_response_stream;
|
||||
|
||||
+139
-61
@@ -4,13 +4,14 @@ use futures::channel::oneshot;
|
||||
use futures::{FutureExt, StreamExt};
|
||||
use galaxy_agent_core::{
|
||||
turn_control, AgentError, AgentEvent, AgentRuntime, MessageContent, MessageRole, StopReason,
|
||||
TurnCommand, TurnRequest, Usage,
|
||||
ToolCall, ToolCallDecision, ToolEvent, ToolPolicy, ToolResult, TurnCommand, Usage,
|
||||
};
|
||||
use galaxy_agent_rig::{OpenAICompatibleRuntime, OpenAICompatibleRuntimeConfig};
|
||||
use uuid::Uuid;
|
||||
use warp_multi_agent_api::response_event::stream_finished;
|
||||
use warp_multi_agent_api::{self as api, ClientAction, ResponseEvent};
|
||||
use warp_multi_agent_api::{self as api, ClientAction, ResponseEvent, ToolType};
|
||||
|
||||
use super::rig_request::{prepare_rig_turn, PreparedRigTurn};
|
||||
use crate::ai::agent::api::{Event, RequestParams, ResponseStream};
|
||||
use crate::ai::bedrock::response_translator::{
|
||||
build_add_agent_output_message, build_append_text, build_create_task, build_stream_init,
|
||||
@@ -18,56 +19,30 @@ use crate::ai::bedrock::response_translator::{
|
||||
};
|
||||
use crate::ai::openai::client::OpenAIClientConfig;
|
||||
use crate::ai::openai::response_translator::{build_stream_finished, StreamUsage};
|
||||
use crate::ai::openai::translator::{prepare_turn, PreparedTurn, TranslatorRequest};
|
||||
use crate::ai::provider::types::ConversationMessage;
|
||||
use crate::ai::provider::types::{ContentPart, ConversationMessage};
|
||||
use crate::server::server_api::AIApiError;
|
||||
|
||||
pub(crate) fn rig_openai_response_stream(
|
||||
config: OpenAIClientConfig,
|
||||
params: RequestParams,
|
||||
request: &mut api::Request,
|
||||
supported_tools: Vec<ToolType>,
|
||||
supported_cli_agent_tools: Vec<ToolType>,
|
||||
cancellation_rx: oneshot::Receiver<()>,
|
||||
) -> ResponseStream {
|
||||
let translator_request = TranslatorRequest {
|
||||
config: config.clone(),
|
||||
model_id: params.model.as_str().to_string(),
|
||||
root_task_id: params.root_task_id,
|
||||
message_history: params.bedrock_message_history,
|
||||
tool_result_archive: params.bedrock_tool_result_archive,
|
||||
progressive_summary: params.bedrock_progressive_summary,
|
||||
messages_sent: params.bedrock_messages_sent,
|
||||
global_rules: params.global_rules,
|
||||
};
|
||||
let PreparedTurn {
|
||||
let PreparedRigTurn {
|
||||
task_id,
|
||||
needs_create_task,
|
||||
user_query,
|
||||
messages,
|
||||
system_prompt,
|
||||
tools: _,
|
||||
model_id,
|
||||
persistent_message_count,
|
||||
} = prepare_turn(&translator_request, request);
|
||||
request: turn_request,
|
||||
persistent_messages,
|
||||
tool_result_archive,
|
||||
messages_sent,
|
||||
} = prepare_rig_turn(&config, params, supported_tools, supported_cli_agent_tools);
|
||||
store_messages_sent(&messages_sent, &persistent_messages);
|
||||
|
||||
store_messages_sent(
|
||||
&translator_request.messages_sent,
|
||||
&messages,
|
||||
persistent_message_count,
|
||||
);
|
||||
|
||||
let conversation_id = request
|
||||
.metadata
|
||||
.as_ref()
|
||||
.map(|metadata| metadata.conversation_id.clone())
|
||||
.filter(|id| !id.is_empty());
|
||||
let mut turn_request = TurnRequest::new(model_id.clone(), messages);
|
||||
turn_request.conversation_id = conversation_id.clone();
|
||||
turn_request.system_prompt = system_prompt;
|
||||
// Phase 2 deliberately validates the model streaming seam. Galaxy tool
|
||||
// execution moves behind AgentRuntime in Phase 3; exposing the legacy tool
|
||||
// list here would split ownership across both systems.
|
||||
turn_request.tools = Vec::new();
|
||||
turn_request.max_output_tokens = config.max_output_tokens.map(u64::from);
|
||||
let conversation_id = turn_request.conversation_id.clone();
|
||||
let model_id = turn_request.model.as_str().to_string();
|
||||
let tool_policy = ToolPolicy::new(&turn_request.tools);
|
||||
|
||||
let runtime = OpenAICompatibleRuntime::new(OpenAICompatibleRuntimeConfig {
|
||||
base_url: config.base_url,
|
||||
@@ -76,7 +51,6 @@ pub(crate) fn rig_openai_response_stream(
|
||||
max_output_tokens: config.max_output_tokens.map(u64::from),
|
||||
supports_system_messages: config.supports_system_messages,
|
||||
});
|
||||
let messages_sent = translator_request.messages_sent;
|
||||
let max_context_tokens = config.max_input_tokens;
|
||||
let stream = async_stream::stream! {
|
||||
let (control_sender, control) = turn_control();
|
||||
@@ -110,6 +84,8 @@ pub(crate) fn rig_openai_response_stream(
|
||||
let mut current_text_message_id: Option<String> = None;
|
||||
let mut current_reasoning_message_id: Option<String> = None;
|
||||
let mut full_text = String::new();
|
||||
let mut proposed_tools = Vec::new();
|
||||
let mut assistant_history_index = None;
|
||||
let mut usage = Usage::default();
|
||||
|
||||
loop {
|
||||
@@ -163,11 +139,57 @@ pub(crate) fn rig_openai_response_stream(
|
||||
}
|
||||
}
|
||||
AgentEvent::UsageUpdated { usage: updated } => usage = updated,
|
||||
AgentEvent::Tool {
|
||||
event: ToolEvent::Proposed { call },
|
||||
} => {
|
||||
proposed_tools.push(call.clone());
|
||||
sync_assistant_turn(
|
||||
&messages_sent,
|
||||
&full_text,
|
||||
&proposed_tools,
|
||||
&mut assistant_history_index,
|
||||
);
|
||||
let history = messages_sent
|
||||
.lock()
|
||||
.map(|sent| sent.clone())
|
||||
.unwrap_or_default();
|
||||
match tool_policy.decide(&call, &history, &tool_result_archive) {
|
||||
ToolCallDecision::Execute => {
|
||||
yield Ok(build_tool_proposed(&task_id, &call));
|
||||
}
|
||||
ToolCallDecision::Inline(result) => {
|
||||
append_tool_result(&messages_sent, result);
|
||||
}
|
||||
ToolCallDecision::Reject(result) => {
|
||||
log::warn!(
|
||||
"Rig model called unavailable tool '{}' (id={})",
|
||||
call.name,
|
||||
call.id
|
||||
);
|
||||
let error_display = format!(
|
||||
"Failed tool call: `{}`\n\n{}",
|
||||
call.name, result.content
|
||||
);
|
||||
append_tool_result(&messages_sent, result);
|
||||
let message_id = Uuid::new_v4().to_string();
|
||||
yield Ok(build_add_agent_output_message(
|
||||
&task_id,
|
||||
&message_id,
|
||||
&error_display,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
AgentEvent::TurnStopped { reason } => {
|
||||
if !initialized {
|
||||
yield Ok(build_stream_init(&request_id, &conversation_id));
|
||||
}
|
||||
store_assistant_text(&messages_sent, full_text);
|
||||
sync_assistant_turn(
|
||||
&messages_sent,
|
||||
&full_text,
|
||||
&proposed_tools,
|
||||
&mut assistant_history_index,
|
||||
);
|
||||
yield Ok(build_stream_finished(
|
||||
map_stop_reason(reason),
|
||||
StreamUsage {
|
||||
@@ -184,13 +206,10 @@ pub(crate) fn rig_openai_response_stream(
|
||||
));
|
||||
return;
|
||||
}
|
||||
AgentEvent::ToolProposed { .. }
|
||||
| AgentEvent::PermissionRequested { .. }
|
||||
| AgentEvent::ToolStarted { .. }
|
||||
| AgentEvent::ToolCompleted { .. } => {
|
||||
AgentEvent::Tool { .. } => {
|
||||
yield Err(agent_error(AgentError::new(
|
||||
galaxy_agent_core::AgentErrorKind::Protocol,
|
||||
"the Phase 2 Rig runtime emitted a tool event while tools are disabled",
|
||||
"the provider runtime attempted to execute a tool outside Galaxy's permission boundary",
|
||||
)));
|
||||
return;
|
||||
}
|
||||
@@ -206,31 +225,90 @@ pub(crate) fn rig_openai_response_stream(
|
||||
fn store_messages_sent(
|
||||
messages_sent: &std::sync::Arc<std::sync::Mutex<Vec<ConversationMessage>>>,
|
||||
messages: &[ConversationMessage],
|
||||
persistent_message_count: usize,
|
||||
) {
|
||||
let Ok(mut sent) = messages_sent.lock() else {
|
||||
return;
|
||||
};
|
||||
if persistent_message_count > 0 && messages.len() >= persistent_message_count {
|
||||
*sent = messages[messages.len() - persistent_message_count..].to_vec();
|
||||
} else {
|
||||
*sent = messages.to_vec();
|
||||
*sent = messages.to_vec();
|
||||
}
|
||||
|
||||
fn append_tool_result(
|
||||
messages_sent: &std::sync::Arc<std::sync::Mutex<Vec<ConversationMessage>>>,
|
||||
result: ToolResult,
|
||||
) {
|
||||
let is_error = result.is_error();
|
||||
let message = ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
content: MessageContent::ToolResult {
|
||||
tool_use_id: result.call_id,
|
||||
content: result.content,
|
||||
is_error,
|
||||
},
|
||||
};
|
||||
if let Ok(mut sent) = messages_sent.lock() {
|
||||
sent.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
fn store_assistant_text(
|
||||
fn sync_assistant_turn(
|
||||
messages_sent: &std::sync::Arc<std::sync::Mutex<Vec<ConversationMessage>>>,
|
||||
text: String,
|
||||
text: &str,
|
||||
tool_calls: &[ToolCall],
|
||||
history_index: &mut Option<usize>,
|
||||
) {
|
||||
if text.is_empty() {
|
||||
let mut parts = Vec::with_capacity(usize::from(!text.is_empty()) + tool_calls.len());
|
||||
if !text.is_empty() {
|
||||
parts.push(ContentPart::Text(text.to_string()));
|
||||
}
|
||||
parts.extend(tool_calls.iter().map(|call| ContentPart::ToolUse {
|
||||
tool_use_id: call.id.clone(),
|
||||
name: call.name.clone(),
|
||||
input: call.arguments.clone(),
|
||||
}));
|
||||
if parts.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Ok(mut sent) = messages_sent.lock() {
|
||||
sent.push(ConversationMessage {
|
||||
role: MessageRole::Assistant,
|
||||
content: MessageContent::Text(text),
|
||||
});
|
||||
|
||||
let content = if parts.len() == 1 {
|
||||
match parts.pop().unwrap() {
|
||||
ContentPart::Text(text) => MessageContent::Text(text),
|
||||
ContentPart::ToolUse {
|
||||
tool_use_id,
|
||||
name,
|
||||
input,
|
||||
} => MessageContent::ToolUse {
|
||||
tool_use_id,
|
||||
name,
|
||||
input,
|
||||
},
|
||||
ContentPart::Image { .. } | ContentPart::ToolResult { .. } => unreachable!(),
|
||||
}
|
||||
} else {
|
||||
MessageContent::MultiPart(parts)
|
||||
};
|
||||
let message = ConversationMessage {
|
||||
role: MessageRole::Assistant,
|
||||
content,
|
||||
};
|
||||
|
||||
let Ok(mut sent) = messages_sent.lock() else {
|
||||
return;
|
||||
};
|
||||
if let Some(index) = *history_index {
|
||||
if index < sent.len() {
|
||||
sent[index] = message;
|
||||
return;
|
||||
}
|
||||
}
|
||||
*history_index = Some(sent.len());
|
||||
sent.push(message);
|
||||
}
|
||||
|
||||
fn build_tool_proposed(task_id: &str, call: &ToolCall) -> ResponseEvent {
|
||||
let arguments = serde_json::to_string(&call.arguments).unwrap_or_else(|_| "{}".to_string());
|
||||
crate::ai::bedrock::response_translator::build_tool_call_message(
|
||||
task_id, &call.id, &call.name, &arguments,
|
||||
)
|
||||
}
|
||||
|
||||
fn build_add_reasoning(task_id: &str, message_id: &str, text: &str) -> ResponseEvent {
|
||||
|
||||
@@ -0,0 +1,625 @@
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use ai::agent::action_result::AnyFileContent;
|
||||
use ai::skills::SkillReference;
|
||||
use base64::engine::general_purpose;
|
||||
use base64::Engine as _;
|
||||
use galaxy_agent_core::{
|
||||
ContentPart, ConversationMessage, MessageContent, MessageRole, ToolDefinition, ToolResult,
|
||||
TurnRequest,
|
||||
};
|
||||
use warp_multi_agent_api::ToolType;
|
||||
|
||||
use crate::ai::agent::api::RequestParams;
|
||||
use crate::ai::agent::{AIAgentContext, AIAgentInput, MCPContext, UserQueryMode};
|
||||
use crate::ai::bedrock::request_translator::{default_tool_definitions, tool_name_is_supported};
|
||||
use crate::ai::openai::client::OpenAIClientConfig;
|
||||
use crate::ai::openai::request_translator::sanitize_messages_for_openai;
|
||||
|
||||
pub(crate) struct PreparedRigTurn {
|
||||
pub task_id: String,
|
||||
pub needs_create_task: bool,
|
||||
pub user_query: Option<String>,
|
||||
pub request: TurnRequest,
|
||||
pub persistent_messages: Vec<ConversationMessage>,
|
||||
pub tool_result_archive: Vec<ConversationMessage>,
|
||||
pub messages_sent: Arc<Mutex<Vec<ConversationMessage>>>,
|
||||
}
|
||||
|
||||
pub(crate) fn prepare_rig_turn(
|
||||
config: &OpenAIClientConfig,
|
||||
params: RequestParams,
|
||||
supported_tools: Vec<ToolType>,
|
||||
supported_cli_agent_tools: Vec<ToolType>,
|
||||
) -> PreparedRigTurn {
|
||||
let RequestParams {
|
||||
input,
|
||||
tool_results,
|
||||
conversation_token,
|
||||
tasks,
|
||||
model,
|
||||
root_task_id,
|
||||
message_history,
|
||||
progressive_summary,
|
||||
tool_result_archive,
|
||||
messages_sent,
|
||||
global_rules,
|
||||
mcp_context,
|
||||
..
|
||||
} = params;
|
||||
|
||||
let task_id = root_task_id
|
||||
.or_else(|| tasks.first().map(|task| task.id.clone()))
|
||||
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
|
||||
let needs_create_task = tasks.is_empty();
|
||||
let user_query = input.iter().find_map(input_user_query);
|
||||
let mode = request_mode(&input);
|
||||
let available_tools = match mode {
|
||||
RigRequestMode::Cli => supported_cli_agent_tools,
|
||||
RigRequestMode::Normal | RigRequestMode::Plan | RigRequestMode::Orchestrate => {
|
||||
supported_tools
|
||||
}
|
||||
};
|
||||
let tools = tool_definitions(&available_tools, mcp_context.as_ref());
|
||||
let system_prompt = build_system_prompt(&input, &tools, &global_rules, mode);
|
||||
|
||||
let mut new_messages = input_messages(input, tool_results);
|
||||
let mut persistent_messages = message_history;
|
||||
persistent_messages.append(&mut new_messages);
|
||||
for message in &mut persistent_messages {
|
||||
message.truncate_tool_results_for_provider_request();
|
||||
}
|
||||
sanitize_messages_for_openai(&mut persistent_messages);
|
||||
|
||||
let mut turn_messages = Vec::new();
|
||||
if let Some(summary) = progressive_summary {
|
||||
turn_messages.push(ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
content: MessageContent::Text(format!(
|
||||
"<conversation-history-summary>\n{summary}\n</conversation-history-summary>\n\n\
|
||||
The above summarizes earlier conversation history. The detailed messages below are the most recent exchanges."
|
||||
)),
|
||||
});
|
||||
turn_messages.push(ConversationMessage {
|
||||
role: MessageRole::Assistant,
|
||||
content: MessageContent::Text(
|
||||
"Understood, I have the prior context. Continuing with the recent conversation."
|
||||
.to_string(),
|
||||
),
|
||||
});
|
||||
}
|
||||
turn_messages.extend(persistent_messages.clone());
|
||||
|
||||
let model_id = config
|
||||
.model
|
||||
.clone()
|
||||
.filter(|model| !model.is_empty() && model != "auto")
|
||||
.unwrap_or_else(|| model.as_str().to_string());
|
||||
let mut request = TurnRequest::new(model_id, turn_messages);
|
||||
request.conversation_id = conversation_token.map(|token| token.as_str().to_string());
|
||||
request.system_prompt = Some(system_prompt);
|
||||
request.tools = tools;
|
||||
request.max_output_tokens = config.max_output_tokens.map(u64::from);
|
||||
|
||||
PreparedRigTurn {
|
||||
task_id,
|
||||
needs_create_task,
|
||||
user_query,
|
||||
request,
|
||||
persistent_messages,
|
||||
tool_result_archive,
|
||||
messages_sent,
|
||||
}
|
||||
}
|
||||
|
||||
fn input_messages(
|
||||
inputs: Vec<AIAgentInput>,
|
||||
tool_results: Vec<ToolResult>,
|
||||
) -> Vec<ConversationMessage> {
|
||||
let mut messages = Vec::new();
|
||||
if !tool_results.is_empty() {
|
||||
let mut parts = tool_results
|
||||
.into_iter()
|
||||
.map(|result| {
|
||||
let is_error = result.is_error();
|
||||
ContentPart::ToolResult {
|
||||
tool_use_id: result.call_id,
|
||||
content: result.content,
|
||||
is_error,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let content = if parts.len() == 1 {
|
||||
let ContentPart::ToolResult {
|
||||
tool_use_id,
|
||||
content,
|
||||
is_error,
|
||||
} = parts.pop().expect("one tool result exists")
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
MessageContent::ToolResult {
|
||||
tool_use_id,
|
||||
content,
|
||||
is_error,
|
||||
}
|
||||
} else {
|
||||
MessageContent::MultiPart(parts)
|
||||
};
|
||||
messages.push(ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
content,
|
||||
});
|
||||
}
|
||||
|
||||
messages.extend(inputs.into_iter().filter_map(input_message));
|
||||
messages
|
||||
}
|
||||
|
||||
fn input_message(input: AIAgentInput) -> Option<ConversationMessage> {
|
||||
let (text, images) = match input {
|
||||
AIAgentInput::UserQuery {
|
||||
query,
|
||||
context,
|
||||
running_command,
|
||||
..
|
||||
} => {
|
||||
let text = if let Some(command) = running_command {
|
||||
format!(
|
||||
"[Running command: {}]\n[Command ID: {}]\n[Terminal output:\n{}\n]\n{}",
|
||||
command.command, command.block_id, command.grid_contents, query
|
||||
)
|
||||
} else {
|
||||
query
|
||||
};
|
||||
(text, image_parts(&context))
|
||||
}
|
||||
AIAgentInput::ActionResult { .. } => return None,
|
||||
AIAgentInput::AutoCodeDiffQuery { query, .. } => (query, Vec::new()),
|
||||
AIAgentInput::ResumeConversation { .. } => (
|
||||
"Continue where we left off. Review the conversation history and proceed with the next steps."
|
||||
.to_string(),
|
||||
Vec::new(),
|
||||
),
|
||||
AIAgentInput::InitProjectRules { .. } => (
|
||||
"Initialize this project. Analyze the codebase structure and files, generate an AGENTS.md file documenting project conventions and setup instructions, and offer to create a development environment configuration. Use the available tools to inspect the project before responding."
|
||||
.to_string(),
|
||||
Vec::new(),
|
||||
),
|
||||
AIAgentInput::CreateEnvironment { repo_paths, .. } => (
|
||||
format!(
|
||||
"Create a development environment for this project. Set up necessary dependencies, configuration files, and tooling. Repositories: {}",
|
||||
repo_paths.join(", ")
|
||||
),
|
||||
Vec::new(),
|
||||
),
|
||||
AIAgentInput::TriggerPassiveSuggestion { .. } => (
|
||||
"Suggest a useful next action based on the current project context.".to_string(),
|
||||
Vec::new(),
|
||||
),
|
||||
AIAgentInput::CreateNewProject { query, .. } => {
|
||||
(format!("Create a new project: {query}"), Vec::new())
|
||||
}
|
||||
AIAgentInput::CloneRepository {
|
||||
clone_repo_url, ..
|
||||
} => (
|
||||
format!(
|
||||
"Clone the repository at {} and set it up for development.",
|
||||
clone_repo_url.into_url()
|
||||
),
|
||||
Vec::new(),
|
||||
),
|
||||
AIAgentInput::CodeReview { .. } => (
|
||||
"Review the provided code changes and address the review comments.".to_string(),
|
||||
Vec::new(),
|
||||
),
|
||||
AIAgentInput::FetchReviewComments { repo_path, .. } => (
|
||||
format!("Fetch and review the pull-request comments for {repo_path}."),
|
||||
Vec::new(),
|
||||
),
|
||||
AIAgentInput::SummarizeConversation { prompt, .. } => (
|
||||
prompt.unwrap_or_else(|| {
|
||||
"Summarize this conversation, preserving decisions, changes, and context needed to continue."
|
||||
.to_string()
|
||||
}),
|
||||
Vec::new(),
|
||||
),
|
||||
AIAgentInput::InvokeSkill {
|
||||
skill, user_query, ..
|
||||
} => {
|
||||
let suffix = user_query
|
||||
.map(|query| query.query)
|
||||
.filter(|query| !query.is_empty())
|
||||
.map(|query| format!("\n\nAdditional context from user: {query}"))
|
||||
.unwrap_or_default();
|
||||
(
|
||||
format!(
|
||||
"Execute the following skill: {}\n\n<skill-instructions>\n{}\n</skill-instructions>{suffix}",
|
||||
skill.name, skill.content
|
||||
),
|
||||
Vec::new(),
|
||||
)
|
||||
}
|
||||
AIAgentInput::StartFromAmbientRunPrompt { ambient_run_id, .. } => (
|
||||
format!("Continue the configured ambient-agent run {ambient_run_id}."),
|
||||
Vec::new(),
|
||||
),
|
||||
AIAgentInput::MessagesReceivedFromAgents { messages } => (
|
||||
messages
|
||||
.into_iter()
|
||||
.map(|message| {
|
||||
format!(
|
||||
"Message from {} ({})\nSubject: {}\n{}",
|
||||
message.sender_agent_id,
|
||||
message.addresses.join(", "),
|
||||
message.subject,
|
||||
message.message_body
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n"),
|
||||
Vec::new(),
|
||||
),
|
||||
AIAgentInput::EventsFromAgents { events } => (
|
||||
format!("Agent lifecycle events:\n{events:#?}"),
|
||||
Vec::new(),
|
||||
),
|
||||
AIAgentInput::PassiveSuggestionResult { suggestion, .. } => (
|
||||
format!("The user responded to a passive suggestion: {suggestion:?}"),
|
||||
Vec::new(),
|
||||
),
|
||||
AIAgentInput::OrchestrationConfigUpdate {
|
||||
plan_id,
|
||||
config,
|
||||
status,
|
||||
} => (
|
||||
format!(
|
||||
"Orchestration configuration updated for plan {plan_id}: status={status:?}, config={config:?}"
|
||||
),
|
||||
Vec::new(),
|
||||
),
|
||||
};
|
||||
|
||||
let content = if images.is_empty() {
|
||||
MessageContent::Text(text)
|
||||
} else {
|
||||
let mut parts = Vec::with_capacity(images.len() + 1);
|
||||
parts.push(ContentPart::Text(text));
|
||||
parts.extend(images);
|
||||
MessageContent::MultiPart(parts)
|
||||
};
|
||||
Some(ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
content,
|
||||
})
|
||||
}
|
||||
|
||||
fn image_parts(context: &[AIAgentContext]) -> Vec<ContentPart> {
|
||||
context
|
||||
.iter()
|
||||
.filter_map(|context| {
|
||||
let AIAgentContext::Image(image) = context else {
|
||||
return None;
|
||||
};
|
||||
let data = match general_purpose::STANDARD.decode(&image.data) {
|
||||
Ok(data) => data,
|
||||
Err(error) => {
|
||||
log::warn!("Skipping invalid base64 image supplied to Rig: {error}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
Some(ContentPart::Image {
|
||||
data,
|
||||
mime_type: image.mime_type.clone(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn input_user_query(input: &AIAgentInput) -> Option<String> {
|
||||
match input {
|
||||
AIAgentInput::UserQuery { query, .. } => Some(query.clone()),
|
||||
AIAgentInput::InvokeSkill { skill, .. } => Some(format!("/{}", skill.name)),
|
||||
AIAgentInput::AutoCodeDiffQuery { .. }
|
||||
| AIAgentInput::ResumeConversation { .. }
|
||||
| AIAgentInput::InitProjectRules { .. }
|
||||
| AIAgentInput::CreateEnvironment { .. }
|
||||
| AIAgentInput::TriggerPassiveSuggestion { .. }
|
||||
| AIAgentInput::CreateNewProject { .. }
|
||||
| AIAgentInput::CloneRepository { .. }
|
||||
| AIAgentInput::CodeReview { .. }
|
||||
| AIAgentInput::FetchReviewComments { .. }
|
||||
| AIAgentInput::SummarizeConversation { .. }
|
||||
| AIAgentInput::StartFromAmbientRunPrompt { .. }
|
||||
| AIAgentInput::ActionResult { .. }
|
||||
| AIAgentInput::MessagesReceivedFromAgents { .. }
|
||||
| AIAgentInput::EventsFromAgents { .. }
|
||||
| AIAgentInput::PassiveSuggestionResult { .. }
|
||||
| AIAgentInput::OrchestrationConfigUpdate { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum RigRequestMode {
|
||||
Normal,
|
||||
Plan,
|
||||
Orchestrate,
|
||||
Cli,
|
||||
}
|
||||
|
||||
fn request_mode(inputs: &[AIAgentInput]) -> RigRequestMode {
|
||||
for input in inputs {
|
||||
if matches!(
|
||||
input,
|
||||
AIAgentInput::UserQuery {
|
||||
running_command: Some(_),
|
||||
..
|
||||
}
|
||||
) {
|
||||
return RigRequestMode::Cli;
|
||||
}
|
||||
if let AIAgentInput::UserQuery {
|
||||
user_query_mode, ..
|
||||
} = input
|
||||
{
|
||||
match user_query_mode {
|
||||
UserQueryMode::Normal => {}
|
||||
UserQueryMode::Plan => return RigRequestMode::Plan,
|
||||
UserQueryMode::Orchestrate => return RigRequestMode::Orchestrate,
|
||||
}
|
||||
}
|
||||
}
|
||||
RigRequestMode::Normal
|
||||
}
|
||||
|
||||
fn tool_definitions(
|
||||
supported_tools: &[ToolType],
|
||||
mcp_context: Option<&MCPContext>,
|
||||
) -> Vec<ToolDefinition> {
|
||||
let supported = supported_tools.iter().copied().collect::<HashSet<_>>();
|
||||
let mut tools = default_tool_definitions()
|
||||
.into_iter()
|
||||
.filter(|tool| tool_name_is_supported(&tool.name, &supported))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if !supported.contains(&ToolType::CallMcpTool) {
|
||||
return tools;
|
||||
}
|
||||
let Some(mcp_context) = mcp_context else {
|
||||
return tools;
|
||||
};
|
||||
let mut seen = tools
|
||||
.iter()
|
||||
.map(|tool| tool.name.clone())
|
||||
.collect::<HashSet<_>>();
|
||||
for server in &mcp_context.servers {
|
||||
for tool in &server.tools {
|
||||
let name = format!("mcp__{}__{}", server.name, tool.name);
|
||||
if seen.insert(name.clone()) {
|
||||
tools.push(ToolDefinition {
|
||||
name,
|
||||
description: tool
|
||||
.description
|
||||
.as_deref()
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| format!("MCP tool from {} server", server.name)),
|
||||
input_schema: serde_json::Value::Object(tool.input_schema.as_ref().clone()),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
#[allow(deprecated)]
|
||||
for tool in &mcp_context.tools {
|
||||
let name = format!("mcp__{}", tool.name);
|
||||
if seen.insert(name.clone()) {
|
||||
tools.push(ToolDefinition {
|
||||
name,
|
||||
description: tool
|
||||
.description
|
||||
.as_deref()
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| "MCP tool".to_string()),
|
||||
input_schema: serde_json::Value::Object(tool.input_schema.as_ref().clone()),
|
||||
});
|
||||
}
|
||||
}
|
||||
tools
|
||||
}
|
||||
|
||||
fn build_system_prompt(
|
||||
inputs: &[AIAgentInput],
|
||||
tools: &[ToolDefinition],
|
||||
global_rules: &[(String, String)],
|
||||
mode: RigRequestMode,
|
||||
) -> String {
|
||||
let mut prompt = String::from(
|
||||
"You are Galaxy, a local-first software-engineering and terminal agent. Complete the user's task through inspection, implementation, and proportionate validation. Galaxy owns tool permissions and execution; use only the tools advertised in this request and treat every result as authoritative evidence.\n\n",
|
||||
);
|
||||
let contexts = inputs.iter().filter_map(AIAgentInput::context).flatten();
|
||||
let mut environment = Vec::new();
|
||||
let mut project_rules = Vec::new();
|
||||
let mut available_skills = Vec::new();
|
||||
let mut attached_context = Vec::new();
|
||||
for context in contexts {
|
||||
match context {
|
||||
AIAgentContext::Directory {
|
||||
pwd,
|
||||
home_dir,
|
||||
are_file_symbols_indexed,
|
||||
} => {
|
||||
if let Some(pwd) = pwd {
|
||||
environment.push(format!("Working directory: {pwd}"));
|
||||
}
|
||||
if let Some(home_dir) = home_dir {
|
||||
environment.push(format!("Home directory: {home_dir}"));
|
||||
}
|
||||
environment.push(format!(
|
||||
"Working-directory file symbols indexed: {are_file_symbols_indexed}"
|
||||
));
|
||||
}
|
||||
AIAgentContext::ExecutionEnvironment(execution) => {
|
||||
let shell_version = execution
|
||||
.shell_version
|
||||
.as_deref()
|
||||
.map(|version| format!(" {version}"))
|
||||
.unwrap_or_default();
|
||||
environment.push(format!("Shell: {}{shell_version}", execution.shell_name));
|
||||
if let Some(os) = &execution.os.category {
|
||||
environment.push(format!("OS: {os}"));
|
||||
}
|
||||
if let Some(distribution) = &execution.os.distribution {
|
||||
environment.push(format!("OS distribution: {distribution}"));
|
||||
}
|
||||
}
|
||||
AIAgentContext::ProjectRules {
|
||||
root_path,
|
||||
active_rules,
|
||||
additional_rule_paths,
|
||||
} => {
|
||||
for rule in active_rules {
|
||||
if let AnyFileContent::StringContent(content) = &rule.content {
|
||||
project_rules.push((root_path.clone(), content.clone()));
|
||||
}
|
||||
}
|
||||
if !additional_rule_paths.is_empty() {
|
||||
environment.push(format!(
|
||||
"Additional project rule paths: {}",
|
||||
additional_rule_paths.join(", ")
|
||||
));
|
||||
}
|
||||
}
|
||||
AIAgentContext::Git { head, branch } => {
|
||||
environment.push(format!("Git HEAD: {head}"));
|
||||
if let Some(branch) = branch {
|
||||
environment.push(format!("Git branch: {branch}"));
|
||||
}
|
||||
}
|
||||
AIAgentContext::Skills { skills } => {
|
||||
for skill in skills {
|
||||
let (reference_type, reference) = match &skill.reference {
|
||||
SkillReference::Path(path) => ("path", path.display_path()),
|
||||
SkillReference::BundledSkillId(id) => ("bundled", id.clone()),
|
||||
};
|
||||
available_skills.push(format!(
|
||||
"- name={:?}; reference_type={reference_type:?}; skill={reference:?}; description={:?}",
|
||||
skill.name, skill.description
|
||||
));
|
||||
}
|
||||
}
|
||||
AIAgentContext::SelectedText(text) => {
|
||||
attached_context.push(("Selected text".to_string(), text.clone()));
|
||||
}
|
||||
AIAgentContext::CurrentTime { current_time } => {
|
||||
environment.push(format!("Current time: {current_time}"));
|
||||
}
|
||||
AIAgentContext::Codebase { path, name } => {
|
||||
environment.push(format!("Indexed codebase: {name} ({path})"));
|
||||
}
|
||||
AIAgentContext::File(file) => match &file.content {
|
||||
AnyFileContent::StringContent(content) => {
|
||||
attached_context.push((format!("Attached file: {file}"), content.clone()));
|
||||
}
|
||||
AnyFileContent::BinaryContent(_) => {
|
||||
environment.push(format!("Attached binary file (content omitted): {file}"));
|
||||
}
|
||||
},
|
||||
AIAgentContext::Repository { name, owner } => {
|
||||
let owner = owner
|
||||
.as_deref()
|
||||
.map(|owner| format!("{owner}/"))
|
||||
.unwrap_or_default();
|
||||
environment.push(format!("Repository: {owner}{name}"));
|
||||
}
|
||||
AIAgentContext::PullRequest {
|
||||
number,
|
||||
state,
|
||||
draft,
|
||||
base_branch,
|
||||
} => {
|
||||
environment.push(format!(
|
||||
"Pull request: #{number}; state={state}; draft={draft}; base={base_branch}"
|
||||
));
|
||||
}
|
||||
AIAgentContext::Block(block) => {
|
||||
let details = format!(
|
||||
"Command: {}\nExit code: {}\nOutput:\n{}",
|
||||
block.command, block.exit_code, block.output
|
||||
);
|
||||
attached_context.push((format!("Terminal block {}", block.id), details));
|
||||
}
|
||||
AIAgentContext::Image(_) => {}
|
||||
}
|
||||
}
|
||||
if !environment.is_empty() {
|
||||
prompt.push_str("## Environment\n");
|
||||
for item in environment {
|
||||
prompt.push_str("- ");
|
||||
prompt.push_str(&item);
|
||||
prompt.push('\n');
|
||||
}
|
||||
prompt.push('\n');
|
||||
}
|
||||
if !project_rules.is_empty() {
|
||||
prompt.push_str("## Project Rules\n");
|
||||
for (root, content) in project_rules {
|
||||
prompt.push_str(&format!("### Rules from {root}\n{content}\n"));
|
||||
}
|
||||
prompt.push('\n');
|
||||
}
|
||||
if !attached_context.is_empty() {
|
||||
prompt.push_str("## Attached Context\n");
|
||||
for (label, content) in attached_context {
|
||||
prompt.push_str(&format!(
|
||||
"<context label={label:?}>\n{content}\n</context>\n"
|
||||
));
|
||||
}
|
||||
prompt.push('\n');
|
||||
}
|
||||
if !available_skills.is_empty() && tools.iter().any(|tool| tool.name == "read_skill") {
|
||||
prompt.push_str("## Available Skills\n");
|
||||
prompt.push_str(&available_skills.join("\n"));
|
||||
prompt.push_str("\n\n");
|
||||
}
|
||||
if !global_rules.is_empty() {
|
||||
prompt.push_str("## Global Rules\n");
|
||||
for (name, content) in global_rules {
|
||||
if !name.is_empty() {
|
||||
prompt.push_str(&format!("### {name}\n"));
|
||||
}
|
||||
prompt.push_str(content);
|
||||
prompt.push_str("\n\n");
|
||||
}
|
||||
}
|
||||
match mode {
|
||||
RigRequestMode::Normal => {}
|
||||
RigRequestMode::Plan => prompt.push_str(
|
||||
"## Plan Mode\nInspect and produce an implementation-ready plan. Do not edit files or perform state-changing actions.\n\n",
|
||||
),
|
||||
RigRequestMode::Orchestrate => prompt.push_str(
|
||||
"## Orchestration Mode\nDelegate only independent, bounded work where parallelism materially helps, then synthesize the results.\n\n",
|
||||
),
|
||||
RigRequestMode::Cli => prompt.push_str(
|
||||
"## Running Command Monitor\nMonitor the existing command by its command ID. Never start a duplicate command. Poll briefly, respect stop conditions, and report only verified outcomes.\n\n",
|
||||
),
|
||||
}
|
||||
prompt.push_str("## Available Tools\n");
|
||||
if tools.is_empty() {
|
||||
prompt.push_str("No tools are available. Do not invent tool calls.\n");
|
||||
} else {
|
||||
prompt.push_str("Use only these tools: ");
|
||||
prompt.push_str(
|
||||
&tools
|
||||
.iter()
|
||||
.map(|tool| tool.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
);
|
||||
prompt.push_str(".\nNever invent tool names or parameters. Check command exit codes and tool error results before claiming success.\n");
|
||||
}
|
||||
prompt
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "rig_request_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,170 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use galaxy_agent_core::{ContentPart, MessageContent, MessageRole, ToolResult, ToolResultStatus};
|
||||
use warp_multi_agent_api::ToolType;
|
||||
|
||||
use super::{input_messages, prepare_rig_turn};
|
||||
use crate::ai::agent::api::RequestParams;
|
||||
use crate::ai::agent::{AIAgentContext, AIAgentInput, AnyFileContent, FileContext, UserQueryMode};
|
||||
use crate::ai::llms::LLMId;
|
||||
use crate::ai::openai::client::OpenAIClientConfig;
|
||||
|
||||
fn config() -> OpenAIClientConfig {
|
||||
OpenAIClientConfig {
|
||||
base_url: "http://localhost:4000/v1".to_string(),
|
||||
api_key: None,
|
||||
model: Some("provider-model".to_string()),
|
||||
max_input_tokens: Some(128_000),
|
||||
max_output_tokens: Some(8_192),
|
||||
use_rig: true,
|
||||
supports_system_messages: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn user_query(query: &str) -> AIAgentInput {
|
||||
user_query_with_context(query, Vec::new())
|
||||
}
|
||||
|
||||
fn user_query_with_context(query: &str, context: Vec<AIAgentContext>) -> AIAgentInput {
|
||||
AIAgentInput::UserQuery {
|
||||
query: query.to_string(),
|
||||
context: Arc::from(context),
|
||||
static_query_type: None,
|
||||
referenced_attachments: HashMap::new(),
|
||||
user_query_mode: UserQueryMode::Normal,
|
||||
running_command: None,
|
||||
intended_agent: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_context_reaches_rig_without_a_proto_context_conversion() {
|
||||
let mut params = RequestParams::new_for_test();
|
||||
params.input = vec![user_query_with_context(
|
||||
"Explain the selected implementation",
|
||||
vec![
|
||||
AIAgentContext::SelectedText("prepare_rig_turn(params)".to_string()),
|
||||
AIAgentContext::File(FileContext::new(
|
||||
"/repo/src/runtime.rs".to_string(),
|
||||
AnyFileContent::StringContent("fn prepare_rig_turn() {}".to_string()),
|
||||
None,
|
||||
None,
|
||||
)),
|
||||
AIAgentContext::Codebase {
|
||||
path: "/repo".to_string(),
|
||||
name: "galaxy".to_string(),
|
||||
},
|
||||
],
|
||||
)];
|
||||
|
||||
let prepared = prepare_rig_turn(&config(), params, Vec::new(), Vec::new());
|
||||
let prompt = prepared.request.system_prompt.expect("system prompt");
|
||||
|
||||
assert!(prompt.contains("prepare_rig_turn(params)"));
|
||||
assert!(prompt.contains("fn prepare_rig_turn() {}"));
|
||||
assert!(prompt.contains("Indexed codebase: galaxy (/repo)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_a_rig_turn_directly_from_galaxy_request_state() {
|
||||
let mut params = RequestParams::new_for_test();
|
||||
params.model = LLMId::from("selected-model");
|
||||
params.root_task_id = Some("task-1".to_string());
|
||||
params.input = vec![user_query("Inspect this repository")];
|
||||
|
||||
let prepared = prepare_rig_turn(
|
||||
&config(),
|
||||
params,
|
||||
vec![ToolType::ReadFiles, ToolType::RunShellCommand],
|
||||
Vec::new(),
|
||||
);
|
||||
|
||||
assert_eq!(prepared.task_id, "task-1");
|
||||
assert_eq!(
|
||||
prepared.user_query.as_deref(),
|
||||
Some("Inspect this repository")
|
||||
);
|
||||
assert_eq!(prepared.request.model.as_str(), "provider-model");
|
||||
assert_eq!(prepared.request.max_output_tokens, Some(8_192));
|
||||
assert_eq!(prepared.request.messages, prepared.persistent_messages);
|
||||
assert!(prepared
|
||||
.request
|
||||
.tools
|
||||
.iter()
|
||||
.any(|tool| tool.name == "read_files"));
|
||||
assert!(prepared
|
||||
.request
|
||||
.tools
|
||||
.iter()
|
||||
.any(|tool| tool.name == "run_shell_command"));
|
||||
assert!(prepared
|
||||
.request
|
||||
.system_prompt
|
||||
.as_deref()
|
||||
.is_some_and(|prompt| prompt.contains("Galaxy owns tool permissions and execution")));
|
||||
assert!(matches!(
|
||||
&prepared.request.messages[0],
|
||||
galaxy_agent_core::ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
content: MessageContent::Text(text),
|
||||
} if text == "Inspect this repository"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalized_tool_outcomes_are_the_only_action_results_sent_to_rig() {
|
||||
let statuses = [
|
||||
("read", ToolResultStatus::Success, false),
|
||||
("shell", ToolResultStatus::Error, true),
|
||||
("denied", ToolResultStatus::Denied, true),
|
||||
("cancelled", ToolResultStatus::Cancelled, false),
|
||||
];
|
||||
let tool_results = statuses
|
||||
.iter()
|
||||
.map(|(call_id, status, _)| ToolResult {
|
||||
call_id: (*call_id).to_string(),
|
||||
content: format!("normalized-{call_id}"),
|
||||
status: *status,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let messages = input_messages(Vec::new(), tool_results);
|
||||
|
||||
assert_eq!(messages.len(), 1);
|
||||
let MessageContent::MultiPart(parts) = &messages[0].content else {
|
||||
panic!("expected normalized tool results to remain in one user turn");
|
||||
};
|
||||
for ((call_id, _, expected_error), part) in statuses.iter().zip(parts) {
|
||||
assert!(matches!(
|
||||
part,
|
||||
ContentPart::ToolResult {
|
||||
tool_use_id,
|
||||
content,
|
||||
is_error,
|
||||
} if tool_use_id == call_id
|
||||
&& content == &format!("normalized-{call_id}")
|
||||
&& is_error == expected_error
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progressive_summary_is_provider_context_not_persistent_history() {
|
||||
let mut params = RequestParams::new_for_test();
|
||||
params.input = vec![user_query("Continue")];
|
||||
params.progressive_summary = Some("Earlier work was validated.".to_string());
|
||||
|
||||
let prepared = prepare_rig_turn(&config(), params, Vec::new(), Vec::new());
|
||||
|
||||
assert_eq!(prepared.persistent_messages.len(), 1);
|
||||
assert_eq!(prepared.request.messages.len(), 3);
|
||||
assert!(matches!(
|
||||
&prepared.request.messages[0].content,
|
||||
MessageContent::Text(text) if text.contains("Earlier work was validated.")
|
||||
));
|
||||
assert!(matches!(
|
||||
&prepared.request.messages[2].content,
|
||||
MessageContent::Text(text) if text == "Continue"
|
||||
));
|
||||
}
|
||||
@@ -1,7 +1,14 @@
|
||||
use galaxy_agent_core::StopReason;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use galaxy_agent_core::{
|
||||
MessageContent, MessageRole, StopReason, ToolCall, ToolResult, ToolResultStatus,
|
||||
};
|
||||
use warp_multi_agent_api::response_event::stream_finished;
|
||||
|
||||
use super::{build_add_reasoning, build_append_reasoning, map_stop_reason, saturating_i32};
|
||||
use super::{
|
||||
append_tool_result, build_add_reasoning, build_append_reasoning, build_tool_proposed,
|
||||
map_stop_reason, saturating_i32, sync_assistant_turn,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn stop_reasons_map_to_the_existing_ui_contract() {
|
||||
@@ -57,3 +64,169 @@ fn reasoning_events_match_the_existing_ui_message_contract() {
|
||||
["agent_reasoning.reasoning"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_proposal_matches_the_existing_permission_ui_contract() {
|
||||
let event = build_tool_proposed(
|
||||
"task",
|
||||
&ToolCall {
|
||||
id: "call-1".to_string(),
|
||||
name: "run_shell_command".to_string(),
|
||||
arguments: serde_json::json!({
|
||||
"command": "cargo test",
|
||||
"is_read_only": true
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
let Some(warp_multi_agent_api::response_event::Type::ClientActions(actions)) = event.r#type
|
||||
else {
|
||||
panic!("expected client actions");
|
||||
};
|
||||
let Some(warp_multi_agent_api::client_action::Action::AddMessagesToTask(add)) =
|
||||
&actions.actions[0].action
|
||||
else {
|
||||
panic!("expected add-message action");
|
||||
};
|
||||
let Some(warp_multi_agent_api::message::Message::ToolCall(tool_call)) =
|
||||
&add.messages[0].message
|
||||
else {
|
||||
panic!("expected tool-call message");
|
||||
};
|
||||
let Some(warp_multi_agent_api::message::tool_call::Tool::RunShellCommand(command)) =
|
||||
&tool_call.tool
|
||||
else {
|
||||
panic!("expected run-shell-command payload");
|
||||
};
|
||||
|
||||
assert_eq!(tool_call.tool_call_id, "call-1");
|
||||
assert_eq!(command.command, "cargo test");
|
||||
assert!(command.is_read_only);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_tool_proposal_routes_through_the_existing_mcp_executor_contract() {
|
||||
let event = build_tool_proposed(
|
||||
"task",
|
||||
&ToolCall {
|
||||
id: "call-mcp".to_string(),
|
||||
name: "mcp__filesystem__read_file".to_string(),
|
||||
arguments: serde_json::json!({"path": "Cargo.toml"}),
|
||||
},
|
||||
);
|
||||
|
||||
let Some(warp_multi_agent_api::response_event::Type::ClientActions(actions)) = event.r#type
|
||||
else {
|
||||
panic!("expected client actions");
|
||||
};
|
||||
let Some(warp_multi_agent_api::client_action::Action::AddMessagesToTask(add)) =
|
||||
&actions.actions[0].action
|
||||
else {
|
||||
panic!("expected add-message action");
|
||||
};
|
||||
let Some(warp_multi_agent_api::message::Message::ToolCall(tool_call)) =
|
||||
&add.messages[0].message
|
||||
else {
|
||||
panic!("expected tool-call message");
|
||||
};
|
||||
let Some(warp_multi_agent_api::message::tool_call::Tool::CallMcpTool(call)) = &tool_call.tool
|
||||
else {
|
||||
panic!("expected MCP tool payload");
|
||||
};
|
||||
|
||||
assert_eq!(tool_call.tool_call_id, "call-mcp");
|
||||
assert_eq!(call.server_id, "filesystem");
|
||||
assert_eq!(call.name, "read_file");
|
||||
assert!(call.args.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assistant_history_is_updated_before_fast_tool_execution_can_continue() {
|
||||
let messages = Arc::new(Mutex::new(Vec::new()));
|
||||
let mut history_index = None;
|
||||
let first_call = ToolCall {
|
||||
id: "call-1".to_string(),
|
||||
name: "read_files".to_string(),
|
||||
arguments: serde_json::json!({"files": ["Cargo.toml"]}),
|
||||
};
|
||||
let second_call = ToolCall {
|
||||
id: "call-2".to_string(),
|
||||
name: "grep".to_string(),
|
||||
arguments: serde_json::json!({"queries": ["rig"]}),
|
||||
};
|
||||
|
||||
sync_assistant_turn(
|
||||
&messages,
|
||||
"I'll inspect both.",
|
||||
std::slice::from_ref(&first_call),
|
||||
&mut history_index,
|
||||
);
|
||||
sync_assistant_turn(
|
||||
&messages,
|
||||
"I'll inspect both.",
|
||||
&[first_call, second_call],
|
||||
&mut history_index,
|
||||
);
|
||||
|
||||
let messages = messages.lock().unwrap();
|
||||
assert_eq!(messages.len(), 1);
|
||||
let MessageContent::MultiPart(parts) = &messages[0].content else {
|
||||
panic!("expected combined assistant content");
|
||||
};
|
||||
assert_eq!(parts.len(), 3);
|
||||
assert!(
|
||||
matches!(&parts[0], galaxy_agent_core::ContentPart::Text(text) if text == "I'll inspect both.")
|
||||
);
|
||||
assert!(
|
||||
matches!(&parts[1], galaxy_agent_core::ContentPart::ToolUse { tool_use_id, .. } if tool_use_id == "call-1")
|
||||
);
|
||||
assert!(
|
||||
matches!(&parts[2], galaxy_agent_core::ContentPart::ToolUse { tool_use_id, .. } if tool_use_id == "call-2")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_tool_result_is_paired_with_the_assistant_call_in_history() {
|
||||
let messages = Arc::new(Mutex::new(Vec::new()));
|
||||
let mut history_index = None;
|
||||
let call = ToolCall {
|
||||
id: "call-unknown".to_string(),
|
||||
name: "invented_tool".to_string(),
|
||||
arguments: serde_json::json!({}),
|
||||
};
|
||||
sync_assistant_turn(
|
||||
&messages,
|
||||
"",
|
||||
std::slice::from_ref(&call),
|
||||
&mut history_index,
|
||||
);
|
||||
append_tool_result(
|
||||
&messages,
|
||||
ToolResult {
|
||||
call_id: call.id.clone(),
|
||||
content: "tool is unavailable".to_string(),
|
||||
status: ToolResultStatus::Error,
|
||||
},
|
||||
);
|
||||
|
||||
let messages = messages.lock().unwrap();
|
||||
assert_eq!(messages.len(), 2);
|
||||
assert_eq!(messages[0].role, MessageRole::Assistant);
|
||||
assert!(matches!(
|
||||
&messages[0].content,
|
||||
MessageContent::ToolUse {
|
||||
tool_use_id,
|
||||
name,
|
||||
..
|
||||
} if tool_use_id == "call-unknown" && name == "invented_tool"
|
||||
));
|
||||
assert_eq!(messages[1].role, MessageRole::User);
|
||||
assert!(matches!(
|
||||
&messages[1].content,
|
||||
MessageContent::ToolResult {
|
||||
tool_use_id,
|
||||
content,
|
||||
is_error: true,
|
||||
} if tool_use_id == "call-unknown" && content == "tool is unavailable"
|
||||
));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user