Migrate Rig tool flow to domain runtime

This commit is contained in:
2026-08-04 14:14:51 -05:00
parent 4c7270db8d
commit 91d8bd0381
34 changed files with 2728 additions and 374 deletions
+1 -1
View File
@@ -53,7 +53,7 @@ fn canonical_json_value(value: &serde_json::Value) -> String {
),
serde_json::Value::Object(values) => {
let mut entries = values.iter().collect::<Vec<_>>();
entries.sort_by(|(left, _), (right, _)| left.cmp(right));
entries.sort_by_key(|(key, _)| *key);
format!(
"{{{}}}",
entries
+2 -4
View File
@@ -164,10 +164,8 @@ impl AcpRuntimeModel {
) -> BTreeMap<String, serde_json::Value> {
options
.iter()
.filter_map(|option| {
(!option.current_value.is_null())
.then(|| (option.id.clone(), option.current_value.clone()))
})
.filter(|option| !option.current_value.is_null())
.map(|option| (option.id.clone(), option.current_value.clone()))
.collect()
}
+23 -19
View File
@@ -14,6 +14,7 @@ pub use convert_from::{
MaybeAIAgentOutputMessage, MessageToAIAgentOutputMessageError,
};
use futures_lite::Stream;
use galaxy_agent_core::ToolResult;
use galaxy_core::channel::ChannelState;
use galaxy_core::execution_mode::AppExecutionMode;
use galaxy_core::features::FeatureFlag;
@@ -96,6 +97,8 @@ pub struct RequestParams {
/// locally so ACP-provided Galaxy tools can be pinned to the exact pane.
pub terminal_view_id: Option<EntityId>,
pub input: Vec<AIAgentInput>,
/// Normalized results consumed directly by Rig-selected models.
pub tool_results: Vec<ToolResult>,
pub conversation_token: Option<ServerConversationToken>,
pub forked_from_conversation_token: Option<ServerConversationToken>,
pub ambient_agent_task_id: Option<AmbientAgentTaskId>,
@@ -140,21 +143,20 @@ pub struct RequestParams {
pub parent_agent_id: Option<String>,
/// The display name for this agent (e.g. "Agent 1"), assigned by the orchestrator.
pub agent_name: Option<String>,
/// Full Bedrock conversation history for direct Bedrock calls.
/// When present, the Bedrock path uses this instead of extracting from task_context.
pub bedrock_message_history: Vec<crate::ai::bedrock::convert::ConversationMessage>,
/// Provider-neutral conversation history for direct model calls.
pub message_history: Vec<crate::ai::provider::types::ConversationMessage>,
/// Progressive summary of older conversation history. Prepended as the first
/// message pair in the messages array sent to Bedrock.
pub bedrock_progressive_summary: Option<String>,
/// message pair in the messages array sent to the model.
pub progressive_summary: Option<String>,
/// Archived tool_use/tool_result pairs from previous summarization drains.
/// Passed to the Bedrock translator so `recall_tool_history` can search archived
/// results even after they've been summarized away from live history.
pub bedrock_tool_result_archive: Vec<crate::ai::bedrock::convert::ConversationMessage>,
/// Populated by the Bedrock path after building the message list.
/// Kept separately so `recall_tool_history` can search archived results even after
/// they've been summarized away from live history.
pub tool_result_archive: Vec<crate::ai::provider::types::ConversationMessage>,
/// Populated by direct-provider paths after building the message list.
/// Contains the full messages sent (old history + new input) so the controller
/// can store them back into the conversation for the next request cycle.
pub bedrock_messages_sent:
std::sync::Arc<std::sync::Mutex<Vec<crate::ai::bedrock::convert::ConversationMessage>>>,
pub messages_sent:
std::sync::Arc<std::sync::Mutex<Vec<crate::ai::provider::types::ConversationMessage>>>,
/// Global rules (name, content) from the local CloudModel (AIFact/AIMemory).
/// Injected into the system prompt when `is_memory_enabled` is true.
pub global_rules: Vec<(String, String)>,
@@ -187,6 +189,7 @@ impl RequestParams {
Self {
terminal_view_id: None,
input: vec![],
tool_results: vec![],
conversation_token: None,
forked_from_conversation_token: None,
ambient_agent_task_id: None,
@@ -218,10 +221,10 @@ impl RequestParams {
parent_agent_id: None,
agent_name: None,
root_task_id: None,
bedrock_message_history: vec![],
bedrock_progressive_summary: None,
bedrock_tool_result_archive: vec![],
bedrock_messages_sent: std::sync::Arc::new(std::sync::Mutex::new(vec![])),
message_history: vec![],
progressive_summary: None,
tool_result_archive: vec![],
messages_sent: std::sync::Arc::new(std::sync::Mutex::new(vec![])),
global_rules: vec![],
}
}
@@ -391,6 +394,7 @@ impl RequestParams {
Self {
terminal_view_id,
input: request_input.all_inputs().cloned().collect(),
tool_results: Vec::new(),
conversation_token: conversation.server_conversation_token,
forked_from_conversation_token: conversation.forked_from_conversation_token,
ambient_agent_task_id: conversation.ambient_agent_task_id,
@@ -426,10 +430,10 @@ impl RequestParams {
.map(|id| id.to_string()),
parent_agent_id: None,
agent_name: None,
bedrock_message_history: Vec::new(),
bedrock_progressive_summary: None,
bedrock_tool_result_archive: Vec::new(),
bedrock_messages_sent: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
message_history: Vec::new(),
progressive_summary: None,
tool_result_archive: Vec::new(),
messages_sent: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
global_rules: if is_memory_enabled {
Self::load_global_rules(app)
} else {
+24 -26
View File
@@ -24,6 +24,22 @@ pub async fn generate_multi_agent_output(
.unwrap_or_else(|| get_supported_tools(&params));
let supported_cli_agent_tools =
supported_tools_override.unwrap_or_else(|| get_supported_cli_agent_tools(&params));
if params.should_redact_secrets {
redaction::redact_inputs(&mut params.input);
}
if let ProviderConfig::OpenAI(config) = &provider_config {
if config.use_rig {
return Ok(crate::ai::runtime::rig_openai_response_stream(
config.clone(),
params,
supported_tools,
supported_cli_agent_tools,
cancellation_rx,
));
}
}
let mut logging_metadata = HashMap::new();
if let Some(ref metadata) = params.metadata {
logging_metadata.insert(
@@ -52,16 +68,6 @@ pub async fn generate_multi_agent_output(
);
}
if params.should_redact_secrets {
redaction::redact_inputs(&mut params.input);
}
let rig_params = matches!(
&provider_config,
ProviderConfig::OpenAI(config) if config.use_rig
)
.then(|| params.clone());
let mut request = api::Request {
task_context: Some(api::request::TaskContext {
tasks: params.tasks,
@@ -144,23 +150,15 @@ pub async fn generate_multi_agent_output(
};
match provider_config {
ProviderConfig::OpenAI(config) if config.use_rig => {
Ok(crate::ai::runtime::rig_openai_response_stream(
config,
rig_params.expect("Rig request parameters should be retained for a Rig model"),
&mut request,
cancellation_rx,
))
}
ProviderConfig::OpenAI(config) => {
let translator_request = openai_translator::TranslatorRequest {
config,
model_id: params.model.as_str().to_string(),
root_task_id: params.root_task_id.clone(),
message_history: params.bedrock_message_history.clone(),
tool_result_archive: params.bedrock_tool_result_archive.clone(),
progressive_summary: params.bedrock_progressive_summary.clone(),
messages_sent: params.bedrock_messages_sent.clone(),
message_history: params.message_history.clone(),
tool_result_archive: params.tool_result_archive.clone(),
progressive_summary: params.progressive_summary.clone(),
messages_sent: params.messages_sent.clone(),
global_rules: params.global_rules.clone(),
};
@@ -189,10 +187,10 @@ pub async fn generate_multi_agent_output(
config,
model_id: params.model.as_str().to_string(),
root_task_id: params.root_task_id.clone(),
bedrock_message_history: params.bedrock_message_history.clone(),
bedrock_tool_result_archive: params.bedrock_tool_result_archive.clone(),
bedrock_progressive_summary: params.bedrock_progressive_summary.clone(),
bedrock_messages_sent: params.bedrock_messages_sent.clone(),
bedrock_message_history: params.message_history.clone(),
bedrock_tool_result_archive: params.tool_result_archive.clone(),
bedrock_progressive_summary: params.progressive_summary.clone(),
bedrock_messages_sent: params.messages_sent.clone(),
global_rules: params.global_rules.clone(),
};
+5 -4
View File
@@ -14,6 +14,7 @@ fn request_params_with_ask_user_question_enabled(ask_user_question_enabled: bool
RequestParams {
terminal_view_id: None,
input: vec![],
tool_results: vec![],
conversation_token: None,
forked_from_conversation_token: None,
ambient_agent_task_id: None,
@@ -45,10 +46,10 @@ fn request_params_with_ask_user_question_enabled(ask_user_question_enabled: bool
root_task_id: None,
parent_agent_id: None,
agent_name: None,
bedrock_message_history: Vec::new(),
bedrock_progressive_summary: None,
bedrock_tool_result_archive: Vec::new(),
bedrock_messages_sent: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
message_history: Vec::new(),
progressive_summary: None,
tool_result_archive: Vec::new(),
messages_sent: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
global_rules: Vec::new(),
}
}
+1 -1
View File
@@ -1617,7 +1617,7 @@ fn supported_tool_types(request: &api::Request) -> Option<HashSet<api::ToolType>
)
}
fn tool_name_is_supported(name: &str, supported: &HashSet<api::ToolType>) -> bool {
pub(crate) fn tool_name_is_supported(name: &str, supported: &HashSet<api::ToolType>) -> bool {
use api::ToolType;
let has = |tool| supported.contains(&tool);
+8 -139
View File
@@ -8,6 +8,7 @@ use aws_sdk_bedrockruntime::types::{
ReasoningContentBlockDelta, StopReason,
};
use futures::stream::BoxStream;
use galaxy_agent_core::{recall_tool_history, ToolHistoryQuery};
use uuid::Uuid;
use warp_multi_agent_api::response_event::stream_finished;
use warp_multi_agent_api::{self as api, ClientAction, ResponseEvent};
@@ -231,13 +232,15 @@ pub fn bedrock_stream_to_response_events(
.unwrap_or(0) as usize;
let recall_result = match messages_sent.lock() {
Ok(sent) => recall_from_history(
Ok(sent) => recall_tool_history(
&sent,
&tool_result_archive,
search_query,
tool_name_filter,
tool_use_id,
offset,
ToolHistoryQuery {
search_query,
tool_name: tool_name_filter,
tool_use_id,
offset_from_end: offset,
},
),
Err(_) => "Error: could not access conversation history.".to_string(),
};
@@ -1504,137 +1507,3 @@ pub(super) fn is_known_tool(name: &str) -> bool {
fn is_notebook_tool(name: &str) -> bool {
matches!(name, "create_notebook" | "read_notebook" | "edit_notebook")
}
/// Searches conversation message history for tool call results matching the given criteria.
pub(crate) fn recall_from_history(
messages: &[ConversationMessage],
archive: &[ConversationMessage],
search_query: &str,
tool_name_filter: &str,
tool_use_id: &str,
offset_from_end: usize,
) -> String {
use super::convert::{ContentPart, MessageContent};
struct ToolEntry {
tool_use_id: String,
name: String,
input: String,
result: String,
}
let mut tool_entries: Vec<ToolEntry> = Vec::new();
let mut pending_tool_uses: Vec<(String, String, String)> = Vec::new(); // (id, name, input)
for msg in messages.iter().chain(archive.iter()) {
match &msg.content {
MessageContent::ToolUse {
tool_use_id,
name,
input,
} => {
pending_tool_uses.push((tool_use_id.clone(), name.clone(), input.to_string()));
}
MessageContent::ToolResult {
tool_use_id,
content,
..
} => {
if let Some(pos) = pending_tool_uses
.iter()
.position(|(id, _, _)| id == tool_use_id)
{
let (tuid, name, input) = pending_tool_uses.remove(pos);
tool_entries.push(ToolEntry {
tool_use_id: tuid,
name,
input,
result: content.clone(),
});
}
}
MessageContent::MultiPart(parts) => {
for part in parts {
match part {
ContentPart::ToolUse {
tool_use_id,
name,
input,
} => {
pending_tool_uses.push((
tool_use_id.clone(),
name.clone(),
input.to_string(),
));
}
ContentPart::ToolResult {
tool_use_id,
content,
..
} => {
if let Some(pos) = pending_tool_uses
.iter()
.position(|(id, _, _)| id == tool_use_id)
{
let (tuid, name, input) = pending_tool_uses.remove(pos);
tool_entries.push(ToolEntry {
tool_use_id: tuid,
name,
input,
result: content.clone(),
});
}
}
_ => {}
}
}
}
_ => {}
}
}
let filtered: Vec<&ToolEntry> = tool_entries
.iter()
.filter(|entry| {
if !tool_use_id.is_empty() && entry.tool_use_id != tool_use_id {
return false;
}
if !tool_name_filter.is_empty() && entry.name != tool_name_filter {
return false;
}
if !search_query.is_empty() {
let haystack = format!("{} {} {}", entry.name, entry.input, entry.result);
let query_lower = search_query.to_lowercase();
if !haystack.to_lowercase().contains(&query_lower) {
return false;
}
}
true
})
.collect();
if filtered.is_empty() {
return "No matching tool calls found in conversation history.".to_string();
}
// Get the entry at offset_from_end (0 = most recent)
let idx = if offset_from_end >= filtered.len() {
0
} else {
filtered.len() - 1 - offset_from_end
};
let entry = &filtered[idx];
let result_display = if entry.result.len() > 50000 {
let trunc = entry.result.chars().take(50000).collect::<String>();
format!("{trunc}... [truncated, {} total chars]", entry.result.len())
} else {
entry.result.clone()
};
format!(
"Tool: {}\nTool Use ID: {}\nInput: {}\nResult:\n{}",
entry.name, entry.tool_use_id, entry.input, result_display
)
}
+174 -3
View File
@@ -34,6 +34,9 @@ pub use execute::{
StartAgentExecutorEvent, StartAgentRequest, StartAgentRequestId,
};
use futures::future::{join_all, BoxFuture};
use galaxy_agent_core::{
PermissionDecision, PermissionKind, PermissionRequest, ToolEvent, ToolResult, ToolResultStatus,
};
use galaxyui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
use itertools::Itertools;
use parking_lot::FairMutex;
@@ -210,6 +213,67 @@ fn can_start_action_with_current_phase(
}
}
fn permission_request_id(action_id: &AIAgentActionId) -> String {
format!("permission:{action_id}")
}
fn is_permission_denial(reason: CancellationReason, status: Option<&AIActionStatus>) -> bool {
matches!(reason, CancellationReason::ManuallyCancelled)
&& matches!(status, Some(AIActionStatus::Blocked))
}
fn permission_kind_for_action(action: &AIAgentActionType) -> PermissionKind {
match action {
AIAgentActionType::ReadFiles(_)
| AIAgentActionType::SearchCodebase(_)
| AIAgentActionType::Grep { .. }
| AIAgentActionType::FileGlob { .. }
| AIAgentActionType::FileGlobV2 { .. }
| AIAgentActionType::ReadMCPResource { .. }
| AIAgentActionType::ReadDocuments(_)
| AIAgentActionType::ReadSkill(_)
| AIAgentActionType::FetchConversation { .. }
| AIAgentActionType::WaitForEvents { .. } => PermissionKind::Read,
AIAgentActionType::RequestFileEdits { .. }
| AIAgentActionType::EditDocuments(_)
| AIAgentActionType::CreateDocuments(_)
| AIAgentActionType::InitProject
| AIAgentActionType::InsertCodeReviewComments { .. } => PermissionKind::Write,
AIAgentActionType::RequestCommandOutput { .. }
| AIAgentActionType::WriteToLongRunningShellCommand { .. }
| AIAgentActionType::ReadShellCommandOutput { .. }
| AIAgentActionType::UseComputer(_)
| AIAgentActionType::RequestComputerUse(_)
| AIAgentActionType::TransferShellCommandControlToUser { .. }
| AIAgentActionType::OpenCodeReview => PermissionKind::Execute,
AIAgentActionType::UploadArtifact(_) => PermissionKind::Network,
AIAgentActionType::CallMCPTool { .. }
| AIAgentActionType::SuggestNewConversation { .. }
| AIAgentActionType::SuggestPrompt(_)
| AIAgentActionType::StartAgent { .. }
| AIAgentActionType::SendMessageToAgent { .. }
| AIAgentActionType::AskUserQuestion { .. }
| AIAgentActionType::RunAgents(_) => PermissionKind::ExternalTool,
}
}
fn domain_tool_result(action_result: &AIAgentActionResult, permission_denied: bool) -> ToolResult {
let status = if permission_denied {
ToolResultStatus::Denied
} else if action_result.result.is_cancelled() {
ToolResultStatus::Cancelled
} else if action_result.result.is_failed() {
ToolResultStatus::Error
} else {
ToolResultStatus::Success
};
ToolResult {
call_id: action_result.id.to_string(),
content: action_result.result.model_content(),
status,
}
}
pub struct BlocklistAIActionModel {
executor: ModelHandle<BlocklistAIActionExecutor>,
@@ -224,12 +288,19 @@ pub struct BlocklistAIActionModel {
/// Map from conversation ID to actions received in the most recent AI output that are finished.
finished_action_results: HashMap<AIConversationId, Vec<Arc<AIAgentActionResult>>>,
/// Provider-neutral results for the same finished actions. Rig consumes these directly rather
/// than reconstructing them from the legacy request protobuf.
finished_tool_results: HashMap<AIConversationId, Vec<ToolResult>>,
/// Original order for the current batch of actions.
///
/// We maintain this so that even though we might process actions in parallel,
/// we can still order the results consistently.
action_order: HashMap<AIConversationId, HashMap<AIAgentActionId, usize>>,
/// Permission-card rejections that still need a correlated completion event.
denied_permissions: HashSet<(AIConversationId, AIAgentActionId)>,
/// Past actions and their corresponding statuses from previous AI exchanges.
past_action_results: HashMap<AIAgentActionId, Arc<AIAgentActionResult>>,
@@ -266,6 +337,12 @@ impl BlocklistAIActionModel {
ctx.subscribe_to_model(&executor, move |me, _, event, ctx| match event {
BlocklistAIActionExecutorEvent::ExecutingAction { action_id } => {
ctx.emit(BlocklistAIActionEvent::ExecutingAction(action_id.clone()));
ctx.emit(BlocklistAIActionEvent::ToolLifecycle {
action_id: action_id.clone(),
event: ToolEvent::Started {
call_id: action_id.to_string(),
},
});
}
BlocklistAIActionExecutorEvent::FinishedAction {
result,
@@ -298,10 +375,12 @@ impl BlocklistAIActionModel {
Self {
pending_actions: Default::default(),
finished_action_results: Default::default(),
finished_tool_results: Default::default(),
executor,
past_action_results: HashMap::new(),
running_actions: Default::default(),
action_order: Default::default(),
denied_permissions: Default::default(),
terminal_view_id,
pending_preprocessed_actions: Default::default(),
is_view_only: false,
@@ -533,6 +612,18 @@ impl BlocklistAIActionModel {
action_order.get(&result.id).copied().unwrap_or(usize::MAX)
});
}
if let Some(tool_results) = self.finished_tool_results.get_mut(&conversation_id) {
let tool_order = action_order
.iter()
.map(|(id, index)| (id.to_string(), *index))
.collect::<HashMap<_, _>>();
tool_results.sort_by_key(|result| {
tool_order
.get(&result.call_id)
.copied()
.unwrap_or(usize::MAX)
});
}
}
}
@@ -833,6 +924,17 @@ impl BlocklistAIActionModel {
ctx.emit(BlocklistAIActionEvent::ActionBlockedOnUserConfirmation(
action.id.clone(),
));
ctx.emit(BlocklistAIActionEvent::ToolLifecycle {
action_id: action.id.clone(),
event: ToolEvent::PermissionRequested {
request: PermissionRequest {
id: permission_request_id(&action.id),
call_id: action.id.to_string(),
kind: permission_kind_for_action(&action.action),
reason: Some(action.action.user_friendly_name()),
},
},
});
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, ctx| {
let blocked_action_user_friendly_str = action.action.user_friendly_name();
history_model.update_conversation_status(
@@ -897,6 +999,16 @@ impl BlocklistAIActionModel {
let action_id = action.id.clone();
let phase = self.action_phase_for_action(&action, ctx);
if is_user_initiated {
ctx.emit(BlocklistAIActionEvent::ToolLifecycle {
action_id: action_id.clone(),
event: ToolEvent::PermissionResolved {
request_id: permission_request_id(&action_id),
call_id: action_id.to_string(),
decision: PermissionDecision::AllowOnce,
},
});
}
// WaitForEvents owns its own status transition; skip the default
// in-progress update.
let is_wait_for_events = matches!(action.action, AIAgentActionType::WaitForEvents { .. });
@@ -1073,6 +1185,8 @@ impl BlocklistAIActionModel {
reason: CancellationReason,
ctx: &mut ModelContext<Self>,
) {
let status = self.get_action_status(action_id);
let permission_denied = is_permission_denial(reason, status.as_ref());
if self
.running_actions
.get(&conversation_id)
@@ -1092,7 +1206,13 @@ impl BlocklistAIActionModel {
.find_position(|action| action.id == *action_id)
{
if let Some(action) = pending_actions_for_conversation.remove(idx) {
self.cancel_pending_action(conversation_id, action, Some(reason), ctx);
self.cancel_pending_action(
conversation_id,
action,
Some(reason),
permission_denied,
ctx,
);
}
}
}
@@ -1140,7 +1260,7 @@ impl BlocklistAIActionModel {
reason,
std::backtrace::Backtrace::force_capture()
);
self.cancel_pending_action(conversation_id, action, reason, ctx);
self.cancel_pending_action(conversation_id, action, reason, false, ctx);
}
}
@@ -1177,8 +1297,22 @@ impl BlocklistAIActionModel {
conversation_id: AIConversationId,
pending_action: AIAgentAction,
reason: Option<CancellationReason>,
permission_denied: bool,
ctx: &mut ModelContext<Self>,
) {
if permission_denied {
self.denied_permissions
.insert((conversation_id, pending_action.id.clone()));
ctx.emit(BlocklistAIActionEvent::ToolLifecycle {
action_id: pending_action.id.clone(),
event: ToolEvent::PermissionResolved {
request_id: permission_request_id(&pending_action.id),
call_id: pending_action.id.to_string(),
decision: PermissionDecision::Denied { reason: None },
},
});
}
if matches!(
pending_action.action,
AIAgentActionType::RequestComputerUse(_)
@@ -1227,10 +1361,20 @@ impl BlocklistAIActionModel {
.collect_vec()
}
pub(super) fn drain_finished_tool_results(
&mut self,
conversation_id: AIConversationId,
) -> Vec<ToolResult> {
self.finished_tool_results
.remove(&conversation_id)
.unwrap_or_default()
}
/// Clears finished action results for a conversation. Used when reverting.
pub(super) fn clear_finished_action_results(&mut self, conversation_id: AIConversationId) {
self.action_order.remove(&conversation_id);
self.finished_action_results.remove(&conversation_id);
self.finished_tool_results.remove(&conversation_id);
}
/// The control flow for initiating cancellations across suggested plans, requested commands,
@@ -1308,10 +1452,31 @@ impl BlocklistAIActionModel {
)
) {
for action in self.drain_pending_request_command_actions(conversation_id) {
self.cancel_pending_action(conversation_id, action, cancellation_reason, ctx);
self.cancel_pending_action(
conversation_id,
action,
cancellation_reason,
false,
ctx,
);
}
}
let permission_denied = self
.denied_permissions
.remove(&(conversation_id, action_result.id.clone()));
let tool_result = domain_tool_result(&action_result, permission_denied);
self.finished_tool_results
.entry(conversation_id)
.or_default()
.push(tool_result.clone());
ctx.emit(BlocklistAIActionEvent::ToolLifecycle {
action_id: action_result.id.clone(),
event: ToolEvent::Completed {
result: tool_result,
},
});
self.finished_action_results
.entry(conversation_id)
.or_default()
@@ -1474,6 +1639,11 @@ pub enum BlocklistAIActionEvent {
conversation_id: AIConversationId,
cancellation_reason: Option<CancellationReason>,
},
/// Provider-neutral permission and execution lifecycle event for runtime consumers.
ToolLifecycle {
action_id: AIAgentActionId,
event: ToolEvent,
},
InitProject(AIAgentActionId),
ToggleCodeReview(AIAgentActionId),
InsertCodeReviewComments {
@@ -1491,6 +1661,7 @@ impl BlocklistAIActionEvent {
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation(action_id) => action_id,
BlocklistAIActionEvent::ExecutingAction(action_id) => action_id,
BlocklistAIActionEvent::FinishedAction { action_id, .. } => action_id,
BlocklistAIActionEvent::ToolLifecycle { action_id, .. } => action_id,
BlocklistAIActionEvent::InitProject(action_id) => action_id,
BlocklistAIActionEvent::ToggleCodeReview(action_id) => action_id,
BlocklistAIActionEvent::InsertCodeReviewComments { action_id, .. } => action_id,
+116 -1
View File
@@ -3,7 +3,9 @@ use std::sync::Arc;
use super::*;
use crate::ai::agent::task::TaskId;
use crate::ai::agent::AIAgentActionResultType;
use crate::ai::agent::{
AIAgentActionResultType, AnyFileContent, FileContext, GrepResult, ReadFilesResult,
};
fn make_action_result(id: &str) -> Arc<AIAgentActionResult> {
Arc::new(AIAgentActionResult {
@@ -13,6 +15,14 @@ fn make_action_result(id: &str) -> Arc<AIAgentActionResult> {
})
}
fn action_result(id: &str, result: AIAgentActionResultType) -> AIAgentActionResult {
AIAgentActionResult {
id: AIAgentActionId::from(id.to_owned()),
task_id: TaskId::new("task".to_owned()),
result,
}
}
fn count_startable_actions_for_pass(phases: &[(RunningActionPhase, bool)]) -> usize {
let mut current_phase = None;
let mut count = 0;
@@ -100,3 +110,108 @@ fn finished_results_stay_in_original_action_order() {
AIAgentActionId::from("third".to_owned())
);
}
#[test]
fn domain_tool_results_preserve_success_failure_cancellation_and_denial() {
let success = domain_tool_result(
&action_result("success", AIAgentActionResultType::InitProject),
false,
);
let failure = domain_tool_result(
&action_result(
"failure",
AIAgentActionResultType::Grep(GrepResult::Error("boom".to_string())),
),
false,
);
let cancelled_result = action_result(
"cancelled",
AIAgentActionResultType::Grep(GrepResult::Cancelled),
);
let cancelled = domain_tool_result(&cancelled_result, false);
let denied = domain_tool_result(&cancelled_result, true);
assert_eq!(success.status, ToolResultStatus::Success);
assert_eq!(failure.status, ToolResultStatus::Error);
assert_eq!(cancelled.status, ToolResultStatus::Cancelled);
assert_eq!(denied.status, ToolResultStatus::Denied);
assert_eq!(success.call_id, "success");
assert_eq!(failure.call_id, "failure");
assert_eq!(cancelled.call_id, "cancelled");
assert_eq!(denied.call_id, "cancelled");
}
#[test]
fn domain_read_result_contains_the_file_contents_for_the_next_model_turn() {
let result = action_result(
"read-call",
AIAgentActionResultType::ReadFiles(ReadFilesResult::Success {
files: vec![FileContext::new(
"/workspace/src/lib.rs".to_string(),
AnyFileContent::StringContent("pub fn answer() -> u8 { 42 }".to_string()),
None,
None,
)],
}),
);
let result = domain_tool_result(&result, false);
assert_eq!(result.status, ToolResultStatus::Success);
assert_eq!(result.call_id, "read-call");
assert!(result.content.contains("/workspace/src/lib.rs"));
assert!(result.content.contains("pub fn answer() -> u8 { 42 }"));
}
#[test]
fn action_permission_kinds_match_the_safety_boundary() {
assert_eq!(
permission_kind_for_action(&AIAgentActionType::Grep {
queries: vec!["needle".to_string()],
path: ".".to_string(),
}),
PermissionKind::Read
);
assert_eq!(
permission_kind_for_action(&AIAgentActionType::InitProject),
PermissionKind::Write
);
assert_eq!(
permission_kind_for_action(&AIAgentActionType::RequestCommandOutput {
command: "cargo test".to_string(),
is_read_only: Some(true),
is_risky: Some(false),
wait_until_completion: true,
uses_pager: Some(false),
rationale: None,
citations: Vec::new(),
}),
PermissionKind::Execute
);
assert_eq!(
permission_kind_for_action(&AIAgentActionType::CallMCPTool {
server_id: None,
name: "tool".to_string(),
input: serde_json::json!({}),
}),
PermissionKind::ExternalTool
);
}
#[test]
fn only_rejecting_a_blocked_action_is_a_permission_denial() {
assert!(is_permission_denial(
CancellationReason::ManuallyCancelled,
Some(&AIActionStatus::Blocked),
));
assert!(!is_permission_denial(
CancellationReason::ManuallyCancelled,
Some(&AIActionStatus::Queued),
));
assert!(!is_permission_denial(
CancellationReason::FollowUpSubmitted {
is_for_same_conversation: true,
},
Some(&AIActionStatus::Blocked),
));
}
+2 -1
View File
@@ -4929,7 +4929,8 @@ impl AIBlock {
}
}
BlocklistAIActionEvent::InitProject(_)
BlocklistAIActionEvent::ToolLifecycle { .. }
| BlocklistAIActionEvent::InitProject(_)
| BlocklistAIActionEvent::ToggleCodeReview(_) => {}
}
});
+23 -72
View File
@@ -10,7 +10,7 @@ mod pending_response_streams;
pub mod response_stream;
pub(super) mod shared_session;
mod slash_command;
use std::collections::{HashMap, HashSet, VecDeque};
use std::collections::{HashMap, HashSet};
#[cfg(not(target_family = "wasm"))]
use std::path::PathBuf;
use std::sync::Arc;
@@ -19,6 +19,7 @@ use std::time::Duration;
use ai::skills::SkillPathOrigin;
use anyhow::anyhow;
use chrono::{DateTime, Local};
use galaxy_agent_core::ToolLoopGuard;
use galaxy_core::assertions::safe_assert;
use input_context::{input_context_for_request, parse_context_attachments};
use itertools::Itertools;
@@ -196,60 +197,6 @@ pub enum BlocklistAIControllerEvent {
FreeTierLimitCheckTriggered,
}
/// Tracks recent failed action signatures for loop detection.
/// When the same tool+input pattern fails repeatedly, we inject
/// corrective instructions to break the cycle.
#[derive(Debug, Clone)]
struct LoopDetectionEntry {
/// Discriminant of the action result type (e.g. RequestCommandOutput, ApplyFileDiffs)
tool_discriminant: std::mem::Discriminant<AIAgentActionResultType>,
/// Hash of the action's identifying input (command string, file paths, etc.)
input_hash: u64,
/// Human-readable description of what failed
description: String,
}
#[derive(Debug, Default, Clone)]
struct LoopDetectionState {
recent_failures: VecDeque<LoopDetectionEntry>,
}
const LOOP_DETECTION_WINDOW: usize = 10;
const LOOP_DETECTION_THRESHOLD: usize = 3;
impl LoopDetectionState {
fn record_failure(&mut self, entry: LoopDetectionEntry) {
self.recent_failures.push_back(entry);
if self.recent_failures.len() > LOOP_DETECTION_WINDOW {
self.recent_failures.pop_front();
}
}
fn detect_loop(&self) -> Option<&LoopDetectionEntry> {
use std::collections::HashMap as CountMap;
let mut counts: CountMap<
(std::mem::Discriminant<AIAgentActionResultType>, u64),
(usize, usize),
> = CountMap::new();
for (idx, entry) in self.recent_failures.iter().enumerate() {
let key = (entry.tool_discriminant, entry.input_hash);
let counter = counts.entry(key).or_insert((0, 0));
counter.0 += 1;
counter.1 = idx; // Track most recent occurrence
}
for ((_disc, _hash), (count, latest_idx)) in &counts {
if *count >= LOOP_DETECTION_THRESHOLD {
return self.recent_failures.get(*latest_idx);
}
}
None
}
fn clear(&mut self) {
self.recent_failures.clear();
}
}
#[derive(Debug)]
pub struct RequestInput {
pub conversation_id: AIConversationId,
@@ -419,7 +366,7 @@ pub struct BlocklistAIController {
pending_passive_follow_ups: HashSet<AIConversationId>,
/// Per-conversation loop detection state for preventing recursive tool failures.
loop_detection: HashMap<AIConversationId, LoopDetectionState>,
loop_detection: HashMap<AIConversationId, ToolLoopGuard>,
/// Per-conversation error retry count for injecting corrective messages on failure.
error_retry_counts: HashMap<AIConversationId, usize>,
/// Passive suggestion results that should be included with the next request
@@ -1992,11 +1939,7 @@ impl BlocklistAIController {
description.hash(&mut hasher);
let input_hash = hasher.finish();
state.record_failure(LoopDetectionEntry {
tool_discriminant: discriminant,
input_hash,
description: description.clone(),
});
state.record_failure(input_hash, description);
} else if result.result.is_successful() {
has_success = true;
}
@@ -2005,23 +1948,21 @@ impl BlocklistAIController {
// If we had at least one success in this batch, clear loop state —
// the agent is making progress.
if has_success {
state.clear();
state.record_success();
return None;
}
// Check for loops
if let Some(looping_entry) = state.detect_loop() {
if let Some(looping_entry) = state.detect_and_reset() {
let warning = format!(
"[SYSTEM] Loop detected: the same action has failed {} or more times consecutively. \
Do NOT repeat this action or any similar approach.\n\n\
Failing action: {}\n\n\
Take a completely different approach to accomplish the goal. \
If you cannot find an alternative, explain to the user what is failing and why.",
LOOP_DETECTION_THRESHOLD,
looping_entry.threshold,
looping_entry.description
);
// Clear the state so we don't keep injecting on every subsequent turn
state.clear();
Some(warning)
} else {
None
@@ -3061,11 +3002,23 @@ impl BlocklistAIController {
query_metadata,
ctx,
);
let action_result_ids = request_input
.all_inputs()
.filter_map(AIAgentInput::action_result)
.map(|result| result.id.to_string())
.collect::<HashSet<_>>();
request_params.tool_results = self.action_model.update(ctx, |action_model, _| {
action_model
.drain_finished_tool_results(conversation_id)
.into_iter()
.filter(|result| action_result_ids.contains(&result.call_id))
.collect()
});
request_params.parent_agent_id = parent_agent_id;
request_params.agent_name = agent_name;
request_params.bedrock_message_history = bedrock_history;
request_params.bedrock_tool_result_archive = bedrock_tool_result_archive;
request_params.bedrock_progressive_summary = bedrock_progressive_summary;
request_params.message_history = bedrock_history;
request_params.tool_result_archive = bedrock_tool_result_archive;
request_params.progressive_summary = bedrock_progressive_summary;
// For the Bedrock path, when this is the first request in a new conversation
// (no tasks established yet), use the conversation's root task ID so the
@@ -3555,9 +3508,7 @@ impl BlocklistAIController {
// history (input + assistant response) from the Arc back
// into the conversation for the next request cycle.
let new_history = (!response_stream.as_ref(ctx).is_acp())
.then(|| {
response_stream.as_ref(ctx).bedrock_messages_sent().clone()
})
.then(|| response_stream.as_ref(ctx).messages_sent().clone())
.and_then(|messages_sent| {
messages_sent.lock().ok().and_then(|sent| {
if sent.is_empty() {
@@ -552,11 +552,11 @@ impl ResponseStream {
}
}
pub fn bedrock_messages_sent(
pub fn messages_sent(
&self,
) -> &std::sync::Arc<std::sync::Mutex<Vec<crate::ai::bedrock::convert::ConversationMessage>>>
) -> &std::sync::Arc<std::sync::Mutex<Vec<crate::ai::provider::types::ConversationMessage>>>
{
&self.params.bedrock_messages_sent
&self.params.messages_sent
}
/// Returns the model ID associated with this response stream's request.
+9 -6
View File
@@ -3,6 +3,7 @@ use std::sync::{Arc, Mutex};
use bytes::Bytes;
use futures::stream::BoxStream;
use futures::Stream;
use galaxy_agent_core::{recall_tool_history, ToolHistoryQuery};
use serde_json::Value as JsonValue;
use uuid::Uuid;
use warp_multi_agent_api::response_event::stream_finished;
@@ -10,7 +11,7 @@ use warp_multi_agent_api::{self as api, ClientAction, ResponseEvent};
use crate::ai::agent::api::Event;
use crate::ai::bedrock::response_translator::{
build_create_task, build_stream_init, context_window_for_model, recall_from_history,
build_create_task, build_stream_init, context_window_for_model,
};
use crate::ai::provider::types::{ContentPart, ConversationMessage, MessageContent, MessageRole};
use crate::server::server_api::AIApiError;
@@ -276,13 +277,15 @@ pub fn openai_stream_to_response_events(
.and_then(|value| value.as_u64())
.unwrap_or(0) as usize;
let recall_result = match messages_sent.lock() {
Ok(sent) => recall_from_history(
Ok(sent) => recall_tool_history(
&sent,
&tool_result_archive,
search_query,
tool_name_filter,
tool_use_id,
offset,
ToolHistoryQuery {
search_query,
tool_name: tool_name_filter,
tool_use_id,
offset_from_end: offset,
},
),
Err(_) => "Error: could not access conversation history.".to_string(),
};
+1
View File
@@ -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
View File
@@ -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 {
+625
View File
@@ -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;
+170
View File
@@ -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"
));
}
+175 -2
View File
@@ -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"
));
}