1use async_openai::types::chat::{
4 ChatCompletionMessageToolCall, ChatCompletionMessageToolCalls,
5 ChatCompletionRequestAssistantMessage, ChatCompletionRequestMessage,
6 ChatCompletionRequestSystemMessage, ChatCompletionRequestToolMessage,
7 ChatCompletionRequestUserMessage, FunctionCall,
8};
9use futures::StreamExt;
10use robit_ai::config::ContextConfig;
11use robit_ai::LlmClient;
12use std::collections::HashMap;
13use std::path::PathBuf;
14use std::sync::Arc;
15use tokio::sync::mpsc;
16
17use crate::context::ContextManager;
18use crate::error::{AgentError, Result};
19use crate::event::{new_session_id, AgentEvent, FrontendMessage, SessionId};
20use crate::frontend::Frontend;
21use crate::prompt::PromptBuilder;
22use crate::skill::SkillRegistry;
23use crate::tool::{ToolCallInfo, ToolContext, ToolRegistry, ToolResult};
24
25pub struct AgentSession {
31 pub session_id: SessionId,
32 pub history: Vec<ChatCompletionRequestMessage>,
33 pub working_dir: PathBuf,
34}
35
36impl AgentSession {
37 fn new(session_id: SessionId, working_dir: PathBuf, system_prompt: String) -> Self {
38 let system_msg = ChatCompletionRequestMessage::System(
39 ChatCompletionRequestSystemMessage {
40 content: system_prompt.into(),
41 name: None,
42 }
43 .into(),
44 );
45
46 Self {
47 session_id,
48 history: vec![system_msg],
49 working_dir,
50 }
51 }
52}
53
54pub struct Agent {
60 llm_client: Arc<LlmClient>,
61 tools: Arc<ToolRegistry>,
62 skills: Arc<SkillRegistry>,
63 sessions: HashMap<SessionId, AgentSession>,
64 default_session_id: SessionId,
65 context_manager: ContextManager,
66 frontend: Arc<dyn Frontend>,
67 auto_approve: bool,
68}
69
70impl Agent {
71 pub fn new(
73 llm_client: Arc<LlmClient>,
74 tools: Arc<ToolRegistry>,
75 skills: Arc<SkillRegistry>,
76 frontend: Arc<dyn Frontend>,
77 context_config: Option<&ContextConfig>,
78 context_window: Option<u64>,
79 working_dir: PathBuf,
80 auto_approve: bool,
81 ) -> Self {
82 let prompt_builder = PromptBuilder::new();
83 let context_manager = ContextManager::new(context_window, context_config);
84
85 let tool_refs: Vec<&dyn crate::tool::Tool> = tools.tools();
87 let skill_descs = skills.skill_descriptions();
88 let system_prompt = prompt_builder.build_system_prompt(&tool_refs, &skill_descs, &working_dir);
89
90 let session_id = new_session_id();
92 let session = AgentSession::new(session_id.clone(), working_dir, system_prompt);
93
94 let mut sessions = HashMap::new();
95 sessions.insert(session_id.clone(), session);
96
97 Self {
98 llm_client,
99 tools,
100 skills,
101 sessions,
102 default_session_id: session_id,
103 context_manager,
104 frontend,
105 auto_approve,
106 }
107 }
108
109 pub async fn run(mut self, mut message_rx: mpsc::Receiver<FrontendMessage>) {
112 tracing::info!("Agent started, session: {}", self.default_session_id);
113
114 while let Some(msg) = message_rx.recv().await {
115 match msg {
116 FrontendMessage::UserInput(input) => {
117 if input == "/exit" || input == "/quit" {
118 break;
119 }
120 if input == "/clear" {
121 self.clear_session();
122 let _ = self
123 .frontend
124 .on_event(AgentEvent::TextDelta(
125 "\n[Conversation history cleared]\n".to_string(),
126 ))
127 .await;
128 let _ = self.frontend.on_event(AgentEvent::TurnComplete).await;
129 continue;
130 }
131
132 if let Some((skill, args)) = self.skills.match_trigger(&input) {
134 let skill = skill.clone();
135 self.run_skill_turn(&skill, &args).await;
136 continue;
137 }
138
139 self.run_turn(&input).await;
140 }
141 FrontendMessage::Cancel => {
142 tracing::info!("Cancel requested (MVP: no-op)");
143 }
144 FrontendMessage::ConfirmationResponse { .. } => {
145 tracing::warn!("Unexpected ConfirmationResponse outside tool confirmation");
148 }
149 }
150 }
151
152 tracing::info!("Agent stopped");
153 }
154
155 async fn run_turn(&mut self, user_input: &str) {
157 let session_id = self.default_session_id.clone();
158
159 if let Some(session) = self.sessions.get_mut(&session_id) {
161 session.history.push(ChatCompletionRequestMessage::User(
162 ChatCompletionRequestUserMessage {
163 content: user_input.to_string().into(),
164 name: None,
165 }
166 .into(),
167 ));
168 }
169
170 let max_iterations = 20;
172 for iteration in 0..max_iterations {
173 match self.run_one_step(&session_id).await {
174 Ok(has_tool_calls) => {
175 if !has_tool_calls {
176 let _ = self.frontend.on_event(AgentEvent::TurnComplete).await;
177 return;
178 }
179 tracing::debug!(
180 "Iteration {}: tool calls executed, continuing loop",
181 iteration
182 );
183 }
184 Err(e) => {
185 let _ = self.frontend.on_event(AgentEvent::Error(e)).await;
186 let _ = self.frontend.on_event(AgentEvent::TurnComplete).await;
187 return;
188 }
189 }
190 }
191
192 let _ = self
194 .frontend
195 .on_event(AgentEvent::Error(AgentError::InternalError(
196 format!("Max iterations reached ({})", max_iterations),
197 )))
198 .await;
199 let _ = self.frontend.on_event(AgentEvent::TurnComplete).await;
200 }
201
202 async fn run_one_step(&mut self, session_id: &SessionId) -> Result<bool> {
206 let session = self
207 .sessions
208 .get_mut(session_id)
209 .ok_or_else(|| AgentError::InternalError("Session not found".to_string()))?;
210
211 let truncation_result = self.context_manager.maybe_truncate(&mut session.history);
213
214 if truncation_result.needs_compression {
216 if let Some(msg) = session.history.get_mut(truncation_result.insert_position) {
219 let notice = format!(
220 "[Omitted {} rounds, {} messages. Context compressed to save space]",
221 truncation_result.rounds_removed,
222 truncation_result.messages_removed
223 );
224 *msg = ChatCompletionRequestMessage::User(
225 ChatCompletionRequestUserMessage {
226 content: notice.into(),
227 name: Some("system_notice".to_string()),
228 }
229 .into(),
230 );
231 }
232
233 tracing::info!(
234 "Compression triggered: removed {} tokens (threshold: {})",
235 crate::context::estimate_messages_tokens(&truncation_result.removed_messages),
236 self.context_manager.compression_token_threshold
237 );
238 }
239
240 let tool_schemas = self.tools.tool_schemas();
242 let tools_param = if tool_schemas.is_empty() {
243 None
244 } else {
245 Some(tool_schemas)
246 };
247
248 let mut stream = self
250 .llm_client
251 .chat_stream(session.history.clone(), tools_param)
252 .await?;
253
254 let mut full_text = String::new();
256 let mut tool_call_chunks: HashMap<usize, ToolCallAccumulator> = HashMap::new();
257
258 while let Some(chunk_result) = stream.next().await {
259 let chunk = chunk_result.map_err(|e| AgentError::LlmError(e.into()))?;
260
261 if let Some(choice) = chunk.choices.first() {
262 if let Some(content) = &choice.delta.content {
264 full_text.push_str(content);
265 let _ = self
266 .frontend
267 .on_event(AgentEvent::TextDelta(content.clone()))
268 .await;
269 }
270
271 if let Some(tool_calls) = &choice.delta.tool_calls {
273 for tc in tool_calls {
274 let acc = tool_call_chunks
275 .entry(tc.index as usize)
276 .or_insert_with(ToolCallAccumulator::new);
277
278 if let Some(id) = &tc.id {
279 acc.id = Some(id.clone());
280 }
281 if let Some(function) = &tc.function {
282 if let Some(name) = &function.name {
283 acc.name = Some(name.clone());
284 }
285 if let Some(args) = &function.arguments {
286 acc.arguments.push_str(args);
287 }
288 }
289 }
290 }
291 }
292 }
293
294 let assembled_tool_calls: Vec<ChatCompletionMessageToolCall> = {
296 let mut indices: Vec<usize> = tool_call_chunks.keys().cloned().collect();
297 indices.sort();
298 indices
299 .into_iter()
300 .filter_map(|idx| tool_call_chunks.remove(&idx)?.into_tool_call())
301 .collect()
302 };
303
304 let assistant_msg = ChatCompletionRequestMessage::Assistant(
306 ChatCompletionRequestAssistantMessage {
307 content: if full_text.is_empty() {
308 None
309 } else {
310 Some(full_text.clone().into())
311 },
312 name: None,
313 tool_calls: if assembled_tool_calls.is_empty() {
314 None
315 } else {
316 Some(
317 assembled_tool_calls
318 .clone()
319 .into_iter()
320 .map(ChatCompletionMessageToolCalls::Function)
321 .collect(),
322 )
323 },
324 refusal: None,
325 audio: None,
326 #[allow(deprecated)]
327 function_call: None,
328 }
329 .into(),
330 );
331
332 let session = self
333 .sessions
334 .get_mut(session_id)
335 .ok_or_else(|| AgentError::InternalError("Session not found".to_string()))?;
336 session.history.push(assistant_msg);
337 let working_dir = session.working_dir.clone();
338
339 if assembled_tool_calls.is_empty() {
341 return Ok(false);
342 }
343
344 for tc in &assembled_tool_calls {
346 let tc_info = ToolCallInfo {
347 id: tc.id.clone(),
348 name: tc.function.name.clone(),
349 arguments: tc.function.arguments.clone(),
350 };
351
352 let _ = self
354 .frontend
355 .on_event(AgentEvent::ToolCallRequested {
356 tool_call_id: tc_info.id.clone(),
357 name: tc_info.name.clone(),
358 arguments: tc_info.arguments.clone(),
359 })
360 .await;
361
362 let approved = if self.tools.requires_confirmation(&tc.function.name) && !self.auto_approve {
364 self.frontend.request_tool_confirmation(&tc_info).await?
365 } else {
366 true
367 };
368
369 let result = if approved {
371 let args: serde_json::Value = serde_json::from_str(&tc.function.arguments)
372 .unwrap_or(serde_json::Value::Null);
373
374 let ctx = ToolContext {
375 working_dir: working_dir.clone(),
376 session_id: session_id.clone(),
377 frontend: self.frontend.clone(),
378 };
379
380 self.tools.execute(&tc.function.name, args, &ctx).await
381 } else {
382 ToolResult::error("User rejected this tool call")
383 };
384
385 let truncated_result = ToolResult {
387 content: self.context_manager.truncate_tool_output(&result.content),
388 is_error: result.is_error,
389 };
390
391 let _ = self
393 .frontend
394 .on_event(AgentEvent::ToolCallResult {
395 tool_call_id: tc.id.clone(),
396 result: truncated_result.clone(),
397 })
398 .await;
399
400 let tool_msg = ChatCompletionRequestMessage::Tool(
402 ChatCompletionRequestToolMessage {
403 content: truncated_result.content.into(),
404 tool_call_id: tc.id.clone(),
405 }
406 .into(),
407 );
408
409 let session = self
410 .sessions
411 .get_mut(session_id)
412 .ok_or_else(|| AgentError::InternalError("Session not found".to_string()))?;
413 session.history.push(tool_msg);
414 }
415
416 Ok(true)
417 }
418
419 fn clear_session(&mut self) {
421 if let Some(session) = self.sessions.get_mut(&self.default_session_id) {
422 session.history.truncate(1);
423 }
424 }
425
426 async fn run_skill_turn(&mut self, skill: &crate::skill::Skill, args: &str) {
431 let _ = self
433 .frontend
434 .on_event(AgentEvent::SkillTriggered {
435 name: skill.frontmatter.name.clone(),
436 description: skill.frontmatter.description.clone(),
437 })
438 .await;
439
440 let session_id = self.default_session_id.clone();
441
442 let skill_message = format!(
444 "## Skill: {}\n\n{}\n\n{}",
445 skill.frontmatter.name,
446 skill.frontmatter.description,
447 skill.content
448 );
449
450 let skill_msg = ChatCompletionRequestMessage::System(
451 ChatCompletionRequestSystemMessage {
452 content: skill_message.into(),
453 name: Some(skill.frontmatter.name.clone()),
454 }
455 .into(),
456 );
457
458 if let Some(session) = self.sessions.get_mut(&session_id) {
459 session.history.push(skill_msg);
460 }
461
462 let user_content = if args.is_empty() {
464 "(User triggered skill, no additional arguments)".to_string()
465 } else {
466 args.to_string()
467 };
468
469 if let Some(session) = self.sessions.get_mut(&session_id) {
470 session.history.push(ChatCompletionRequestMessage::User(
471 ChatCompletionRequestUserMessage {
472 content: user_content.into(),
473 name: None,
474 }
475 .into(),
476 ));
477 }
478
479 let max_iterations = 20;
481 let mut completed = false;
482 for iteration in 0..max_iterations {
483 match self.run_one_step(&session_id).await {
484 Ok(has_tool_calls) => {
485 if !has_tool_calls {
486 completed = true;
487 break;
488 }
489 tracing::debug!(
490 "Skill iteration {}: tool calls executed",
491 iteration
492 );
493 }
494 Err(e) => {
495 let _ = self.frontend.on_event(AgentEvent::Error(e)).await;
496 break;
497 }
498 }
499 }
500
501 if !completed {
502 let _ = self
503 .frontend
504 .on_event(AgentEvent::Error(AgentError::InternalError(
505 format!("Max iterations reached ({})", max_iterations),
506 )))
507 .await;
508 }
509
510 let _ = self.frontend.on_event(AgentEvent::TurnComplete).await;
511
512 if let Some(session) = self.sessions.get_mut(&session_id) {
514 let skill_name = skill.frontmatter.name.clone();
515 session.history.retain(|msg| {
516 !matches!(
517 msg,
518 ChatCompletionRequestMessage::System(s)
519 if s.name.as_deref() == Some(&skill_name)
520 )
521 });
522 }
523 }
524}
525
526struct ToolCallAccumulator {
532 id: Option<String>,
533 name: Option<String>,
534 arguments: String,
535}
536
537impl ToolCallAccumulator {
538 fn new() -> Self {
539 Self {
540 id: None,
541 name: None,
542 arguments: String::new(),
543 }
544 }
545
546 fn into_tool_call(self) -> Option<ChatCompletionMessageToolCall> {
548 let id = self.id?;
549 let name = self.name?;
550 Some(ChatCompletionMessageToolCall {
551 id,
552 function: FunctionCall {
553 name,
554 arguments: self.arguments,
555 },
556 })
557 }
558}