e2e testing
This commit is contained in:
@@ -19,11 +19,16 @@ pub async fn generate_multi_agent_output(
|
||||
cancellation_rx: futures::channel::oneshot::Receiver<()>,
|
||||
) -> Result<ResponseStream, ConvertToAPITypeError> {
|
||||
let supported_tools_override = params.supported_tools_override.take();
|
||||
let supported_tools = supported_tools_override
|
||||
let mut supported_tools = supported_tools_override
|
||||
.clone()
|
||||
.unwrap_or_else(|| get_supported_tools(¶ms));
|
||||
let supported_cli_agent_tools =
|
||||
remove_orchestration_tools_if_disabled(&mut supported_tools, params.orchestration_enabled);
|
||||
let mut supported_cli_agent_tools =
|
||||
supported_tools_override.unwrap_or_else(|| get_supported_cli_agent_tools(¶ms));
|
||||
remove_orchestration_tools_if_disabled(
|
||||
&mut supported_cli_agent_tools,
|
||||
params.orchestration_enabled,
|
||||
);
|
||||
if params.should_redact_secrets {
|
||||
redaction::redact_inputs(&mut params.input);
|
||||
}
|
||||
@@ -254,6 +259,21 @@ pub async fn generate_multi_agent_output(
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_orchestration_tools_if_disabled(
|
||||
supported_tools: &mut Vec<api::ToolType>,
|
||||
orchestration_enabled: bool,
|
||||
) {
|
||||
if orchestration_enabled {
|
||||
return;
|
||||
}
|
||||
supported_tools.retain(|tool| {
|
||||
!matches!(
|
||||
tool,
|
||||
api::ToolType::Subagent | api::ToolType::RunAgents | api::ToolType::StartAgentV2
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
fn get_supported_tools(params: &RequestParams) -> Vec<api::ToolType> {
|
||||
let mut supported_tools = vec![
|
||||
api::ToolType::Grep,
|
||||
@@ -262,7 +282,6 @@ fn get_supported_tools(params: &RequestParams) -> Vec<api::ToolType> {
|
||||
api::ToolType::ReadMcpResource,
|
||||
api::ToolType::CallMcpTool,
|
||||
api::ToolType::RunShellCommand,
|
||||
api::ToolType::Subagent,
|
||||
api::ToolType::WriteToLongRunningShellCommand,
|
||||
api::ToolType::ReadShellCommandOutput,
|
||||
api::ToolType::ReadDocuments,
|
||||
@@ -270,6 +289,10 @@ fn get_supported_tools(params: &RequestParams) -> Vec<api::ToolType> {
|
||||
api::ToolType::EditDocuments,
|
||||
];
|
||||
|
||||
if params.orchestration_enabled {
|
||||
supported_tools.push(api::ToolType::Subagent);
|
||||
}
|
||||
|
||||
if FeatureFlag::ConversationsAsContext.is_enabled() {
|
||||
supported_tools.push(api::ToolType::FetchConversation);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ use galaxy_core::features::FeatureFlag;
|
||||
use galaxy_core::HostId;
|
||||
use warp_multi_agent_api as api;
|
||||
|
||||
use super::{get_supported_cli_agent_tools, get_supported_tools};
|
||||
use super::{
|
||||
get_supported_cli_agent_tools, get_supported_tools, remove_orchestration_tools_if_disabled,
|
||||
};
|
||||
use crate::ai::agent::api::RequestParams;
|
||||
use crate::ai::blocklist::SessionContext;
|
||||
use crate::ai::llms::LLMId;
|
||||
@@ -77,6 +79,40 @@ fn supported_tools_expose_local_subagents_without_hosted_orchestration_tools() {
|
||||
assert!(!supported_tools.contains(&api::ToolType::StartAgentV2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supported_tools_omit_subagents_when_orchestration_is_disabled() {
|
||||
let params = request_params_with_ask_user_question_enabled(false);
|
||||
let supported_tools = get_supported_tools(¶ms);
|
||||
|
||||
assert!(!supported_tools.contains(&api::ToolType::Subagent));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supported_tool_override_cannot_restore_leaf_orchestration_tools() {
|
||||
let mut supported_tools = vec![
|
||||
api::ToolType::Grep,
|
||||
api::ToolType::Subagent,
|
||||
api::ToolType::RunAgents,
|
||||
api::ToolType::StartAgentV2,
|
||||
];
|
||||
|
||||
remove_orchestration_tools_if_disabled(&mut supported_tools, false);
|
||||
|
||||
assert_eq!(supported_tools, vec![api::ToolType::Grep]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_orchestration_preserves_supported_tool_override() {
|
||||
let mut supported_tools = vec![api::ToolType::Grep, api::ToolType::Subagent];
|
||||
|
||||
remove_orchestration_tools_if_disabled(&mut supported_tools, true);
|
||||
|
||||
assert_eq!(
|
||||
supported_tools,
|
||||
vec![api::ToolType::Grep, api::ToolType::Subagent]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supported_tools_omit_hosted_only_capabilities() {
|
||||
let params = request_params_with_ask_user_question_enabled(false);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use warp_multi_agent_api as api;
|
||||
|
||||
@@ -360,6 +360,59 @@ impl TaskStore {
|
||||
append_refs_for_task(tasks, &mut refs, root_task);
|
||||
}
|
||||
|
||||
let indexed_task_ids = refs
|
||||
.iter()
|
||||
.map(|exchange_ref| exchange_ref.task_id.clone())
|
||||
.collect::<HashSet<_>>();
|
||||
let mut direct_cli_tasks = tasks
|
||||
.values()
|
||||
.filter(|task| {
|
||||
!indexed_task_ids.contains(task.id())
|
||||
&& task.parent_id().as_ref() == Some(root_task_id)
|
||||
&& task.is_cli_subagent()
|
||||
&& task
|
||||
.subagent_params()
|
||||
.is_some_and(|params| params.tool_call_id.is_empty())
|
||||
&& task.exchanges().next().is_some()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
direct_cli_tasks.sort_by(|left, right| {
|
||||
left.exchanges()
|
||||
.next()
|
||||
.map(|exchange| exchange.start_time)
|
||||
.cmp(&right.exchanges().next().map(|exchange| exchange.start_time))
|
||||
.then_with(|| left.id().to_string().cmp(&right.id().to_string()))
|
||||
});
|
||||
|
||||
// Direct providers synthesize CLI monitor tasks without a parent Subagent message.
|
||||
// Place each task as one chronological block so its exchanges remain reachable without
|
||||
// changing the DFS order of server-linked subtasks.
|
||||
for task in direct_cli_tasks {
|
||||
let first_start_time = task
|
||||
.exchanges()
|
||||
.next()
|
||||
.expect("direct CLI task was filtered to contain an exchange")
|
||||
.start_time;
|
||||
let insertion_index = refs
|
||||
.iter()
|
||||
.position(|exchange_ref| {
|
||||
tasks
|
||||
.get(&exchange_ref.task_id)
|
||||
.and_then(|task| task.exchanges().nth(exchange_ref.exchange_index))
|
||||
.is_some_and(|exchange| exchange.start_time > first_start_time)
|
||||
})
|
||||
.unwrap_or(refs.len());
|
||||
let task_id = task.id().clone();
|
||||
let task_refs = task
|
||||
.exchanges()
|
||||
.enumerate()
|
||||
.map(|(exchange_index, _)| ExchangeRef {
|
||||
task_id: task_id.clone(),
|
||||
exchange_index,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
refs.splice(insertion_index..insertion_index, task_refs);
|
||||
}
|
||||
refs
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +146,46 @@ fn test_insert_subtask() {
|
||||
assert!(store.contains(&subtask_id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unlinked_direct_cli_task_is_linearized_chronologically() {
|
||||
let base_time = Local::now();
|
||||
let mut root_task = Task::new_optimistic_root();
|
||||
let root_task_id = root_task.id().clone();
|
||||
|
||||
let mut before_cli = create_test_exchange();
|
||||
before_cli.start_time = base_time;
|
||||
let before_cli_id = before_cli.id;
|
||||
root_task.append_exchange(before_cli);
|
||||
|
||||
let mut after_cli = create_test_exchange();
|
||||
after_cli.start_time = base_time + chrono::Duration::seconds(2);
|
||||
let after_cli_id = after_cli.id;
|
||||
root_task.append_exchange(after_cli);
|
||||
|
||||
let mut cli_task =
|
||||
Task::new_optimistic_cli_agent_subtask(BlockId::new(), Some(root_task_id.to_string()));
|
||||
let mut cli_exchange = create_test_exchange();
|
||||
cli_exchange.start_time = base_time + chrono::Duration::seconds(1);
|
||||
let cli_exchange_id = cli_exchange.id;
|
||||
cli_task.append_exchange(cli_exchange);
|
||||
|
||||
let mut store = TaskStore::with_root_task(root_task);
|
||||
store.insert(cli_task);
|
||||
|
||||
let exchange_ids = store
|
||||
.all_exchanges()
|
||||
.map(|exchange| exchange.id)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
exchange_ids,
|
||||
vec![before_cli_id, cli_exchange_id, after_cli_id]
|
||||
);
|
||||
assert_eq!(
|
||||
store.latest_exchange().map(|exchange| exchange.id),
|
||||
Some(after_cli_id)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_task() {
|
||||
let task = create_test_task_with_exchanges(3);
|
||||
|
||||
@@ -1655,7 +1655,7 @@ pub(crate) fn tool_name_is_supported(name: &str, supported: &HashSet<api::ToolTy
|
||||
"read_plan" | "read_notebook" => has(ToolType::ReadDocuments),
|
||||
"create_plan" | "create_notebook" => has(ToolType::CreateDocuments),
|
||||
"edit_plan" | "edit_notebook" => has(ToolType::EditDocuments),
|
||||
"start_agent" => has(ToolType::Subagent) || has(ToolType::StartAgentV2),
|
||||
"run_agents" | "start_agent" => has(ToolType::Subagent) || has(ToolType::StartAgentV2),
|
||||
"ask_user_question" => has(ToolType::AskUserQuestion),
|
||||
"read_skill" => has(ToolType::ReadSkill),
|
||||
"fetch_conversation" => has(ToolType::FetchConversation),
|
||||
@@ -1910,6 +1910,54 @@ pub fn default_tool_definitions() -> Vec<ToolDefinition> {
|
||||
"required": ["diffs"]
|
||||
}),
|
||||
},
|
||||
ToolDefinition {
|
||||
name: "run_agents".to_string(),
|
||||
description: "Start one or more child agents that share run-wide configuration. Use independent, uniquely named tasks and do not launch duplicate agents for follow-up work. Omitted run-wide fields use the embedded local child runtime and inherit the parent model.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"summary": { "type": "string", "description": "Brief explanation of why child agents help with this task" },
|
||||
"base_prompt": { "type": "string", "default": "", "description": "Instructions prepended to every child prompt" },
|
||||
"skills": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"skill": { "type": "string" },
|
||||
"reference_type": { "type": "string", "enum": ["path", "bundled"] }
|
||||
},
|
||||
"required": ["skill", "reference_type"]
|
||||
}
|
||||
},
|
||||
"model_id": { "type": "string", "default": "", "description": "Optional child model override; empty inherits the parent model" },
|
||||
"harness_type": { "type": "string", "default": "", "description": "Optional harness identifier; empty selects the embedded local child runtime" },
|
||||
"execution_mode": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": { "type": "string", "enum": ["local", "remote"], "default": "local" },
|
||||
"environment_id": { "type": "string", "default": "" },
|
||||
"worker_host": { "type": "string", "default": "" },
|
||||
"computer_use_enabled": { "type": "boolean", "default": false }
|
||||
}
|
||||
},
|
||||
"agent_run_configs": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": { "type": "string", "description": "Unique child name" },
|
||||
"prompt": { "type": "string", "default": "", "description": "Child-specific instructions" },
|
||||
"title": { "type": "string", "default": "", "description": "Optional display title" }
|
||||
},
|
||||
"required": ["name", "prompt"]
|
||||
}
|
||||
},
|
||||
"plan_id": { "type": "string", "default": "", "description": "Optional associated plan document ID" }
|
||||
},
|
||||
"required": ["summary", "agent_run_configs"]
|
||||
}),
|
||||
},
|
||||
ToolDefinition {
|
||||
name: "start_agent".to_string(),
|
||||
description: "Start a sub-agent to handle a specific task autonomously. Use for delegating independent work that can run in parallel. The agent gets its own conversation context and tool access. IMPORTANT: Only use this for the initial investigation or when genuinely new research is needed. Do NOT re-spawn agents for follow-up questions if you already have their output in context — just answer from the information you already have.".to_string(),
|
||||
|
||||
@@ -99,6 +99,7 @@ fn advertised_tools_follow_client_capabilities_and_include_local_subagents() {
|
||||
vec![
|
||||
"run_shell_command",
|
||||
"read_files",
|
||||
"run_agents",
|
||||
"start_agent",
|
||||
"recall_tool_history"
|
||||
]
|
||||
|
||||
@@ -106,6 +106,27 @@ use crate::util::image::{
|
||||
use crate::util::openable_file_type::is_binary_file;
|
||||
use crate::BlocklistAIHistoryModel;
|
||||
|
||||
const CHILD_AGENT_DELEGATION_DENIAL_REASON: &str =
|
||||
"Child agents are leaf workers and cannot launch additional agents. Complete the assigned task directly or report the blocker to the lead agent.";
|
||||
const CHILD_AGENT_LEAF_INSTRUCTIONS: &str = r#"You are a leaf worker launched by a lead agent.
|
||||
- Complete the assigned task directly and stay within its stated scope.
|
||||
- Do not launch, delegate to, or create additional agents.
|
||||
- Report blockers and completion to the lead through the available coordination channel."#;
|
||||
|
||||
pub(super) fn child_agent_delegation_denial_reason(
|
||||
conversation_id: AIConversationId,
|
||||
ctx: &AppContext,
|
||||
) -> Option<String> {
|
||||
BlocklistAIHistoryModel::as_ref(ctx)
|
||||
.conversation(&conversation_id)
|
||||
.is_some_and(|conversation| conversation.is_child_agent_conversation())
|
||||
.then(|| CHILD_AGENT_DELEGATION_DENIAL_REASON.to_string())
|
||||
}
|
||||
|
||||
pub(super) fn compose_leaf_agent_prompt(task_prompt: &str) -> String {
|
||||
format!("{CHILD_AGENT_LEAF_INSTRUCTIONS}\n\nAssigned task:\n{task_prompt}")
|
||||
}
|
||||
|
||||
/// Types of actions that can be executed in parallel.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum ParallelExecutionPolicy {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! Fans out per-child via [`super::start_agent::StartAgentExecutor::dispatch`]
|
||||
//! and aggregates the outcomes into a single `RunAgentsResult`.
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::time::Duration;
|
||||
|
||||
use ai::agent::action::{RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest};
|
||||
@@ -20,7 +20,10 @@ use warp_cli::agent::Harness;
|
||||
use warpui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
|
||||
|
||||
use super::start_agent::{StartAgentExecutor, StartAgentOutcome};
|
||||
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
|
||||
use super::{
|
||||
child_agent_delegation_denial_reason, ActionExecution, AnyActionExecution, ExecuteActionInput,
|
||||
PreprocessActionInput,
|
||||
};
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::agent::{
|
||||
AIAgentAction, AIAgentActionId, AIAgentActionResultType, AIAgentActionType, AIAgentInput,
|
||||
@@ -413,6 +416,9 @@ impl RunAgentsExecutor {
|
||||
let AIAgentActionType::RunAgents(request) = &input.action.action else {
|
||||
return false;
|
||||
};
|
||||
if child_agent_delegation_denial_reason(input.conversation_id, ctx).is_some() {
|
||||
return true;
|
||||
}
|
||||
if AppExecutionMode::as_ref(ctx).is_autonomous() {
|
||||
return true;
|
||||
}
|
||||
@@ -476,9 +482,9 @@ fn resolve_request_from_approved_config(
|
||||
|
||||
/// Normalizes the request and returns a denial reason when launch is blocked.
|
||||
///
|
||||
/// Autonomous agents always run: their calls may still inherit approved plan
|
||||
/// config fields and default auth secrets, but they bypass interactive policy
|
||||
/// denials because they cannot present a confirmation card.
|
||||
/// Root autonomous agents bypass interactive policy denials because they cannot
|
||||
/// present a confirmation card. Child-agent delegation is rejected before that
|
||||
/// bypass, while allowed root calls still inherit approved config and auth fields.
|
||||
fn prepare_request_for_execution(
|
||||
request: &mut RunAgentsRequest,
|
||||
parent_conversation_id: AIConversationId,
|
||||
@@ -486,6 +492,10 @@ fn prepare_request_for_execution(
|
||||
launched_agents: &HashMap<AIConversationId, HashMap<String, ExistingLaunchedAgent>>,
|
||||
ctx: &ModelContext<RunAgentsExecutor>,
|
||||
) -> Option<String> {
|
||||
if let Some(reason) = child_agent_delegation_denial_reason(parent_conversation_id, ctx) {
|
||||
return Some(reason);
|
||||
}
|
||||
|
||||
let status = resolve_request_from_approved_config(request, parent_conversation_id, ctx);
|
||||
populate_default_auth_secret_for_execution(request, ctx);
|
||||
if let Some(reason) =
|
||||
@@ -544,8 +554,11 @@ fn duplicate_launched_agents_reason(
|
||||
|
||||
let duplicates = requested_agents
|
||||
.iter()
|
||||
.map(|(normalized_name, _)| existing_agents.get(normalized_name))
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
.filter_map(|(normalized_name, _)| existing_agents.get(normalized_name))
|
||||
.collect::<Vec<_>>();
|
||||
if duplicates.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let duplicate_list = duplicates
|
||||
.iter()
|
||||
.map(|agent| format!("{} ({})", agent.name, agent.agent_id))
|
||||
@@ -696,6 +709,20 @@ fn validate_request(request: &RunAgentsRequest) -> Result<(), String> {
|
||||
if request.agent_run_configs.is_empty() {
|
||||
return Err("orchestrate: empty agent_run_configs".to_string());
|
||||
}
|
||||
|
||||
let mut normalized_names = HashSet::new();
|
||||
for config in &request.agent_run_configs {
|
||||
let Some(normalized_name) = normalize_agent_name(&config.name) else {
|
||||
return Err("orchestrate: agent names must not be empty".to_string());
|
||||
};
|
||||
if !normalized_names.insert(normalized_name) {
|
||||
return Err(format!(
|
||||
"orchestrate: duplicate agent name '{}' in the same batch",
|
||||
config.name.trim()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if matches!(request.execution_mode, RunAgentsExecutionMode::Local) {
|
||||
if let Some(harness) = Harness::parse_local_child_harness(&request.harness_type) {
|
||||
if let Some(message) = local_harness_product_disabled_message(harness) {
|
||||
|
||||
@@ -91,6 +91,15 @@ fn persist_plan_config_with_harness(
|
||||
});
|
||||
}
|
||||
|
||||
fn mark_conversation_as_child(app: &mut App, conversation_id: AIConversationId) {
|
||||
BlocklistAIHistoryModel::handle(app).update(app, |history, _ctx| {
|
||||
history
|
||||
.conversation_mut(&conversation_id)
|
||||
.expect("conversation should exist")
|
||||
.set_parent_agent_id("parent-agent".to_string());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_autoexecute_duplicate_launched_agent_denial() {
|
||||
App::test((), |mut app| async move {
|
||||
@@ -162,6 +171,159 @@ fn execute_denies_duplicate_launched_agent() {
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_denies_run_agents_from_child_conversation() {
|
||||
App::test((), |mut app| async move {
|
||||
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
|
||||
mark_conversation_as_child(&mut app, state.conversation_id);
|
||||
let action = remote_run_agents_action("oz");
|
||||
|
||||
let should_autoexecute = state.executor.update(&mut app, |executor, ctx| {
|
||||
executor.should_autoexecute(
|
||||
ExecuteActionInput {
|
||||
action: &action,
|
||||
conversation_id: state.conversation_id,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
assert!(
|
||||
should_autoexecute,
|
||||
"the denial should not require user approval"
|
||||
);
|
||||
|
||||
let execution = state.executor.update(&mut app, |executor, ctx| {
|
||||
executor
|
||||
.execute(
|
||||
ExecuteActionInput {
|
||||
action: &action,
|
||||
conversation_id: state.conversation_id,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
.into()
|
||||
});
|
||||
|
||||
assert!(matches!(
|
||||
execution,
|
||||
AnyActionExecution::Sync(AIAgentActionResultType::RunAgents(
|
||||
RunAgentsResult::Denied { reason }
|
||||
)) if reason.contains("leaf workers")
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autonomous_mode_still_denies_run_agents_from_child_conversation() {
|
||||
App::test((), |mut app| async move {
|
||||
let state = initialize_run_agents_test(&mut app, ExecutionMode::Sdk);
|
||||
mark_conversation_as_child(&mut app, state.conversation_id);
|
||||
let action = remote_run_agents_action("oz");
|
||||
|
||||
let execution = state.executor.update(&mut app, |executor, ctx| {
|
||||
executor
|
||||
.execute(
|
||||
ExecuteActionInput {
|
||||
action: &action,
|
||||
conversation_id: state.conversation_id,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
.into()
|
||||
});
|
||||
|
||||
assert!(matches!(
|
||||
execution,
|
||||
AnyActionExecution::Sync(AIAgentActionResultType::RunAgents(
|
||||
RunAgentsResult::Denied { reason }
|
||||
)) if reason.contains("leaf workers")
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_denies_mixed_batch_containing_launched_agent() {
|
||||
App::test((), |mut app| async move {
|
||||
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
|
||||
state.executor.update(&mut app, |executor, _ctx| {
|
||||
executor.record_launched_agents(
|
||||
state.conversation_id,
|
||||
&[RunAgentsAgentOutcome {
|
||||
name: "child".to_string(),
|
||||
kind: RunAgentsAgentOutcomeKind::Launched {
|
||||
agent_id: "agent-123".to_string(),
|
||||
},
|
||||
}],
|
||||
);
|
||||
});
|
||||
let mut action = remote_run_agents_action("oz");
|
||||
let AIAgentActionType::RunAgents(request) = &mut action.action else {
|
||||
panic!("expected run_agents action");
|
||||
};
|
||||
request.agent_run_configs.push(RunAgentsAgentRunConfig {
|
||||
name: "new-child".to_string(),
|
||||
prompt: "Do separate work".to_string(),
|
||||
title: String::new(),
|
||||
});
|
||||
|
||||
let execution = state.executor.update(&mut app, |executor, ctx| {
|
||||
executor
|
||||
.execute(
|
||||
ExecuteActionInput {
|
||||
action: &action,
|
||||
conversation_id: state.conversation_id,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
.into()
|
||||
});
|
||||
|
||||
assert!(matches!(
|
||||
execution,
|
||||
AnyActionExecution::Sync(AIAgentActionResultType::RunAgents(
|
||||
RunAgentsResult::Denied { reason }
|
||||
)) if reason.contains("child (agent-123)")
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_request_rejects_blank_and_duplicate_agent_names() {
|
||||
let AIAgentActionType::RunAgents(mut request) = remote_run_agents_action("oz").action else {
|
||||
panic!("expected run_agents action");
|
||||
};
|
||||
request.agent_run_configs[0].name = " ".to_string();
|
||||
assert_eq!(
|
||||
validate_request(&request),
|
||||
Err("orchestrate: agent names must not be empty".to_string())
|
||||
);
|
||||
|
||||
request.agent_run_configs[0].name = "Child".to_string();
|
||||
request.agent_run_configs.push(RunAgentsAgentRunConfig {
|
||||
name: " child ".to_string(),
|
||||
prompt: "Do separate work".to_string(),
|
||||
title: String::new(),
|
||||
});
|
||||
assert_eq!(
|
||||
validate_request(&request),
|
||||
Err("orchestrate: duplicate agent name 'child' in the same batch".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_request_allows_unique_sibling_names() {
|
||||
let AIAgentActionType::RunAgents(mut request) = remote_run_agents_action("oz").action else {
|
||||
panic!("expected run_agents action");
|
||||
};
|
||||
request.agent_run_configs.push(RunAgentsAgentRunConfig {
|
||||
name: "second-child".to_string(),
|
||||
prompt: "Do separate work".to_string(),
|
||||
title: String::new(),
|
||||
});
|
||||
|
||||
assert_eq!(validate_request(&request), Ok(()));
|
||||
}
|
||||
|
||||
fn initialize_run_agents_test(app: &mut App, mode: ExecutionMode) -> RunAgentsTestState {
|
||||
initialize_settings_for_tests_with_mode(app, mode, false);
|
||||
let global_resource_handles = GlobalResourceHandles::mock(app);
|
||||
|
||||
@@ -6,7 +6,10 @@ use galaxy_cli::agent::Harness;
|
||||
use galaxyui::{Entity, ModelContext, ModelHandle, SingletonEntity};
|
||||
use shell_words::split as split_shell_words;
|
||||
|
||||
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
|
||||
use super::{
|
||||
child_agent_delegation_denial_reason, compose_leaf_agent_prompt, ActionExecution,
|
||||
AnyActionExecution, ExecuteActionInput, PreprocessActionInput,
|
||||
};
|
||||
use crate::ai::agent::conversation::{AIConversation, AIConversationId, ConversationStatus};
|
||||
use crate::ai::agent::{
|
||||
AIAgentAction, AIAgentActionId, AIAgentActionResultType, AIAgentActionType, LifecycleEventType,
|
||||
@@ -400,12 +403,19 @@ impl StartAgentExecutor {
|
||||
return ActionExecution::InvalidAction;
|
||||
};
|
||||
|
||||
let prompt = prompt.clone();
|
||||
let version = *version;
|
||||
let action_id = input.action.id.clone();
|
||||
let parent_conversation_id = input.conversation_id;
|
||||
if let Some(error) = child_agent_delegation_denial_reason(parent_conversation_id, ctx) {
|
||||
return ActionExecution::Sync(AIAgentActionResultType::StartAgent(
|
||||
StartAgentResult::Error { error, version },
|
||||
));
|
||||
}
|
||||
|
||||
let prompt = prompt.clone();
|
||||
let action_id = input.action.id.clone();
|
||||
let (prompt, execution_mode) =
|
||||
normalize_legacy_local_child_harness_command(prompt, execution_mode.clone());
|
||||
let prompt = compose_leaf_agent_prompt(&prompt);
|
||||
let (execution_mode, parent_run_id) = match execution_mode {
|
||||
StartAgentExecutionMode::Local {
|
||||
harness_type: None,
|
||||
@@ -597,9 +607,15 @@ impl StartAgentExecutor {
|
||||
parent_run_id: Option<String>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> async_channel::Receiver<StartAgentOutcome> {
|
||||
let (sender, receiver) = async_channel::bounded(1);
|
||||
if let Some(error) = child_agent_delegation_denial_reason(parent_conversation_id, ctx) {
|
||||
let _ = sender.try_send(StartAgentOutcome::Error(error));
|
||||
return receiver;
|
||||
}
|
||||
|
||||
let (prompt, execution_mode) =
|
||||
normalize_legacy_local_child_harness_command(prompt, execution_mode);
|
||||
let (sender, receiver) = async_channel::bounded(1);
|
||||
let prompt = compose_leaf_agent_prompt(&prompt);
|
||||
let request_id = self.next_request_id();
|
||||
self.pending.insert(
|
||||
request_id,
|
||||
|
||||
@@ -28,6 +28,28 @@ impl Entity for CapturedDirectProviderChildLinks {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct CapturedStartAgentPrompts(Vec<String>);
|
||||
|
||||
impl Entity for CapturedStartAgentPrompts {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
fn capture_start_agent_prompts(
|
||||
app: &mut App,
|
||||
executor: &ModelHandle<StartAgentExecutor>,
|
||||
) -> ModelHandle<CapturedStartAgentPrompts> {
|
||||
let captured = app.add_model(|_| CapturedStartAgentPrompts::default());
|
||||
captured.update(app, |_, ctx| {
|
||||
ctx.subscribe_to_model(executor, |captured, _, event, _ctx| {
|
||||
if let StartAgentExecutorEvent::CreateAgent(request) = event {
|
||||
captured.0.push(request.prompt.clone());
|
||||
}
|
||||
});
|
||||
});
|
||||
captured
|
||||
}
|
||||
|
||||
fn capture_direct_provider_child_links(
|
||||
app: &mut App,
|
||||
executor: &ModelHandle<StartAgentExecutor>,
|
||||
@@ -79,6 +101,126 @@ fn build_start_agent_action_with_prompt(
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_wraps_child_prompt_with_leaf_worker_contract() {
|
||||
App::test((), |mut app| async move {
|
||||
let terminal_view_id = EntityId::new();
|
||||
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
||||
let executor = app.add_model(StartAgentExecutor::new);
|
||||
let captured = capture_start_agent_prompts(&mut app, &executor);
|
||||
let root_conversation_id = history_model.update(&mut app, |history, ctx| {
|
||||
history.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||
});
|
||||
let action = build_start_agent_action(
|
||||
StartAgentVersion::V1,
|
||||
StartAgentExecutionMode::local_with_defaults(),
|
||||
);
|
||||
|
||||
let execution = executor.update(&mut app, |executor, ctx| {
|
||||
executor
|
||||
.execute(
|
||||
ExecuteActionInput {
|
||||
action: &action,
|
||||
conversation_id: root_conversation_id,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
.into()
|
||||
});
|
||||
assert!(matches!(execution, AnyActionExecution::Async { .. }));
|
||||
captured.read(&app, |captured, _ctx| {
|
||||
assert_eq!(captured.0.len(), 1);
|
||||
assert!(captured.0[0].contains("You are a leaf worker"));
|
||||
assert!(
|
||||
captured.0[0].contains("Do not launch, delegate to, or create additional agents")
|
||||
);
|
||||
assert!(captured.0[0].ends_with("Assigned task:\nInvestigate the failure"));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_denies_start_agent_from_child_conversation() {
|
||||
App::test((), |mut app| async move {
|
||||
let terminal_view_id = EntityId::new();
|
||||
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
||||
let executor = app.add_model(StartAgentExecutor::new);
|
||||
let child_conversation_id = history_model.update(&mut app, |history, ctx| {
|
||||
let conversation_id =
|
||||
history.start_new_conversation(terminal_view_id, false, false, false, ctx);
|
||||
history
|
||||
.conversation_mut(&conversation_id)
|
||||
.expect("conversation should exist")
|
||||
.set_parent_agent_id("parent-agent".to_string());
|
||||
conversation_id
|
||||
});
|
||||
let action = build_start_agent_action(
|
||||
StartAgentVersion::V1,
|
||||
StartAgentExecutionMode::local_with_defaults(),
|
||||
);
|
||||
|
||||
let execution = executor.update(&mut app, |executor, ctx| {
|
||||
executor
|
||||
.execute(
|
||||
ExecuteActionInput {
|
||||
action: &action,
|
||||
conversation_id: child_conversation_id,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
.into()
|
||||
});
|
||||
|
||||
assert!(matches!(
|
||||
execution,
|
||||
AnyActionExecution::Sync(AIAgentActionResultType::StartAgent(
|
||||
StartAgentResult::Error { error, .. }
|
||||
)) if error.contains("leaf workers")
|
||||
));
|
||||
executor.read(&app, |executor, _ctx| {
|
||||
assert!(executor.pending.is_empty());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_denies_child_conversation_defense_in_depth() {
|
||||
App::test((), |mut app| async move {
|
||||
let terminal_view_id = EntityId::new();
|
||||
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
||||
let executor = app.add_model(StartAgentExecutor::new);
|
||||
let child_conversation_id = history_model.update(&mut app, |history, ctx| {
|
||||
let conversation_id =
|
||||
history.start_new_conversation(terminal_view_id, false, false, false, ctx);
|
||||
history
|
||||
.conversation_mut(&conversation_id)
|
||||
.expect("conversation should exist")
|
||||
.set_parent_agent_id("parent-agent".to_string());
|
||||
conversation_id
|
||||
});
|
||||
|
||||
let receiver = executor.update(&mut app, |executor, ctx| {
|
||||
executor.dispatch(
|
||||
"grandchild".to_string(),
|
||||
"Do more work".to_string(),
|
||||
StartAgentExecutionMode::local_with_defaults(),
|
||||
None,
|
||||
child_conversation_id,
|
||||
None,
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
assert!(matches!(
|
||||
receiver.try_recv(),
|
||||
Ok(StartAgentOutcome::Error(error)) if error.contains("leaf workers")
|
||||
));
|
||||
executor.read(&app, |executor, _ctx| {
|
||||
assert!(executor.pending.is_empty());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_local_codex_command_prompt_normalizes_to_local_harness() {
|
||||
let (prompt, execution_mode) = normalize_legacy_local_child_harness_command(
|
||||
|
||||
@@ -3044,6 +3044,13 @@ impl BlocklistAIController {
|
||||
});
|
||||
request_params.parent_agent_id = parent_agent_id;
|
||||
request_params.agent_name = agent_name;
|
||||
if history_model
|
||||
.as_ref(ctx)
|
||||
.conversation(&conversation_id)
|
||||
.is_some_and(|conversation| conversation.is_child_agent_conversation())
|
||||
{
|
||||
request_params.orchestration_enabled = false;
|
||||
}
|
||||
request_params.message_history = bedrock_history;
|
||||
request_params.tool_result_archive = bedrock_tool_result_archive;
|
||||
request_params.progressive_summary = bedrock_progressive_summary;
|
||||
|
||||
@@ -560,9 +560,31 @@ fn disabled_providers_do_not_leave_models_in_the_runtime_inventory() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chatgpt_reasoning_modes_route_to_the_base_model_with_effort_metadata() {
|
||||
fn chatgpt_reasoning_modes_route_with_catalog_context_metadata() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_settings_for_tests(&mut app);
|
||||
let provider = crate::settings::ai::default_chatgpt_provider();
|
||||
let configured_model = |model_id: &str| {
|
||||
provider
|
||||
.models
|
||||
.iter()
|
||||
.find(|model| model.model_id == model_id)
|
||||
.expect("ChatGPT model should be configured")
|
||||
};
|
||||
|
||||
let gpt_54 = configured_model("gpt-5.4");
|
||||
assert_eq!(gpt_54.context_size, 1_000_000);
|
||||
assert_eq!(gpt_54.max_input_tokens, Some(950_000));
|
||||
let gpt_56_sol = configured_model("gpt-5.6-sol");
|
||||
assert_eq!(gpt_56_sol.context_size, 272_000);
|
||||
assert_eq!(gpt_56_sol.max_input_tokens, Some(258_400));
|
||||
let codex_spark = configured_model("gpt-5.3-codex-spark");
|
||||
assert_eq!(codex_spark.context_size, 128_000);
|
||||
assert_eq!(codex_spark.max_input_tokens, Some(121_600));
|
||||
let uncached_model = configured_model("gpt-5.4-pro");
|
||||
assert_eq!(uncached_model.context_size, 200_000);
|
||||
assert_eq!(uncached_model.max_input_tokens, None);
|
||||
|
||||
AISettings::handle(&app).update(&mut app, |settings, ctx| {
|
||||
settings
|
||||
.bedrock_enabled
|
||||
@@ -582,34 +604,56 @@ fn chatgpt_reasoning_modes_route_to_the_base_model_with_effort_metadata() {
|
||||
.expect("OpenAI model setting should update");
|
||||
settings
|
||||
.openai_providers
|
||||
.set_value(vec![crate::settings::ai::default_chatgpt_provider()], ctx)
|
||||
.set_value(vec![provider], ctx)
|
||||
.expect("OpenAI provider setting should update");
|
||||
});
|
||||
|
||||
let mut preferences = empty_preferences();
|
||||
app.read(|ctx| preferences.inject_openai_models(ctx));
|
||||
|
||||
let model_info = |model_id: &str| {
|
||||
preferences
|
||||
.models_by_feature
|
||||
.agent_mode
|
||||
.choices
|
||||
.iter()
|
||||
.find(|model| model.id.as_str() == model_id)
|
||||
.expect("ChatGPT model should be available")
|
||||
};
|
||||
let assert_fixed_context = |model_id: &str, expected: u32| {
|
||||
let context_window = &model_info(model_id).context_window;
|
||||
assert!(!context_window.is_configurable);
|
||||
assert_eq!(context_window.min, expected);
|
||||
assert_eq!(context_window.max, expected);
|
||||
assert_eq!(context_window.default_max, expected);
|
||||
};
|
||||
|
||||
let mode_id = "gpt-5.4::reasoning::high";
|
||||
let mode = preferences
|
||||
.models_by_feature
|
||||
.agent_mode
|
||||
.choices
|
||||
.iter()
|
||||
.find(|model| model.id.as_str() == mode_id)
|
||||
.expect("GPT-5.4 high mode should be available");
|
||||
let mode = model_info(mode_id);
|
||||
assert_eq!(mode.reasoning_level.as_deref(), Some("high"));
|
||||
assert_fixed_context(mode_id, 950_000);
|
||||
let routing = preferences
|
||||
.openai_client_config_for_model(mode_id)
|
||||
.expect("reasoning mode should have a routing entry");
|
||||
assert_eq!(routing.model.as_deref(), Some("gpt-5.4"));
|
||||
assert_eq!(routing.reasoning_effort.as_deref(), Some("high"));
|
||||
assert_eq!(routing.max_input_tokens, Some(950_000));
|
||||
|
||||
let ultra_id = "gpt-5.6-sol::reasoning::ultra";
|
||||
assert_fixed_context(ultra_id, 258_400);
|
||||
let ultra_routing = preferences
|
||||
.openai_client_config_for_model(ultra_id)
|
||||
.expect("GPT-5.6 Sol ultra mode should have a routing entry");
|
||||
assert_eq!(ultra_routing.model.as_deref(), Some("gpt-5.6-sol"));
|
||||
assert_eq!(ultra_routing.reasoning_effort.as_deref(), Some("ultra"));
|
||||
assert_eq!(ultra_routing.max_input_tokens, Some(258_400));
|
||||
|
||||
let spark_id = "gpt-5.3-codex-spark";
|
||||
assert_fixed_context(spark_id, 121_600);
|
||||
let spark_routing = preferences
|
||||
.openai_client_config_for_model(spark_id)
|
||||
.expect("GPT-5.3 Codex Spark should have a routing entry");
|
||||
assert_eq!(spark_routing.max_input_tokens, Some(121_600));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -283,6 +283,25 @@ fn bedrock_rig_turn_uses_bedrock_history_invariants_without_a_proto_round_trip()
|
||||
assert_eq!(prepared.request.messages, prepared.persistent_messages);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modern_and_legacy_orchestration_tools_follow_subagent_capabilities() {
|
||||
for capability in [ToolType::Subagent, ToolType::StartAgentV2] {
|
||||
let (tools, _) = tool_definitions(&[capability], None);
|
||||
let names = tools
|
||||
.iter()
|
||||
.map(|tool| tool.name.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert!(names.contains(&"run_agents"));
|
||||
assert!(names.contains(&"start_agent"));
|
||||
}
|
||||
|
||||
let (leaf_tools, _) = tool_definitions(&[ToolType::Grep], None);
|
||||
assert!(!leaf_tools
|
||||
.iter()
|
||||
.any(|tool| matches!(tool.name.as_str(), "run_agents" | "start_agent")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(deprecated)]
|
||||
fn grouped_mcp_tool_names_use_the_installation_id_not_the_display_name() {
|
||||
|
||||
@@ -12,8 +12,9 @@ use crate::ai::agent::{
|
||||
AIAgentAction, AIAgentActionType, AIAgentPtyWriteMode, AskUserQuestionItem,
|
||||
AskUserQuestionOption, AskUserQuestionType, CreateDocumentsRequest, DocumentDiff,
|
||||
DocumentToCreate, EditDocumentsRequest, FileEdit, FileLocations, ReadDocumentsRequest,
|
||||
ReadFilesRequest, ReadSkillRequest, SearchCodebaseRequest, ShellCommandDelay,
|
||||
StartAgentExecutionMode, StartAgentVersion,
|
||||
ReadFilesRequest, ReadSkillRequest, RunAgentsAgentRunConfig, RunAgentsExecutionMode,
|
||||
RunAgentsRequest, SearchCodebaseRequest, ShellCommandDelay, StartAgentExecutionMode,
|
||||
StartAgentVersion,
|
||||
};
|
||||
use crate::ai::document::ai_document_model::AIDocumentId;
|
||||
|
||||
@@ -140,6 +141,27 @@ pub(super) fn action_from_tool_call(
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
"run_agents" => AIAgentActionType::RunAgents(RunAgentsRequest {
|
||||
summary: string(input, "summary"),
|
||||
base_prompt: string(input, "base_prompt"),
|
||||
skills: skill_references(input, skill_path_origin),
|
||||
model_id: string(input, "model_id"),
|
||||
harness_type: string(input, "harness_type"),
|
||||
execution_mode: run_agents_execution_mode(input),
|
||||
agent_run_configs: input
|
||||
.get("agent_run_configs")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|config| RunAgentsAgentRunConfig {
|
||||
name: string(config, "name"),
|
||||
prompt: string(config, "prompt"),
|
||||
title: string(config, "title"),
|
||||
})
|
||||
.collect(),
|
||||
plan_id: string(input, "plan_id"),
|
||||
harness_auth_secret_name: None,
|
||||
}),
|
||||
"start_agent" => AIAgentActionType::StartAgent {
|
||||
version: StartAgentVersion::V1,
|
||||
name: string(input, "name"),
|
||||
@@ -263,6 +285,52 @@ fn uuid(input: &serde_json::Value, key: &str) -> Option<Uuid> {
|
||||
.and_then(|value| Uuid::parse_str(value).ok())
|
||||
}
|
||||
|
||||
fn skill_references(
|
||||
input: &serde_json::Value,
|
||||
skill_path_origin: &SkillPathOrigin,
|
||||
) -> Vec<SkillReference> {
|
||||
input
|
||||
.get("skills")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|skill| {
|
||||
let reference = string(skill, "skill");
|
||||
if reference.is_empty() {
|
||||
return None;
|
||||
}
|
||||
match skill
|
||||
.get("reference_type")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
{
|
||||
Some("bundled") => Some(SkillReference::BundledSkillId(reference)),
|
||||
Some("path") | Some(_) | None => skill_path_origin
|
||||
.location_for_path(reference)
|
||||
.ok()
|
||||
.map(SkillReference::Path),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn run_agents_execution_mode(input: &serde_json::Value) -> RunAgentsExecutionMode {
|
||||
let Some(execution_mode) = input.get("execution_mode") else {
|
||||
return RunAgentsExecutionMode::Local;
|
||||
};
|
||||
let mode_type = execution_mode
|
||||
.get("type")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.or_else(|| execution_mode.as_str());
|
||||
match mode_type {
|
||||
Some("remote") => RunAgentsExecutionMode::Remote {
|
||||
environment_id: string(execution_mode, "environment_id"),
|
||||
worker_host: string(execution_mode, "worker_host"),
|
||||
computer_use_enabled: boolean(execution_mode, "computer_use_enabled"),
|
||||
},
|
||||
Some("local") | Some(_) | None => RunAgentsExecutionMode::Local,
|
||||
}
|
||||
}
|
||||
|
||||
fn file_location(file: &serde_json::Value) -> Option<FileLocations> {
|
||||
if let Some(name) = file.as_str() {
|
||||
return Some(FileLocations {
|
||||
|
||||
@@ -6,7 +6,7 @@ use ai::skills::{SkillPathOrigin, SkillReference};
|
||||
use galaxy_agent_core::ToolCall;
|
||||
|
||||
use super::{action_from_tool_call, MCPToolTarget};
|
||||
use crate::ai::agent::{AIAgentActionType, FileEdit};
|
||||
use crate::ai::agent::{AIAgentActionType, FileEdit, RunAgentsExecutionMode};
|
||||
|
||||
fn call(name: &str, arguments: serde_json::Value) -> ToolCall {
|
||||
ToolCall {
|
||||
@@ -164,6 +164,110 @@ fn local_skill_paths_preserve_the_session_origin() {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_agents_calls_decode_to_local_domain_requests_with_safe_defaults() {
|
||||
let action = action_from_tool_call(
|
||||
"task-1",
|
||||
&call(
|
||||
"run_agents",
|
||||
serde_json::json!({
|
||||
"summary": "Parallel investigation",
|
||||
"base_prompt": "Inspect before changing files.",
|
||||
"agent_run_configs": [
|
||||
{
|
||||
"name": "runtime",
|
||||
"prompt": "Inspect runtime behavior",
|
||||
"title": "Runtime investigator"
|
||||
},
|
||||
{
|
||||
"name": "tests",
|
||||
"prompt": "Design focused tests"
|
||||
}
|
||||
]
|
||||
}),
|
||||
),
|
||||
&SkillPathOrigin::Local,
|
||||
&HashMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let AIAgentActionType::RunAgents(request) = action.action else {
|
||||
panic!("expected run-agents action");
|
||||
};
|
||||
assert_eq!(request.summary, "Parallel investigation");
|
||||
assert_eq!(request.base_prompt, "Inspect before changing files.");
|
||||
assert!(request.skills.is_empty());
|
||||
assert!(request.model_id.is_empty());
|
||||
assert!(request.harness_type.is_empty());
|
||||
assert_eq!(request.execution_mode, RunAgentsExecutionMode::Local);
|
||||
assert!(request.plan_id.is_empty());
|
||||
assert!(request.harness_auth_secret_name.is_none());
|
||||
assert_eq!(request.agent_run_configs.len(), 2);
|
||||
assert_eq!(request.agent_run_configs[0].name, "runtime");
|
||||
assert_eq!(
|
||||
request.agent_run_configs[0].prompt,
|
||||
"Inspect runtime behavior"
|
||||
);
|
||||
assert_eq!(request.agent_run_configs[0].title, "Runtime investigator");
|
||||
assert_eq!(request.agent_run_configs[1].name, "tests");
|
||||
assert_eq!(request.agent_run_configs[1].prompt, "Design focused tests");
|
||||
assert!(request.agent_run_configs[1].title.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_agents_calls_preserve_remote_config_and_skills() {
|
||||
let action = action_from_tool_call(
|
||||
"task-1",
|
||||
&call(
|
||||
"run_agents",
|
||||
serde_json::json!({
|
||||
"summary": "Remote investigation",
|
||||
"model_id": "remote-model",
|
||||
"harness_type": "codex",
|
||||
"execution_mode": {
|
||||
"type": "remote",
|
||||
"environment_id": "env-1",
|
||||
"worker_host": "worker.example",
|
||||
"computer_use_enabled": true
|
||||
},
|
||||
"skills": [
|
||||
{"skill": "galaxyctrl", "reference_type": "bundled"},
|
||||
{"skill": "/repo/SKILL.md", "reference_type": "path"}
|
||||
],
|
||||
"agent_run_configs": [{"name": "remote", "prompt": "Inspect"}],
|
||||
"plan_id": "plan-1"
|
||||
}),
|
||||
),
|
||||
&SkillPathOrigin::Local,
|
||||
&HashMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let AIAgentActionType::RunAgents(request) = action.action else {
|
||||
panic!("expected run-agents action");
|
||||
};
|
||||
assert_eq!(request.model_id, "remote-model");
|
||||
assert_eq!(request.harness_type, "codex");
|
||||
assert_eq!(request.plan_id, "plan-1");
|
||||
assert_eq!(
|
||||
request.skills,
|
||||
vec![
|
||||
SkillReference::BundledSkillId("galaxyctrl".to_string()),
|
||||
SkillReference::Path(galaxy_util::local_or_remote_path::LocalOrRemotePath::Local(
|
||||
PathBuf::from("/repo/SKILL.md")
|
||||
)),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
request.execution_mode,
|
||||
RunAgentsExecutionMode::Remote {
|
||||
environment_id: "env-1".to_string(),
|
||||
worker_host: "worker.example".to_string(),
|
||||
computer_use_enabled: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_tools_are_rejected_before_the_permission_boundary() {
|
||||
let error = action_from_tool_call(
|
||||
|
||||
@@ -92,6 +92,99 @@ pub fn assert_latest_exchange_text(
|
||||
})
|
||||
}
|
||||
|
||||
/// Asserts that the active conversation created exactly one hidden leaf child
|
||||
/// and that the child completed with the expected identity and output.
|
||||
pub fn assert_single_hidden_child_agent_succeeds(
|
||||
expected_agent_name: &'static str,
|
||||
expected_output: &'static str,
|
||||
) -> AssertionCallback {
|
||||
Box::new(move |app, window_id| {
|
||||
let terminal_view = terminal_view(app, window_id, 0, 0);
|
||||
BlocklistAIHistoryModel::handle(app).update(app, |history_model, _| {
|
||||
let Some(parent) = history_model.active_conversation(terminal_view.id()) else {
|
||||
return AssertionOutcome::failure("No active parent conversation".to_owned());
|
||||
};
|
||||
let parent_id = parent.id();
|
||||
let children = history_model.child_conversations_of(parent_id);
|
||||
let child = match children.as_slice() {
|
||||
[] => {
|
||||
return AssertionOutcome::failure(
|
||||
"Waiting for the hidden child conversation".to_owned(),
|
||||
);
|
||||
}
|
||||
[child] => *child,
|
||||
_ => {
|
||||
return AssertionOutcome::immediate_failure(format!(
|
||||
"Expected exactly one child conversation, found {}",
|
||||
children.len()
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
match child.status() {
|
||||
ConversationStatus::Success => {}
|
||||
ConversationStatus::InProgress
|
||||
| ConversationStatus::TransientError
|
||||
| ConversationStatus::WaitingForEvents => {
|
||||
return AssertionOutcome::failure(format!(
|
||||
"Waiting for child agent to succeed; current status: {:?}",
|
||||
child.status()
|
||||
));
|
||||
}
|
||||
ConversationStatus::Blocked { .. }
|
||||
| ConversationStatus::Error
|
||||
| ConversationStatus::Cancelled => {
|
||||
return AssertionOutcome::immediate_failure(format!(
|
||||
"Child agent finished unsuccessfully: {:?}",
|
||||
child.status()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if child.agent_name() != Some(expected_agent_name) {
|
||||
return AssertionOutcome::immediate_failure(format!(
|
||||
"Expected child name {expected_agent_name:?}, found {:?}",
|
||||
child.agent_name()
|
||||
));
|
||||
}
|
||||
if child.parent_conversation_id() != Some(parent_id) {
|
||||
return AssertionOutcome::immediate_failure(format!(
|
||||
"Child {:?} was not linked to active parent {parent_id:?}",
|
||||
child.id()
|
||||
));
|
||||
}
|
||||
if !child.is_child_agent_conversation() || !child.should_exclude_from_navigation() {
|
||||
return AssertionOutcome::immediate_failure(
|
||||
"Child conversation was not hidden from normal navigation".to_owned(),
|
||||
);
|
||||
}
|
||||
let grandchildren = history_model.child_conversations_of(child.id());
|
||||
if !grandchildren.is_empty() {
|
||||
return AssertionOutcome::immediate_failure(format!(
|
||||
"Leaf child unexpectedly created {} grandchildren",
|
||||
grandchildren.len()
|
||||
));
|
||||
}
|
||||
|
||||
let output = child
|
||||
.all_exchanges()
|
||||
.into_iter()
|
||||
.filter_map(|exchange| exchange.output_status.output())
|
||||
.map(|output| output.get().format_for_copy(None))
|
||||
.filter(|text| !text.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n");
|
||||
if !output.contains(expected_output) {
|
||||
return AssertionOutcome::immediate_failure(format!(
|
||||
"Child output did not contain {expected_output:?}: {output}"
|
||||
));
|
||||
}
|
||||
|
||||
AssertionOutcome::Success
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Make an assertion on the action requested in the exchange at exchange_index.
|
||||
/// This is private because `AIAgentActionType` is not public outside the warp app crate
|
||||
/// for use within agent mode evals, so they can't write the `ActionAssertion` directly.
|
||||
@@ -180,22 +273,41 @@ pub fn assert_any_exchange_text(
|
||||
Box::new(move |app, window_id| {
|
||||
let terminal_view = terminal_view(app, window_id, 0, 0);
|
||||
BlocklistAIHistoryModel::handle(app).update(app, |history_model, _| {
|
||||
let exchange_count = get_exchange_count(terminal_view.id(), history_model);
|
||||
(0..exchange_count)
|
||||
.map(|exchange_index| {
|
||||
exchange_succeeds_with_expected_output(
|
||||
Some(Box::new(assertion.clone())),
|
||||
None,
|
||||
ConversationTarget::Active,
|
||||
terminal_view.id(),
|
||||
exchange_index,
|
||||
history_model,
|
||||
)
|
||||
})
|
||||
.find(|outcome| matches!(outcome, AssertionOutcome::Success))
|
||||
.unwrap_or(AssertionOutcome::failure(
|
||||
"No exchanges match assertion".to_owned(),
|
||||
))
|
||||
let Some(conversation) = history_model.active_conversation(terminal_view.id()) else {
|
||||
return AssertionOutcome::failure("No active conversation".to_owned());
|
||||
};
|
||||
let mut output_texts = Vec::with_capacity(conversation.exchange_count());
|
||||
for exchange in conversation.all_exchanges() {
|
||||
let AIAgentOutputStatus::Finished { finished_output } = &exchange.output_status
|
||||
else {
|
||||
return AssertionOutcome::failure(format!(
|
||||
"Exchange {:?} is not finished",
|
||||
exchange.id
|
||||
));
|
||||
};
|
||||
match finished_output {
|
||||
FinishedAIAgentOutput::Success { output } => {
|
||||
let text = output.get().format_for_copy(None);
|
||||
if assertion(&text) {
|
||||
return AssertionOutcome::Success;
|
||||
}
|
||||
output_texts.push(text);
|
||||
}
|
||||
FinishedAIAgentOutput::Error { error, .. } => {
|
||||
return AssertionOutcome::immediate_failure(format!(
|
||||
"Exchange failed with error: {error:?}"
|
||||
));
|
||||
}
|
||||
FinishedAIAgentOutput::Cancelled { .. } => {
|
||||
return AssertionOutcome::immediate_failure(
|
||||
"Exchange was cancelled".to_owned(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
AssertionOutcome::failure(format!(
|
||||
"No exchanges match assertion. Exchange outputs: {output_texts:?}"
|
||||
))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ use galaxyui::{async_assert, SingletonEntity};
|
||||
use prost::Message;
|
||||
|
||||
use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel;
|
||||
use crate::ai::execution_profiles::ActionPermission;
|
||||
use crate::ai::execution_profiles::{ActionPermission, RunAgentsPermission};
|
||||
use crate::ai::llms::{LLMId, LLMPreferences};
|
||||
use crate::ai::mcp::{
|
||||
JsonTemplate, TemplatableMCPServer, TemplatableMCPServerInstallation,
|
||||
@@ -284,6 +284,20 @@ pub fn set_execution_profile_auto_execute() -> TestStep {
|
||||
)
|
||||
}
|
||||
|
||||
/// Sets the execution profile to auto-run child agents.
|
||||
pub fn set_execution_profile_auto_run_agents() -> TestStep {
|
||||
TestStep::new("Set execution profile to auto-run child agents").add_named_assertion(
|
||||
"Update execution profile",
|
||||
|app, _window_id| {
|
||||
AIExecutionProfilesModel::handle(app).update(app, |profiles, ctx| {
|
||||
let default_profile_id = *profiles.default_profile(ctx).id();
|
||||
profiles.set_run_agents(default_profile_id, RunAgentsPermission::AlwaysAllow, ctx);
|
||||
});
|
||||
async_assert!(true, "Successfully updated execution profile")
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Sets the execution profile to auto-apply code diffs.
|
||||
pub fn set_execution_profile_auto_apply_code_diffs() -> TestStep {
|
||||
TestStep::new("Set execution profile to auto-apply code diffs").add_named_assertion(
|
||||
|
||||
@@ -807,8 +807,12 @@ pub fn assert_active_session_local_path(expected_path: &'static str) -> Assertio
|
||||
}
|
||||
|
||||
pub fn assert_input_is_focused() -> AssertionCallback {
|
||||
Box::new(|app, window_id| {
|
||||
let terminal_view = single_terminal_view_for_tab(app, window_id, 0);
|
||||
assert_input_is_focused_for_pane(0, 0)
|
||||
}
|
||||
|
||||
pub fn assert_input_is_focused_for_pane(tab_index: usize, pane_index: usize) -> AssertionCallback {
|
||||
Box::new(move |app, window_id| {
|
||||
let terminal_view = terminal_view(app, window_id, tab_index, pane_index);
|
||||
terminal_view.read(app, |view, ctx| {
|
||||
let is_input_focused = view.input().as_ref(ctx).editor().as_ref(ctx).is_focused();
|
||||
async_assert!(is_input_focused)
|
||||
|
||||
@@ -167,6 +167,15 @@ fn build_local_codex_child_command_quotes_the_prompt() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_harness_commands_preserve_leaf_worker_contract() {
|
||||
let prompt = "You are a leaf worker. Do not launch additional agents.";
|
||||
|
||||
assert!(local_claude_child_prompt(prompt).contains(prompt));
|
||||
assert!(build_local_codex_child_command(prompt).contains(prompt));
|
||||
assert!(build_local_opencode_child_command(prompt).contains(prompt));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_child_task_config_records_supported_third_party_harnesses() {
|
||||
for harness in [Harness::Claude, Harness::OpenCode, Harness::Codex] {
|
||||
|
||||
+59
-23
@@ -1033,58 +1033,94 @@ const INITIAL_RIG_MODEL_ID: &str = "codex-gpt-5.6-sol-xhigh";
|
||||
|
||||
fn default_chatgpt_models() -> Vec<OpenAIModelConfig> {
|
||||
// The ChatGPT OAuth backend does not expose a model-listing capability through Rig,
|
||||
// so keep this catalog small and explicit. Reasoning variants are expanded into
|
||||
// selectable LLM entries when the provider is injected into the runtime inventory.
|
||||
// so keep this catalog small and explicit. Context limits come from Codex model
|
||||
// metadata; models absent from that catalog retain the generic fallback.
|
||||
[
|
||||
(
|
||||
"gpt-5.6-sol",
|
||||
"GPT-5.6 Sol",
|
||||
vec!["low", "medium", "high", "xhigh", "max", "ultra"],
|
||||
272_000,
|
||||
Some(258_400),
|
||||
),
|
||||
(
|
||||
"gpt-5.6-terra",
|
||||
"GPT-5.6 Terra",
|
||||
vec!["low", "medium", "high", "xhigh", "max", "ultra"],
|
||||
272_000,
|
||||
Some(258_400),
|
||||
),
|
||||
(
|
||||
"gpt-5.6-luna",
|
||||
"GPT-5.6 Luna",
|
||||
vec!["low", "medium", "high", "xhigh", "max", "ultra"],
|
||||
272_000,
|
||||
Some(258_400),
|
||||
),
|
||||
(
|
||||
"gpt-5.4",
|
||||
"GPT-5.4",
|
||||
vec!["low", "medium", "high", "xhigh"],
|
||||
1_000_000,
|
||||
Some(950_000),
|
||||
),
|
||||
("gpt-5.4", "GPT-5.4", vec!["low", "medium", "high", "xhigh"]),
|
||||
(
|
||||
"gpt-5.4-pro",
|
||||
"GPT-5.4 Pro",
|
||||
vec!["medium", "high", "xhigh"],
|
||||
default_context_size(),
|
||||
None,
|
||||
),
|
||||
(
|
||||
"gpt-5.3-codex",
|
||||
"GPT-5.3 Codex",
|
||||
vec!["low", "medium", "high", "xhigh"],
|
||||
default_context_size(),
|
||||
None,
|
||||
),
|
||||
(
|
||||
"gpt-5.3-codex-spark",
|
||||
"GPT-5.3 Codex Spark",
|
||||
vec![],
|
||||
128_000,
|
||||
Some(121_600),
|
||||
),
|
||||
(
|
||||
"gpt-5.3-instant",
|
||||
"GPT-5.3 Instant",
|
||||
vec![],
|
||||
default_context_size(),
|
||||
None,
|
||||
),
|
||||
(
|
||||
"gpt-5.3-chat-latest",
|
||||
"GPT-5.3 Chat Latest",
|
||||
vec![],
|
||||
default_context_size(),
|
||||
None,
|
||||
),
|
||||
("gpt-5.3-codex-spark", "GPT-5.3 Codex Spark", vec![]),
|
||||
("gpt-5.3-instant", "GPT-5.3 Instant", vec![]),
|
||||
("gpt-5.3-chat-latest", "GPT-5.3 Chat Latest", vec![]),
|
||||
]
|
||||
.into_iter()
|
||||
.map(
|
||||
|(model_id, display_name, reasoning_efforts)| OpenAIModelConfig {
|
||||
model_id: model_id.to_string(),
|
||||
display_name: display_name.to_string(),
|
||||
// ChatGPT's subscription backend accepts image input for its chat
|
||||
// models, but it does not expose a public capability discovery
|
||||
// endpoint. Keep this explicit catalog in sync with that contract
|
||||
// so the model picker does not hide vision context.
|
||||
vision_supported: true,
|
||||
context_size: default_context_size(),
|
||||
max_input_tokens: None,
|
||||
max_output_tokens: None,
|
||||
provider: Some("openai".to_string()),
|
||||
use_rig: true,
|
||||
supports_system_messages: Some(true),
|
||||
capability_overrides: HashMap::new(),
|
||||
reasoning_efforts: reasoning_efforts.into_iter().map(str::to_string).collect(),
|
||||
enabled: true,
|
||||
|(model_id, display_name, reasoning_efforts, context_size, max_input_tokens)| {
|
||||
OpenAIModelConfig {
|
||||
model_id: model_id.to_string(),
|
||||
display_name: display_name.to_string(),
|
||||
// ChatGPT's subscription backend accepts image input for its chat
|
||||
// models, but it does not expose a public capability discovery
|
||||
// endpoint. Keep this explicit catalog in sync with that contract
|
||||
// so the model picker does not hide vision context.
|
||||
vision_supported: true,
|
||||
context_size,
|
||||
max_input_tokens,
|
||||
max_output_tokens: None,
|
||||
provider: Some("openai".to_string()),
|
||||
use_rig: true,
|
||||
supports_system_messages: Some(true),
|
||||
capability_overrides: HashMap::new(),
|
||||
reasoning_efforts: reasoning_efforts.into_iter().map(str::to_string).collect(),
|
||||
enabled: true,
|
||||
}
|
||||
},
|
||||
)
|
||||
.collect()
|
||||
|
||||
@@ -1145,7 +1145,7 @@ impl ProviderSetupModalBody {
|
||||
if let ChatGPTAuthState::Failed(error) = &state {
|
||||
children.push(
|
||||
Text::new(error.clone(), appearance.monospace_font_family(), 11.)
|
||||
.with_color(appearance.theme().ui_error_color().into())
|
||||
.with_color(appearance.theme().ui_error_color())
|
||||
.soft_wrap(true)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user