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 pending_truncation: Option<(SessionId, crate::context::TruncationResult)>,
108}
109
110impl Agent {
111 pub fn new(
113 llm_client: Arc<LlmClient>,
114 tools: Arc<ToolRegistry>,
115 skills: Arc<SkillRegistry>,
116 frontend: Arc<dyn Frontend>,
117 context_config: Option<&ContextConfig>,
118 context_window: Option<u64>,
119 working_dir: PathBuf,
120 auto_approve: bool,
121 extensions: HashMap<String, Arc<dyn Any + Send + Sync>>,
122 ) -> Self {
123 let prompt_builder = PromptBuilder::with_working_dir(Some(&working_dir));
124 let context_manager = ContextManager::new(context_window, context_config);
125
126 let tool_refs: Vec<&dyn crate::tool::Tool> = tools.tools();
128 let skill_descs = skills.skill_descriptions();
129 let system_prompt = prompt_builder.build_system_prompt(&tool_refs, &skill_descs, &working_dir);
130
131 let session_id = new_session_id();
133 let session = AgentSession::new(session_id.clone(), working_dir, system_prompt);
134
135 let mut sessions = HashMap::new();
136 sessions.insert(session_id.clone(), session);
137
138 Self {
139 llm_client,
140 tools,
141 skills,
142 sessions,
143 default_session_id: session_id,
144 context_manager,
145 frontend,
146 auto_approve,
147 extensions,
148 pending_truncation: None,
149 }
150 }
151
152 pub fn with_history(
154 llm_client: Arc<LlmClient>,
155 tools: Arc<ToolRegistry>,
156 skills: Arc<SkillRegistry>,
157 frontend: Arc<dyn Frontend>,
158 context_config: Option<&ContextConfig>,
159 context_window: Option<u64>,
160 working_dir: PathBuf,
161 auto_approve: bool,
162 extensions: HashMap<String, Arc<dyn Any + Send + Sync>>,
163 session_id: SessionId,
164 history: Vec<ChatCompletionRequestMessage>,
165 ) -> Self {
166 tracing::info!(
167 "Agent::with_history: session_id={}, received {} history messages",
168 session_id,
169 history.len()
170 );
171 for (idx, msg) in history.iter().enumerate() {
172 let role = match msg {
173 ChatCompletionRequestMessage::System(_) => "system",
174 ChatCompletionRequestMessage::User(_) => "user",
175 ChatCompletionRequestMessage::Assistant(_) => "assistant",
176 ChatCompletionRequestMessage::Tool(_) => "tool",
177 ChatCompletionRequestMessage::Developer(_) => "developer",
178 ChatCompletionRequestMessage::Function(_) => "function",
179 };
180 tracing::debug!(" History message {}: role={}", idx, role);
181 }
182
183 let prompt_builder = PromptBuilder::with_working_dir(Some(&working_dir));
184 let context_manager = ContextManager::new(context_window, context_config);
185
186 let tool_refs: Vec<&dyn crate::tool::Tool> = tools.tools();
188 let skill_descs = skills.skill_descriptions();
189 let system_prompt = prompt_builder.build_system_prompt(&tool_refs, &skill_descs, &working_dir);
190
191 let mut session = AgentSession::with_history(
193 session_id.clone(),
194 working_dir,
195 system_prompt,
196 history,
197 );
198
199 tracing::info!(
200 "Agent::with_history: after adding system prompt, session history length = {}",
201 session.history.len()
202 );
203 for (idx, msg) in session.history.iter().enumerate() {
204 let role = match msg {
205 ChatCompletionRequestMessage::System(_) => "system",
206 ChatCompletionRequestMessage::User(_) => "user",
207 ChatCompletionRequestMessage::Assistant(_) => "assistant",
208 ChatCompletionRequestMessage::Tool(_) => "tool",
209 ChatCompletionRequestMessage::Developer(_) => "developer",
210 ChatCompletionRequestMessage::Function(_) => "function",
211 };
212 tracing::debug!(" Session history {}: role={}", idx, role);
213 }
214
215 let truncation_result = context_manager.maybe_truncate(&mut session.history);
217 if truncation_result.rounds_removed > 0 {
218 tracing::info!(
219 "Agent::with_history: truncated {} rounds ({} messages), needs_compression={}",
220 truncation_result.rounds_removed,
221 truncation_result.messages_removed,
222 truncation_result.needs_compression
223 );
224 }
225 tracing::debug!(
226 "Agent::with_history: after truncation, session history length = {}",
227 session.history.len()
228 );
229
230 let pending_truncation = if truncation_result.needs_compression {
231 Some((session_id.clone(), truncation_result))
232 } else {
233 None
234 };
235
236 let mut sessions = HashMap::new();
237 sessions.insert(session_id.clone(), session);
238
239 Self {
240 llm_client,
241 tools,
242 skills,
243 sessions,
244 default_session_id: session_id,
245 context_manager,
246 frontend,
247 auto_approve,
248 extensions,
249 pending_truncation,
250 }
251 }
252
253 pub async fn run(mut self, mut message_rx: mpsc::Receiver<FrontendMessage>) {
256 tracing::info!("Agent started, session: {}", self.default_session_id);
257
258 if let Some((session_id, truncation_result)) = self.pending_truncation.take() {
260 tracing::info!("=== Starting pending compression processing ===");
261 tracing::info!("Session ID: {}", session_id);
262 tracing::info!("Removed rounds: {}", truncation_result.rounds_removed);
263 tracing::info!("Removed messages: {}", truncation_result.messages_removed);
264 tracing::info!("Insert position: {}", truncation_result.insert_position);
265
266 let removed_tokens = crate::context::estimate_messages_tokens(&truncation_result.removed_messages);
267 tracing::info!("Estimated removed tokens: {}", removed_tokens);
268
269 tracing::info!("Generating summary for {} removed messages...", truncation_result.removed_messages.len());
270 let summary = generate_summary(&self.llm_client, &truncation_result.removed_messages).await;
271 tracing::info!("Generated summary: \"{}\"", summary);
272
273 let mut replaced = false;
275 if let Some(session) = self.sessions.get_mut(&session_id) {
276 tracing::info!("Session found, current history length: {}", session.history.len());
277 if let Some(msg) = session.history.get_mut(truncation_result.insert_position) {
278 let notice = format!("[Earlier conversation summary: {}]", summary);
279 tracing::info!("Replacing message at position {} with summary...", truncation_result.insert_position);
280 *msg = ChatCompletionRequestMessage::User(
281 ChatCompletionRequestUserMessage {
282 content: notice.into(),
283 name: Some("system_notice".to_string()),
284 }
285 );
286 replaced = true;
287 tracing::info!("Compression complete! Summary successfully inserted at position {}", truncation_result.insert_position);
288 } else {
289 tracing::warn!("Insert position {} not found in session history (length: {})",
290 truncation_result.insert_position, session.history.len());
291 }
292 } else {
293 tracing::warn!("Session {} not found, cannot insert summary", session_id);
294 }
295
296 if !replaced {
297 tracing::warn!("Summary not inserted, using fallback behavior");
298 }
299 tracing::info!("=== Compression processing finished ===");
300 } else {
301 tracing::info!("No pending compression needed");
302 }
303
304 while let Some(msg) = message_rx.recv().await {
305 match msg {
306 FrontendMessage::UserInput { text, attachments } => {
307 if text == "/exit" || text == "/quit" {
308 break;
309 }
310 if text == "/clear" {
311 self.clear_session();
312 let _ = self
313 .frontend
314 .on_event(AgentEvent::TextDelta(
315 "\n[Conversation history cleared]\n".to_string(),
316 ))
317 .await;
318 let _ = self.frontend.on_event(AgentEvent::TurnComplete).await;
319 continue;
320 }
321
322 if let Some((skill, args)) = self.skills.match_trigger(&text) {
324 let skill = skill.clone();
325 self.run_skill_turn(&skill, &args).await;
326 continue;
327 }
328
329 self.run_turn(&text, attachments).await;
330 }
331 FrontendMessage::Cancel => {
332 tracing::info!("Cancel requested (MVP: no-op)");
333 }
334 FrontendMessage::ConfirmationResponse { .. } => {
335 tracing::warn!("Unexpected ConfirmationResponse outside tool confirmation");
338 }
339 }
340 }
341
342 tracing::info!("Agent stopped");
343 }
344
345 async fn run_turn(&mut self, user_input: &str, attachments: Vec<MediaAttachment>) {
347 let session_id = self.default_session_id.clone();
348 let max_tool_calls = self.context_manager.max_tool_calls_per_turn;
349
350 let user_message = self.build_user_message(user_input, &attachments).await;
352
353 if let Some(session) = self.sessions.get_mut(&session_id) {
355 session.history.push(user_message);
356 }
357
358 let max_iterations = 20;
360 let mut total_tool_calls = 0usize;
361 for iteration in 0..max_iterations {
362 match self.run_one_step(&session_id).await {
363 Ok(tool_call_count) => {
364 if tool_call_count == 0 {
365 let _ = self.frontend.on_event(AgentEvent::TurnComplete).await;
366 return;
367 }
368 total_tool_calls += tool_call_count;
369
370 if total_tool_calls >= max_tool_calls {
372 tracing::warn!(
373 "Tool call limit reached: {} >= {} (max_tool_calls_per_turn), forcing turn completion",
374 total_tool_calls,
375 max_tool_calls
376 );
377 let _ = self
378 .frontend
379 .on_event(AgentEvent::TextDelta(
380 format!(
381 "\n\n[Tool call limit reached ({} calls). Please summarize progress and continue in the next message.]\n",
382 total_tool_calls
383 ),
384 ))
385 .await;
386 let _ = self.frontend.on_event(AgentEvent::TurnComplete).await;
387 return;
388 }
389
390 tracing::debug!(
391 "Iteration {}: {} tool calls executed (total: {}/{}), continuing loop",
392 iteration,
393 tool_call_count,
394 total_tool_calls,
395 max_tool_calls
396 );
397 }
398 Err(e) => {
399 let _ = self.frontend.on_event(AgentEvent::Error(e)).await;
400 let _ = self.frontend.on_event(AgentEvent::TurnComplete).await;
401 return;
402 }
403 }
404 }
405
406 let _ = self
408 .frontend
409 .on_event(AgentEvent::Error(AgentError::InternalError(
410 format!("Max iterations reached ({})", max_iterations),
411 )))
412 .await;
413 let _ = self.frontend.on_event(AgentEvent::TurnComplete).await;
414 }
415
416 async fn run_one_step(&mut self, session_id: &SessionId) -> Result<usize> {
419 let working_dir = {
421 let session = self
422 .sessions
423 .get(session_id)
424 .ok_or_else(|| AgentError::InternalError("Session not found".to_string()))?;
425 session.working_dir.clone()
426 };
427
428 let session = self
429 .sessions
430 .get_mut(session_id)
431 .ok_or_else(|| AgentError::InternalError("Session not found".to_string()))?;
432
433 let truncation_result = self.context_manager.maybe_truncate(&mut session.history);
435
436 if truncation_result.needs_compression {
438 let summary = generate_summary(&self.llm_client, &truncation_result.removed_messages).await;
439
440 if let Some(msg) = session.history.get_mut(truncation_result.insert_position) {
442 let notice = format!("[Earlier conversation summary: {}]", summary);
443 *msg = ChatCompletionRequestMessage::User(
444 ChatCompletionRequestUserMessage {
445 content: notice.into(),
446 name: Some("system_notice".to_string()),
447 }
448 );
449 }
450
451 tracing::info!(
452 "Compression completed: removed {} tokens, summary inserted",
453 crate::context::estimate_messages_tokens(&truncation_result.removed_messages),
454 );
455 }
456
457 let tool_schemas = self.tools.tool_schemas();
459 let tools_param = if tool_schemas.is_empty() {
460 None
461 } else {
462 Some(tool_schemas)
463 };
464
465 let estimated_prompt = crate::context::estimate_messages_tokens_with_margin(
467 &session.history,
468 self.context_manager.token_safety_margin,
469 );
470 tracing::info!(
471 "LLM call: ~{} prompt tokens (with {:.1}x margin), {} messages, threshold={} tokens",
472 estimated_prompt,
473 self.context_manager.token_safety_margin,
474 session.history.len(),
475 self.context_manager.truncation_threshold(),
476 );
477
478 let mut stream = self
480 .llm_client
481 .chat_stream(session.history.clone(), tools_param)
482 .await?;
483
484 let mut full_text = String::new();
486 let mut tool_call_chunks: HashMap<usize, ToolCallAccumulator> = HashMap::new();
487 let mut api_usage: Option<async_openai::types::chat::CompletionUsage> = None;
488
489 while let Some(chunk_result) = stream.next().await {
490 let chunk = chunk_result.map_err(|e| AgentError::LlmError(e.into()))?;
491
492 if let Some(ref usage) = chunk.usage {
494 api_usage = Some(usage.clone());
495 }
496
497 if let Some(choice) = chunk.choices.first() {
498 if let Some(content) = &choice.delta.content {
500 full_text.push_str(content);
501 let _ = self
502 .frontend
503 .on_event(AgentEvent::TextDelta(content.clone()))
504 .await;
505 }
506
507 if let Some(tool_calls) = &choice.delta.tool_calls {
509 for tc in tool_calls {
510 tracing::debug!(
511 "Received tool call chunk: index={}, id={:?}, function={:?}",
512 tc.index,
513 tc.id,
514 tc.function
515 );
516
517 let acc = tool_call_chunks
518 .entry(tc.index as usize)
519 .or_insert_with(ToolCallAccumulator::new);
520
521 if let Some(id) = &tc.id {
522 if !id.is_empty() {
524 tracing::debug!("Updating tool id: '{}'", id);
525 acc.id = Some(id.clone());
526 }
527 }
528 if let Some(function) = &tc.function {
529 if let Some(name) = &function.name {
530 if !name.is_empty() {
532 tracing::debug!("Tool name chunk: '{}'", name);
533 acc.name = Some(name.clone());
534 }
535 }
536 if let Some(args) = &function.arguments {
537 tracing::debug!("Tool args chunk: '{}'", args);
538 acc.arguments.push_str(args);
539 }
540 }
541
542 tracing::debug!("Accumulator state after chunk: {:?}", acc);
543 }
544 }
545 }
546 }
547
548 let assembled_tool_calls: Vec<ChatCompletionMessageToolCall> = {
550 let mut indices: Vec<usize> = tool_call_chunks.keys().cloned().collect();
551 indices.sort();
552 indices
553 .into_iter()
554 .filter_map(|idx| tool_call_chunks.remove(&idx)?.into_tool_call())
555 .collect()
556 };
557
558 let estimated_response = crate::context::estimate_tokens(&full_text);
560 if let Some(ref usage) = api_usage {
561 tracing::info!(
562 "LLM response: API usage = {} prompt + {} completion = {} total tokens. Estimated: ~{} prompt + ~{} response = ~{} total",
563 usage.prompt_tokens,
564 usage.completion_tokens,
565 usage.total_tokens,
566 estimated_prompt,
567 estimated_response,
568 estimated_prompt + estimated_response,
569 );
570 } else {
571 tracing::info!(
572 "LLM response: {} chars, ~{} estimated tokens ({} tool calls). API usage not available from streaming.",
573 full_text.len(),
574 estimated_response,
575 assembled_tool_calls.len(),
576 );
577 }
578
579 tracing::debug!("Assembled {} tool call(s)", assembled_tool_calls.len());
580 for (i, tc) in assembled_tool_calls.iter().enumerate() {
581 tracing::debug!(
582 "Tool call [{}]: id='{}', name='{}', arguments='{}'",
583 i,
584 tc.id,
585 tc.function.name,
586 tc.function.arguments
587 );
588 }
589
590 let content = if full_text.is_empty() {
592 None
593 } else {
594 Some(full_text.clone().into())
595 };
596 let tool_calls = if assembled_tool_calls.is_empty() {
597 None
598 } else {
599 Some(
600 assembled_tool_calls
601 .clone()
602 .into_iter()
603 .map(ChatCompletionMessageToolCalls::Function)
604 .collect(),
605 )
606 };
607
608 if content.is_some() || tool_calls.is_some() {
610 let assistant_msg = ChatCompletionRequestMessage::Assistant(
611 ChatCompletionRequestAssistantMessage {
612 content,
613 name: None,
614 tool_calls,
615 refusal: None,
616 audio: None,
617 #[allow(deprecated)]
618 function_call: None,
619 }
620 .into(),
621 );
622
623 session.history.push(assistant_msg);
624 } else {
625 tracing::warn!("Not adding empty assistant message to history (no content and no tool calls)");
626 }
627
628 if assembled_tool_calls.is_empty() {
630 return Ok(0);
631 }
632
633 for tc in &assembled_tool_calls {
635 tracing::info!(
636 "About to execute tool: id='{}', name='{}'",
637 tc.id,
638 tc.function.name
639 );
640
641 let tc_info = ToolCallInfo {
642 id: tc.id.clone(),
643 name: tc.function.name.clone(),
644 arguments: tc.function.arguments.clone(),
645 };
646
647 let _ = self
649 .frontend
650 .on_event(AgentEvent::ToolCallRequested {
651 tool_call_id: tc_info.id.clone(),
652 name: tc_info.name.clone(),
653 arguments: tc_info.arguments.clone(),
654 })
655 .await;
656
657 let approved = if self.tools.requires_confirmation(&tc.function.name) && !self.auto_approve {
659 self.frontend.request_tool_confirmation(&tc_info).await?
660 } else {
661 true
662 };
663
664 let result = if approved {
666 let args: serde_json::Value = serde_json::from_str(&tc.function.arguments)
667 .unwrap_or(serde_json::Value::Null);
668
669 let ctx = ToolContext {
670 working_dir: working_dir.clone(),
671 session_id: session_id.clone(),
672 frontend: self.frontend.clone(),
673 extensions: self.extensions.clone(),
674 };
675
676 self.tools.execute(&tc.function.name, args, &ctx).await
677 } else {
678 ToolResult::error("User rejected this tool call")
679 };
680
681 let truncated_result = ToolResult {
683 content: self.context_manager.truncate_tool_output(&result.content),
684 is_error: result.is_error,
685 };
686
687 let _ = self
689 .frontend
690 .on_event(AgentEvent::ToolCallResult {
691 tool_call_id: tc.id.clone(),
692 result: truncated_result.clone(),
693 })
694 .await;
695
696 let tool_msg = ChatCompletionRequestMessage::Tool(
698 ChatCompletionRequestToolMessage {
699 content: truncated_result.content.into(),
700 tool_call_id: tc.id.clone(),
701 }
702 .into(),
703 );
704
705 let session = self
706 .sessions
707 .get_mut(session_id)
708 .ok_or_else(|| AgentError::InternalError("Session not found".to_string()))?;
709 session.history.push(tool_msg);
710 }
711
712 Ok(assembled_tool_calls.len())
713 }
714
715 fn clear_session(&mut self) {
717 if let Some(session) = self.sessions.get_mut(&self.default_session_id) {
718 session.history.truncate(1);
719 }
720 }
721
722 async fn build_user_message(
724 &self,
725 text: &str,
726 attachments: &[MediaAttachment],
727 ) -> ChatCompletionRequestMessage {
728 if self.llm_client.supports_images()
730 && !attachments.is_empty()
731 && attachments.iter().any(|a| a.is_image())
732 {
733 self.build_multimodal_message(text, attachments)
734 .await
735 } else {
736 let mut full_text = text.to_string();
738 for attachment in attachments {
739 full_text = format!("{}\n{}", full_text, attachment.describe());
740 }
741 ChatCompletionRequestMessage::User(ChatCompletionRequestUserMessage {
742 content: full_text.into(),
743 name: None,
744 })
745 }
746 }
747
748 async fn build_multimodal_message(
750 &self,
751 text: &str,
752 attachments: &[MediaAttachment],
753 ) -> ChatCompletionRequestMessage {
754 let mut parts = vec![ChatCompletionRequestUserMessageContentPart::Text(
755 ChatCompletionRequestMessageContentPartText {
756 text: text.to_string(),
757 },
758 )];
759
760 for attachment in attachments {
762 if attachment.is_image() {
763 match media::download_and_encode_base64(
765 &attachment.url,
766 &attachment.content_type,
767 )
768 .await
769 {
770 Ok(base64_url) => {
771 parts.push(ChatCompletionRequestUserMessageContentPart::ImageUrl(
772 ChatCompletionRequestMessageContentPartImage {
773 image_url: ImageUrl {
774 url: base64_url,
775 detail: None,
776 },
777 },
778 ));
779 }
780 Err(e) => {
781 tracing::warn!("Failed to encode image: {}", e);
782 let desc = attachment.describe();
784 let current_text = match &mut parts[0] {
785 ChatCompletionRequestUserMessageContentPart::Text(t) => &mut t.text,
786 _ => unreachable!(),
787 };
788 *current_text = format!("{}\n{}", current_text, desc);
789 }
790 }
791 } else {
792 let desc = attachment.describe();
794 let current_text = match &mut parts[0] {
795 ChatCompletionRequestUserMessageContentPart::Text(t) => &mut t.text,
796 _ => unreachable!(),
797 };
798 *current_text = format!("{}\n{}", current_text, desc);
799 }
800 }
801
802 ChatCompletionRequestMessage::User(ChatCompletionRequestUserMessage {
803 content: ChatCompletionRequestUserMessageContent::Array(parts),
804 name: None,
805 })
806 }
807
808 async fn run_skill_turn(&mut self, skill: &crate::skill::Skill, args: &str) {
813 let _ = self
815 .frontend
816 .on_event(AgentEvent::SkillTriggered {
817 name: skill.frontmatter.name.clone(),
818 description: skill.frontmatter.description.clone(),
819 })
820 .await;
821
822 let session_id = self.default_session_id.clone();
823
824 let skill_message = format!(
826 "## Skill: {}\n\n{}\n\n{}",
827 skill.frontmatter.name,
828 skill.frontmatter.description,
829 skill.content
830 );
831
832 let skill_msg = ChatCompletionRequestMessage::System(
833 ChatCompletionRequestSystemMessage {
834 content: skill_message.into(),
835 name: Some(skill.frontmatter.name.clone()),
836 }
837 .into(),
838 );
839
840 if let Some(session) = self.sessions.get_mut(&session_id) {
841 session.history.push(skill_msg);
842 }
843
844 let user_content = if args.is_empty() {
846 "(User triggered skill, no additional arguments)".to_string()
847 } else {
848 args.to_string()
849 };
850
851 if let Some(session) = self.sessions.get_mut(&session_id) {
852 session.history.push(ChatCompletionRequestMessage::User(
853 ChatCompletionRequestUserMessage {
854 content: user_content.into(),
855 name: None,
856 }
857 .into(),
858 ));
859 }
860
861 let max_iterations = 20;
863 let mut completed = false;
864 for iteration in 0..max_iterations {
865 match self.run_one_step(&session_id).await {
866 Ok(tool_call_count) => {
867 if tool_call_count == 0 {
868 completed = true;
869 break;
870 }
871 tracing::debug!(
872 "Skill iteration {}: tool calls executed",
873 iteration
874 );
875 }
876 Err(e) => {
877 let _ = self.frontend.on_event(AgentEvent::Error(e)).await;
878 break;
879 }
880 }
881 }
882
883 if !completed {
884 let _ = self
885 .frontend
886 .on_event(AgentEvent::Error(AgentError::InternalError(
887 format!("Max iterations reached ({})", max_iterations),
888 )))
889 .await;
890 }
891
892 let _ = self.frontend.on_event(AgentEvent::TurnComplete).await;
893
894 if let Some(session) = self.sessions.get_mut(&session_id) {
896 let skill_name = skill.frontmatter.name.clone();
897 session.history.retain(|msg| {
898 !matches!(
899 msg,
900 ChatCompletionRequestMessage::System(s)
901 if s.name.as_deref() == Some(&skill_name)
902 )
903 });
904 }
905 }
906
907 }
908
909async fn generate_summary(
917 llm_client: &LlmClient,
918 removed_messages: &[ChatCompletionRequestMessage],
919) -> String {
920 tracing::debug!("Generating summary: removed_messages count = {}", removed_messages.len());
921 let transcript = crate::context::format_removed_messages_as_transcript(removed_messages);
922 tracing::debug!("Formatted transcript length: {} characters", transcript.len());
923 tracing::trace!("Transcript content:\n{}", transcript);
924
925 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.";
926
927 let messages = vec![
928 ChatCompletionRequestMessage::System(
929 ChatCompletionRequestSystemMessage {
930 content: system_prompt.into(),
931 name: None,
932 }
933 ),
934 ChatCompletionRequestMessage::User(
935 ChatCompletionRequestUserMessage {
936 content: format!("Conversation transcript:\n\n{}", transcript).into(),
937 name: None,
938 }
939 ),
940 ];
941
942 tracing::info!("Calling LLM to generate summary...");
943 match llm_client.chat(messages, None).await {
944 Ok(response) => {
945 tracing::info!("LLM responded successfully for summary generation");
946 tracing::debug!("Number of choices in response: {}", response.choices.len());
947 if let Some(choice) = response.choices.first() {
948 tracing::debug!("Choice index: 0, has content: {}", choice.message.content.is_some());
949 if let Some(content) = &choice.message.content {
950 let summary = content.trim().to_string();
951 if !summary.is_empty() {
952 tracing::info!("Successfully generated summary (length: {})", summary.len());
953 return summary;
954 }
955 }
956 }
957 tracing::warn!("Summary generation returned empty response, using fallback");
958 "Conversation history compressed.".to_string()
959 }
960 Err(e) => {
961 tracing::error!("Summary generation failed with error: {}, using fallback", e);
962 "Conversation history compressed.".to_string()
963 }
964 }
965}
966
967#[derive(Debug)]
973struct ToolCallAccumulator {
974 id: Option<String>,
975 name: Option<String>,
976 arguments: String,
977}
978
979impl ToolCallAccumulator {
980 fn new() -> Self {
981 Self {
982 id: None,
983 name: None,
984 arguments: String::new(),
985 }
986 }
987
988 fn into_tool_call(self) -> Option<ChatCompletionMessageToolCall> {
990 tracing::debug!("Converting accumulator to tool call: {:?}", self);
991
992 let id = self.id?;
993 let name = self.name?;
994
995 tracing::debug!("Tool call assembled: id='{}', name='{}', args='{}'", id, name, self.arguments);
996
997 Some(ChatCompletionMessageToolCall {
998 id,
999 function: FunctionCall {
1000 name,
1001 arguments: self.arguments,
1002 },
1003 })
1004 }
1005}