1use async_openai::types::chat::{
4 ChatCompletionMessageToolCall, ChatCompletionMessageToolCalls,
5 ChatCompletionRequestAssistantMessage, ChatCompletionRequestMessage,
6 ChatCompletionRequestSystemMessage, ChatCompletionRequestToolMessage,
7 ChatCompletionRequestUserMessage, ChatCompletionRequestUserMessageContent,
8 ChatCompletionRequestUserMessageContentPart,
9 ChatCompletionRequestMessageContentPartText,
10 ChatCompletionRequestMessageContentPartImage,
11 FunctionCall,
12};
13
14use async_openai::types::chat::ImageUrl;
16use futures::StreamExt;
17use robit_ai::config::ContextConfig;
18use robit_ai::LlmClient;
19use std::any::Any;
20use std::collections::HashMap;
21use std::path::PathBuf;
22use std::sync::Arc;
23use tokio::sync::mpsc;
24
25use crate::context::ContextManager;
26use crate::error::{AgentError, Result};
27use crate::event::{new_session_id, AgentEvent, FrontendMessage, MediaAttachment, SessionId};
28use crate::frontend::Frontend;
29use crate::media;
30use crate::prompt::PromptBuilder;
31use crate::skill::SkillRegistry;
32use crate::tool::{ToolCallInfo, ToolContext, ToolRegistry, ToolResult};
33
34pub struct AgentSession {
40 pub session_id: SessionId,
41 pub history: Vec<ChatCompletionRequestMessage>,
42 pub working_dir: PathBuf,
43}
44
45impl AgentSession {
46 fn new(session_id: SessionId, working_dir: PathBuf, system_prompt: String) -> Self {
47 let system_msg = ChatCompletionRequestMessage::System(
48 ChatCompletionRequestSystemMessage {
49 content: system_prompt.into(),
50 name: None,
51 }
52 .into(),
53 );
54
55 Self {
56 session_id,
57 history: vec![system_msg],
58 working_dir,
59 }
60 }
61
62 pub fn with_history(
64 session_id: SessionId,
65 working_dir: PathBuf,
66 system_prompt: String,
67 history: Vec<ChatCompletionRequestMessage>,
68 ) -> Self {
69 let system_msg = ChatCompletionRequestMessage::System(
71 ChatCompletionRequestSystemMessage {
72 content: system_prompt.into(),
73 name: None,
74 }
75 .into(),
76 );
77
78 let mut full_history = vec![system_msg];
80 full_history.extend(history);
81
82 Self {
83 session_id,
84 history: full_history,
85 working_dir,
86 }
87 }
88}
89
90pub struct Agent {
96 llm_client: Arc<LlmClient>,
97 tools: Arc<ToolRegistry>,
98 skills: Arc<SkillRegistry>,
99 sessions: HashMap<SessionId, AgentSession>,
100 default_session_id: SessionId,
101 context_manager: ContextManager,
102 frontend: Arc<dyn Frontend>,
103 auto_approve: bool,
104 extensions: HashMap<String, Arc<dyn Any + Send + Sync>>,
106}
107
108impl Agent {
109 pub fn new(
111 llm_client: Arc<LlmClient>,
112 tools: Arc<ToolRegistry>,
113 skills: Arc<SkillRegistry>,
114 frontend: Arc<dyn Frontend>,
115 context_config: Option<&ContextConfig>,
116 context_window: Option<u64>,
117 working_dir: PathBuf,
118 auto_approve: bool,
119 extensions: HashMap<String, Arc<dyn Any + Send + Sync>>,
120 ) -> Self {
121 let prompt_builder = PromptBuilder::with_working_dir(Some(&working_dir));
122 let context_manager = ContextManager::new(context_window, context_config);
123
124 let tool_refs: Vec<&dyn crate::tool::Tool> = tools.tools();
126 let skill_descs = skills.skill_descriptions();
127 let system_prompt = prompt_builder.build_system_prompt(&tool_refs, &skill_descs, &working_dir);
128
129 let session_id = new_session_id();
131 let session = AgentSession::new(session_id.clone(), working_dir, system_prompt);
132
133 let mut sessions = HashMap::new();
134 sessions.insert(session_id.clone(), session);
135
136 Self {
137 llm_client,
138 tools,
139 skills,
140 sessions,
141 default_session_id: session_id,
142 context_manager,
143 frontend,
144 auto_approve,
145 extensions,
146 }
147 }
148
149 pub fn with_history(
151 llm_client: Arc<LlmClient>,
152 tools: Arc<ToolRegistry>,
153 skills: Arc<SkillRegistry>,
154 frontend: Arc<dyn Frontend>,
155 context_config: Option<&ContextConfig>,
156 context_window: Option<u64>,
157 working_dir: PathBuf,
158 auto_approve: bool,
159 extensions: HashMap<String, Arc<dyn Any + Send + Sync>>,
160 session_id: SessionId,
161 history: Vec<ChatCompletionRequestMessage>,
162 ) -> Self {
163 tracing::info!(
164 "Agent::with_history: session_id={}, received {} history messages",
165 session_id,
166 history.len()
167 );
168 for (idx, msg) in history.iter().enumerate() {
169 let role = match msg {
170 ChatCompletionRequestMessage::System(_) => "system",
171 ChatCompletionRequestMessage::User(_) => "user",
172 ChatCompletionRequestMessage::Assistant(_) => "assistant",
173 ChatCompletionRequestMessage::Tool(_) => "tool",
174 ChatCompletionRequestMessage::Developer(_) => "developer",
175 ChatCompletionRequestMessage::Function(_) => "function",
176 };
177 tracing::debug!(" History message {}: role={}", idx, role);
178 }
179
180 let prompt_builder = PromptBuilder::with_working_dir(Some(&working_dir));
181 let context_manager = ContextManager::new(context_window, context_config);
182
183 let tool_refs: Vec<&dyn crate::tool::Tool> = tools.tools();
185 let skill_descs = skills.skill_descriptions();
186 let system_prompt = prompt_builder.build_system_prompt(&tool_refs, &skill_descs, &working_dir);
187
188 let mut session = AgentSession::with_history(
190 session_id.clone(),
191 working_dir,
192 system_prompt,
193 history,
194 );
195
196 tracing::info!(
197 "Agent::with_history: after adding system prompt, session history length = {}",
198 session.history.len()
199 );
200 for (idx, msg) in session.history.iter().enumerate() {
201 let role = match msg {
202 ChatCompletionRequestMessage::System(_) => "system",
203 ChatCompletionRequestMessage::User(_) => "user",
204 ChatCompletionRequestMessage::Assistant(_) => "assistant",
205 ChatCompletionRequestMessage::Tool(_) => "tool",
206 ChatCompletionRequestMessage::Developer(_) => "developer",
207 ChatCompletionRequestMessage::Function(_) => "function",
208 };
209 tracing::debug!(" Session history {}: role={}", idx, role);
210 }
211
212 let truncation_result = context_manager.maybe_truncate(&mut session.history);
214 if truncation_result.rounds_removed > 0 {
215 tracing::info!(
216 "Agent::with_history: truncated {} rounds ({} messages), needs_compression={}",
217 truncation_result.rounds_removed,
218 truncation_result.messages_removed,
219 truncation_result.needs_compression
220 );
221 }
222 tracing::debug!(
223 "Agent::with_history: after truncation, session history length = {}",
224 session.history.len()
225 );
226
227 let mut sessions = HashMap::new();
228 sessions.insert(session_id.clone(), session);
229
230 Self {
231 llm_client,
232 tools,
233 skills,
234 sessions,
235 default_session_id: session_id,
236 context_manager,
237 frontend,
238 auto_approve,
239 extensions,
240 }
241 }
242
243 pub async fn run(mut self, mut message_rx: mpsc::Receiver<FrontendMessage>) {
246 tracing::info!("Agent started, session: {}", self.default_session_id);
247
248 while let Some(msg) = message_rx.recv().await {
249 match msg {
250 FrontendMessage::UserInput { text, attachments } => {
251 if text == "/exit" || text == "/quit" {
252 break;
253 }
254 if text == "/clear" {
255 self.clear_session();
256 let _ = self
257 .frontend
258 .on_event(AgentEvent::TextDelta(
259 "\n[Conversation history cleared]\n".to_string(),
260 ))
261 .await;
262 let _ = self.frontend.on_event(AgentEvent::TurnComplete).await;
263 continue;
264 }
265
266 if let Some((skill, args)) = self.skills.match_trigger(&text) {
268 let skill = skill.clone();
269 self.run_skill_turn(&skill, &args).await;
270 continue;
271 }
272
273 self.run_turn(&text, attachments).await;
274 }
275 FrontendMessage::Cancel => {
276 tracing::info!("Cancel requested (MVP: no-op)");
277 }
278 FrontendMessage::ConfirmationResponse { .. } => {
279 tracing::warn!("Unexpected ConfirmationResponse outside tool confirmation");
282 }
283 }
284 }
285
286 tracing::info!("Agent stopped");
287 }
288
289 async fn run_turn(&mut self, user_input: &str, attachments: Vec<MediaAttachment>) {
291 let session_id = self.default_session_id.clone();
292 let max_tool_calls = self.context_manager.max_tool_calls_per_turn;
293
294 let user_message = self.build_user_message(user_input, &attachments).await;
296
297 if let Some(session) = self.sessions.get_mut(&session_id) {
299 session.history.push(user_message);
300 }
301
302 let max_iterations = 20;
304 let mut total_tool_calls = 0usize;
305 for iteration in 0..max_iterations {
306 match self.run_one_step(&session_id).await {
307 Ok(tool_call_count) => {
308 if tool_call_count == 0 {
309 let _ = self.frontend.on_event(AgentEvent::TurnComplete).await;
310 return;
311 }
312 total_tool_calls += tool_call_count;
313
314 if total_tool_calls >= max_tool_calls {
316 tracing::warn!(
317 "Tool call limit reached: {} >= {} (max_tool_calls_per_turn), forcing turn completion",
318 total_tool_calls,
319 max_tool_calls
320 );
321 let _ = self
322 .frontend
323 .on_event(AgentEvent::TextDelta(
324 format!(
325 "\n\n[Tool call limit reached ({} calls). Please summarize progress and continue in the next message.]\n",
326 total_tool_calls
327 ),
328 ))
329 .await;
330 let _ = self.frontend.on_event(AgentEvent::TurnComplete).await;
331 return;
332 }
333
334 tracing::debug!(
335 "Iteration {}: {} tool calls executed (total: {}/{}), continuing loop",
336 iteration,
337 tool_call_count,
338 total_tool_calls,
339 max_tool_calls
340 );
341 }
342 Err(e) => {
343 let _ = self.frontend.on_event(AgentEvent::Error(e)).await;
344 let _ = self.frontend.on_event(AgentEvent::TurnComplete).await;
345 return;
346 }
347 }
348 }
349
350 let _ = self
352 .frontend
353 .on_event(AgentEvent::Error(AgentError::InternalError(
354 format!("Max iterations reached ({})", max_iterations),
355 )))
356 .await;
357 let _ = self.frontend.on_event(AgentEvent::TurnComplete).await;
358 }
359
360 async fn run_one_step(&mut self, session_id: &SessionId) -> Result<usize> {
363 let session = self
364 .sessions
365 .get_mut(session_id)
366 .ok_or_else(|| AgentError::InternalError("Session not found".to_string()))?;
367
368 let truncation_result = self.context_manager.maybe_truncate(&mut session.history);
370
371 if truncation_result.needs_compression {
373 let summary = generate_summary(&self.llm_client, &truncation_result.removed_messages).await;
374
375 if let Some(msg) = session.history.get_mut(truncation_result.insert_position) {
377 let notice = format!("[Earlier conversation summary: {}]", summary);
378 *msg = ChatCompletionRequestMessage::User(
379 ChatCompletionRequestUserMessage {
380 content: notice.into(),
381 name: Some("system_notice".to_string()),
382 }
383 );
384 }
385
386 tracing::info!(
387 "Compression completed: removed {} tokens, summary inserted",
388 crate::context::estimate_messages_tokens(&truncation_result.removed_messages),
389 );
390 }
391
392 let tool_schemas = self.tools.tool_schemas();
394 let tools_param = if tool_schemas.is_empty() {
395 None
396 } else {
397 Some(tool_schemas)
398 };
399
400 let estimated_prompt = crate::context::estimate_messages_tokens_with_margin(
402 &session.history,
403 self.context_manager.token_safety_margin,
404 );
405 tracing::info!(
406 "LLM call: ~{} prompt tokens (with {:.1}x margin), {} messages, threshold={} tokens",
407 estimated_prompt,
408 self.context_manager.token_safety_margin,
409 session.history.len(),
410 self.context_manager.truncation_threshold(),
411 );
412
413 let mut stream = self
415 .llm_client
416 .chat_stream(session.history.clone(), tools_param)
417 .await?;
418
419 let mut full_text = String::new();
421 let mut tool_call_chunks: HashMap<usize, ToolCallAccumulator> = HashMap::new();
422 let mut api_usage: Option<async_openai::types::chat::CompletionUsage> = None;
423
424 while let Some(chunk_result) = stream.next().await {
425 let chunk = chunk_result.map_err(|e| AgentError::LlmError(e.into()))?;
426
427 if let Some(ref usage) = chunk.usage {
429 api_usage = Some(usage.clone());
430 }
431
432 if let Some(choice) = chunk.choices.first() {
433 if let Some(content) = &choice.delta.content {
435 full_text.push_str(content);
436 let _ = self
437 .frontend
438 .on_event(AgentEvent::TextDelta(content.clone()))
439 .await;
440 }
441
442 if let Some(tool_calls) = &choice.delta.tool_calls {
444 for tc in tool_calls {
445 tracing::debug!(
446 "Received tool call chunk: index={}, id={:?}, function={:?}",
447 tc.index,
448 tc.id,
449 tc.function
450 );
451
452 let acc = tool_call_chunks
453 .entry(tc.index as usize)
454 .or_insert_with(ToolCallAccumulator::new);
455
456 if let Some(id) = &tc.id {
457 if !id.is_empty() {
459 tracing::debug!("Updating tool id: '{}'", id);
460 acc.id = Some(id.clone());
461 }
462 }
463 if let Some(function) = &tc.function {
464 if let Some(name) = &function.name {
465 if !name.is_empty() {
467 tracing::debug!("Tool name chunk: '{}'", name);
468 acc.name = Some(name.clone());
469 }
470 }
471 if let Some(args) = &function.arguments {
472 tracing::debug!("Tool args chunk: '{}'", args);
473 acc.arguments.push_str(args);
474 }
475 }
476
477 tracing::debug!("Accumulator state after chunk: {:?}", acc);
478 }
479 }
480 }
481 }
482
483 let assembled_tool_calls: Vec<ChatCompletionMessageToolCall> = {
485 let mut indices: Vec<usize> = tool_call_chunks.keys().cloned().collect();
486 indices.sort();
487 indices
488 .into_iter()
489 .filter_map(|idx| tool_call_chunks.remove(&idx)?.into_tool_call())
490 .collect()
491 };
492
493 let estimated_response = crate::context::estimate_tokens(&full_text);
495 if let Some(ref usage) = api_usage {
496 tracing::info!(
497 "LLM response: API usage = {} prompt + {} completion = {} total tokens. Estimated: ~{} prompt + ~{} response = ~{} total",
498 usage.prompt_tokens,
499 usage.completion_tokens,
500 usage.total_tokens,
501 estimated_prompt,
502 estimated_response,
503 estimated_prompt + estimated_response,
504 );
505 } else {
506 tracing::info!(
507 "LLM response: {} chars, ~{} estimated tokens ({} tool calls). API usage not available from streaming.",
508 full_text.len(),
509 estimated_response,
510 assembled_tool_calls.len(),
511 );
512 }
513
514 tracing::debug!("Assembled {} tool call(s)", assembled_tool_calls.len());
515 for (i, tc) in assembled_tool_calls.iter().enumerate() {
516 tracing::debug!(
517 "Tool call [{}]: id='{}', name='{}', arguments='{}'",
518 i,
519 tc.id,
520 tc.function.name,
521 tc.function.arguments
522 );
523 }
524
525 let assistant_msg = ChatCompletionRequestMessage::Assistant(
527 ChatCompletionRequestAssistantMessage {
528 content: if full_text.is_empty() {
529 None
530 } else {
531 Some(full_text.clone().into())
532 },
533 name: None,
534 tool_calls: if assembled_tool_calls.is_empty() {
535 None
536 } else {
537 Some(
538 assembled_tool_calls
539 .clone()
540 .into_iter()
541 .map(ChatCompletionMessageToolCalls::Function)
542 .collect(),
543 )
544 },
545 refusal: None,
546 audio: None,
547 #[allow(deprecated)]
548 function_call: None,
549 }
550 .into(),
551 );
552
553 let session = self
554 .sessions
555 .get_mut(session_id)
556 .ok_or_else(|| AgentError::InternalError("Session not found".to_string()))?;
557 session.history.push(assistant_msg);
558 let working_dir = session.working_dir.clone();
559
560 if assembled_tool_calls.is_empty() {
562 return Ok(0);
563 }
564
565 for tc in &assembled_tool_calls {
567 tracing::info!(
568 "About to execute tool: id='{}', name='{}'",
569 tc.id,
570 tc.function.name
571 );
572
573 let tc_info = ToolCallInfo {
574 id: tc.id.clone(),
575 name: tc.function.name.clone(),
576 arguments: tc.function.arguments.clone(),
577 };
578
579 let _ = self
581 .frontend
582 .on_event(AgentEvent::ToolCallRequested {
583 tool_call_id: tc_info.id.clone(),
584 name: tc_info.name.clone(),
585 arguments: tc_info.arguments.clone(),
586 })
587 .await;
588
589 let approved = if self.tools.requires_confirmation(&tc.function.name) && !self.auto_approve {
591 self.frontend.request_tool_confirmation(&tc_info).await?
592 } else {
593 true
594 };
595
596 let result = if approved {
598 let args: serde_json::Value = serde_json::from_str(&tc.function.arguments)
599 .unwrap_or(serde_json::Value::Null);
600
601 let ctx = ToolContext {
602 working_dir: working_dir.clone(),
603 session_id: session_id.clone(),
604 frontend: self.frontend.clone(),
605 extensions: self.extensions.clone(),
606 };
607
608 self.tools.execute(&tc.function.name, args, &ctx).await
609 } else {
610 ToolResult::error("User rejected this tool call")
611 };
612
613 let truncated_result = ToolResult {
615 content: self.context_manager.truncate_tool_output(&result.content),
616 is_error: result.is_error,
617 };
618
619 let _ = self
621 .frontend
622 .on_event(AgentEvent::ToolCallResult {
623 tool_call_id: tc.id.clone(),
624 result: truncated_result.clone(),
625 })
626 .await;
627
628 let tool_msg = ChatCompletionRequestMessage::Tool(
630 ChatCompletionRequestToolMessage {
631 content: truncated_result.content.into(),
632 tool_call_id: tc.id.clone(),
633 }
634 .into(),
635 );
636
637 let session = self
638 .sessions
639 .get_mut(session_id)
640 .ok_or_else(|| AgentError::InternalError("Session not found".to_string()))?;
641 session.history.push(tool_msg);
642 }
643
644 Ok(assembled_tool_calls.len())
645 }
646
647 fn clear_session(&mut self) {
649 if let Some(session) = self.sessions.get_mut(&self.default_session_id) {
650 session.history.truncate(1);
651 }
652 }
653
654 async fn build_user_message(
656 &self,
657 text: &str,
658 attachments: &[MediaAttachment],
659 ) -> ChatCompletionRequestMessage {
660 if self.llm_client.supports_images()
662 && !attachments.is_empty()
663 && attachments.iter().any(|a| a.is_image())
664 {
665 self.build_multimodal_message(text, attachments)
666 .await
667 } else {
668 let mut full_text = text.to_string();
670 for attachment in attachments {
671 full_text = format!("{}\n{}", full_text, attachment.describe());
672 }
673 ChatCompletionRequestMessage::User(ChatCompletionRequestUserMessage {
674 content: full_text.into(),
675 name: None,
676 })
677 }
678 }
679
680 async fn build_multimodal_message(
682 &self,
683 text: &str,
684 attachments: &[MediaAttachment],
685 ) -> ChatCompletionRequestMessage {
686 let mut parts = vec![ChatCompletionRequestUserMessageContentPart::Text(
687 ChatCompletionRequestMessageContentPartText {
688 text: text.to_string(),
689 },
690 )];
691
692 for attachment in attachments {
694 if attachment.is_image() {
695 match media::download_and_encode_base64(
697 &attachment.url,
698 &attachment.content_type,
699 )
700 .await
701 {
702 Ok(base64_url) => {
703 parts.push(ChatCompletionRequestUserMessageContentPart::ImageUrl(
704 ChatCompletionRequestMessageContentPartImage {
705 image_url: ImageUrl {
706 url: base64_url,
707 detail: None,
708 },
709 },
710 ));
711 }
712 Err(e) => {
713 tracing::warn!("Failed to encode image: {}", e);
714 let desc = attachment.describe();
716 let current_text = match &mut parts[0] {
717 ChatCompletionRequestUserMessageContentPart::Text(t) => &mut t.text,
718 _ => unreachable!(),
719 };
720 *current_text = format!("{}\n{}", current_text, desc);
721 }
722 }
723 } else {
724 let desc = attachment.describe();
726 let current_text = match &mut parts[0] {
727 ChatCompletionRequestUserMessageContentPart::Text(t) => &mut t.text,
728 _ => unreachable!(),
729 };
730 *current_text = format!("{}\n{}", current_text, desc);
731 }
732 }
733
734 ChatCompletionRequestMessage::User(ChatCompletionRequestUserMessage {
735 content: ChatCompletionRequestUserMessageContent::Array(parts),
736 name: None,
737 })
738 }
739
740 async fn run_skill_turn(&mut self, skill: &crate::skill::Skill, args: &str) {
745 let _ = self
747 .frontend
748 .on_event(AgentEvent::SkillTriggered {
749 name: skill.frontmatter.name.clone(),
750 description: skill.frontmatter.description.clone(),
751 })
752 .await;
753
754 let session_id = self.default_session_id.clone();
755
756 let skill_message = format!(
758 "## Skill: {}\n\n{}\n\n{}",
759 skill.frontmatter.name,
760 skill.frontmatter.description,
761 skill.content
762 );
763
764 let skill_msg = ChatCompletionRequestMessage::System(
765 ChatCompletionRequestSystemMessage {
766 content: skill_message.into(),
767 name: Some(skill.frontmatter.name.clone()),
768 }
769 .into(),
770 );
771
772 if let Some(session) = self.sessions.get_mut(&session_id) {
773 session.history.push(skill_msg);
774 }
775
776 let user_content = if args.is_empty() {
778 "(User triggered skill, no additional arguments)".to_string()
779 } else {
780 args.to_string()
781 };
782
783 if let Some(session) = self.sessions.get_mut(&session_id) {
784 session.history.push(ChatCompletionRequestMessage::User(
785 ChatCompletionRequestUserMessage {
786 content: user_content.into(),
787 name: None,
788 }
789 .into(),
790 ));
791 }
792
793 let max_iterations = 20;
795 let mut completed = false;
796 for iteration in 0..max_iterations {
797 match self.run_one_step(&session_id).await {
798 Ok(tool_call_count) => {
799 if tool_call_count == 0 {
800 completed = true;
801 break;
802 }
803 tracing::debug!(
804 "Skill iteration {}: tool calls executed",
805 iteration
806 );
807 }
808 Err(e) => {
809 let _ = self.frontend.on_event(AgentEvent::Error(e)).await;
810 break;
811 }
812 }
813 }
814
815 if !completed {
816 let _ = self
817 .frontend
818 .on_event(AgentEvent::Error(AgentError::InternalError(
819 format!("Max iterations reached ({})", max_iterations),
820 )))
821 .await;
822 }
823
824 let _ = self.frontend.on_event(AgentEvent::TurnComplete).await;
825
826 if let Some(session) = self.sessions.get_mut(&session_id) {
828 let skill_name = skill.frontmatter.name.clone();
829 session.history.retain(|msg| {
830 !matches!(
831 msg,
832 ChatCompletionRequestMessage::System(s)
833 if s.name.as_deref() == Some(&skill_name)
834 )
835 });
836 }
837 }
838
839 }
840
841async fn generate_summary(
849 llm_client: &LlmClient,
850 removed_messages: &[ChatCompletionRequestMessage],
851) -> String {
852 let transcript = crate::context::format_removed_messages_as_transcript(removed_messages);
853
854 let system_prompt = "Summarize the following conversation transcript in 1-2 concise sentences. Focus on: what the user asked for, what actions were taken, and the outcomes. Be brief and factual.";
855
856 let messages = vec![
857 ChatCompletionRequestMessage::System(
858 ChatCompletionRequestSystemMessage {
859 content: system_prompt.into(),
860 name: None,
861 }
862 ),
863 ChatCompletionRequestMessage::User(
864 ChatCompletionRequestUserMessage {
865 content: format!("Conversation transcript:\n\n{}", transcript).into(),
866 name: None,
867 }
868 ),
869 ];
870
871 match llm_client.chat(messages, None).await {
872 Ok(response) => {
873 if let Some(choice) = response.choices.first() {
874 if let Some(content) = &choice.message.content {
875 let summary = content.trim().to_string();
876 if !summary.is_empty() {
877 tracing::info!("Generated summary: {}", summary);
878 return summary;
879 }
880 }
881 }
882 tracing::warn!("Summary generation returned empty response, using fallback");
883 "Conversation history compressed.".to_string()
884 }
885 Err(e) => {
886 tracing::warn!("Summary generation failed: {}, using fallback", e);
887 "Conversation history compressed.".to_string()
888 }
889 }
890}
891
892#[derive(Debug)]
898struct ToolCallAccumulator {
899 id: Option<String>,
900 name: Option<String>,
901 arguments: String,
902}
903
904impl ToolCallAccumulator {
905 fn new() -> Self {
906 Self {
907 id: None,
908 name: None,
909 arguments: String::new(),
910 }
911 }
912
913 fn into_tool_call(self) -> Option<ChatCompletionMessageToolCall> {
915 tracing::debug!("Converting accumulator to tool call: {:?}", self);
916
917 let id = self.id?;
918 let name = self.name?;
919
920 tracing::debug!("Tool call assembled: id='{}', name='{}', args='{}'", id, name, self.arguments);
921
922 Some(ChatCompletionMessageToolCall {
923 id,
924 function: FunctionCall {
925 name,
926 arguments: self.arguments,
927 },
928 })
929 }
930}