Complete Rig tool lifecycle migration
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
mod provider;
|
||||
mod rig;
|
||||
mod rig_request;
|
||||
mod rig_tool;
|
||||
|
||||
pub(crate) use provider::ProviderRuntime;
|
||||
pub(crate) use rig::rig_openai_response_stream;
|
||||
|
||||
+32
-19
@@ -12,7 +12,9 @@ use warp_multi_agent_api::response_event::stream_finished;
|
||||
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 super::rig_tool::action_from_tool_call;
|
||||
use crate::ai::agent::api::{Event, RequestParams, ResponseStream, StreamEvent};
|
||||
use crate::ai::agent::AIAgentAction;
|
||||
use crate::ai::bedrock::response_translator::{
|
||||
build_add_agent_output_message, build_append_text, build_create_task, build_stream_init,
|
||||
build_user_query_message,
|
||||
@@ -29,6 +31,7 @@ pub(crate) fn rig_openai_response_stream(
|
||||
supported_cli_agent_tools: Vec<ToolType>,
|
||||
cancellation_rx: oneshot::Receiver<()>,
|
||||
) -> ResponseStream {
|
||||
let skill_path_origin = params.session_context.skill_path_origin();
|
||||
let PreparedRigTurn {
|
||||
task_id,
|
||||
needs_create_task,
|
||||
@@ -111,30 +114,30 @@ pub(crate) fn rig_openai_response_stream(
|
||||
match event {
|
||||
AgentEvent::TurnStarted { .. } => {
|
||||
initialized = true;
|
||||
yield Ok(build_stream_init(&request_id, &conversation_id));
|
||||
yield Ok(StreamEvent::Response(build_stream_init(&request_id, &conversation_id)));
|
||||
if needs_create_task {
|
||||
yield Ok(build_create_task(&task_id));
|
||||
yield Ok(StreamEvent::Response(build_create_task(&task_id)));
|
||||
}
|
||||
if let Some(user_query) = &user_query {
|
||||
yield Ok(build_user_query_message(&task_id, user_query));
|
||||
yield Ok(StreamEvent::Response(build_user_query_message(&task_id, user_query)));
|
||||
}
|
||||
}
|
||||
AgentEvent::TextDelta { text } => {
|
||||
full_text.push_str(&text);
|
||||
if let Some(message_id) = ¤t_text_message_id {
|
||||
yield Ok(build_append_text(&task_id, message_id, &text));
|
||||
yield Ok(StreamEvent::Response(build_append_text(&task_id, message_id, &text)));
|
||||
} else {
|
||||
let message_id = Uuid::new_v4().to_string();
|
||||
yield Ok(build_add_agent_output_message(&task_id, &message_id, &text));
|
||||
yield Ok(StreamEvent::Response(build_add_agent_output_message(&task_id, &message_id, &text)));
|
||||
current_text_message_id = Some(message_id);
|
||||
}
|
||||
}
|
||||
AgentEvent::ReasoningDelta { text } => {
|
||||
if let Some(message_id) = ¤t_reasoning_message_id {
|
||||
yield Ok(build_append_reasoning(&task_id, message_id, &text));
|
||||
yield Ok(StreamEvent::Response(build_append_reasoning(&task_id, message_id, &text)));
|
||||
} else {
|
||||
let message_id = Uuid::new_v4().to_string();
|
||||
yield Ok(build_add_reasoning(&task_id, &message_id, &text));
|
||||
yield Ok(StreamEvent::Response(build_add_reasoning(&task_id, &message_id, &text)));
|
||||
current_reasoning_message_id = Some(message_id);
|
||||
}
|
||||
}
|
||||
@@ -155,7 +158,16 @@ pub(crate) fn rig_openai_response_stream(
|
||||
.unwrap_or_default();
|
||||
match tool_policy.decide(&call, &history, &tool_result_archive) {
|
||||
ToolCallDecision::Execute => {
|
||||
yield Ok(build_tool_proposed(&task_id, &call));
|
||||
match build_tool_proposed(&task_id, &call, &skill_path_origin) {
|
||||
Ok(action) => yield Ok(StreamEvent::ToolProposed(action)),
|
||||
Err(message) => {
|
||||
yield Err(agent_error(AgentError::new(
|
||||
galaxy_agent_core::AgentErrorKind::Protocol,
|
||||
message,
|
||||
)));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
ToolCallDecision::Inline(result) => {
|
||||
append_tool_result(&messages_sent, result);
|
||||
@@ -172,17 +184,17 @@ pub(crate) fn rig_openai_response_stream(
|
||||
);
|
||||
append_tool_result(&messages_sent, result);
|
||||
let message_id = Uuid::new_v4().to_string();
|
||||
yield Ok(build_add_agent_output_message(
|
||||
yield Ok(StreamEvent::Response(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));
|
||||
yield Ok(StreamEvent::Response(build_stream_init(&request_id, &conversation_id)));
|
||||
}
|
||||
sync_assistant_turn(
|
||||
&messages_sent,
|
||||
@@ -190,7 +202,7 @@ pub(crate) fn rig_openai_response_stream(
|
||||
&proposed_tools,
|
||||
&mut assistant_history_index,
|
||||
);
|
||||
yield Ok(build_stream_finished(
|
||||
yield Ok(StreamEvent::Response(build_stream_finished(
|
||||
map_stop_reason(reason),
|
||||
StreamUsage {
|
||||
input_tokens: saturating_i32(usage.input_tokens),
|
||||
@@ -203,7 +215,7 @@ pub(crate) fn rig_openai_response_stream(
|
||||
model_id,
|
||||
max_context_tokens,
|
||||
},
|
||||
));
|
||||
)));
|
||||
return;
|
||||
}
|
||||
AgentEvent::Tool { .. } => {
|
||||
@@ -304,11 +316,12 @@ fn sync_assistant_turn(
|
||||
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_tool_proposed(
|
||||
task_id: &str,
|
||||
call: &ToolCall,
|
||||
skill_path_origin: &ai::skills::SkillPathOrigin,
|
||||
) -> Result<AIAgentAction, String> {
|
||||
action_from_tool_call(task_id, call, skill_path_origin)
|
||||
}
|
||||
|
||||
fn build_add_reasoning(task_id: &str, message_id: &str, text: &str) -> ResponseEvent {
|
||||
|
||||
@@ -395,7 +395,7 @@ fn tool_definitions(
|
||||
.collect::<HashSet<_>>();
|
||||
for server in &mcp_context.servers {
|
||||
for tool in &server.tools {
|
||||
let name = format!("mcp__{}__{}", server.name, tool.name);
|
||||
let name = format!("mcp__{}__{}", server.id, tool.name);
|
||||
if seen.insert(name.clone()) {
|
||||
tools.push(ToolDefinition {
|
||||
name,
|
||||
|
||||
@@ -4,9 +4,11 @@ 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 super::{input_messages, prepare_rig_turn, tool_definitions};
|
||||
use crate::ai::agent::api::RequestParams;
|
||||
use crate::ai::agent::{AIAgentContext, AIAgentInput, AnyFileContent, FileContext, UserQueryMode};
|
||||
use crate::ai::agent::{
|
||||
AIAgentContext, AIAgentInput, AnyFileContent, FileContext, MCPContext, MCPServer, UserQueryMode,
|
||||
};
|
||||
use crate::ai::llms::LLMId;
|
||||
use crate::ai::openai::client::OpenAIClientConfig;
|
||||
|
||||
@@ -112,6 +114,40 @@ fn builds_a_rig_turn_directly_from_galaxy_request_state() {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(deprecated)]
|
||||
fn grouped_mcp_tool_names_use_the_installation_id_not_the_display_name() {
|
||||
let tool = serde_json::from_value(serde_json::json!({
|
||||
"name": "echo",
|
||||
"description": "Echo input",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {"message": {"type": "string"}}
|
||||
}
|
||||
}))
|
||||
.unwrap();
|
||||
let context = MCPContext {
|
||||
resources: Vec::new(),
|
||||
tools: Vec::new(),
|
||||
servers: vec![MCPServer {
|
||||
id: "11111111-1111-4111-8111-111111111111".to_string(),
|
||||
name: "Friendly Server".to_string(),
|
||||
description: String::new(),
|
||||
resources: Vec::new(),
|
||||
tools: vec![tool],
|
||||
}],
|
||||
};
|
||||
|
||||
let tools = tool_definitions(&[ToolType::CallMcpTool], Some(&context));
|
||||
|
||||
assert!(tools
|
||||
.iter()
|
||||
.any(|tool| { tool.name == "mcp__11111111-1111-4111-8111-111111111111__echo" }));
|
||||
assert!(!tools
|
||||
.iter()
|
||||
.any(|tool| tool.name == "mcp__Friendly Server__echo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalized_tool_outcomes_are_the_only_action_results_sent_to_rig() {
|
||||
let statuses = [
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use ai::skills::SkillPathOrigin;
|
||||
use galaxy_agent_core::{
|
||||
MessageContent, MessageRole, StopReason, ToolCall, ToolResult, ToolResultStatus,
|
||||
};
|
||||
@@ -66,8 +67,8 @@ fn reasoning_events_match_the_existing_ui_message_contract() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_proposal_matches_the_existing_permission_ui_contract() {
|
||||
let event = build_tool_proposed(
|
||||
fn tool_proposal_matches_the_domain_permission_contract() {
|
||||
let action = build_tool_proposed(
|
||||
"task",
|
||||
&ToolCall {
|
||||
id: "call-1".to_string(),
|
||||
@@ -77,67 +78,43 @@ fn tool_proposal_matches_the_existing_permission_ui_contract() {
|
||||
"is_read_only": true
|
||||
}),
|
||||
},
|
||||
);
|
||||
&SkillPathOrigin::Local,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
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);
|
||||
assert_eq!(action.id.to_string(), "call-1");
|
||||
assert!(matches!(
|
||||
action.action,
|
||||
crate::ai::agent::AIAgentActionType::RequestCommandOutput {
|
||||
command,
|
||||
is_read_only: Some(true),
|
||||
..
|
||||
} if command == "cargo test"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_tool_proposal_routes_through_the_existing_mcp_executor_contract() {
|
||||
let event = build_tool_proposed(
|
||||
fn mcp_tool_proposal_routes_directly_to_the_mcp_executor_contract() {
|
||||
let action = build_tool_proposed(
|
||||
"task",
|
||||
&ToolCall {
|
||||
id: "call-mcp".to_string(),
|
||||
name: "mcp__filesystem__read_file".to_string(),
|
||||
name: "mcp__11111111-1111-4111-8111-111111111111__read_file".to_string(),
|
||||
arguments: serde_json::json!({"path": "Cargo.toml"}),
|
||||
},
|
||||
);
|
||||
&SkillPathOrigin::Local,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
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());
|
||||
assert!(matches!(
|
||||
action.action,
|
||||
crate::ai::agent::AIAgentActionType::CallMCPTool {
|
||||
server_id: Some(server_id),
|
||||
name,
|
||||
..
|
||||
} if server_id.to_string() == "11111111-1111-4111-8111-111111111111"
|
||||
&& name == "read_file"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use ai::diff_validation::ParsedDiff;
|
||||
use ai::skills::{SkillPathOrigin, SkillReference};
|
||||
use galaxy_agent_core::ToolCall;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::ai::agent::task::TaskId;
|
||||
use crate::ai::agent::{
|
||||
AIAgentAction, AIAgentActionType, AIAgentPtyWriteMode, AskUserQuestionItem,
|
||||
AskUserQuestionOption, AskUserQuestionType, CreateDocumentsRequest, DocumentDiff,
|
||||
DocumentToCreate, EditDocumentsRequest, FileEdit, FileLocations, ReadDocumentsRequest,
|
||||
ReadFilesRequest, ReadSkillRequest, SearchCodebaseRequest, ShellCommandDelay,
|
||||
StartAgentExecutionMode, StartAgentVersion,
|
||||
};
|
||||
use crate::ai::document::ai_document_model::AIDocumentId;
|
||||
|
||||
pub(super) fn action_from_tool_call(
|
||||
task_id: &str,
|
||||
call: &ToolCall,
|
||||
skill_path_origin: &SkillPathOrigin,
|
||||
) -> Result<AIAgentAction, String> {
|
||||
let input = &call.arguments;
|
||||
let action = match call.name.as_str() {
|
||||
"run_shell_command" => AIAgentActionType::RequestCommandOutput {
|
||||
command: string(input, "command"),
|
||||
is_read_only: Some(boolean(input, "is_read_only")),
|
||||
is_risky: Some(boolean(input, "is_risky")),
|
||||
wait_until_completion: boolean(input, "wait_until_complete"),
|
||||
uses_pager: Some(boolean(input, "uses_pager")),
|
||||
rationale: None,
|
||||
citations: Vec::new(),
|
||||
},
|
||||
"read_files" => AIAgentActionType::ReadFiles(ReadFilesRequest {
|
||||
locations: input
|
||||
.get("files")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(file_location)
|
||||
.collect(),
|
||||
}),
|
||||
"apply_file_diffs" => AIAgentActionType::RequestFileEdits {
|
||||
file_edits: file_edits(input),
|
||||
title: nonempty_string(input, "summary"),
|
||||
},
|
||||
"grep" => AIAgentActionType::Grep {
|
||||
queries: strings(input, "queries"),
|
||||
path: string(input, "path"),
|
||||
},
|
||||
"file_glob" => AIAgentActionType::FileGlob {
|
||||
patterns: strings(input, "patterns"),
|
||||
path: nonempty_string(input, "path"),
|
||||
},
|
||||
"search_codebase" => AIAgentActionType::SearchCodebase(SearchCodebaseRequest {
|
||||
query: string(input, "query"),
|
||||
partial_paths: nonempty_strings(input, "path_filters"),
|
||||
codebase_path: nonempty_string(input, "path"),
|
||||
}),
|
||||
"write_to_long_running_shell_command" => {
|
||||
AIAgentActionType::WriteToLongRunningShellCommand {
|
||||
block_id: string(input, "command_id").into(),
|
||||
input: string(input, "input").into_bytes().into(),
|
||||
mode: match input.get("mode").and_then(serde_json::Value::as_str) {
|
||||
Some("line") => AIAgentPtyWriteMode::Line,
|
||||
Some("block") => AIAgentPtyWriteMode::Block,
|
||||
Some("raw") | Some(_) | None => AIAgentPtyWriteMode::Raw,
|
||||
},
|
||||
}
|
||||
}
|
||||
"interrupt_shell_command" => AIAgentActionType::WriteToLongRunningShellCommand {
|
||||
block_id: string(input, "command_id").into(),
|
||||
input: vec![galaxy_terminal::model::escape_sequences::C0::ETX].into(),
|
||||
mode: AIAgentPtyWriteMode::Raw,
|
||||
},
|
||||
"read_shell_command_output" => AIAgentActionType::ReadShellCommandOutput {
|
||||
block_id: string(input, "command_id").into(),
|
||||
delay: Some(ShellCommandDelay::Duration(Duration::from_secs(
|
||||
input
|
||||
.get("wait_seconds")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.unwrap_or(2)
|
||||
.min(crate::ai::bedrock::request_translator::COMMAND_MONITOR_MAX_POLL_SECONDS),
|
||||
))),
|
||||
},
|
||||
"read_mcp_resource" => AIAgentActionType::ReadMCPResource {
|
||||
server_id: uuid(input, "server_id"),
|
||||
name: String::new(),
|
||||
uri: nonempty_string(input, "uri"),
|
||||
},
|
||||
"read_plan" | "read_documents" | "read_notebook" => {
|
||||
AIAgentActionType::ReadDocuments(ReadDocumentsRequest {
|
||||
document_ids: strings(input, "document_ids")
|
||||
.into_iter()
|
||||
.filter_map(|id| AIDocumentId::try_from(id).ok())
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
"create_plan" | "create_documents" | "create_notebook" => {
|
||||
AIAgentActionType::CreateDocuments(CreateDocumentsRequest {
|
||||
documents: input
|
||||
.get("documents")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|document| {
|
||||
Some(DocumentToCreate {
|
||||
title: document.get("title")?.as_str()?.to_string(),
|
||||
content: document.get("content")?.as_str()?.to_string(),
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
"edit_plan" | "edit_documents" | "edit_notebook" => {
|
||||
AIAgentActionType::EditDocuments(EditDocumentsRequest {
|
||||
diffs: input
|
||||
.get("diffs")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|diff| {
|
||||
Some(DocumentDiff {
|
||||
document_id: AIDocumentId::try_from(diff.get("document_id")?.as_str()?)
|
||||
.ok()?,
|
||||
search: string(diff, "search"),
|
||||
replace: string(diff, "replace"),
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
"start_agent" => AIAgentActionType::StartAgent {
|
||||
version: StartAgentVersion::V1,
|
||||
name: string(input, "name"),
|
||||
prompt: string(input, "prompt"),
|
||||
execution_mode: StartAgentExecutionMode::local_with_defaults(),
|
||||
lifecycle_subscription: None,
|
||||
},
|
||||
"send_message_to_agent" => AIAgentActionType::SendMessageToAgent {
|
||||
addresses: vec![string(input, "agent_id")],
|
||||
subject: String::new(),
|
||||
message: string(input, "message"),
|
||||
},
|
||||
"ask_user_question" => AIAgentActionType::AskUserQuestion {
|
||||
questions: vec![AskUserQuestionItem {
|
||||
question_id: Uuid::new_v4().to_string(),
|
||||
question: string(input, "question"),
|
||||
question_type: AskUserQuestionType::MultipleChoice {
|
||||
is_multiselect: false,
|
||||
options: strings(input, "options")
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, label)| AskUserQuestionOption {
|
||||
label,
|
||||
recommended: index == 0,
|
||||
})
|
||||
.collect(),
|
||||
supports_other: true,
|
||||
},
|
||||
}],
|
||||
},
|
||||
"read_skill" => {
|
||||
let skill = string(input, "skill");
|
||||
let skill = match input
|
||||
.get("reference_type")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
{
|
||||
Some("bundled") => SkillReference::BundledSkillId(skill),
|
||||
Some("path") | Some(_) | None => SkillReference::Path(
|
||||
skill_path_origin
|
||||
.location_for_path(skill)
|
||||
.map_err(|error| error.to_string())?,
|
||||
),
|
||||
};
|
||||
AIAgentActionType::ReadSkill(ReadSkillRequest { skill })
|
||||
}
|
||||
"fetch_conversation" => AIAgentActionType::FetchConversation {
|
||||
conversation_id: string(input, "conversation_id"),
|
||||
},
|
||||
name if name.starts_with("mcp__") => {
|
||||
let mut parts = name.splitn(3, "__");
|
||||
let _prefix = parts.next();
|
||||
let server_id = parts.next().and_then(|value| Uuid::parse_str(value).ok());
|
||||
let name = parts
|
||||
.next()
|
||||
.unwrap_or_else(|| name.strip_prefix("mcp__").unwrap_or(name))
|
||||
.to_string();
|
||||
AIAgentActionType::CallMCPTool {
|
||||
server_id,
|
||||
name,
|
||||
input: input.clone(),
|
||||
}
|
||||
}
|
||||
name => return Err(format!("unsupported Rig tool proposal: {name}")),
|
||||
};
|
||||
|
||||
let tool_name = matches!(
|
||||
call.name.as_str(),
|
||||
"read_notebook" | "create_notebook" | "edit_notebook"
|
||||
)
|
||||
.then(|| "notebook".to_string());
|
||||
Ok(AIAgentAction {
|
||||
id: call.id.clone().into(),
|
||||
task_id: TaskId::new(task_id.to_string()),
|
||||
action,
|
||||
requires_result: true,
|
||||
tool_name,
|
||||
})
|
||||
}
|
||||
|
||||
fn string(input: &serde_json::Value, key: &str) -> String {
|
||||
input
|
||||
.get(key)
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn nonempty_string(input: &serde_json::Value, key: &str) -> Option<String> {
|
||||
let value = string(input, key);
|
||||
(!value.is_empty()).then_some(value)
|
||||
}
|
||||
|
||||
fn boolean(input: &serde_json::Value, key: &str) -> bool {
|
||||
input
|
||||
.get(key)
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn strings(input: &serde_json::Value, key: &str) -> Vec<String> {
|
||||
input
|
||||
.get(key)
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(serde_json::Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn nonempty_strings(input: &serde_json::Value, key: &str) -> Option<Vec<String>> {
|
||||
let values = strings(input, key);
|
||||
(!values.is_empty()).then_some(values)
|
||||
}
|
||||
|
||||
fn uuid(input: &serde_json::Value, key: &str) -> Option<Uuid> {
|
||||
input
|
||||
.get(key)
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.and_then(|value| Uuid::parse_str(value).ok())
|
||||
}
|
||||
|
||||
fn file_location(file: &serde_json::Value) -> Option<FileLocations> {
|
||||
if let Some(name) = file.as_str() {
|
||||
return Some(FileLocations {
|
||||
name: name.to_string(),
|
||||
lines: Vec::new(),
|
||||
});
|
||||
}
|
||||
let name = file
|
||||
.get("path")
|
||||
.or_else(|| file.get("name"))?
|
||||
.as_str()?
|
||||
.to_string();
|
||||
let lines = file
|
||||
.get("line_ranges")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|range| {
|
||||
let start = usize::try_from(range.get("start")?.as_u64()?).ok()?;
|
||||
let end = usize::try_from(range.get("end")?.as_u64()?).ok()?;
|
||||
(start > 0 && end >= start).then_some(start..end)
|
||||
})
|
||||
.collect();
|
||||
Some(FileLocations { name, lines })
|
||||
}
|
||||
|
||||
fn file_edits(input: &serde_json::Value) -> Vec<FileEdit> {
|
||||
let diffs = input
|
||||
.get("diffs")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|diff| {
|
||||
FileEdit::Edit(ParsedDiff::StrReplaceEdit {
|
||||
file: nonempty_string(diff, "file_path"),
|
||||
search: nonempty_string(diff, "search"),
|
||||
replace: nonempty_string(diff, "replace"),
|
||||
})
|
||||
});
|
||||
let creates = input
|
||||
.get("new_files")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|file| FileEdit::Create {
|
||||
file: nonempty_string(file, "file_path"),
|
||||
content: nonempty_string(file, "content"),
|
||||
});
|
||||
let deletes = input
|
||||
.get("deleted_files")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|file| FileEdit::Delete {
|
||||
file: file
|
||||
.as_str()
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| nonempty_string(file, "file_path")),
|
||||
});
|
||||
diffs.chain(creates).chain(deletes).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "rig_tool_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,140 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use ai::diff_validation::ParsedDiff;
|
||||
use ai::skills::{SkillPathOrigin, SkillReference};
|
||||
use galaxy_agent_core::ToolCall;
|
||||
|
||||
use super::action_from_tool_call;
|
||||
use crate::ai::agent::{AIAgentActionType, FileEdit};
|
||||
|
||||
fn call(name: &str, arguments: serde_json::Value) -> ToolCall {
|
||||
ToolCall {
|
||||
id: "call-1".to_string(),
|
||||
name: name.to_string(),
|
||||
arguments,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_calls_become_domain_actions_without_a_proto_round_trip() {
|
||||
let action = action_from_tool_call(
|
||||
"task-1",
|
||||
&call(
|
||||
"run_shell_command",
|
||||
serde_json::json!({
|
||||
"command": "cargo test",
|
||||
"is_read_only": true,
|
||||
"is_risky": false
|
||||
}),
|
||||
),
|
||||
&SkillPathOrigin::Local,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(action.id.to_string(), "call-1");
|
||||
assert_eq!(action.task_id.to_string(), "task-1");
|
||||
assert!(matches!(
|
||||
action.action,
|
||||
AIAgentActionType::RequestCommandOutput {
|
||||
command,
|
||||
is_read_only: Some(true),
|
||||
is_risky: Some(false),
|
||||
..
|
||||
} if command == "cargo test"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_calls_preserve_file_edits_in_the_domain_model() {
|
||||
let action = action_from_tool_call(
|
||||
"task-1",
|
||||
&call(
|
||||
"apply_file_diffs",
|
||||
serde_json::json!({
|
||||
"summary": "Update greeting",
|
||||
"diffs": [{
|
||||
"file_path": "/tmp/greeting.txt",
|
||||
"search": "hello",
|
||||
"replace": "hello galaxy"
|
||||
}]
|
||||
}),
|
||||
),
|
||||
&SkillPathOrigin::Local,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let AIAgentActionType::RequestFileEdits { file_edits, title } = action.action else {
|
||||
panic!("expected file-edit action");
|
||||
};
|
||||
assert_eq!(title.as_deref(), Some("Update greeting"));
|
||||
assert!(matches!(
|
||||
&file_edits[0],
|
||||
FileEdit::Edit(ParsedDiff::StrReplaceEdit {
|
||||
file: Some(file),
|
||||
search: Some(search),
|
||||
replace: Some(replace),
|
||||
}) if file == "/tmp/greeting.txt" && search == "hello" && replace == "hello galaxy"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grouped_mcp_calls_keep_the_installation_uuid_and_json_input() {
|
||||
let action = action_from_tool_call(
|
||||
"task-1",
|
||||
&call(
|
||||
"mcp__11111111-1111-4111-8111-111111111111__echo",
|
||||
serde_json::json!({"message": "hello"}),
|
||||
),
|
||||
&SkillPathOrigin::Local,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
action.action,
|
||||
AIAgentActionType::CallMCPTool {
|
||||
server_id: Some(server_id),
|
||||
name,
|
||||
input,
|
||||
} if server_id.to_string() == "11111111-1111-4111-8111-111111111111"
|
||||
&& name == "echo"
|
||||
&& input == serde_json::json!({"message": "hello"})
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_skill_paths_preserve_the_session_origin() {
|
||||
let action = action_from_tool_call(
|
||||
"task-1",
|
||||
&call(
|
||||
"read_skill",
|
||||
serde_json::json!({
|
||||
"skill": "/tmp/example/SKILL.md",
|
||||
"reference_type": "path"
|
||||
}),
|
||||
),
|
||||
&SkillPathOrigin::Local,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
action.action,
|
||||
AIAgentActionType::ReadSkill(request)
|
||||
if request.skill == SkillReference::Path(
|
||||
galaxy_util::local_or_remote_path::LocalOrRemotePath::Local(PathBuf::from(
|
||||
"/tmp/example/SKILL.md"
|
||||
))
|
||||
)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_tools_are_rejected_before_the_permission_boundary() {
|
||||
let error = action_from_tool_call(
|
||||
"task-1",
|
||||
&call("invented_tool", serde_json::json!({})),
|
||||
&SkillPathOrigin::Local,
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(error.contains("unsupported Rig tool proposal"));
|
||||
}
|
||||
Reference in New Issue
Block a user