1use std::sync::atomic::{AtomicBool, Ordering};
2use std::sync::Arc;
3
4#[cfg(feature = "tools")]
5use async_trait::async_trait;
6use log;
7use tokio::sync::mpsc;
8
9use crate::backend::{Backend, GenerationResult, InferenceParams, TokenCallback};
10use crate::context::{plan_prune, prepare_context, ContextConfig, PreparedContext, PruneStrategy};
11use crate::error::{CoreError, CoreResult};
12use crate::events::AgentEvent;
13use crate::messages::{Message, Role, ToolCall};
14use crate::template::{ChatMLTemplate, ChatTemplate};
15use crate::tools::{parse_tool_calls, ToolSchema};
16#[cfg(feature = "tools")]
17use crate::{
18 messages::ToolResult,
19 tools::{Tool, ToolOutput, ToolUpdateCallback},
20};
21
22const SUMMARY_MARKER: &str = "[Summary of earlier conversation]";
25
26const SUMMARY_MAX_TOKENS: u32 = 320;
28
29const SUMMARY_SYSTEM: &str = "You summarize conversations faithfully and concisely.";
31
32#[derive(Debug, Clone)]
34pub struct AgentConfig {
35 pub system_prompt: String,
37 pub inference_params: InferenceParams,
39 pub context_config: ContextConfig,
41 pub max_tool_iterations: usize,
45}
46
47impl Default for AgentConfig {
48 fn default() -> Self {
49 Self {
50 system_prompt: "You are a helpful assistant.".to_string(),
51 inference_params: InferenceParams::default(),
52 context_config: ContextConfig::default(),
53 max_tool_iterations: 8,
54 }
55 }
56}
57
58#[cfg(feature = "tools")]
60#[derive(Debug, Clone)]
61pub enum ApprovalDecision {
62 Approve,
64 Deny {
67 reason: String,
69 },
70}
71
72#[cfg(feature = "tools")]
113#[async_trait]
114pub trait ApprovalHook: Send + Sync {
115 async fn review(&self, call: &ToolCall) -> ApprovalDecision;
117}
118
119pub struct Agent {
147 config: AgentConfig,
148 messages: Vec<Message>,
149 #[cfg(feature = "tools")]
150 tools: Vec<Box<dyn Tool>>,
151 #[cfg(feature = "tools")]
152 approval_hook: Option<Arc<dyn ApprovalHook>>,
153 template: Arc<dyn ChatTemplate>,
154 abort: Arc<AtomicBool>,
155 msg_counter: u64,
156}
157
158impl Agent {
159 pub fn new(config: AgentConfig) -> Self {
161 Self::with_template(config, Arc::new(ChatMLTemplate))
162 }
163
164 pub fn with_template(config: AgentConfig, template: Arc<dyn ChatTemplate>) -> Self {
166 log::debug!(
167 "Agent created: system_prompt_len={}, max_ctx={}, max_resp={}, template={}",
168 config.system_prompt.len(),
169 config.context_config.max_context_tokens,
170 config.context_config.max_response_tokens,
171 template.name(),
172 );
173 Self {
174 config,
175 messages: Vec::new(),
176 #[cfg(feature = "tools")]
177 tools: Vec::new(),
178 #[cfg(feature = "tools")]
179 approval_hook: None,
180 template,
181 abort: Arc::new(AtomicBool::new(false)),
182 msg_counter: 0,
183 }
184 }
185
186 pub fn messages(&self) -> &[Message] {
188 &self.messages
189 }
190
191 pub fn config(&self) -> &AgentConfig {
193 &self.config
194 }
195
196 pub fn template(&self) -> &dyn ChatTemplate {
198 self.template.as_ref()
199 }
200
201 pub fn set_system_prompt(&mut self, prompt: impl Into<String>) {
203 let prompt = prompt.into();
204 log::debug!("Agent system prompt updated: len={}", prompt.len());
205 self.config.system_prompt = prompt;
206 }
207
208 pub fn set_inference_params(&mut self, params: InferenceParams) {
210 log::debug!(
211 "Agent inference params: max_tokens={}, temp={}, ctx={}, threads={}",
212 params.max_tokens,
213 params.temperature,
214 params.context_size,
215 params.n_threads,
216 );
217 self.config.inference_params = params;
218 }
219
220 pub fn set_context_config(&mut self, config: ContextConfig) {
222 log::debug!(
223 "Agent context config: max_ctx={}, max_resp={}",
224 config.max_context_tokens,
225 config.max_response_tokens,
226 );
227 self.config.context_config = config;
228 }
229
230 pub fn set_prune_strategy(&mut self, strategy: PruneStrategy) {
232 log::debug!("Agent prune strategy: {strategy:?}");
233 self.config.context_config.prune_strategy = strategy;
234 }
235
236 pub fn set_pinned(&mut self, message_id: &str, pinned: bool) -> bool {
239 match self.messages.iter_mut().find(|m| m.id == message_id) {
240 Some(msg) => {
241 msg.pinned = pinned;
242 log::debug!("Agent message {message_id} pinned={pinned}");
243 true
244 }
245 None => {
246 log::warn!("set_pinned: message {message_id} not found");
247 false
248 }
249 }
250 }
251
252 pub fn set_template(&mut self, template: Arc<dyn ChatTemplate>) {
254 log::debug!("Agent template updated: {}", template.name());
255 self.template = template;
256 }
257
258 #[cfg(feature = "tools")]
262 pub fn set_tools(&mut self, tools: Vec<Box<dyn Tool>>) {
263 log::debug!("Agent tools set: count={}", tools.len());
264 self.tools = tools;
265 }
266
267 #[cfg(feature = "tools")]
274 pub fn set_approval_hook(&mut self, hook: Arc<dyn ApprovalHook>) {
275 log::debug!("Agent approval hook installed");
276 self.approval_hook = Some(hook);
277 }
278
279 pub fn clear(&mut self) {
281 let count = self.messages.len();
282 self.messages.clear();
283 log::debug!("Agent conversation cleared: {count} messages removed");
284 }
285
286 pub fn replace_messages(&mut self, messages: Vec<Message>) {
291 log::debug!("Agent messages replaced: count={}", messages.len());
292 let max_loaded = messages
296 .iter()
297 .filter_map(|m| m.id.strip_prefix("msg-"))
298 .filter_map(|n| n.parse::<u64>().ok())
299 .max()
300 .unwrap_or(0);
301 self.msg_counter = self.msg_counter.max(max_loaded);
302 self.messages = messages;
303 }
304
305 pub fn abort(&self) {
307 log::debug!("Agent abort requested");
308 self.abort.store(true, Ordering::Relaxed);
309 }
310
311 pub fn abort_flag(&self) -> Arc<AtomicBool> {
313 self.abort.clone()
314 }
315
316 fn next_id(&mut self) -> String {
317 self.msg_counter += 1;
318 format!("msg-{}", self.msg_counter)
319 }
320
321 #[cfg(feature = "tools")]
322 fn tool_schemas(&self) -> Vec<ToolSchema> {
323 self.tools.iter().map(|t| t.schema()).collect()
324 }
325
326 #[cfg(not(feature = "tools"))]
328 fn tool_schemas(&self) -> Vec<ToolSchema> {
329 Vec::new()
330 }
331
332 pub async fn prompt(
348 &mut self,
349 text: impl Into<String>,
350 backend: impl Into<Backend>,
351 tx: mpsc::UnboundedSender<AgentEvent>,
352 ) -> CoreResult<()> {
353 let backend = backend.into();
354 let text = text.into().trim().to_string();
355 if text.is_empty() {
356 return Err(CoreError::Agent("Empty message".into()));
357 }
358
359 self.abort.store(false, Ordering::Relaxed);
360
361 let user_msg = Message::user(self.next_id(), &text);
362 self.messages.push(user_msg.clone());
363
364 tx.send(AgentEvent::AgentStart).ok();
365 tx.send(AgentEvent::MessageStart {
366 message: user_msg.clone(),
367 })
368 .ok();
369 tx.send(AgentEvent::MessageEnd { message: user_msg }).ok();
370
371 self.compress_if_needed(&backend, &tx).await;
374
375 let mut new_messages: Vec<Message> = Vec::new();
378 #[cfg(feature = "tools")]
379 let has_tools = !self.tools.is_empty();
380 #[cfg(not(feature = "tools"))]
381 let has_tools = false;
382
383 for iteration in 0..self.config.max_tool_iterations {
384 tx.send(AgentEvent::TurnStart).ok();
385
386 let gen = match self.generate_once(&backend, &tx).await {
387 Ok(gen) => gen,
388 Err(CoreError::Aborted) => {
389 log::info!("Agent::prompt: generation aborted by user");
392 let assistant_msg = Message::assistant(self.next_id(), "");
393 self.messages.push(assistant_msg.clone());
394 new_messages.push(assistant_msg.clone());
395 tx.send(AgentEvent::MessageEnd {
396 message: assistant_msg.clone(),
397 })
398 .ok();
399 tx.send(AgentEvent::TurnEnd {
400 message: assistant_msg,
401 tool_results: vec![],
402 })
403 .ok();
404 tx.send(AgentEvent::AgentEnd {
405 messages: new_messages,
406 })
407 .ok();
408 return Ok(());
409 }
410 Err(e) => {
411 log::error!("Agent::prompt: generation error: {e}");
413 if iteration == 0 {
417 self.messages.pop();
418 }
419 tx.send(AgentEvent::Error {
420 message: e.to_string(),
421 })
422 .ok();
423 tx.send(AgentEvent::AgentEnd { messages: vec![] }).ok();
424 return Ok(());
425 }
426 };
427
428 log::debug!(
429 "Agent::prompt: turn {} → {} tokens, {:.1} t/s, {:.1}ms ttft",
430 iteration,
431 gen.tokens_generated,
432 gen.tokens_per_sec,
433 gen.time_to_first_token_ms,
434 );
435
436 let mut assistant_msg = Message::assistant(self.next_id(), &gen.text);
437 let parsed = if has_tools {
438 parse_tool_calls(&gen.text)
439 } else {
440 Vec::new()
441 };
442 let tool_calls: Vec<ToolCall> = parsed
443 .iter()
444 .enumerate()
445 .map(|(i, p)| ToolCall {
446 id: format!("{}-call-{}", assistant_msg.id, i + 1),
447 name: p.name.clone(),
448 arguments: p.arguments.clone(),
449 })
450 .collect();
451 assistant_msg.tool_calls = tool_calls.clone();
452
453 self.messages.push(assistant_msg.clone());
454 new_messages.push(assistant_msg.clone());
455
456 tx.send(AgentEvent::GenerationStats {
457 tokens_generated: gen.tokens_generated,
458 prompt_tokens: gen.prompt_tokens,
459 tokens_per_sec: gen.tokens_per_sec,
460 time_to_first_token_ms: gen.time_to_first_token_ms,
461 generation_time_ms: gen.generation_time_ms,
462 })
463 .ok();
464 tx.send(AgentEvent::MessageEnd {
465 message: assistant_msg.clone(),
466 })
467 .ok();
468
469 if tool_calls.is_empty() {
471 tx.send(AgentEvent::TurnEnd {
472 message: assistant_msg,
473 tool_results: vec![],
474 })
475 .ok();
476 tx.send(AgentEvent::AgentEnd {
477 messages: new_messages,
478 })
479 .ok();
480 return Ok(());
481 }
482
483 #[cfg(feature = "tools")]
487 {
488 let aborted = self
489 .run_tool_calls(&tool_calls, assistant_msg, &mut new_messages, &tx)
490 .await;
491 if aborted {
492 tx.send(AgentEvent::AgentEnd {
493 messages: new_messages,
494 })
495 .ok();
496 return Ok(());
497 }
498 }
500 }
501
502 log::warn!(
504 "Agent::prompt: stopped after {} tool iterations",
505 self.config.max_tool_iterations
506 );
507 tx.send(AgentEvent::Warning {
508 message: format!(
509 "Stopped after {} tool iterations without a final answer",
510 self.config.max_tool_iterations
511 ),
512 })
513 .ok();
514 tx.send(AgentEvent::AgentEnd {
515 messages: new_messages,
516 })
517 .ok();
518 Ok(())
519 }
520
521 #[cfg(feature = "tools")]
525 async fn run_tool_calls(
526 &mut self,
527 tool_calls: &[ToolCall],
528 assistant_msg: Message,
529 new_messages: &mut Vec<Message>,
530 tx: &mpsc::UnboundedSender<AgentEvent>,
531 ) -> bool {
532 let mut tool_results: Vec<ToolResult> = Vec::new();
533 for call in tool_calls {
534 let decision = match &self.approval_hook {
538 Some(hook) => hook.review(call).await,
539 None => ApprovalDecision::Approve,
540 };
541
542 let (content, is_error) = match decision {
543 ApprovalDecision::Deny { reason } => {
544 log::info!("Agent::prompt: tool '{}' denied: {reason}", call.name);
545 tx.send(AgentEvent::ToolDenied {
546 tool_call_id: call.id.clone(),
547 tool_name: call.name.clone(),
548 reason: reason.clone(),
549 })
550 .ok();
551 (reason, true)
552 }
553 ApprovalDecision::Approve => {
554 tx.send(AgentEvent::ToolExecStart {
555 tool_call_id: call.id.clone(),
556 tool_name: call.name.clone(),
557 args: call.arguments.clone(),
558 })
559 .ok();
560
561 let (content, is_error) = match self.execute_tool(call, tx).await {
562 Ok(out) => (out.content, false),
563 Err(e) => {
564 log::warn!("Agent::prompt: tool '{}' failed: {e}", call.name);
565 (e.to_string(), true)
566 }
567 };
568
569 tx.send(AgentEvent::ToolExecEnd {
570 tool_call_id: call.id.clone(),
571 tool_name: call.name.clone(),
572 result: ToolResult {
573 tool_call_id: call.id.clone(),
574 tool_name: call.name.clone(),
575 content: content.clone(),
576 is_error,
577 },
578 })
579 .ok();
580
581 (content, is_error)
582 }
583 };
584
585 let result = ToolResult {
586 tool_call_id: call.id.clone(),
587 tool_name: call.name.clone(),
588 content: content.clone(),
589 is_error,
590 };
591 let result_msg =
592 Message::tool_result(self.next_id(), &call.id, &call.name, content, is_error);
593 self.messages.push(result_msg.clone());
594 new_messages.push(result_msg.clone());
595 tx.send(AgentEvent::MessageStart {
596 message: result_msg.clone(),
597 })
598 .ok();
599 tx.send(AgentEvent::MessageEnd {
600 message: result_msg,
601 })
602 .ok();
603
604 tool_results.push(result);
605 }
606
607 tx.send(AgentEvent::TurnEnd {
608 message: assistant_msg,
609 tool_results,
610 })
611 .ok();
612
613 self.abort.load(Ordering::Relaxed)
614 }
615
616 pub fn prompt_stream(
662 &mut self,
663 text: impl Into<String>,
664 backend: impl Into<Backend>,
665 ) -> (
666 mpsc::UnboundedReceiver<AgentEvent>,
667 impl std::future::Future<Output = CoreResult<()>> + '_,
668 ) {
669 let (tx, rx) = mpsc::unbounded_channel();
670 let text = text.into();
671 let backend = backend.into();
672 let fut = async move { self.prompt(text, backend, tx).await };
673 (rx, fut)
674 }
675
676 pub async fn send(
686 &mut self,
687 text: impl Into<String>,
688 backend: impl Into<Backend>,
689 ) -> CoreResult<Message> {
690 let (tx, mut rx) = mpsc::unbounded_channel();
691 self.prompt(text, backend, tx).await?;
692
693 let mut reply = None;
696 let mut failed = None;
697 while let Ok(event) = rx.try_recv() {
698 match event {
699 AgentEvent::TurnEnd { message, .. } => reply = Some(message),
700 AgentEvent::Error { message } => failed = Some(message),
701 _ => {}
702 }
703 }
704
705 if let Some(message) = failed {
706 return Err(CoreError::Agent(message));
707 }
708 reply.ok_or_else(|| CoreError::Agent("The agent produced no reply".into()))
709 }
710
711 async fn generate_once(
718 &self,
719 backend: &Backend,
720 tx: &mpsc::UnboundedSender<AgentEvent>,
721 ) -> CoreResult<GenerationResult> {
722 let messages = self.messages.clone();
723 let system_prompt = self.config.system_prompt.clone();
724 let ctx_config = self.config.context_config.clone();
725 let tool_schemas = self.tool_schemas();
726 let params = self.config.inference_params.clone();
727 let abort = self.abort.clone();
728 let max_ctx = self.config.context_config.max_context_tokens;
729 let template = self.template.clone();
730 let token_tx = tx.clone();
731 let budget_tx = tx.clone();
732
733 let on_token: TokenCallback = Box::new(move |token, count, tps| {
734 token_tx
735 .send(AgentEvent::MessageDelta {
736 delta: token.to_string(),
737 tokens_generated: count,
738 tokens_per_sec: tps,
739 })
740 .ok();
741 });
742
743 let report = move |prepared: &PreparedContext| {
744 log::debug!(
745 "Context prepared: tokens={}, kept={}, pruned={}",
746 prepared.token_count,
747 prepared.messages_included,
748 prepared.messages_pruned,
749 );
750 budget_tx
751 .send(AgentEvent::ContextBudget {
752 used_tokens: prepared.token_count,
753 max_tokens: max_ctx,
754 messages_in_context: prepared.messages_included,
755 messages_pruned: prepared.messages_pruned,
756 })
757 .ok();
758 };
759
760 match backend {
761 Backend::Prompt(backend) => {
764 let backend = backend.clone();
765 log::debug!(
766 "Agent::generate_once: spawning blocking (max_tokens={}, temp={}, ctx={}, threads={})",
767 params.max_tokens,
768 params.temperature,
769 params.context_size,
770 params.n_threads,
771 );
772
773 let handle = tokio::task::spawn_blocking(move || {
774 if !backend.is_ready() {
775 return Err(CoreError::Backend("No model loaded".into()));
776 }
777
778 let prepared = prepare_context(
779 template.as_ref(),
780 &system_prompt,
781 &messages,
782 &tool_schemas,
783 &ctx_config,
784 &|text| backend.tokenize_count(text).unwrap_or(0),
785 )?;
786 report(&prepared);
787
788 backend.generate(&prepared.prompt, ¶ms, abort, on_token)
789 });
790
791 handle.await.map_err(|e| {
792 log::error!("Agent::generate_once: blocking task panicked: {e}");
793 CoreError::Agent(format!("Inference task failed: {e}"))
794 })?
795 }
796
797 #[cfg(feature = "chat-backend")]
800 Backend::Chat(backend) => {
801 if !backend.is_ready() {
802 return Err(CoreError::Backend("Backend not ready".into()));
803 }
804
805 let prepared = prepare_context(
806 template.as_ref(),
807 &system_prompt,
808 &messages,
809 &tool_schemas,
810 &ctx_config,
811 &|text| backend.tokenize_count(text),
812 )?;
813 report(&prepared);
814
815 backend
816 .chat(
817 &prepared.system,
818 &prepared.messages,
819 ¶ms,
820 abort,
821 on_token,
822 )
823 .await
824 }
825 }
826 }
827
828 #[cfg(feature = "tools")]
833 async fn execute_tool(
834 &self,
835 call: &ToolCall,
836 tx: &mpsc::UnboundedSender<AgentEvent>,
837 ) -> CoreResult<ToolOutput> {
838 let Some(tool) = self.tools.iter().find(|t| t.name() == call.name) else {
839 return Err(CoreError::Tool(format!("unknown tool: {}", call.name)));
840 };
841
842 let update_tx = tx.clone();
843 let tool_call_id = call.id.clone();
844 let tool_name = call.name.clone();
845 let on_update: ToolUpdateCallback = Box::new(move |partial: &str| {
846 update_tx
847 .send(AgentEvent::ToolExecUpdate {
848 tool_call_id: tool_call_id.clone(),
849 tool_name: tool_name.clone(),
850 partial: partial.to_string(),
851 })
852 .ok();
853 });
854
855 tool.execute(&call.id, call.arguments.clone(), Some(on_update))
856 .await
857 }
858
859 async fn compress_if_needed(
867 &mut self,
868 backend: &Backend,
869 tx: &mpsc::UnboundedSender<AgentEvent>,
870 ) {
871 if self.config.context_config.prune_strategy != PruneStrategy::Summarize {
872 return;
873 }
874
875 let messages = self.messages.clone();
876 let system_prompt = self.config.system_prompt.clone();
877 let tools = self.tool_schemas();
878 let ctx_config = self.config.context_config.clone();
879 let template = self.template.clone();
880 let abort = self.abort.clone();
881 let sum_params = InferenceParams {
882 max_tokens: SUMMARY_MAX_TOKENS,
883 ..self.config.inference_params.clone()
884 };
885
886 let outcome: Option<(Vec<usize>, String)> = match backend {
887 Backend::Prompt(backend) => {
889 let backend = backend.clone();
890 tokio::task::spawn_blocking(move || {
891 if !backend.is_ready() {
892 return None;
893 }
894 let counter = |text: &str| backend.tokenize_count(text).unwrap_or(0);
895 let (remove, _asked, prompt) = plan_summary(
896 template.as_ref(),
897 &system_prompt,
898 &messages,
899 &tools,
900 &ctx_config,
901 &counter,
902 )?;
903
904 let gen = backend
905 .generate(&prompt, &sum_params, abort, Box::new(|_, _, _| {}))
906 .ok()?;
907 let summary = gen.text.trim().to_string();
908 (!summary.is_empty()).then_some((remove, summary))
909 })
910 .await
911 .ok()
912 .flatten()
913 }
914
915 #[cfg(feature = "chat-backend")]
916 Backend::Chat(backend) => {
917 let counter = |text: &str| backend.tokenize_count(text);
918 let planned = backend.is_ready().then(|| {
919 plan_summary(
920 template.as_ref(),
921 &system_prompt,
922 &messages,
923 &tools,
924 &ctx_config,
925 &counter,
926 )
927 });
928
929 match planned.flatten() {
930 None => None,
931 Some((remove, asked, _prompt)) => backend
932 .chat(
933 SUMMARY_SYSTEM,
934 std::slice::from_ref(&asked),
935 &sum_params,
936 abort,
937 Box::new(|_, _, _| {}),
938 )
939 .await
940 .ok()
941 .and_then(|gen| {
942 let summary = gen.text.trim().to_string();
943 (!summary.is_empty()).then_some((remove, summary))
944 }),
945 }
946 }
947 };
948
949 let Some((remove, summary)) = outcome else {
950 return;
951 };
952
953 self.fold_into_summary(&remove, summary);
954 tx.send(AgentEvent::Warning {
955 message: format!(
956 "Summarized {} earlier message(s) to fit the context window",
957 remove.len()
958 ),
959 })
960 .ok();
961 }
962
963 fn fold_into_summary(&mut self, remove: &[usize], summary: String) {
966 if remove.is_empty() {
967 return;
968 }
969 let insert_at = *remove.iter().min().unwrap();
970 let mut sorted = remove.to_vec();
971 sorted.sort_unstable();
972 for &i in sorted.iter().rev() {
973 if i < self.messages.len() {
974 self.messages.remove(i);
975 }
976 }
977 let summary_msg =
978 Message::user(self.next_id(), format!("{SUMMARY_MARKER}\n{summary}")).pinned();
979 let at = insert_at.min(self.messages.len());
980 self.messages.insert(at, summary_msg);
981 log::info!(
982 "Folded {} messages into a pinned summary at index {at}",
983 remove.len()
984 );
985 }
986}
987
988fn plan_summary(
995 template: &dyn ChatTemplate,
996 system_prompt: &str,
997 messages: &[Message],
998 tools: &[ToolSchema],
999 ctx_config: &ContextConfig,
1000 counter: &dyn Fn(&str) -> u32,
1001) -> Option<(Vec<usize>, Message, String)> {
1002 let plan = plan_prune(
1003 template,
1004 system_prompt,
1005 messages,
1006 tools,
1007 ctx_config,
1008 counter,
1009 )
1010 .ok()?;
1011 if plan.dropped.is_empty() {
1012 return None; }
1014
1015 let mut remove: Vec<usize> = plan.dropped.iter().flat_map(|r| r.clone()).collect();
1018 let prior_summary = messages
1019 .iter()
1020 .position(|m| m.pinned && m.content.starts_with(SUMMARY_MARKER));
1021 let prior_body = prior_summary.map(|i| {
1022 remove.push(i);
1023 messages[i]
1024 .content
1025 .strip_prefix(SUMMARY_MARKER)
1026 .unwrap_or(&messages[i].content)
1027 .trim()
1028 .to_string()
1029 });
1030 remove.sort_unstable();
1031 remove.dedup();
1032
1033 let transcript = render_transcript(messages, &remove);
1034 let mut body = String::new();
1035 if let Some(prev) = prior_body.filter(|s| !s.is_empty()) {
1036 body.push_str("Earlier summary:\n");
1037 body.push_str(&prev);
1038 body.push_str("\n\n");
1039 }
1040 body.push_str("Conversation excerpt:\n");
1041 body.push_str(&transcript);
1042
1043 let instruction = "You compress conversation history. Summarize the \
1044 material below into a concise note that preserves key facts, \
1045 decisions, names, and unresolved questions. Reply with only the \
1046 summary.";
1047 let asked = Message::user("summary-req", format!("{instruction}\n\n{body}"));
1048 let prompt = template.format(SUMMARY_SYSTEM, std::slice::from_ref(&asked), &[]);
1049
1050 Some((remove, asked, prompt))
1051}
1052
1053fn render_transcript(messages: &[Message], indices: &[usize]) -> String {
1054 indices
1055 .iter()
1056 .filter_map(|&i| messages.get(i))
1057 .map(|m| {
1058 let role = match m.role {
1059 Role::User => "User",
1060 Role::Assistant | Role::ToolCall => "Assistant",
1061 Role::ToolResult => "Tool",
1062 Role::System => "System",
1063 };
1064 format!("{role}: {}", m.content)
1065 })
1066 .collect::<Vec<_>>()
1067 .join("\n")
1068}