mirror of
https://github.com/warpdotdev/warp.git
synced 2026-05-06 15:22:21 +08:00
[QUALITY-569] Stage 1: orchestrate tool (client) (#9628)
## Description Client-side implementation of the `RunAgents` orchestration tool, paired with server PR https://github.com/warpdotdev/warp-server/pull/10809. ### What - **RunAgentsCardView**: confirmation card UI with inline editor for model, harness, environment, host, and execution mode (Local/Cloud) - **RunAgentsExecutor**: executor that dispatches accepted requests, spawning child agent conversations via `StartAgentExecutor` - **Cancel handling**: card subscribes to `FinishedAction` events so Ctrl+C (both card-level and terminal-level) shows the cancelled state - **Harness support**: Oz, Claude, Gemini, Codex with brand icons; local-dev host gated on `Channel::Local` - **Spawning card**: in-flight "Spawning N agents..." status while children are being launched - **Proto adoption**: `RunAgents` request/result types, `Harness` oneof shape, `StartAgentV2` advertised alongside `Orchestrate` ### Demo https://www.loom.com/share/dacad55f436b42d29191bf54461ab3cf ## Testing - Manual validation of accept, reject (Ctrl+C), edit/discard, picker interactions, local and cloud execution modes - Existing tests updated for new harness list and import ordering ## Server API dependencies - [x] Does this change rely on a new server API? - Server PR: https://github.com/warpdotdev/warp-server/pull/10809 ## Agent Mode - [x] Warp Agent Mode - This PR was created via Warp's AI Agent Mode Co-Authored-By: Oz <oz-agent@warp.dev> Co-authored-by: Oz <oz-agent@warp.dev>
This commit is contained in:
@@ -16,8 +16,8 @@ use crate::{
|
||||
FileGlobV2Result, GrepResult, InsertReviewCommentsResult, ReadDocumentsResult,
|
||||
ReadFilesResult, ReadMCPResourceResult, ReadShellCommandOutputResult, ReadSkillResult,
|
||||
RequestCommandOutputResult, RequestComputerUseResult, RequestFileEditsResult,
|
||||
SearchCodebaseResult, SendMessageToAgentResult, StartAgentResult, StartAgentVersion,
|
||||
SuggestNewConversationResult, SuggestPromptResult,
|
||||
RunAgentsResult, SearchCodebaseResult, SendMessageToAgentResult, StartAgentResult,
|
||||
StartAgentVersion, SuggestNewConversationResult, SuggestPromptResult,
|
||||
TransferShellCommandControlToUserResult, UploadArtifactResult, UseComputerResult,
|
||||
WriteToLongRunningShellCommandResult,
|
||||
},
|
||||
@@ -167,6 +167,54 @@ pub enum AIAgentActionType {
|
||||
AskUserQuestion {
|
||||
questions: Vec<AskUserQuestionItem>,
|
||||
},
|
||||
|
||||
/// AI requested batched orchestration of one-or-more child agents that
|
||||
/// share run-wide configuration (model, harness, execution mode).
|
||||
/// The full per-child prompt is computed at dispatch time as
|
||||
/// `base_prompt + "\n\n" + agent_run_configs[i].prompt` (or just
|
||||
/// `base_prompt` when the per-agent `prompt` is empty).
|
||||
RunAgents(RunAgentsRequest),
|
||||
}
|
||||
|
||||
/// Run-wide + per-agent configuration for a `RunAgents` tool call.
|
||||
///
|
||||
/// Mirrors the proto `RunAgents` message. Server-resolved fields
|
||||
/// (`model_id`, `harness_type`, `execution_mode`'s remote details) are
|
||||
/// folded in by the server's final tool-call re-emission once the
|
||||
/// payload is complete; the client renders the full layout from a
|
||||
/// fully-resolved instance only.
|
||||
#[derive(Debug, Clone, Eq, PartialEq)]
|
||||
pub struct RunAgentsRequest {
|
||||
pub summary: String,
|
||||
pub base_prompt: String,
|
||||
pub skills: Vec<SkillReference>,
|
||||
pub model_id: String,
|
||||
pub harness_type: String,
|
||||
pub execution_mode: RunAgentsExecutionMode,
|
||||
pub agent_run_configs: Vec<RunAgentsAgentRunConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Eq, PartialEq)]
|
||||
pub enum RunAgentsExecutionMode {
|
||||
Local,
|
||||
Remote {
|
||||
environment_id: String,
|
||||
worker_host: String,
|
||||
computer_use_enabled: bool,
|
||||
},
|
||||
}
|
||||
|
||||
impl RunAgentsExecutionMode {
|
||||
pub fn is_remote(&self) -> bool {
|
||||
matches!(self, Self::Remote { .. })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Eq, PartialEq)]
|
||||
pub struct RunAgentsAgentRunConfig {
|
||||
pub name: String,
|
||||
pub prompt: String,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Eq, PartialEq)]
|
||||
@@ -175,6 +223,11 @@ pub enum StartAgentExecutionMode {
|
||||
/// `None` selects the legacy embedded local child-agent flow.
|
||||
/// `Some(...)` selects a third-party CLI harness to launch locally.
|
||||
harness_type: Option<String>,
|
||||
/// `None` inherits the parent agent's preferred LLM (legacy behavior).
|
||||
/// `Some(_)` overrides the child's preferred LLM with the supplied
|
||||
/// model id (used by the orchestrate confirmation card so the user's
|
||||
/// model selection is honored on local launches).
|
||||
model_id: Option<String>,
|
||||
},
|
||||
Remote {
|
||||
environment_id: String,
|
||||
@@ -190,12 +243,16 @@ pub enum StartAgentExecutionMode {
|
||||
impl StartAgentExecutionMode {
|
||||
/// Constructs a local execution mode using the legacy v1 default harness.
|
||||
pub fn local_with_defaults() -> Self {
|
||||
Self::Local { harness_type: None }
|
||||
Self::Local {
|
||||
harness_type: None,
|
||||
model_id: None,
|
||||
}
|
||||
}
|
||||
/// Constructs a local execution mode for a specific third-party harness.
|
||||
pub fn local_harness(harness_type: String) -> Self {
|
||||
Self::Local {
|
||||
harness_type: Some(harness_type),
|
||||
model_id: None,
|
||||
}
|
||||
}
|
||||
/// Constructs a remote execution mode using the legacy v1 defaults for
|
||||
@@ -317,6 +374,7 @@ impl AIAgentActionType {
|
||||
Self::AskUserQuestion { .. } => {
|
||||
AIAgentActionResultType::AskUserQuestion(AskUserQuestionResult::Cancelled)
|
||||
}
|
||||
Self::RunAgents(_) => AIAgentActionResultType::RunAgents(RunAgentsResult::Cancelled),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,6 +420,9 @@ impl AIAgentActionType {
|
||||
Self::AskUserQuestion { questions } => {
|
||||
format!("Ask user {} question(s)", questions.len())
|
||||
}
|
||||
Self::RunAgents(req) => {
|
||||
format!("Orchestrate {} agent(s)", req.agent_run_configs.len())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -534,6 +595,15 @@ impl Display for AIAgentActionType {
|
||||
AIAgentActionType::AskUserQuestion { questions } => {
|
||||
write!(f, "AskUserQuestion: {} question(s)", questions.len())
|
||||
}
|
||||
AIAgentActionType::RunAgents(req) => {
|
||||
let names = req
|
||||
.agent_run_configs
|
||||
.iter()
|
||||
.map(|c| c.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
write!(f, "Orchestrate: summary='{}' agents=[{names}]", req.summary,)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1287,6 +1287,121 @@ impl From<AskUserQuestionResult> for api::request::input::tool_call_result::Resu
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RunAgentsLaunchedExecutionMode>
|
||||
for api::run_agents_result::launched::ResolvedExecutionMode
|
||||
{
|
||||
fn from(mode: RunAgentsLaunchedExecutionMode) -> Self {
|
||||
match mode {
|
||||
RunAgentsLaunchedExecutionMode::Local => {
|
||||
api::run_agents_result::launched::ResolvedExecutionMode::Local(
|
||||
api::run_agents::Local {},
|
||||
)
|
||||
}
|
||||
RunAgentsLaunchedExecutionMode::Remote {
|
||||
environment_id,
|
||||
worker_host,
|
||||
computer_use_enabled,
|
||||
} => api::run_agents_result::launched::ResolvedExecutionMode::Remote(
|
||||
api::run_agents::Remote {
|
||||
environment_id,
|
||||
worker_host,
|
||||
computer_use_enabled,
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RunAgentsAgentOutcome> for api::run_agents_result::AgentOutcome {
|
||||
fn from(outcome: RunAgentsAgentOutcome) -> Self {
|
||||
let result = match outcome.kind {
|
||||
RunAgentsAgentOutcomeKind::Launched { agent_id } => {
|
||||
api::run_agents_result::agent_outcome::Result::Launched(
|
||||
api::run_agents_result::LaunchedAgent { agent_id },
|
||||
)
|
||||
}
|
||||
RunAgentsAgentOutcomeKind::Failed { error } => {
|
||||
api::run_agents_result::agent_outcome::Result::Failed(
|
||||
api::run_agents_result::FailedAgent { error },
|
||||
)
|
||||
}
|
||||
};
|
||||
api::run_agents_result::AgentOutcome {
|
||||
name: outcome.name,
|
||||
result: Some(result),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a client-side harness string identifier (e.g. "oz", "claude")
|
||||
/// to the new proto `Harness` oneof. Returns `None` for empty,
|
||||
/// unrecognized, or `"unknown"` strings; callers leave
|
||||
/// `resolved_harness` unset in that case.
|
||||
pub(super) fn build_api_harness(harness_type: &str) -> Option<api::Harness> {
|
||||
let normalized = harness_type.trim().to_ascii_lowercase().replace('_', "-");
|
||||
let variant = match normalized.as_str() {
|
||||
"oz" => api::harness::Variant::Oz(api::harness::Oz {}),
|
||||
"claude" | "claude-code" => api::harness::Variant::ClaudeCode(api::harness::ClaudeCode {}),
|
||||
"opencode" | "open-code" => api::harness::Variant::OpenCode(api::harness::OpenCode {}),
|
||||
"gemini" => api::harness::Variant::Gemini(api::harness::Gemini {}),
|
||||
"codex" => api::harness::Variant::Codex(api::harness::Codex {}),
|
||||
_ => return None,
|
||||
};
|
||||
Some(api::Harness {
|
||||
variant: Some(variant),
|
||||
})
|
||||
}
|
||||
|
||||
impl TryFrom<RunAgentsResult> for api::request::input::tool_call_result::Result {
|
||||
type Error = ConvertToAPITypeError;
|
||||
|
||||
fn try_from(result: RunAgentsResult) -> Result<Self, Self::Error> {
|
||||
match result {
|
||||
RunAgentsResult::Launched {
|
||||
model_id,
|
||||
harness_type,
|
||||
execution_mode,
|
||||
agents,
|
||||
} => Ok(
|
||||
api::request::input::tool_call_result::Result::RunAgentsResult(
|
||||
api::RunAgentsResult {
|
||||
outcome: Some(api::run_agents_result::Outcome::Launched(
|
||||
api::run_agents_result::Launched {
|
||||
resolved_model_id: model_id,
|
||||
resolved_harness: build_api_harness(&harness_type),
|
||||
resolved_execution_mode: Some(execution_mode.into()),
|
||||
agents: agents.into_iter().map(Into::into).collect(),
|
||||
},
|
||||
)),
|
||||
},
|
||||
),
|
||||
),
|
||||
RunAgentsResult::Denied { reason } => Ok(
|
||||
api::request::input::tool_call_result::Result::RunAgentsResult(
|
||||
api::RunAgentsResult {
|
||||
outcome: Some(api::run_agents_result::Outcome::Denied(
|
||||
api::run_agents_result::Denied { reason },
|
||||
)),
|
||||
},
|
||||
),
|
||||
),
|
||||
RunAgentsResult::Failure { error } => Ok(
|
||||
api::request::input::tool_call_result::Result::RunAgentsResult(
|
||||
api::RunAgentsResult {
|
||||
outcome: Some(api::run_agents_result::Outcome::Failure(
|
||||
api::run_agents_result::Failure { error },
|
||||
)),
|
||||
},
|
||||
),
|
||||
),
|
||||
// Reject is conveyed by the generic ToolCallResult.Cancel marker
|
||||
// synthesized server-side on the next user input; nothing for the
|
||||
// client to send on the wire here.
|
||||
RunAgentsResult::Cancelled => Err(ConvertToAPITypeError::Ignore),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<InsertReviewCommentsResult> for api::request::input::tool_call_result::Result {
|
||||
type Error = ConvertToAPITypeError;
|
||||
|
||||
|
||||
@@ -95,6 +95,10 @@ pub enum AIAgentActionResultType {
|
||||
TransferShellCommandControlToUser(TransferShellCommandControlToUserResult),
|
||||
/// The result of asking the user a question.
|
||||
AskUserQuestion(AskUserQuestionResult),
|
||||
|
||||
/// The result of an orchestrate tool call: launched (with per-agent
|
||||
/// outcomes), launch denied (Stage 2), failure, or cancelled.
|
||||
RunAgents(RunAgentsResult),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
@@ -161,6 +165,7 @@ impl Display for AIAgentActionResultType {
|
||||
AIAgentActionResultType::SendMessageToAgent(result) => result.fmt(f),
|
||||
AIAgentActionResultType::TransferShellCommandControlToUser(result) => result.fmt(f),
|
||||
AIAgentActionResultType::AskUserQuestion(result) => result.fmt(f),
|
||||
AIAgentActionResultType::RunAgents(result) => result.fmt(f),
|
||||
AIAgentActionResultType::OpenCodeReview | AIAgentActionResultType::InitProject => {
|
||||
Ok(())
|
||||
}
|
||||
@@ -753,6 +758,9 @@ impl AIAgentActionResultType {
|
||||
AIAgentActionResultType::AskUserQuestion(_) => {
|
||||
"The user's answers to clarifying questions"
|
||||
}
|
||||
AIAgentActionResultType::RunAgents(_) => {
|
||||
"The result of an orchestrate batch of child agents"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -790,6 +798,7 @@ impl AIAgentActionResultType {
|
||||
| TransferShellCommandControlToUserResult::CommandFinished { .. },
|
||||
) => true,
|
||||
Self::AskUserQuestion(AskUserQuestionResult::Success { .. }) => true,
|
||||
Self::RunAgents(RunAgentsResult::Launched { .. }) => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -818,7 +827,10 @@ impl AIAgentActionResultType {
|
||||
| Self::AskUserQuestion(AskUserQuestionResult::Error(_))
|
||||
| Self::TransferShellCommandControlToUser(
|
||||
TransferShellCommandControlToUserResult::Error(_),
|
||||
) => true,
|
||||
)
|
||||
| Self::RunAgents(RunAgentsResult::Failure { .. } | RunAgentsResult::Denied { .. }) => {
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -860,7 +872,8 @@ impl AIAgentActionResultType {
|
||||
| Self::StartAgent(StartAgentResult::Cancelled { .. })
|
||||
| Self::SendMessageToAgent(SendMessageToAgentResult::Cancelled)
|
||||
// SkippedByAutoApprove is intentionally excluded: the agent should continue.
|
||||
| Self::AskUserQuestion(AskUserQuestionResult::Cancelled) => true,
|
||||
| Self::AskUserQuestion(AskUserQuestionResult::Cancelled)
|
||||
| Self::RunAgents(RunAgentsResult::Cancelled) => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -1229,6 +1242,83 @@ impl Display for StartAgentResult {
|
||||
}
|
||||
}
|
||||
|
||||
/// The terminal outcome of an orchestrate tool call.
|
||||
///
|
||||
/// Mirrors the proto `RunAgentsResult` oneof, with an additional
|
||||
/// `Cancelled` variant used internally by the action machinery when the
|
||||
/// user clicks Reject. The proto wire form for cancellation is the
|
||||
/// generic `ToolCallResult.Cancel` marker; the conversion code emits
|
||||
/// `ConvertToAPITypeError::Ignore` for `Cancelled` so the input
|
||||
/// interceptor can synthesize the marker on the next outbound input.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum RunAgentsResult {
|
||||
/// Orchestration launched. Carries the resolved configuration and one
|
||||
/// `AgentOutcome` per `agent_run_configs[]` entry, in input order.
|
||||
Launched {
|
||||
model_id: String,
|
||||
harness_type: String,
|
||||
execution_mode: RunAgentsLaunchedExecutionMode,
|
||||
agents: Vec<RunAgentsAgentOutcome>,
|
||||
},
|
||||
/// Declined for a non-error reason (currently disapproval).
|
||||
Denied { reason: String },
|
||||
/// Actual error path: server-side validation rejected the call, or the
|
||||
/// client could not begin the launch sequence at all.
|
||||
Failure { error: String },
|
||||
/// User rejected via the Reject button. Wire form is the generic
|
||||
/// `ToolCallResult.Cancel` marker, synthesized by the server's input
|
||||
/// interceptor on the next user input.
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum RunAgentsLaunchedExecutionMode {
|
||||
Local,
|
||||
Remote {
|
||||
environment_id: String,
|
||||
worker_host: String,
|
||||
computer_use_enabled: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// Per-agent outcome reported in `RunAgentsResult::Launched.agents`.
|
||||
/// Order mirrors the input order of `RunAgents.agent_run_configs[]`,
|
||||
/// regardless of which `CreateAgentTask` call returned first.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct RunAgentsAgentOutcome {
|
||||
pub name: String,
|
||||
pub kind: RunAgentsAgentOutcomeKind,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum RunAgentsAgentOutcomeKind {
|
||||
Launched { agent_id: String },
|
||||
Failed { error: String },
|
||||
}
|
||||
|
||||
impl Display for RunAgentsResult {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
RunAgentsResult::Launched { agents, .. } => {
|
||||
let launched = agents
|
||||
.iter()
|
||||
.filter(|a| matches!(a.kind, RunAgentsAgentOutcomeKind::Launched { .. }))
|
||||
.count();
|
||||
write!(
|
||||
f,
|
||||
"Orchestrate launched ({launched}/{} agents started)",
|
||||
agents.len()
|
||||
)
|
||||
}
|
||||
RunAgentsResult::Denied { reason } => {
|
||||
write!(f, "Orchestrate launch denied: {reason}")
|
||||
}
|
||||
RunAgentsResult::Failure { error } => write!(f, "Orchestrate failure: {error}"),
|
||||
RunAgentsResult::Cancelled => write!(f, "Orchestrate cancelled"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum SendMessageToAgentResult {
|
||||
Success { message_id: String },
|
||||
|
||||
@@ -715,6 +715,15 @@ pub enum FeatureFlag {
|
||||
/// real time.
|
||||
OrchestrationV2,
|
||||
|
||||
/// Gates client-side support for the `orchestrate` tool, which batches
|
||||
/// multiple child agents into a single tool call with an inline
|
||||
/// confirmation card. When enabled, the client advertises
|
||||
/// `RequestSettings.SupportsOrchestrate = true` and the server's
|
||||
/// orchestrate tool replaces `start_agent` / `start_agent_v2` for
|
||||
/// orchestration-capable conversations. Layered on top of
|
||||
/// `OrchestrationV2`; has no effect when v2 is off.
|
||||
RunAgentsTool,
|
||||
|
||||
/// Renders a horizontal pill bar in the agent view pane header showing the
|
||||
/// orchestrator agent and all of its child agents, with click-to-switch
|
||||
/// behavior between siblings.
|
||||
@@ -915,6 +924,7 @@ pub const DOGFOOD_FLAGS: &[FeatureFlag] = &[
|
||||
FeatureFlag::RememberFastForwardState,
|
||||
FeatureFlag::HOANotifications,
|
||||
FeatureFlag::OrchestrationV2,
|
||||
FeatureFlag::RunAgentsTool,
|
||||
FeatureFlag::GeminiNotifications,
|
||||
FeatureFlag::LocalDockerSandbox,
|
||||
FeatureFlag::VerticalTabsSummaryMode,
|
||||
|
||||
Reference in New Issue
Block a user