| use std::collections::HashSet; |
| use std::sync::Arc; |
| use std::time::Duration; |
|
|
| use async_recursion::async_recursion; |
| use derive_setters::Setters; |
| use forge_domain::{Agent, *}; |
| use forge_template::Element; |
| use futures::future::join_all; |
| use tokio::sync::Notify; |
| use tracing::warn; |
|
|
| use crate::agent::AgentService; |
| use crate::transformers::{DropReasoningOnlyMessages, ModelSpecificReasoning}; |
| use crate::{EnvironmentInfra, TemplateEngine}; |
|
|
| #[derive(Clone, Setters)] |
| #[setters(into)] |
| pub struct Orchestrator<S> { |
| services: Arc<S>, |
| sender: Option<ArcSender>, |
| conversation: Conversation, |
| tool_definitions: Vec<ToolDefinition>, |
| models: Vec<Model>, |
| agent: Agent, |
| error_tracker: ToolErrorTracker, |
| hook: Arc<Hook>, |
| config: forge_config::ForgeConfig, |
| } |
|
|
| impl<S: AgentService + EnvironmentInfra<Config = forge_config::ForgeConfig>> Orchestrator<S> { |
| pub fn new( |
| services: Arc<S>, |
| conversation: Conversation, |
| agent: Agent, |
| config: forge_config::ForgeConfig, |
| ) -> Self { |
| Self { |
| conversation, |
| services, |
| agent, |
| config, |
| sender: Default::default(), |
| tool_definitions: Default::default(), |
| models: Default::default(), |
| error_tracker: Default::default(), |
| hook: Arc::new(Hook::default()), |
| } |
| } |
|
|
| |
| pub fn get_conversation(&self) -> &Conversation { |
| &self.conversation |
| } |
|
|
| |
| #[async_recursion] |
| async fn execute_tool_calls( |
| &mut self, |
| tool_calls: &[ToolCallFull], |
| tool_context: &ToolCallContext, |
| ) -> anyhow::Result<Vec<(ToolCallFull, ToolResult)>> { |
| let task_tool_name = ToolKind::Task.name(); |
|
|
| |
| let is_task = |tc: &ToolCallFull| { |
| tc.name |
| .as_str() |
| .eq_ignore_ascii_case(task_tool_name.as_str()) |
| }; |
|
|
| |
| |
| |
| let is_task_call = |
| |tc: &&ToolCallFull| tc.name.as_str().to_lowercase() == task_tool_name.as_str(); |
| let (task_calls, other_calls): (Vec<_>, Vec<_>) = tool_calls.iter().partition(is_task_call); |
|
|
| |
| |
| let task_results: Vec<(ToolCallFull, ToolResult)> = join_all( |
| task_calls |
| .iter() |
| .map(|tc| self.services.call(&self.agent, tool_context, (*tc).clone())), |
| ) |
| .await |
| .into_iter() |
| .zip(task_calls.iter()) |
| .map(|(result, tc)| ((*tc).clone(), result)) |
| .collect(); |
|
|
| let system_tools = self |
| .tool_definitions |
| .iter() |
| .map(|tool| &tool.name) |
| .collect::<HashSet<_>>(); |
|
|
| |
| |
| let mut other_results: Vec<(ToolCallFull, ToolResult)> = |
| Vec::with_capacity(other_calls.len()); |
| for tool_call in &other_calls { |
| |
| let is_system_tool = system_tools.contains(&tool_call.name); |
| if is_system_tool { |
| let notifier = Arc::new(Notify::new()); |
| self.send(ChatResponse::ToolCallStart { |
| tool_call: (*tool_call).clone(), |
| notifier: notifier.clone(), |
| }) |
| .await?; |
| |
| |
| |
| notifier.notified().await; |
| } |
|
|
| |
| let toolcall_start_event = LifecycleEvent::ToolcallStart(EventData::new( |
| self.agent.clone(), |
| self.agent.model.clone(), |
| ToolcallStartPayload::new((*tool_call).clone()), |
| )); |
| self.hook |
| .handle(&toolcall_start_event, &mut self.conversation) |
| .await?; |
|
|
| |
| let tool_result = self |
| .services |
| .call(&self.agent, tool_context, (*tool_call).clone()) |
| .await; |
|
|
| |
| let toolcall_end_event = LifecycleEvent::ToolcallEnd(EventData::new( |
| self.agent.clone(), |
| self.agent.model.clone(), |
| ToolcallEndPayload::new((*tool_call).clone(), tool_result.clone()), |
| )); |
| self.hook |
| .handle(&toolcall_end_event, &mut self.conversation) |
| .await?; |
|
|
| |
| if is_system_tool { |
| self.send(ChatResponse::ToolCallEnd(tool_result.clone())) |
| .await?; |
| } |
| other_results.push(((*tool_call).clone(), tool_result)); |
| } |
|
|
| |
| let mut task_iter = task_results.into_iter(); |
| let mut other_iter = other_results.into_iter(); |
| let tool_call_records = tool_calls |
| .iter() |
| .map(|tc| { |
| if is_task(tc) { |
| task_iter.next().expect("task result count mismatch") |
| } else { |
| other_iter.next().expect("other result count mismatch") |
| } |
| }) |
| .collect(); |
|
|
| Ok(tool_call_records) |
| } |
|
|
| async fn send(&self, message: ChatResponse) -> anyhow::Result<()> { |
| if let Some(sender) = &self.sender { |
| sender.send(Ok(message)).await? |
| } |
| Ok(()) |
| } |
|
|
| |
| fn is_tool_supported(&self) -> anyhow::Result<bool> { |
| let model_id = &self.agent.model; |
|
|
| |
| let tool_supported = match self.agent.tool_supported { |
| Some(tool_supported) => tool_supported, |
| None => { |
| |
|
|
| let model = self.models.iter().find(|model| &model.id == model_id); |
| model |
| .and_then(|model| model.tools_supported) |
| .unwrap_or_default() |
| } |
| }; |
|
|
| Ok(tool_supported) |
| } |
|
|
| async fn execute_chat_turn( |
| &self, |
| model_id: &ModelId, |
| context: Context, |
| reasoning_supported: bool, |
| ) -> anyhow::Result<ChatCompletionMessageFull> { |
| let tool_supported = self.is_tool_supported()?; |
| let mut transformers = DefaultTransformation::default() |
| .pipe(SortTools::new(self.agent.tool_order())) |
| .pipe(NormalizeToolCallArguments::new()) |
| .pipe(TransformToolCalls::new().when(|_| !tool_supported)) |
| .pipe(ImageHandling::new()) |
| |
| .pipe(DropReasoningDetails.when(|_| !reasoning_supported)) |
| |
| |
| .pipe(ReasoningNormalizer::new(model_id.clone())) |
| |
| .pipe( |
| ModelSpecificReasoning::new(model_id.as_str()) |
| .when(|_| model_id.as_str().to_lowercase().contains("claude")), |
| ) |
| |
| |
| .pipe( |
| DropReasoningOnlyMessages |
| .when(|_| model_id.as_str().to_lowercase().contains("claude")), |
| ); |
| let response = self |
| .services |
| .chat_agent( |
| model_id, |
| transformers.transform(context), |
| Some(self.agent.provider.clone()), |
| ) |
| .await?; |
|
|
| |
| response |
| .into_full_streaming(!tool_supported, self.sender.clone()) |
| .await |
| } |
|
|
| |
| pub async fn run(&mut self) -> anyhow::Result<()> { |
| let model_id = self.get_model(); |
|
|
| let mut context = self.conversation.context.clone().unwrap_or_default(); |
|
|
| |
| let start_event = LifecycleEvent::Start(EventData::new( |
| self.agent.clone(), |
| model_id.clone(), |
| StartPayload, |
| )); |
| self.hook |
| .handle(&start_event, &mut self.conversation) |
| .await?; |
|
|
| |
| let mut should_yield = false; |
|
|
| |
| let mut is_complete = false; |
|
|
| let mut request_count = 0; |
|
|
| |
| let max_requests_per_turn = self.agent.max_requests_per_turn; |
| let tool_context = |
| ToolCallContext::new(self.conversation.metrics.clone()).sender(self.sender.clone()); |
|
|
| while !should_yield { |
| |
| self.conversation.context = Some(context.clone()); |
| self.services.update(self.conversation.clone()).await?; |
|
|
| let request_event = LifecycleEvent::Request(EventData::new( |
| self.agent.clone(), |
| model_id.clone(), |
| RequestPayload::new(request_count), |
| )); |
| self.hook |
| .handle(&request_event, &mut self.conversation) |
| .await?; |
|
|
| let message = crate::retry::retry_with_config( |
| &self.config.clone().retry.unwrap_or_default(), |
| || { |
| self.execute_chat_turn( |
| &model_id, |
| context.clone(), |
| context.is_reasoning_supported(), |
| ) |
| }, |
| self.sender.as_ref().map(|sender| { |
| let sender = sender.clone(); |
| let agent_id = self.agent.id.clone(); |
| let model_id = model_id.clone(); |
| move |error: &anyhow::Error, duration: Duration| { |
| let root_cause = error.root_cause(); |
| |
| tracing::error!( |
| agent_id = %agent_id, |
| error = ?root_cause, |
| model = %model_id, |
| "Retry attempt due to error" |
| ); |
| let retry_event = |
| ChatResponse::RetryAttempt { cause: error.into(), duration }; |
| let _ = sender.try_send(Ok(retry_event)); |
| } |
| }), |
| ) |
| .await?; |
|
|
| |
| let response_event = LifecycleEvent::Response(EventData::new( |
| self.agent.clone(), |
| model_id.clone(), |
| ResponsePayload::new(message.clone()), |
| )); |
| self.hook |
| .handle(&response_event, &mut self.conversation) |
| .await?; |
|
|
| |
| |
| is_complete = |
| message.finish_reason == Some(FinishReason::Stop) && message.tool_calls.is_empty(); |
|
|
| |
| should_yield = is_complete |
| || message |
| .tool_calls |
| .iter() |
| .any(|call| ToolCatalog::should_yield(&call.name)); |
|
|
| |
| let mut tool_call_records = self |
| .execute_tool_calls(&message.tool_calls, &tool_context) |
| .await?; |
|
|
| |
| if let Some(updated_context) = &self.conversation.context { |
| context = updated_context.clone(); |
| } |
|
|
| self.error_tracker.adjust_record(&tool_call_records); |
| let allowed_max_attempts = self.error_tracker.limit(); |
| for (_, result) in tool_call_records.iter_mut() { |
| if result.is_error() { |
| let attempts_left = self.error_tracker.remaining_attempts(&result.name); |
| |
| let context = serde_json::json!({ |
| "attempts_left": attempts_left, |
| "allowed_max_attempts": allowed_max_attempts, |
| }); |
| let text = TemplateEngine::default() |
| .render("forge-tool-retry-message.md", &context)?; |
| let message = Element::new("retry").text(text); |
|
|
| result.output.combine_mut(ToolOutput::text(message)); |
| } |
| } |
|
|
| context = context.append_message( |
| message.content.clone(), |
| message.thought_signature.clone(), |
| message.reasoning.clone(), |
| message.reasoning_details.clone(), |
| message.usage, |
| tool_call_records, |
| message.phase, |
| ); |
|
|
| if self.error_tracker.limit_reached() { |
| self.send(ChatResponse::Interrupt { |
| reason: InterruptionReason::MaxToolFailurePerTurnLimitReached { |
| limit: *self.error_tracker.limit() as u64, |
| errors: self.error_tracker.errors().clone(), |
| }, |
| }) |
| .await?; |
| |
| should_yield = true; |
| } |
|
|
| |
| context = SetModel::new(model_id.clone()).transform(context); |
| self.conversation.context = Some(context.clone()); |
| self.services.update(self.conversation.clone()).await?; |
| request_count += 1; |
|
|
| if !should_yield && let Some(max_request_allowed) = max_requests_per_turn { |
| |
| if request_count >= max_request_allowed { |
| |
| warn!( |
| agent_id = %self.agent.id, |
| model_id = %model_id, |
| request_count, |
| max_request_allowed, |
| "Agent has reached the maximum request per turn limit" |
| ); |
| |
| self.send(ChatResponse::Interrupt { |
| reason: InterruptionReason::MaxRequestPerTurnLimitReached { |
| limit: max_request_allowed as u64, |
| }, |
| }) |
| .await?; |
| |
| should_yield = true; |
| } |
| } |
|
|
| |
| tool_context.with_metrics(|metrics| { |
| self.conversation.metrics = metrics.clone(); |
| })?; |
|
|
| |
| |
| if should_yield { |
| let end_count_before = self.conversation.len(); |
| self.hook |
| .handle( |
| &LifecycleEvent::End(EventData::new( |
| self.agent.clone(), |
| model_id.clone(), |
| EndPayload, |
| )), |
| &mut self.conversation, |
| ) |
| .await?; |
| self.services.update(self.conversation.clone()).await?; |
| |
| if self.conversation.len() > end_count_before { |
| |
| if let Some(updated_context) = &self.conversation.context { |
| context = updated_context.clone(); |
| } |
| should_yield = false; |
| } |
| } |
| } |
|
|
| self.services.update(self.conversation.clone()).await?; |
|
|
| |
| if is_complete { |
| self.send(ChatResponse::TaskComplete).await?; |
| } |
|
|
| Ok(()) |
| } |
|
|
| fn get_model(&self) -> ModelId { |
| self.agent.model.clone() |
| } |
| } |
|
|