Skip to content

Commit 71c0081

Browse files
authored
feat(agent): hot swap (#2637)
* changes prompt list result to be sent over via messenger * changes tool manager orchestrator tasks to keep prompts * changes mpsc to broadcast * restores prompt list functionality * restore prompt get functionality * adds api on tool manager to hotswap * spawns task to send deinit msg via messenger * adds slash command to hotswap agent * modifies load tool wait time depending on context * adds comments to retry logic for prompt completer * fixes lint * adds pid field to messenger message * adds interactive menu for swapping agent * fixes stale mcp load record * documents build method on tool manager builder and refactor to make the build method smaller
1 parent bd7521c commit 71c0081

File tree

11 files changed

+1075
-686
lines changed

11 files changed

+1075
-686
lines changed

crates/chat-cli/src/cli/chat/cli/profile.rs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ use crossterm::{
1111
execute,
1212
queue,
1313
};
14+
use dialoguer::Select;
1415
use syntect::easy::HighlightLines;
1516
use syntect::highlighting::{
1617
Style,
@@ -77,6 +78,9 @@ pub enum AgentSubcommand {
7778
#[arg(long, short)]
7879
name: String,
7980
},
81+
/// Swap to a new agent at runtime
82+
#[command(alias = "switch")]
83+
Swap { name: Option<String> },
8084
}
8185

8286
impl AgentSubcommand {
@@ -224,6 +228,49 @@ impl AgentSubcommand {
224228
)?;
225229
},
226230
},
231+
Self::Swap { name } => {
232+
if let Some(name) = name {
233+
session.conversation.swap_agent(os, &mut session.stderr, &name).await?;
234+
} else {
235+
let labels = session
236+
.conversation
237+
.agents
238+
.agents
239+
.keys()
240+
.map(|name| name.as_str())
241+
.collect::<Vec<_>>();
242+
243+
let name = {
244+
let idx = match Select::with_theme(&crate::util::dialoguer_theme())
245+
.with_prompt("Choose one of the following agents")
246+
.items(&labels)
247+
.default(1)
248+
.interact_on_opt(&dialoguer::console::Term::stdout())
249+
{
250+
Ok(sel) => {
251+
let _ = crossterm::execute!(
252+
std::io::stdout(),
253+
crossterm::style::SetForegroundColor(crossterm::style::Color::Magenta)
254+
);
255+
sel
256+
},
257+
// Ctrl‑C -> Err(Interrupted)
258+
Err(dialoguer::Error::IO(ref e)) if e.kind() == std::io::ErrorKind::Interrupted => None,
259+
Err(e) => {
260+
return Err(ChatError::Custom(
261+
format!("Dialog has failed to make a selection {e}").into(),
262+
));
263+
},
264+
};
265+
266+
idx.and_then(|idx| labels.get(idx).cloned().map(str::to_string))
267+
};
268+
269+
if let Some(name) = name {
270+
session.conversation.swap_agent(os, &mut session.stderr, &name).await?;
271+
}
272+
}
273+
},
227274
}
228275

229276
Ok(ChatState::PromptUser {
@@ -239,6 +286,7 @@ impl AgentSubcommand {
239286
Self::Set { .. } => "set",
240287
Self::Schema => "schema",
241288
Self::SetDefault { .. } => "set_default",
289+
Self::Swap { .. } => "swap",
242290
}
243291
}
244292
}

crates/chat-cli/src/cli/chat/cli/prompts.rs

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,14 @@ pub enum GetPromptError {
3838
MissingClient,
3939
#[error("Missing prompt name")]
4040
MissingPromptName,
41-
#[error("Synchronization error: {0}")]
42-
Synchronization(String),
4341
#[error("Missing prompt bundle")]
4442
MissingPromptInfo,
4543
#[error(transparent)]
4644
General(#[from] eyre::Report),
45+
#[error("Incorrect response type received")]
46+
IncorrectResponseType,
47+
#[error("Missing channel")]
48+
MissingChannel,
4749
}
4850

4951
#[deny(missing_docs)]
@@ -76,10 +78,7 @@ impl PromptsArgs {
7678
}
7779

7880
let terminal_width = session.terminal_width();
79-
let mut prompts_wl = session.conversation.tool_manager.prompts.write().map_err(|e| {
80-
ChatError::Custom(format!("Poison error encountered while retrieving prompts: {}", e).into())
81-
})?;
82-
session.conversation.tool_manager.refresh_prompts(&mut prompts_wl)?;
81+
let prompts = session.conversation.tool_manager.list_prompts().await?;
8382
let mut longest_name = "";
8483
let arg_pos = {
8584
let optimal_case = UnicodeWidthStr::width(longest_name) + terminal_width / 4;
@@ -121,7 +120,7 @@ impl PromptsArgs {
121120
style::Print("\n"),
122121
style::Print(format!("{}\n", "▔".repeat(terminal_width))),
123122
)?;
124-
let mut prompts_by_server: Vec<_> = prompts_wl
123+
let mut prompts_by_server: Vec<_> = prompts
125124
.iter()
126125
.fold(
127126
HashMap::<&String, Vec<&PromptBundle>>::new(),

crates/chat-cli/src/cli/chat/conversation.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -699,6 +699,31 @@ impl ConversationState {
699699
}
700700
self.transcript.push_back(message);
701701
}
702+
703+
/// Swapping agent involves the following:
704+
/// - Reinstantiate the context manager
705+
/// - Swap agent on tool manager
706+
pub async fn swap_agent(
707+
&mut self,
708+
os: &mut Os,
709+
output: &mut impl Write,
710+
agent_name: &str,
711+
) -> Result<(), ChatError> {
712+
let agent = self.agents.switch(agent_name).map_err(ChatError::AgentSwapError)?;
713+
self.context_manager.replace({
714+
ContextManager::from_agent(agent, calc_max_context_files_size(self.model_info.as_ref()))
715+
.map_err(|e| ChatError::Custom(format!("Context manager has failed to instantiate: {e}").into()))?
716+
});
717+
718+
self.tool_manager
719+
.swap_agent(os, output, agent)
720+
.await
721+
.map_err(ChatError::AgentSwapError)?;
722+
723+
self.update_state(true).await;
724+
725+
Ok(())
726+
}
702727
}
703728

704729
/// Represents a conversation state that can be converted into a [FigConversationState] (the type

crates/chat-cli/src/cli/chat/input_source.rs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
use eyre::Result;
22
use rustyline::error::ReadlineError;
33

4-
use super::prompt::rl;
4+
use super::prompt::{
5+
PromptQueryResponseReceiver,
6+
PromptQuerySender,
7+
rl,
8+
};
59
#[cfg(unix)]
610
use super::skim_integration::SkimCommandSelector;
711
use crate::os::Os;
@@ -28,11 +32,7 @@ mod inner {
2832
}
2933

3034
impl InputSource {
31-
pub fn new(
32-
os: &Os,
33-
sender: std::sync::mpsc::Sender<Option<String>>,
34-
receiver: std::sync::mpsc::Receiver<Vec<String>>,
35-
) -> Result<Self> {
35+
pub fn new(os: &Os, sender: PromptQuerySender, receiver: PromptQueryResponseReceiver) -> Result<Self> {
3636
Ok(Self(inner::Inner::Readline(rl(os, sender, receiver)?)))
3737
}
3838

crates/chat-cli/src/cli/chat/mod.rs

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,8 @@ use tokio::sync::{
9696
broadcast,
9797
};
9898
use tool_manager::{
99+
PromptQuery,
100+
PromptQueryResult,
99101
ToolManager,
100102
ToolManagerBuilder,
101103
};
@@ -334,11 +336,14 @@ impl ChatArgs {
334336
Some(default_model_opt.model_id.clone())
335337
};
336338

337-
let (prompt_request_sender, prompt_request_receiver) = std::sync::mpsc::channel::<Option<String>>();
338-
let (prompt_response_sender, prompt_response_receiver) = std::sync::mpsc::channel::<Vec<String>>();
339+
let (prompt_request_sender, prompt_request_receiver) = tokio::sync::broadcast::channel::<PromptQuery>(5);
340+
let (prompt_response_sender, prompt_response_receiver) =
341+
tokio::sync::broadcast::channel::<PromptQueryResult>(5);
339342
let mut tool_manager = ToolManagerBuilder::default()
340-
.prompt_list_sender(prompt_response_sender)
341-
.prompt_list_receiver(prompt_request_receiver)
343+
.prompt_query_result_sender(prompt_response_sender)
344+
.prompt_query_receiver(prompt_request_receiver)
345+
.prompt_query_sender(prompt_request_sender.clone())
346+
.prompt_query_result_receiver(prompt_response_receiver.resubscribe())
342347
.conversation_id(&conversation_id)
343348
.agent(agents.get_active().cloned().unwrap_or_default())
344349
.build(os, Box::new(std::io::stderr()), !self.no_interactive)
@@ -470,6 +475,8 @@ pub enum ChatError {
470475
NonInteractiveToolApproval,
471476
#[error("The conversation history is too large to compact")]
472477
CompactHistoryFailure,
478+
#[error("Failed to swap to agent: {0}")]
479+
AgentSwapError(eyre::Report),
473480
}
474481

475482
impl ChatError {
@@ -486,6 +493,7 @@ impl ChatError {
486493
ChatError::GetPromptError(_) => None,
487494
ChatError::NonInteractiveToolApproval => None,
488495
ChatError::CompactHistoryFailure => None,
496+
ChatError::AgentSwapError(_) => None,
489497
}
490498
}
491499
}
@@ -504,6 +512,7 @@ impl ReasonCode for ChatError {
504512
ChatError::Auth(_) => "AuthError".to_string(),
505513
ChatError::NonInteractiveToolApproval => "NonInteractiveToolApproval".to_string(),
506514
ChatError::CompactHistoryFailure => "CompactHistoryFailure".to_string(),
515+
ChatError::AgentSwapError(_) => "AgentSwapError".to_string(),
507516
}
508517
}
509518
}
@@ -1602,6 +1611,7 @@ impl ChatSession {
16021611
.await;
16031612

16041613
if matches!(chat_state, ChatState::Exit)
1614+
|| matches!(chat_state, ChatState::HandleResponseStream(_))
16051615
|| matches!(chat_state, ChatState::HandleInput { input: _ })
16061616
// TODO(bskiser): this is just a hotfix for handling state changes
16071617
// from manually running /compact, without impacting behavior of

0 commit comments

Comments
 (0)