mod tools;
use std::{env, path::Path, pin::Pin};
use anyhow::Result;
use tokio::sync::mpsc::{Receiver, Sender};
use crate::{
config::{self},
core::conversation::{ConversationItem, ConversationItemKind},
session::SessionMessage,
};
use futures::{Stream, StreamExt};
use rig::{
Agent,
agent::{MultiTurnStreamItem, StreamingError},
client::{AgentClientExt, Client},
completion::Message,
providers::openai::{
self, OpenAIResponsesExt,
responses_api::{GenericResponsesCompletionModel, streaming::StreamingCompletionResponse},
},
streaming::{StreamedAssistantContent, StreamingChat},
};
#[derive(Clone, Debug, PartialEq)]
pub enum LlmMessage {
SubmitPrompt {
prompt: String,
history: Vec<ConversationItem>,
},
SelectModel(String),
SetEndpoint(config::Endpoint),
Quit,
}
pub enum LlmProviderType {
Completion(Agent<openai::GenericCompletionModel>),
Responses(Agent<GenericResponsesCompletionModel>),
}
pub enum LlmStream {
Completion(
Pin<
Box<
dyn Stream<
Item = Result<
MultiTurnStreamItem<openai::streaming::StreamingCompletionResponse>,
StreamingError,
>,
> + Send
+ 'static,
>,
>,
),
Responses(
Pin<
Box<
dyn Stream<
Item = Result<
MultiTurnStreamItem<StreamingCompletionResponse>,
StreamingError,
>,
> + Send
+ 'static,
>,
>,
),
}
pub enum LlmState {
Unconnected,
Idle,
Requesting {
user_prompt: String,
history: Vec<Message>,
},
Streaming {
stream: LlmStream,
},
Quitting,
}
#[derive(Debug)]
pub struct Llm {
session_tx: Sender<SessionMessage>,
rx: Receiver<LlmMessage>,
config: config::General,
endpoint: Option<config::Endpoint>,
model: Option<String>,
}
impl Llm {
pub fn new(
session_tx: Sender<SessionMessage>,
rx: Receiver<LlmMessage>,
config: config::General,
) -> Self {
Self {
session_tx,
rx,
config,
endpoint: None,
model: None,
}
}
pub async fn run(&mut self) {
let base_dir = env::current_dir().expect("Cannot get current directory");
let mut agent = None;
let mut state = LlmState::Idle;
loop {
state = match state {
LlmState::Unconnected => self.handle_unconnected(&mut agent, &base_dir).await,
LlmState::Idle => self.handle_idle().await,
LlmState::Requesting {
user_prompt,
history,
} => self.handle_requesting(&agent, user_prompt, history).await,
LlmState::Streaming { stream } => self.handle_streaming(stream).await,
LlmState::Quitting => break,
};
}
}
async fn handle_idle(&mut self) -> LlmState {
match self.rx.recv().await {
Some(LlmMessage::SelectModel(model)) => {
self.model = Some(model);
LlmState::Unconnected
}
Some(LlmMessage::SetEndpoint(endpoint)) => {
self.endpoint = Some(endpoint);
LlmState::Unconnected
}
Some(LlmMessage::SubmitPrompt { prompt, history }) => {
let history = rig_history_from_internal(&history);
LlmState::Requesting {
user_prompt: prompt,
history,
}
}
Some(LlmMessage::Quit) => LlmState::Quitting,
None => LlmState::Quitting,
}
}
async fn handle_requesting(
&mut self,
agent: &Option<LlmProviderType>,
user_prompt: String,
history: Vec<Message>,
) -> LlmState {
let stream = match agent {
Some(LlmProviderType::Responses(a)) => {
let s = a.stream_chat(user_prompt, history).await;
LlmStream::Responses(s)
}
Some(LlmProviderType::Completion(a)) => {
let s = a.stream_chat(user_prompt, history).await;
LlmStream::Completion(s)
}
None => return LlmState::Unconnected,
};
LlmState::Streaming { stream }
}
async fn handle_streaming(&mut self, mut stream: LlmStream) -> LlmState {
match stream {
LlmStream::Completion(s) => self.handle_stream_inner(s).await,
LlmStream::Responses(s) => self.handle_stream_inner(s).await,
}
}
async fn handle_stream_inner<S, T>(&mut self, mut s: S) -> LlmState
where
S: Stream<Item = Result<MultiTurnStreamItem<T>, StreamingError>> + Unpin,
{
loop {
tokio::select! {
maybe_chunk = s.next() => {
if let Some(result) = maybe_chunk {
match result {
Ok(chunk) => self.handle_llm_item(chunk).await,
Err(e) => {
self.session_tx
.send(SessionMessage::Error(e.to_string()))
.await
.unwrap();
}
}
} else {
return LlmState::Idle;
}
}
Some(LlmMessage::Quit) = self.rx.recv() => {
return LlmState::Idle;
}
}
}
}
async fn handle_llm_item<R>(&mut self, chunk: MultiTurnStreamItem<R>) {
match chunk {
MultiTurnStreamItem::StreamAssistantItem(streamed_assistant_content) => {
match streamed_assistant_content {
StreamedAssistantContent::Text(text) => {
self.session_tx
.send(SessionMessage::AgentResponse(text.to_string()))
.await
.unwrap();
}
StreamedAssistantContent::ToolCall {
tool_call,
internal_call_id: _,
} => {
self.session_tx
.send(SessionMessage::ToolCall {
name: tool_call.function.name,
args: tool_call.function.arguments,
id: tool_call.id,
call_id: tool_call.call_id,
})
.await
.unwrap();
}
StreamedAssistantContent::ToolCallDelta {
id: _,
internal_call_id: _,
content: _,
} => {
}
StreamedAssistantContent::Reasoning(_reasoning) => {
}
StreamedAssistantContent::ReasoningDelta { id: _, reasoning } => {
self.session_tx
.send(SessionMessage::AgentReasoning(reasoning))
.await
.unwrap();
}
StreamedAssistantContent::Final(_message) => {
}
StreamedAssistantContent::Unknown(_value) => {
}
}
}
MultiTurnStreamItem::ToolExecutionCommitted {
tool_call: _,
internal_call_id: _,
} => {
}
MultiTurnStreamItem::StreamUserItem(
rig::streaming::StreamedUserContent::ToolResult {
tool_result,
internal_call_id: _,
},
) => {
self.session_tx
.send(SessionMessage::ToolResult {
output: format!("{:?}", tool_result.content),
success: true,
id: tool_result.id,
call_id: tool_result.call_id,
})
.await
.unwrap();
}
MultiTurnStreamItem::CompletionCall(completion_call)
if completion_call.usage.has_values() =>
{
self.session_tx
.send(SessionMessage::AgentCompletion(completion_call.usage))
.await
.unwrap();
}
MultiTurnStreamItem::ModelTurnRetried { turn } => {
}
MultiTurnStreamItem::FinalResponse(prompt_response) => {
}
_ => {
}
}
}
async fn handle_unconnected(
&mut self,
agent: &mut Option<LlmProviderType>,
base_dir: &Path,
) -> LlmState {
if let Some(endpoint) = &self.endpoint
&& let Some(model) = &self.model
{
let api_token = endpoint
.secrets
.clone()
.unwrap_or(config::EndpointSecrets {
api_token: Some("".to_string()),
})
.api_token
.unwrap_or("".to_string());
let new_agent =
match endpoint.api_type {
config::EndpointApi::OpenAICompletions => {
let client = openai::Client::builder()
.base_url(&endpoint.base_url)
.api_key(api_token)
.build()
.unwrap()
.completions_api();
LlmProviderType::Completion(
client
.agent(model)
.preamble(
"You are a experienced software developer advising a colleague.",
)
.tool(tools::ListDir::new(
base_dir,
self.config.max_list_dir_results,
))
.tool(tools::ReadFile::new(base_dir))
.tool(tools::SearchText::new(
base_dir,
self.config.max_search_text_results,
))
.default_max_turns(self.config.max_iterations_per_request)
.max_tokens(self.config.max_tokens)
.build(),
)
}
config::EndpointApi::OpenAIResponses => {
let client = openai::Client::builder()
.base_url(&endpoint.base_url)
.api_key(api_token)
.build()
.unwrap();
LlmProviderType::Responses(
client
.agent(model)
.preamble("You are a experienced software developer advising a colleague.")
.tool(tools::ListDir::new(
base_dir,
self.config.max_list_dir_results,
))
.tool(tools::ReadFile::new(base_dir))
.tool(tools::SearchText::new(
base_dir,
self.config.max_search_text_results,
))
.default_max_turns(self.config.max_iterations_per_request)
.max_tokens(self.config.max_tokens)
.build(),
)
}
};
*agent = Some(new_agent);
return LlmState::Idle;
}
match self.rx.recv().await {
Some(LlmMessage::SelectModel(model)) => {
self.model = Some(model);
LlmState::Unconnected
}
Some(LlmMessage::SetEndpoint(endpoint)) => {
self.endpoint = Some(endpoint);
LlmState::Unconnected
}
Some(LlmMessage::SubmitPrompt {
prompt: _,
history: _,
}) => {
LlmState::Unconnected
}
Some(LlmMessage::Quit) => LlmState::Quitting,
None => LlmState::Quitting,
}
}
}
fn rig_history_from_internal(history: &[ConversationItem]) -> Vec<Message> {
let rig_history = history
.iter()
.filter_map(|item| match &item.content {
ConversationItemKind::UserMessage(message_content) => {
Some(Message::user(message_content.raw().to_string()))
}
ConversationItemKind::AssistantMessage(message_content) => {
if message_content.raw().trim().is_empty() {
None
} else {
Some(Message::assistant(message_content.raw().to_string()))
}
}
ConversationItemKind::AssistantReasoning(_message_content) => None,
ConversationItemKind::ToolInvocation(tool_invocation_content) => None,
ConversationItemKind::ToolOutput(tool_output_content) => None,
ConversationItemKind::Notification(_notification_type, _) => None,
})
.collect();
rig_history
}