1use std::{
4 collections::{HashMap, VecDeque},
5 fmt,
6 future::Future,
7 path::PathBuf,
8 sync::{Arc, Mutex, PoisonError},
9 time::Duration,
10};
11
12use async_trait::async_trait;
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15use thiserror::Error;
16use tokio_util::sync::CancellationToken;
17
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
19#[serde(tag = "role", rename_all = "snake_case")]
20pub enum Message {
21 User {
22 content: String,
23 #[serde(default, skip_serializing_if = "Vec::is_empty")]
26 images: Vec<ImageInput>,
27 },
28 Assistant {
29 content: String,
30 #[serde(default, skip_serializing_if = "Vec::is_empty")]
31 tool_calls: Vec<ToolCall>,
32 },
33 Tool {
34 call_id: String,
35 name: String,
36 content: String,
37 is_error: bool,
38 },
39 HistoryNote {
40 content: String,
41 },
42}
43
44impl Message {
45 pub fn user(content: impl Into<String>) -> Self {
47 Self::User {
48 content: content.into(),
49 images: Vec::new(),
50 }
51 }
52
53 fn estimated_tokens(&self, bytes_per_token: usize) -> usize {
54 let bytes = serde_json::to_vec(self).map_or(0, |value| value.len());
55 let images = match self {
56 Self::User { images, .. } => images.len(),
57 _ => 0,
58 };
59 bytes
60 .div_ceil(bytes_per_token)
61 .saturating_add(4)
62 .saturating_add(images.saturating_mul(IMAGE_TOKENS))
63 }
64}
65
66pub const IMAGE_TOKENS: usize = 1600;
69
70#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
72pub struct ImageInput {
73 pub path: PathBuf,
76 pub mime: String,
78}
79
80#[derive(Debug, Clone, Default, PartialEq, Eq)]
82pub struct TurnInput {
83 pub text: String,
84 pub images: Vec<ImageInput>,
85}
86
87impl From<String> for TurnInput {
88 fn from(text: String) -> Self {
89 Self {
90 text,
91 images: Vec::new(),
92 }
93 }
94}
95
96impl From<&str> for TurnInput {
97 fn from(text: &str) -> Self {
98 text.to_owned().into()
99 }
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
103pub struct ToolCall {
104 pub id: String,
105 pub name: String,
106 pub arguments: Value,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
110pub struct ToolSpec {
111 pub name: String,
112 pub description: String,
113 pub parameters: Value,
114}
115
116#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
117#[serde(rename_all = "snake_case")]
118pub enum ToolRisk {
119 ReadOnly,
120 Filesystem,
121 Process,
122 Delegate,
123 Network,
126}
127
128impl ToolRisk {
129 pub fn as_str(self) -> &'static str {
130 match self {
131 Self::ReadOnly => "read_only",
132 Self::Filesystem => "filesystem",
133 Self::Process => "process",
134 Self::Delegate => "delegate",
135 Self::Network => "network",
136 }
137 }
138
139 pub fn parse(value: &str) -> Option<Self> {
142 [
143 Self::ReadOnly,
144 Self::Filesystem,
145 Self::Process,
146 Self::Delegate,
147 Self::Network,
148 ]
149 .into_iter()
150 .find(|risk| risk.as_str() == value)
151 }
152}
153
154#[derive(Debug, Clone)]
155pub struct ToolContext {
156 pub workspace: PathBuf,
157 pub cancellation: CancellationToken,
158 pub progress: ProgressSink,
160 pub approvals: ToolApprovals,
163}
164
165impl ToolContext {
166 pub fn new(workspace: PathBuf, cancellation: CancellationToken) -> Self {
169 Self {
170 workspace,
171 cancellation,
172 progress: ProgressSink::default(),
173 approvals: ToolApprovals::default(),
174 }
175 }
176}
177
178#[derive(Clone, Default)]
183pub struct ToolApprovals {
184 gate: Option<Arc<dyn ApprovalGate>>,
185 call_id: String,
186}
187
188impl fmt::Debug for ToolApprovals {
189 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
190 formatter
191 .debug_struct("ToolApprovals")
192 .field("enabled", &self.gate.is_some())
193 .field("call_id", &self.call_id)
194 .finish()
195 }
196}
197
198impl ToolApprovals {
199 pub fn new(gate: Arc<dyn ApprovalGate>, call_id: impl Into<String>) -> Self {
201 Self {
202 gate: Some(gate),
203 call_id: call_id.into(),
204 }
205 }
206
207 pub fn is_enabled(&self) -> bool {
208 self.gate.is_some()
209 }
210
211 pub async fn request(
215 &self,
216 name: impl Into<String>,
217 risk: ToolRisk,
218 cwd: PathBuf,
219 summary: impl Into<String>,
220 cancellation: CancellationToken,
221 ) -> Result<bool, AgentError> {
222 let Some(gate) = &self.gate else {
223 return Ok(false);
224 };
225 gate.approve(
226 ApprovalRequest {
227 call_id: self.call_id.clone(),
228 name: name.into(),
229 risk,
230 cwd,
231 summary: summary.into(),
232 },
233 cancellation,
234 )
235 .await
236 }
237}
238
239pub const MAX_PROGRESS_LINE_BYTES: usize = 200;
241pub const MAX_PROGRESS_EVENT_BYTES: usize = 512;
244pub const PROGRESS_INTERVAL: Duration = Duration::from_millis(500);
246
247#[derive(Clone, Default)]
253pub struct ProgressSink {
254 pending: Option<Arc<Mutex<PendingProgress>>>,
255}
256
257impl fmt::Debug for ProgressSink {
258 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
259 formatter
260 .debug_struct("ProgressSink")
261 .field("enabled", &self.is_enabled())
262 .finish()
263 }
264}
265
266impl ProgressSink {
267 pub fn buffered() -> Self {
269 Self {
270 pending: Some(Arc::default()),
271 }
272 }
273
274 pub fn is_enabled(&self) -> bool {
275 self.pending.is_some()
276 }
277
278 pub fn report(&self, text: &str) {
281 let Some(pending) = &self.pending else {
282 return;
283 };
284 let line = progress_line(text);
285 if !line.is_empty() {
286 pending
287 .lock()
288 .unwrap_or_else(PoisonError::into_inner)
289 .push(line);
290 }
291 }
292
293 pub fn take(&self) -> Option<String> {
296 self.pending
297 .as_ref()?
298 .lock()
299 .unwrap_or_else(PoisonError::into_inner)
300 .take()
301 }
302}
303
304const PROGRESS_ELIDED: &str = "…";
306
307#[derive(Debug, Default)]
308struct PendingProgress {
309 lines: VecDeque<String>,
310 bytes: usize,
312 dropped: bool,
313}
314
315impl PendingProgress {
316 fn push(&mut self, line: String) {
317 self.bytes += line.len() + usize::from(!self.lines.is_empty());
318 self.lines.push_back(line);
319 let budget = MAX_PROGRESS_EVENT_BYTES - PROGRESS_ELIDED.len() - 1;
321 while self.bytes > budget && self.lines.len() > 1 {
322 if let Some(oldest) = self.lines.pop_front() {
323 self.bytes -= oldest.len() + 1;
324 self.dropped = true;
325 }
326 }
327 }
328
329 fn take(&mut self) -> Option<String> {
330 if self.lines.is_empty() {
331 return None;
332 }
333 let mut text = String::with_capacity(self.bytes + PROGRESS_ELIDED.len() + 1);
334 if std::mem::take(&mut self.dropped) {
335 text.push_str(PROGRESS_ELIDED);
336 text.push('\n');
337 }
338 for (index, line) in self.lines.drain(..).enumerate() {
339 if index > 0 {
340 text.push('\n');
341 }
342 text.push_str(&line);
343 }
344 self.bytes = 0;
345 Some(text)
346 }
347}
348
349fn progress_line(text: &str) -> String {
352 let mut line = String::new();
353 for word in text
354 .split(|character: char| character.is_whitespace() || character.is_control())
355 .filter(|word| !word.is_empty())
356 {
357 if !line.is_empty() {
358 line.push(' ');
359 }
360 line.push_str(word);
361 if line.len() > MAX_PROGRESS_LINE_BYTES {
362 break;
363 }
364 }
365 if line.len() <= MAX_PROGRESS_LINE_BYTES {
366 return line;
367 }
368 let mut end = MAX_PROGRESS_LINE_BYTES - PROGRESS_ELIDED.len();
369 while !line.is_char_boundary(end) {
370 end -= 1;
371 }
372 line.truncate(end);
373 line.push_str(PROGRESS_ELIDED);
374 line
375}
376
377#[derive(Debug, Clone, PartialEq, Eq)]
378pub struct ToolOutput {
379 pub content: String,
380 pub is_error: bool,
381 pub truncated: bool,
382}
383
384impl ToolOutput {
385 pub fn success(content: impl Into<String>) -> Self {
386 Self {
387 content: content.into(),
388 is_error: false,
389 truncated: false,
390 }
391 }
392
393 pub fn failure(content: impl Into<String>) -> Self {
394 Self {
395 content: content.into(),
396 is_error: true,
397 truncated: false,
398 }
399 }
400}
401
402#[derive(Debug, Error)]
403#[error("{0}")]
404pub struct ToolError(pub String);
405
406#[async_trait]
407pub trait Tool: Send + Sync {
408 fn spec(&self) -> ToolSpec;
409 fn risk(&self, arguments: &Value) -> Result<ToolRisk, ToolError>;
410 fn approval_summary(&self, arguments: &Value) -> Result<String, ToolError>;
411 async fn execute(
412 &self,
413 arguments: Value,
414 context: ToolContext,
415 ) -> Result<ToolOutput, ToolError>;
416}
417
418#[derive(Default)]
419pub struct ToolRegistry {
420 tools: HashMap<String, Arc<dyn Tool>>,
421}
422
423impl ToolRegistry {
424 pub fn register(&mut self, tool: Arc<dyn Tool>) -> Result<(), ToolError> {
425 let name = tool.spec().name;
426 if self.tools.contains_key(&name) {
427 return Err(ToolError(format!("duplicate tool name: {name}")));
428 }
429 self.tools.insert(name, tool);
430 Ok(())
431 }
432
433 pub fn get(&self, name: &str) -> Option<Arc<dyn Tool>> {
434 self.tools.get(name).cloned()
435 }
436
437 pub fn specs(&self) -> Vec<ToolSpec> {
438 let mut specs: Vec<_> = self.tools.values().map(|tool| tool.spec()).collect();
439 specs.sort_by(|a, b| a.name.cmp(&b.name));
440 specs
441 }
442}
443
444#[derive(Debug, Clone, Default, PartialEq, Eq)]
445pub struct Usage {
446 pub input_tokens: Option<u64>,
447 pub output_tokens: Option<u64>,
448}
449
450impl Usage {
451 fn add(&mut self, other: &Self) {
452 self.input_tokens = add_optional(self.input_tokens, other.input_tokens);
453 self.output_tokens = add_optional(self.output_tokens, other.output_tokens);
454 }
455}
456
457fn add_optional(left: Option<u64>, right: Option<u64>) -> Option<u64> {
458 match (left, right) {
459 (None, None) => None,
460 (left, right) => Some(left.unwrap_or(0).saturating_add(right.unwrap_or(0))),
461 }
462}
463
464#[derive(Debug, Clone)]
465pub struct ProviderRequest {
466 pub system_prompt: String,
467 pub messages: Vec<Message>,
468 pub tools: Vec<ToolSpec>,
469}
470
471#[derive(Debug, Clone)]
472pub struct AssistantResponse {
473 pub content: String,
474 pub tool_calls: Vec<ToolCall>,
475 pub usage: Usage,
476}
477
478#[derive(Debug, Clone, Copy, PartialEq, Eq)]
479pub enum ProviderErrorKind {
480 Provider,
481 ResponseLimit,
482 ToolLimit,
483 Cancelled,
484}
485
486#[derive(Debug, Error)]
487#[error("{message}")]
488pub struct ProviderError {
489 pub kind: ProviderErrorKind,
490 pub message: String,
491}
492
493impl ProviderError {
494 pub fn new(kind: ProviderErrorKind, message: impl Into<String>) -> Self {
495 Self {
496 kind,
497 message: message.into(),
498 }
499 }
500}
501
502#[async_trait]
503pub trait TextDeltaSink: Send + Sync {
504 async fn push(&self, delta: &str) -> Result<(), ProviderError>;
505}
506
507#[async_trait]
508pub trait Provider: Send + Sync {
509 fn model(&self) -> &str;
510
511 async fn complete(
512 &self,
513 request: ProviderRequest,
514 deltas: Arc<dyn TextDeltaSink>,
515 cancellation: CancellationToken,
516 ) -> Result<AssistantResponse, ProviderError>;
517}
518
519#[derive(Debug, Clone)]
520pub enum CoreEvent {
521 AssistantDelta {
522 content: String,
523 },
524 AssistantCompleted {
525 content: String,
526 },
527 ToolProposed {
528 call_id: String,
529 name: String,
530 arguments: Value,
531 },
532 ToolStarted {
533 call_id: String,
534 name: String,
535 },
536 ToolProgress {
538 call_id: String,
539 text: String,
540 },
541 ToolCompleted {
542 call_id: String,
543 name: String,
544 output: ToolOutput,
545 },
546 ContextCompacted {
547 before_tokens: usize,
548 after_tokens: usize,
549 removed_messages: usize,
550 },
551 SessionTrimmed {
552 removed_messages: usize,
553 history_bytes: usize,
554 },
555}
556
557#[async_trait]
558pub trait EventSink: Send + Sync {
559 async fn emit(&self, event: CoreEvent) -> Result<(), AgentError>;
560}
561
562#[derive(Debug, Clone)]
563pub struct ApprovalRequest {
564 pub call_id: String,
565 pub name: String,
566 pub risk: ToolRisk,
567 pub cwd: PathBuf,
568 pub summary: String,
569}
570
571#[async_trait]
572pub trait ApprovalGate: Send + Sync {
573 async fn approve(
574 &self,
575 request: ApprovalRequest,
576 cancellation: CancellationToken,
577 ) -> Result<bool, AgentError>;
578}
579
580#[derive(Debug, Clone)]
581pub struct ContextConfig {
582 pub max_tokens: usize,
583 pub reserve_output_tokens: usize,
584 pub safety_margin_tokens: usize,
585 pub bytes_per_token: usize,
586 pub summary_max_chars: usize,
587}
588
589impl Default for ContextConfig {
590 fn default() -> Self {
591 Self {
592 max_tokens: 128_000,
593 reserve_output_tokens: 8_192,
594 safety_margin_tokens: 2_048,
595 bytes_per_token: 3,
596 summary_max_chars: 6_000,
597 }
598 }
599}
600
601#[derive(Debug, Clone)]
602pub struct ContextSelection {
603 pub messages: Vec<Message>,
604 pub before_tokens: usize,
605 pub after_tokens: usize,
606 pub removed_messages: usize,
607}
608
609#[derive(Debug, Error)]
610#[error("{0}")]
611pub struct ContextError(pub String);
612
613pub trait ContextPolicy: Send + Sync {
614 fn select(
615 &self,
616 history: &[Message],
617 system_prompt: &str,
618 tools: &[ToolSpec],
619 ) -> Result<ContextSelection, ContextError>;
620}
621
622pub struct BudgetContextPolicy {
623 config: ContextConfig,
624}
625
626impl BudgetContextPolicy {
627 pub fn new(config: ContextConfig) -> Result<Self, ContextError> {
628 if config.bytes_per_token == 0 {
629 return Err(ContextError(
630 "context.bytes_per_token must be positive".into(),
631 ));
632 }
633 if config
634 .reserve_output_tokens
635 .saturating_add(config.safety_margin_tokens)
636 >= config.max_tokens
637 {
638 return Err(ContextError(
639 "context reserve and safety margin consume the model window".into(),
640 ));
641 }
642 Ok(Self { config })
643 }
644
645 fn string_tokens(&self, value: &str) -> usize {
646 value.len().div_ceil(self.config.bytes_per_token)
647 }
648
649 fn group_messages(history: &[Message]) -> Vec<Vec<Message>> {
650 let mut groups: Vec<Vec<Message>> = Vec::new();
651 for message in history {
652 if matches!(message, Message::User { .. }) || groups.is_empty() {
653 groups.push(Vec::new());
654 }
655 groups
656 .last_mut()
657 .expect("a group was just created")
658 .push(message.clone());
659 }
660 groups
661 }
662
663 fn summarize(&self, messages: &[Message]) -> String {
664 let mut output = format!(
665 "[SCV compacted {} earlier messages. Bounded extracts follow.]\n",
666 messages.len()
667 );
668 for message in messages {
669 let (label, content) = match message {
670 Message::User { content, .. } => ("user", content.as_str()),
671 Message::Assistant { content, .. } => ("assistant", content.as_str()),
672 Message::Tool {
673 name,
674 content,
675 is_error,
676 ..
677 } => {
678 let status = if *is_error { "failed" } else { "ok" };
679 output.push_str(&format!("tool {name} ({status}): "));
680 ("", content.as_str())
681 }
682 Message::HistoryNote { content } => ("earlier", content.as_str()),
683 };
684 if !label.is_empty() {
685 output.push_str(label);
686 output.push_str(": ");
687 }
688 let tail = char_tail(content, 240);
689 output.push_str(&tail.replace('\n', " "));
690 output.push('\n');
691 if output.chars().count() >= self.config.summary_max_chars {
692 break;
693 }
694 }
695 truncate_chars(&output, self.config.summary_max_chars)
696 }
697}
698
699impl ContextPolicy for BudgetContextPolicy {
700 fn select(
701 &self,
702 history: &[Message],
703 system_prompt: &str,
704 tools: &[ToolSpec],
705 ) -> Result<ContextSelection, ContextError> {
706 if history.is_empty() {
707 return Ok(ContextSelection {
708 messages: Vec::new(),
709 before_tokens: 0,
710 after_tokens: 0,
711 removed_messages: 0,
712 });
713 }
714 let tools_bytes = serde_json::to_vec(tools).map_or(0, |value| value.len());
715 let static_tokens = self
716 .string_tokens(system_prompt)
717 .saturating_add(tools_bytes.div_ceil(self.config.bytes_per_token))
718 .saturating_add(self.config.reserve_output_tokens)
719 .saturating_add(self.config.safety_margin_tokens);
720 if static_tokens >= self.config.max_tokens {
721 return Err(ContextError(
722 "system prompt and tool schemas exceed context budget".into(),
723 ));
724 }
725 let budget = self.config.max_tokens - static_tokens;
726 let groups = Self::group_messages(history);
727 let newest = groups.last().expect("history produced at least one group");
728 let newest_cost: usize = newest
729 .iter()
730 .map(|message| message.estimated_tokens(self.config.bytes_per_token))
731 .sum();
732 if newest_cost > budget {
733 return Err(ContextError("newest turn exceeds context budget".into()));
734 }
735
736 let before_history_tokens: usize = history
737 .iter()
738 .map(|message| message.estimated_tokens(self.config.bytes_per_token))
739 .sum();
740 let mut selected_groups: Vec<Vec<Message>> = vec![newest.clone()];
741 let mut selected_cost = newest_cost;
742 for group in groups[..groups.len() - 1].iter().rev() {
743 let cost: usize = group
744 .iter()
745 .map(|message| message.estimated_tokens(self.config.bytes_per_token))
746 .sum();
747 if selected_cost.saturating_add(cost) <= budget {
748 selected_groups.insert(0, group.clone());
749 selected_cost += cost;
750 } else {
751 break;
752 }
753 }
754
755 let mut removed_messages = groups[..groups.len() - selected_groups.len()]
756 .iter()
757 .map(Vec::len)
758 .sum::<usize>();
759 if removed_messages > 0 {
760 loop {
761 let note = Message::HistoryNote {
762 content: self.summarize(&history[..removed_messages]),
763 };
764 let note_cost = note.estimated_tokens(self.config.bytes_per_token);
765 if selected_cost.saturating_add(note_cost) <= budget {
766 let mut selected: Vec<Message> =
767 selected_groups.into_iter().flatten().collect();
768 selected.insert(0, note);
769 selected_cost += note_cost;
770 return Ok(ContextSelection {
771 messages: selected,
772 before_tokens: static_tokens.saturating_add(before_history_tokens),
773 after_tokens: static_tokens.saturating_add(selected_cost),
774 removed_messages,
775 });
776 }
777 if selected_groups.len() == 1 {
778 let available_tokens = budget.saturating_sub(selected_cost);
779 let content = match note {
780 Message::HistoryNote { content } => content,
781 _ => unreachable!(),
782 };
783 let Some(note) =
784 fit_history_note(&content, available_tokens, self.config.bytes_per_token)
785 else {
786 return Err(ContextError(
787 "compaction note cannot fit context budget".into(),
788 ));
789 };
790 let note_cost = note.estimated_tokens(self.config.bytes_per_token);
791 let mut selected: Vec<Message> =
792 selected_groups.into_iter().flatten().collect();
793 selected.insert(0, note);
794 selected_cost += note_cost;
795 return Ok(ContextSelection {
796 messages: selected,
797 before_tokens: static_tokens.saturating_add(before_history_tokens),
798 after_tokens: static_tokens.saturating_add(selected_cost),
799 removed_messages,
800 });
801 }
802 let removed_group = selected_groups.remove(0);
803 let removed_cost: usize = removed_group
804 .iter()
805 .map(|message| message.estimated_tokens(self.config.bytes_per_token))
806 .sum();
807 selected_cost = selected_cost.saturating_sub(removed_cost);
808 removed_messages += removed_group.len();
809 }
810 }
811
812 let selected: Vec<Message> = selected_groups.into_iter().flatten().collect();
813 Ok(ContextSelection {
814 messages: selected,
815 before_tokens: static_tokens.saturating_add(before_history_tokens),
816 after_tokens: static_tokens.saturating_add(selected_cost),
817 removed_messages,
818 })
819 }
820}
821
822#[derive(Debug, Clone)]
823pub struct HistoryLimits {
824 pub max_bytes: usize,
825 pub max_messages: usize,
826 pub note_max_chars: usize,
827}
828
829impl Default for HistoryLimits {
830 fn default() -> Self {
831 Self {
832 max_bytes: 16 * 1024 * 1024,
833 max_messages: 10_000,
834 note_max_chars: 4_000,
835 }
836 }
837}
838
839#[derive(Debug, Clone)]
840pub struct AgentConfig {
841 pub system_prompt: String,
842 pub max_steps: usize,
843 pub history_limits: HistoryLimits,
844}
845
846#[derive(Debug, Clone)]
847pub struct TurnOutcome {
848 pub steps: usize,
849 pub usage: Usage,
850}
851
852#[derive(Debug, Error)]
853pub enum AgentError {
854 #[error("turn cancelled")]
855 Cancelled,
856 #[error("{0}")]
857 Provider(String),
858 #[error("{0}")]
859 ContextLimit(String),
860 #[error("agent reached its maximum step count")]
861 StepLimit,
862 #[error("{0}")]
863 HistoryLimit(String),
864 #[error("{0}")]
865 ResponseLimit(String),
866 #[error("{0}")]
867 ToolLimit(String),
868 #[error("{0}")]
869 Internal(String),
870}
871
872impl AgentError {
873 pub fn code(&self) -> &'static str {
874 match self {
875 Self::Cancelled => "cancelled",
876 Self::Provider(_) => "provider_error",
877 Self::ContextLimit(_) => "context_limit",
878 Self::StepLimit => "step_limit",
879 Self::HistoryLimit(_) => "history_limit",
880 Self::ResponseLimit(_) => "response_limit",
881 Self::ToolLimit(_) => "tool_limit",
882 Self::Internal(_) => "internal_error",
883 }
884 }
885}
886
887pub struct AgentRuntime {
888 provider: Arc<dyn Provider>,
889 tools: Arc<ToolRegistry>,
890 context: Arc<dyn ContextPolicy>,
891 config: AgentConfig,
892 workspace: PathBuf,
893}
894
895impl AgentRuntime {
896 pub fn new(
897 provider: Arc<dyn Provider>,
898 tools: Arc<ToolRegistry>,
899 context: Arc<dyn ContextPolicy>,
900 config: AgentConfig,
901 workspace: PathBuf,
902 ) -> Self {
903 Self {
904 provider,
905 tools,
906 context,
907 config,
908 workspace,
909 }
910 }
911
912 pub fn model(&self) -> &str {
913 self.provider.model()
914 }
915
916 pub async fn run_turn(
917 &self,
918 history: &mut Vec<Message>,
919 prompt: impl Into<TurnInput>,
920 sink: Arc<dyn EventSink>,
921 approvals: Arc<dyn ApprovalGate>,
922 cancellation: CancellationToken,
923 ) -> Result<TurnOutcome, AgentError> {
924 let checkpoint = history.clone();
925 let result = self
926 .run_turn_inner(history, prompt.into(), sink, approvals, cancellation)
927 .await;
928 if result.is_err() {
929 *history = checkpoint;
930 }
931 result
932 }
933
934 async fn run_turn_inner(
935 &self,
936 history: &mut Vec<Message>,
937 prompt: TurnInput,
938 sink: Arc<dyn EventSink>,
939 approvals: Arc<dyn ApprovalGate>,
940 cancellation: CancellationToken,
941 ) -> Result<TurnOutcome, AgentError> {
942 if cancellation.is_cancelled() {
943 return Err(AgentError::Cancelled);
944 }
945 history.push(Message::User {
946 content: prompt.text,
947 images: prompt.images,
948 });
949 self.enforce_history_limits(history, sink.as_ref()).await?;
950 let specs = self.tools.specs();
951 let mut usage = Usage::default();
952
953 for step in 1..=self.config.max_steps {
954 if cancellation.is_cancelled() {
955 return Err(AgentError::Cancelled);
956 }
957 let selection = self
958 .context
959 .select(history, &self.config.system_prompt, &specs)
960 .map_err(|error| AgentError::ContextLimit(error.to_string()))?;
961 if selection.removed_messages > 0 {
962 sink.emit(CoreEvent::ContextCompacted {
963 before_tokens: selection.before_tokens,
964 after_tokens: selection.after_tokens,
965 removed_messages: selection.removed_messages,
966 })
967 .await?;
968 }
969 let delta_sink: Arc<dyn TextDeltaSink> = Arc::new(ForwardDeltas {
970 sink: Arc::clone(&sink),
971 });
972 let response = self
973 .provider
974 .complete(
975 ProviderRequest {
976 system_prompt: self.config.system_prompt.clone(),
977 messages: selection.messages,
978 tools: specs.clone(),
979 },
980 delta_sink,
981 cancellation.child_token(),
982 )
983 .await
984 .map_err(map_provider_error)?;
985 usage.add(&response.usage);
986 sink.emit(CoreEvent::AssistantCompleted {
987 content: response.content.clone(),
988 })
989 .await?;
990 let calls = response.tool_calls.clone();
991 history.push(Message::Assistant {
992 content: response.content,
993 tool_calls: response.tool_calls,
994 });
995 self.enforce_history_limits(history, sink.as_ref()).await?;
996 if calls.is_empty() {
997 return Ok(TurnOutcome { steps: step, usage });
998 }
999
1000 for call in calls {
1001 if cancellation.is_cancelled() {
1002 return Err(AgentError::Cancelled);
1003 }
1004 sink.emit(CoreEvent::ToolProposed {
1005 call_id: call.id.clone(),
1006 name: call.name.clone(),
1007 arguments: call.arguments.clone(),
1008 })
1009 .await?;
1010 let Some(tool) = self.tools.get(&call.name) else {
1011 let output = ToolOutput::failure(format!("unknown tool: {}", call.name));
1012 sink.emit(CoreEvent::ToolCompleted {
1013 call_id: call.id.clone(),
1014 name: call.name.clone(),
1015 output: output.clone(),
1016 })
1017 .await?;
1018 history.push(Message::Tool {
1019 call_id: call.id,
1020 name: call.name,
1021 content: output.content,
1022 is_error: true,
1023 });
1024 self.enforce_history_limits(history, sink.as_ref()).await?;
1025 continue;
1026 };
1027 let risk = match tool.risk(&call.arguments) {
1028 Ok(risk) => risk,
1029 Err(error) => {
1030 self.record_tool_error(history, sink.as_ref(), &call, error.to_string())
1031 .await?;
1032 self.enforce_history_limits(history, sink.as_ref()).await?;
1033 continue;
1034 }
1035 };
1036 let summary = match tool.approval_summary(&call.arguments) {
1037 Ok(summary) => summary,
1038 Err(error) => {
1039 self.record_tool_error(history, sink.as_ref(), &call, error.to_string())
1040 .await?;
1041 self.enforce_history_limits(history, sink.as_ref()).await?;
1042 continue;
1043 }
1044 };
1045 let approved = approvals
1046 .approve(
1047 ApprovalRequest {
1048 call_id: call.id.clone(),
1049 name: call.name.clone(),
1050 risk,
1051 cwd: self.workspace.clone(),
1052 summary,
1053 },
1054 cancellation.child_token(),
1055 )
1056 .await?;
1057 let output = if approved {
1058 sink.emit(CoreEvent::ToolStarted {
1059 call_id: call.id.clone(),
1060 name: call.name.clone(),
1061 })
1062 .await?;
1063 let progress = ProgressSink::buffered();
1064 let execution = tool.execute(
1065 call.arguments.clone(),
1066 ToolContext {
1067 workspace: self.workspace.clone(),
1068 cancellation: cancellation.child_token(),
1069 progress: progress.clone(),
1070 approvals: ToolApprovals::new(Arc::clone(&approvals), call.id.clone()),
1071 },
1072 );
1073 forward_progress(execution, &progress, sink.as_ref(), &call.id)
1074 .await
1075 .unwrap_or_else(|error| ToolOutput::failure(error.to_string()))
1076 } else {
1077 ToolOutput::failure("tool call denied by policy or user")
1078 };
1079 sink.emit(CoreEvent::ToolCompleted {
1080 call_id: call.id.clone(),
1081 name: call.name.clone(),
1082 output: output.clone(),
1083 })
1084 .await?;
1085 history.push(Message::Tool {
1086 call_id: call.id,
1087 name: call.name,
1088 content: output.content,
1089 is_error: output.is_error,
1090 });
1091 self.enforce_history_limits(history, sink.as_ref()).await?;
1092 }
1093 }
1094 Err(AgentError::StepLimit)
1095 }
1096
1097 async fn record_tool_error(
1098 &self,
1099 history: &mut Vec<Message>,
1100 sink: &dyn EventSink,
1101 call: &ToolCall,
1102 message: String,
1103 ) -> Result<(), AgentError> {
1104 let output = ToolOutput::failure(message);
1105 sink.emit(CoreEvent::ToolCompleted {
1106 call_id: call.id.clone(),
1107 name: call.name.clone(),
1108 output: output.clone(),
1109 })
1110 .await?;
1111 history.push(Message::Tool {
1112 call_id: call.id.clone(),
1113 name: call.name.clone(),
1114 content: output.content,
1115 is_error: true,
1116 });
1117 Ok(())
1118 }
1119
1120 async fn enforce_history_limits(
1121 &self,
1122 history: &mut Vec<Message>,
1123 sink: &dyn EventSink,
1124 ) -> Result<(), AgentError> {
1125 let limits = &self.config.history_limits;
1126 let mut total_removed = 0;
1127 while history.len() > limits.max_messages || history_bytes(history) > limits.max_bytes {
1128 let latest_user = history
1129 .iter()
1130 .rposition(|message| matches!(message, Message::User { .. }))
1131 .unwrap_or(0);
1132 let active = &history[latest_user..];
1133 if active.len() > limits.max_messages || history_bytes(active) > limits.max_bytes {
1134 return Err(AgentError::HistoryLimit(
1135 "active turn exceeds configured session history limit".into(),
1136 ));
1137 }
1138 let first_user = history
1139 .iter()
1140 .position(|message| matches!(message, Message::User { .. }))
1141 .unwrap_or(latest_user);
1142 if first_user == latest_user {
1143 if matches!(history.first(), Some(Message::HistoryNote { .. })) {
1144 history.remove(0);
1145 total_removed += 1;
1146 continue;
1147 }
1148 return Err(AgentError::HistoryLimit(
1149 "session history cannot be reduced within its configured limit".into(),
1150 ));
1151 }
1152 let end = history[first_user + 1..]
1153 .iter()
1154 .position(|message| matches!(message, Message::User { .. }))
1155 .map(|index| first_user + 1 + index)
1156 .ok_or_else(|| {
1157 AgentError::HistoryLimit(
1158 "session history has no complete group available to trim".into(),
1159 )
1160 })?;
1161 let removed: Vec<Message> = history.drain(..end).collect();
1162 total_removed += removed.len();
1163 let note = Message::HistoryNote {
1164 content: summarize_history_trim(&removed, total_removed, limits.note_max_chars),
1165 };
1166 if matches!(history.first(), Some(Message::HistoryNote { .. })) {
1167 history.remove(0);
1168 }
1169 history.insert(0, note);
1170 }
1171 if total_removed > 0 {
1172 sink.emit(CoreEvent::SessionTrimmed {
1173 removed_messages: total_removed,
1174 history_bytes: history_bytes(history),
1175 })
1176 .await?;
1177 }
1178 Ok(())
1179 }
1180}
1181
1182async fn forward_progress<T>(
1186 execution: impl Future<Output = T>,
1187 progress: &ProgressSink,
1188 sink: &dyn EventSink,
1189 call_id: &str,
1190) -> T {
1191 let mut execution = std::pin::pin!(execution);
1192 let mut ticker = tokio::time::interval_at(
1193 tokio::time::Instant::now() + PROGRESS_INTERVAL,
1194 PROGRESS_INTERVAL,
1195 );
1196 ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
1197 let mut last_sent: Option<tokio::time::Instant> = None;
1198 let mut forwarding = true;
1199 let result = loop {
1200 tokio::select! {
1201 biased;
1202 result = &mut execution => break result,
1203 _ = ticker.tick(), if forwarding => {
1204 if let Some(text) = progress.take() {
1205 let event = CoreEvent::ToolProgress { call_id: call_id.to_owned(), text };
1206 forwarding = sink.emit(event).await.is_ok();
1207 last_sent = Some(tokio::time::Instant::now());
1208 }
1209 }
1210 }
1211 };
1212 if forwarding
1214 && last_sent.is_none_or(|sent| sent.elapsed() >= PROGRESS_INTERVAL)
1215 && let Some(text) = progress.take()
1216 {
1217 let _ = sink
1218 .emit(CoreEvent::ToolProgress {
1219 call_id: call_id.to_owned(),
1220 text,
1221 })
1222 .await;
1223 }
1224 result
1225}
1226
1227fn map_provider_error(error: ProviderError) -> AgentError {
1228 match error.kind {
1229 ProviderErrorKind::Provider => AgentError::Provider(error.message),
1230 ProviderErrorKind::ResponseLimit => AgentError::ResponseLimit(error.message),
1231 ProviderErrorKind::ToolLimit => AgentError::ToolLimit(error.message),
1232 ProviderErrorKind::Cancelled => AgentError::Cancelled,
1233 }
1234}
1235
1236struct ForwardDeltas {
1237 sink: Arc<dyn EventSink>,
1238}
1239
1240#[async_trait]
1241impl TextDeltaSink for ForwardDeltas {
1242 async fn push(&self, delta: &str) -> Result<(), ProviderError> {
1243 self.sink
1244 .emit(CoreEvent::AssistantDelta {
1245 content: delta.to_owned(),
1246 })
1247 .await
1248 .map_err(|error| match error {
1249 AgentError::Cancelled => {
1250 ProviderError::new(ProviderErrorKind::Cancelled, "turn cancelled")
1251 }
1252 AgentError::ResponseLimit(message) => {
1253 ProviderError::new(ProviderErrorKind::ResponseLimit, message)
1254 }
1255 AgentError::ToolLimit(message) => {
1256 ProviderError::new(ProviderErrorKind::ToolLimit, message)
1257 }
1258 error => ProviderError::new(ProviderErrorKind::Provider, error.to_string()),
1259 })
1260 }
1261}
1262
1263fn history_bytes(history: &[Message]) -> usize {
1264 serde_json::to_vec(history).map_or(usize::MAX, |value| value.len())
1265}
1266
1267fn summarize_history_trim(messages: &[Message], removed: usize, max_chars: usize) -> String {
1268 let mut note =
1269 format!("[SCV trimmed {removed} earlier canonical messages to enforce session limits.]\n");
1270 for message in messages {
1271 let (label, content) = match message {
1272 Message::User { content, .. } => ("user", content.as_str()),
1273 Message::Assistant { content, .. } => ("assistant", content.as_str()),
1274 Message::Tool {
1275 name,
1276 content,
1277 is_error,
1278 ..
1279 } => {
1280 let status = if *is_error { "failed" } else { "ok" };
1281 note.push_str(&format!("tool {name} ({status}): "));
1282 ("", content.as_str())
1283 }
1284 Message::HistoryNote { content } => ("earlier", content.as_str()),
1285 };
1286 if !label.is_empty() {
1287 note.push_str(label);
1288 note.push_str(": ");
1289 }
1290 note.push_str(&char_tail(content, 160).replace('\n', " "));
1291 note.push('\n');
1292 if note.chars().count() >= max_chars {
1293 break;
1294 }
1295 }
1296 truncate_chars(¬e, max_chars)
1297}
1298
1299fn truncate_chars(value: &str, max_chars: usize) -> String {
1300 value.chars().take(max_chars).collect()
1301}
1302
1303fn fit_history_note(
1304 content: &str,
1305 available_tokens: usize,
1306 bytes_per_token: usize,
1307) -> Option<Message> {
1308 let chars: Vec<char> = content.chars().collect();
1309 let mut low = 0usize;
1310 let mut high = chars.len();
1311 let mut best = None;
1312 while low <= high {
1313 let middle = low + (high - low) / 2;
1314 let candidate = Message::HistoryNote {
1315 content: chars[..middle].iter().collect(),
1316 };
1317 if candidate.estimated_tokens(bytes_per_token) <= available_tokens {
1318 best = Some(candidate);
1319 low = middle.saturating_add(1);
1320 } else if middle == 0 {
1321 break;
1322 } else {
1323 high = middle - 1;
1324 }
1325 }
1326 best
1327}
1328
1329fn char_tail(value: &str, max_chars: usize) -> String {
1330 let count = value.chars().count();
1331 value
1332 .chars()
1333 .skip(count.saturating_sub(max_chars))
1334 .collect()
1335}
1336
1337#[cfg(test)]
1338mod tests {
1339 use std::{collections::VecDeque, sync::Mutex};
1340
1341 use tokio::sync::Notify;
1342
1343 use super::*;
1344
1345 #[test]
1346 fn images_cost_a_fixed_amount_of_context_whatever_their_size() {
1347 let plain = Message::user("look");
1348 let with_image = Message::User {
1349 content: "look".into(),
1350 images: vec![ImageInput {
1351 path: "/media/huge.png".into(),
1352 mime: "image/png".into(),
1353 }],
1354 };
1355 let extra = with_image.estimated_tokens(4) - plain.estimated_tokens(4);
1356 assert!(
1357 (IMAGE_TOKENS..IMAGE_TOKENS + 20).contains(&extra),
1358 "{extra}"
1359 );
1360 let json = serde_json::to_string(&plain).unwrap();
1362 assert_eq!(json, r#"{"role":"user","content":"look"}"#);
1363 assert_eq!(serde_json::from_str::<Message>(&json).unwrap(), plain);
1364 }
1365
1366 #[test]
1367 fn risks_parse_from_their_wire_names() {
1368 for risk in [
1369 ToolRisk::ReadOnly,
1370 ToolRisk::Filesystem,
1371 ToolRisk::Process,
1372 ToolRisk::Delegate,
1373 ToolRisk::Network,
1374 ] {
1375 assert_eq!(ToolRisk::parse(risk.as_str()), Some(risk));
1376 }
1377 assert_eq!(ToolRisk::parse("root"), None);
1378 }
1379
1380 struct RecordingGate(Mutex<Vec<ApprovalRequest>>);
1381
1382 #[async_trait]
1383 impl ApprovalGate for RecordingGate {
1384 async fn approve(
1385 &self,
1386 request: ApprovalRequest,
1387 _cancellation: CancellationToken,
1388 ) -> Result<bool, AgentError> {
1389 self.0.lock().unwrap().push(request);
1390 Ok(true)
1391 }
1392 }
1393
1394 #[tokio::test]
1395 async fn tool_approvals_carry_the_call_and_deny_without_a_gate() {
1396 let denied = ToolApprovals::default()
1397 .request(
1398 "bash",
1399 ToolRisk::Process,
1400 PathBuf::from("/w"),
1401 "Run it",
1402 CancellationToken::new(),
1403 )
1404 .await
1405 .unwrap();
1406 assert!(!denied);
1407 let gate = Arc::new(RecordingGate(Mutex::new(Vec::new())));
1408 let approvals = ToolApprovals::new(Arc::clone(&gate) as Arc<dyn ApprovalGate>, "call-7");
1409 assert!(approvals.is_enabled());
1410 assert!(
1411 approvals
1412 .request(
1413 "bash",
1414 ToolRisk::Process,
1415 PathBuf::from("/w"),
1416 "[scv-1 depth 1] Run it",
1417 CancellationToken::new(),
1418 )
1419 .await
1420 .unwrap()
1421 );
1422 let requests = gate.0.lock().unwrap();
1423 assert_eq!(requests[0].call_id, "call-7");
1424 assert_eq!(requests[0].summary, "[scv-1 depth 1] Run it");
1425 }
1426
1427 struct ScriptedProvider {
1428 responses: Mutex<VecDeque<AssistantResponse>>,
1429 }
1430
1431 #[async_trait]
1432 impl Provider for ScriptedProvider {
1433 fn model(&self) -> &str {
1434 "test-model"
1435 }
1436
1437 async fn complete(
1438 &self,
1439 _request: ProviderRequest,
1440 deltas: Arc<dyn TextDeltaSink>,
1441 _cancellation: CancellationToken,
1442 ) -> Result<AssistantResponse, ProviderError> {
1443 let response = self.responses.lock().unwrap().pop_front().unwrap();
1444 deltas.push(&response.content).await?;
1445 Ok(response)
1446 }
1447 }
1448
1449 struct CollectSink(Mutex<Vec<CoreEvent>>);
1450
1451 #[async_trait]
1452 impl EventSink for CollectSink {
1453 async fn emit(&self, event: CoreEvent) -> Result<(), AgentError> {
1454 self.0.lock().unwrap().push(event);
1455 Ok(())
1456 }
1457 }
1458
1459 struct Allow;
1460
1461 #[async_trait]
1462 impl ApprovalGate for Allow {
1463 async fn approve(
1464 &self,
1465 _request: ApprovalRequest,
1466 _cancellation: CancellationToken,
1467 ) -> Result<bool, AgentError> {
1468 Ok(true)
1469 }
1470 }
1471
1472 struct Deny;
1473
1474 #[async_trait]
1475 impl ApprovalGate for Deny {
1476 async fn approve(
1477 &self,
1478 _request: ApprovalRequest,
1479 _cancellation: CancellationToken,
1480 ) -> Result<bool, AgentError> {
1481 Ok(false)
1482 }
1483 }
1484
1485 struct WaitForCancellation {
1486 entered: Arc<Notify>,
1487 }
1488
1489 #[async_trait]
1490 impl ApprovalGate for WaitForCancellation {
1491 async fn approve(
1492 &self,
1493 _request: ApprovalRequest,
1494 cancellation: CancellationToken,
1495 ) -> Result<bool, AgentError> {
1496 self.entered.notify_one();
1497 cancellation.cancelled().await;
1498 Err(AgentError::Cancelled)
1499 }
1500 }
1501
1502 struct EchoTool;
1503
1504 #[async_trait]
1505 impl Tool for EchoTool {
1506 fn spec(&self) -> ToolSpec {
1507 ToolSpec {
1508 name: "echo".into(),
1509 description: "Echo a value".into(),
1510 parameters: serde_json::json!({
1511 "type":"object",
1512 "properties":{"value":{"type":"string"}},
1513 "required":["value"]
1514 }),
1515 }
1516 }
1517
1518 fn risk(&self, arguments: &Value) -> Result<ToolRisk, ToolError> {
1519 arguments
1520 .get("value")
1521 .and_then(Value::as_str)
1522 .ok_or_else(|| ToolError("value must be a string".into()))?;
1523 Ok(ToolRisk::ReadOnly)
1524 }
1525
1526 fn approval_summary(&self, arguments: &Value) -> Result<String, ToolError> {
1527 self.risk(arguments)?;
1528 Ok("Echo a value".into())
1529 }
1530
1531 async fn execute(
1532 &self,
1533 arguments: Value,
1534 _context: ToolContext,
1535 ) -> Result<ToolOutput, ToolError> {
1536 Ok(ToolOutput::success(
1537 arguments["value"].as_str().unwrap_or_default(),
1538 ))
1539 }
1540 }
1541
1542 #[test]
1543 fn progress_lines_are_single_bounded_lines() {
1544 assert_eq!(
1545 progress_line(" run\n\tcargo \u{7}test "),
1546 "run cargo test"
1547 );
1548 let long = progress_line(&"x".repeat(1000));
1549 assert!(long.len() <= MAX_PROGRESS_LINE_BYTES && long.ends_with(PROGRESS_ELIDED));
1550 let wide = progress_line(&"é".repeat(300));
1551 assert!(wide.len() <= MAX_PROGRESS_LINE_BYTES);
1552 let discard = ProgressSink::default();
1553 discard.report("ignored");
1554 assert!(!discard.is_enabled() && discard.take().is_none());
1555 }
1556
1557 #[test]
1558 fn progress_events_keep_the_newest_lines_within_the_limit() {
1559 let progress = ProgressSink::buffered();
1560 assert!(progress.take().is_none());
1561 progress.report("first");
1562 progress.report("second");
1563 assert_eq!(progress.take().as_deref(), Some("first\nsecond"));
1564 assert!(progress.take().is_none());
1565 for index in 0..50 {
1566 progress.report(&format!("{index:03} {}", "y".repeat(96)));
1567 }
1568 let text = progress.take().unwrap();
1569 assert!(text.len() <= MAX_PROGRESS_EVENT_BYTES, "{}", text.len());
1570 assert!(text.starts_with(&format!("{PROGRESS_ELIDED}\n")));
1571 assert!(text.lines().last().unwrap().starts_with("049 "));
1572 progress.report("after");
1573 assert_eq!(progress.take().as_deref(), Some("after"));
1574 }
1575
1576 struct ProgressTool;
1577
1578 #[async_trait]
1579 impl Tool for ProgressTool {
1580 fn spec(&self) -> ToolSpec {
1581 ToolSpec {
1582 name: "work".into(),
1583 description: "Report progress while working".into(),
1584 parameters: serde_json::json!({"type":"object"}),
1585 }
1586 }
1587
1588 fn risk(&self, _arguments: &Value) -> Result<ToolRisk, ToolError> {
1589 Ok(ToolRisk::ReadOnly)
1590 }
1591
1592 fn approval_summary(&self, _arguments: &Value) -> Result<String, ToolError> {
1593 Ok("Work".into())
1594 }
1595
1596 async fn execute(
1597 &self,
1598 _arguments: Value,
1599 context: ToolContext,
1600 ) -> Result<ToolOutput, ToolError> {
1601 for step in 0..22 {
1602 context.progress.report(&format!("step {step}"));
1603 tokio::time::sleep(Duration::from_millis(100)).await;
1604 }
1605 Ok(ToolOutput::success("worked"))
1606 }
1607 }
1608
1609 struct TimedSink(Mutex<Vec<(tokio::time::Instant, CoreEvent)>>);
1610
1611 #[async_trait]
1612 impl EventSink for TimedSink {
1613 async fn emit(&self, event: CoreEvent) -> Result<(), AgentError> {
1614 self.0
1615 .lock()
1616 .unwrap()
1617 .push((tokio::time::Instant::now(), event));
1618 Ok(())
1619 }
1620 }
1621
1622 #[tokio::test(start_paused = true)]
1623 async fn tool_progress_is_paced_and_kept_out_of_history() {
1624 let provider = Arc::new(ScriptedProvider {
1625 responses: Mutex::new(VecDeque::from([
1626 AssistantResponse {
1627 content: String::new(),
1628 tool_calls: vec![ToolCall {
1629 id: "call-1".into(),
1630 name: "work".into(),
1631 arguments: serde_json::json!({}),
1632 }],
1633 usage: Usage::default(),
1634 },
1635 AssistantResponse {
1636 content: "done".into(),
1637 tool_calls: Vec::new(),
1638 usage: Usage::default(),
1639 },
1640 ])),
1641 });
1642 let mut registry = ToolRegistry::default();
1643 registry.register(Arc::new(ProgressTool)).unwrap();
1644 let runtime = AgentRuntime::new(
1645 provider,
1646 Arc::new(registry),
1647 Arc::new(BudgetContextPolicy::new(ContextConfig::default()).unwrap()),
1648 AgentConfig {
1649 system_prompt: "test".into(),
1650 max_steps: 3,
1651 history_limits: HistoryLimits::default(),
1652 },
1653 PathBuf::from("/tmp"),
1654 );
1655 let sink = Arc::new(TimedSink(Mutex::new(Vec::new())));
1656 let mut history = Vec::new();
1657 runtime
1658 .run_turn(
1659 &mut history,
1660 "go",
1661 sink.clone(),
1662 Arc::new(Allow),
1663 CancellationToken::new(),
1664 )
1665 .await
1666 .unwrap();
1667 let events = sink.0.lock().unwrap();
1668 let started = events
1669 .iter()
1670 .position(|(_, event)| matches!(event, CoreEvent::ToolStarted { .. }))
1671 .unwrap();
1672 let completed = events
1673 .iter()
1674 .position(|(_, event)| matches!(event, CoreEvent::ToolCompleted { .. }))
1675 .unwrap();
1676 let progress: Vec<_> = events
1677 .iter()
1678 .enumerate()
1679 .filter_map(|(index, (at, event))| match event {
1680 CoreEvent::ToolProgress { call_id, text } => Some((index, *at, call_id, text)),
1681 _ => None,
1682 })
1683 .collect();
1684 assert!((4..=5).contains(&progress.len()), "{}", progress.len());
1687 for (index, _, call_id, text) in &progress {
1688 assert!(*index > started && *index < completed);
1689 assert_eq!(call_id.as_str(), "call-1");
1690 assert!(text.len() <= MAX_PROGRESS_EVENT_BYTES);
1691 }
1692 for pair in progress.windows(2) {
1693 assert!(pair[1].1 - pair[0].1 >= PROGRESS_INTERVAL);
1694 }
1695 let all: Vec<&str> = progress
1696 .iter()
1697 .flat_map(|(_, _, _, text)| text.lines())
1698 .collect();
1699 assert_eq!(all.first(), Some(&"step 0"));
1700 assert!(all.contains(&"step 19"));
1703 let stored = serde_json::to_string(&history).unwrap();
1704 assert!(!stored.contains("step 1"), "progress leaked into history");
1705 }
1706
1707 #[tokio::test]
1708 async fn completes_a_simple_turn() {
1709 let provider = Arc::new(ScriptedProvider {
1710 responses: Mutex::new(VecDeque::from([AssistantResponse {
1711 content: "done".into(),
1712 tool_calls: Vec::new(),
1713 usage: Usage {
1714 input_tokens: Some(3),
1715 output_tokens: Some(1),
1716 },
1717 }])),
1718 });
1719 let runtime = AgentRuntime::new(
1720 provider,
1721 Arc::new(ToolRegistry::default()),
1722 Arc::new(BudgetContextPolicy::new(ContextConfig::default()).unwrap()),
1723 AgentConfig {
1724 system_prompt: "test".into(),
1725 max_steps: 2,
1726 history_limits: HistoryLimits::default(),
1727 },
1728 PathBuf::from("/tmp"),
1729 );
1730 let sink = Arc::new(CollectSink(Mutex::new(Vec::new())));
1731 let mut history = Vec::new();
1732 let outcome = runtime
1733 .run_turn(
1734 &mut history,
1735 "hello",
1736 sink.clone(),
1737 Arc::new(Allow),
1738 CancellationToken::new(),
1739 )
1740 .await
1741 .unwrap();
1742 assert_eq!(outcome.steps, 1);
1743 assert_eq!(history.len(), 2);
1744 assert!(matches!(
1745 sink.0.lock().unwrap().last(),
1746 Some(CoreEvent::AssistantCompleted { .. })
1747 ));
1748 }
1749
1750 #[test]
1751 fn context_keeps_tool_groups_together() {
1752 let policy = BudgetContextPolicy::new(ContextConfig {
1753 max_tokens: 120,
1754 reserve_output_tokens: 10,
1755 safety_margin_tokens: 10,
1756 bytes_per_token: 3,
1757 summary_max_chars: 120,
1758 })
1759 .unwrap();
1760 let history = vec![
1761 Message::user("old request ".repeat(20)),
1762 Message::Assistant {
1763 content: String::new(),
1764 tool_calls: vec![ToolCall {
1765 id: "1".into(),
1766 name: "read".into(),
1767 arguments: serde_json::json!({"path":"a"}),
1768 }],
1769 },
1770 Message::Tool {
1771 call_id: "1".into(),
1772 name: "read".into(),
1773 content: "result".into(),
1774 is_error: false,
1775 },
1776 Message::user("new"),
1777 ];
1778 let selection = policy.select(&history, "system", &[]).unwrap();
1779 assert!(selection.removed_messages > 0);
1780 assert_eq!(
1781 selection.removed_messages,
1782 history.len() - (selection.messages.len() - 1)
1783 );
1784 assert!(matches!(
1785 selection.messages.last(),
1786 Some(Message::User { .. })
1787 ));
1788 assert!(
1789 !selection
1790 .messages
1791 .iter()
1792 .any(|message| matches!(message, Message::Tool { call_id, .. } if call_id == "1"))
1793 );
1794 }
1795
1796 #[tokio::test]
1797 async fn repeated_history_trimming_rebuilds_the_note_and_makes_progress() {
1798 let runtime = AgentRuntime::new(
1799 Arc::new(ScriptedProvider {
1800 responses: Mutex::new(VecDeque::new()),
1801 }),
1802 Arc::new(ToolRegistry::default()),
1803 Arc::new(BudgetContextPolicy::new(ContextConfig::default()).unwrap()),
1804 AgentConfig {
1805 system_prompt: "test".into(),
1806 max_steps: 1,
1807 history_limits: HistoryLimits {
1808 max_bytes: 4096,
1809 max_messages: 3,
1810 note_max_chars: 80,
1811 },
1812 },
1813 PathBuf::from("/tmp"),
1814 );
1815 let sink = CollectSink(Mutex::new(Vec::new()));
1816 let mut history = vec![
1817 Message::HistoryNote {
1818 content: "previous trim".into(),
1819 },
1820 Message::user("old request"),
1821 Message::Assistant {
1822 content: "old answer".into(),
1823 tool_calls: Vec::new(),
1824 },
1825 Message::user("active request"),
1826 ];
1827 runtime
1828 .enforce_history_limits(&mut history, &sink)
1829 .await
1830 .unwrap();
1831 assert!(history.len() <= 3);
1832 assert!(matches!(history.first(), Some(Message::HistoryNote { .. })));
1833 assert!(matches!(history.last(), Some(Message::User { .. })));
1834 }
1835
1836 #[tokio::test]
1837 async fn active_turn_over_history_limit_rolls_back() {
1838 let runtime = AgentRuntime::new(
1839 Arc::new(ScriptedProvider {
1840 responses: Mutex::new(VecDeque::new()),
1841 }),
1842 Arc::new(ToolRegistry::default()),
1843 Arc::new(BudgetContextPolicy::new(ContextConfig::default()).unwrap()),
1844 AgentConfig {
1845 system_prompt: "test".into(),
1846 max_steps: 1,
1847 history_limits: HistoryLimits {
1848 max_bytes: 16,
1849 max_messages: 10,
1850 note_max_chars: 8,
1851 },
1852 },
1853 PathBuf::from("/tmp"),
1854 );
1855 let sink = CollectSink(Mutex::new(Vec::new()));
1856 let mut history = Vec::new();
1857 let result = runtime
1858 .run_turn(
1859 &mut history,
1860 "too large for the configured history",
1861 Arc::new(sink),
1862 Arc::new(Allow),
1863 CancellationToken::new(),
1864 )
1865 .await;
1866 assert!(matches!(result, Err(AgentError::HistoryLimit(_))));
1867 assert!(history.is_empty());
1868 }
1869
1870 #[tokio::test]
1871 async fn executes_a_multi_step_tool_loop_and_aggregates_usage() {
1872 let provider = Arc::new(ScriptedProvider {
1873 responses: Mutex::new(VecDeque::from([
1874 AssistantResponse {
1875 content: String::new(),
1876 tool_calls: vec![ToolCall {
1877 id: "call-1".into(),
1878 name: "echo".into(),
1879 arguments: serde_json::json!({"value":"hello"}),
1880 }],
1881 usage: Usage {
1882 input_tokens: Some(2),
1883 output_tokens: Some(1),
1884 },
1885 },
1886 AssistantResponse {
1887 content: "done".into(),
1888 tool_calls: Vec::new(),
1889 usage: Usage {
1890 input_tokens: Some(4),
1891 output_tokens: Some(2),
1892 },
1893 },
1894 ])),
1895 });
1896 let mut registry = ToolRegistry::default();
1897 registry.register(Arc::new(EchoTool)).unwrap();
1898 let runtime = AgentRuntime::new(
1899 provider,
1900 Arc::new(registry),
1901 Arc::new(BudgetContextPolicy::new(ContextConfig::default()).unwrap()),
1902 AgentConfig {
1903 system_prompt: "test".into(),
1904 max_steps: 3,
1905 history_limits: HistoryLimits::default(),
1906 },
1907 PathBuf::from("/tmp"),
1908 );
1909 let sink = Arc::new(CollectSink(Mutex::new(Vec::new())));
1910 let mut history = Vec::new();
1911 let outcome = runtime
1912 .run_turn(
1913 &mut history,
1914 "start",
1915 sink,
1916 Arc::new(Allow),
1917 CancellationToken::new(),
1918 )
1919 .await
1920 .unwrap();
1921 assert_eq!(outcome.steps, 2);
1922 assert_eq!(outcome.usage.input_tokens, Some(6));
1923 assert_eq!(outcome.usage.output_tokens, Some(3));
1924 assert!(matches!(
1925 history.get(2),
1926 Some(Message::Tool {
1927 content,
1928 is_error: false,
1929 ..
1930 }) if content == "hello"
1931 ));
1932 }
1933
1934 #[tokio::test]
1935 async fn denial_is_recorded_as_a_model_visible_tool_failure() {
1936 let provider = Arc::new(ScriptedProvider {
1937 responses: Mutex::new(VecDeque::from([
1938 AssistantResponse {
1939 content: String::new(),
1940 tool_calls: vec![ToolCall {
1941 id: "call-1".into(),
1942 name: "echo".into(),
1943 arguments: serde_json::json!({"value":"blocked"}),
1944 }],
1945 usage: Usage::default(),
1946 },
1947 AssistantResponse {
1948 content: "handled".into(),
1949 tool_calls: Vec::new(),
1950 usage: Usage::default(),
1951 },
1952 ])),
1953 });
1954 let mut registry = ToolRegistry::default();
1955 registry.register(Arc::new(EchoTool)).unwrap();
1956 let runtime = AgentRuntime::new(
1957 provider,
1958 Arc::new(registry),
1959 Arc::new(BudgetContextPolicy::new(ContextConfig::default()).unwrap()),
1960 AgentConfig {
1961 system_prompt: "test".into(),
1962 max_steps: 3,
1963 history_limits: HistoryLimits::default(),
1964 },
1965 PathBuf::from("/tmp"),
1966 );
1967 let mut history = Vec::new();
1968 runtime
1969 .run_turn(
1970 &mut history,
1971 "start",
1972 Arc::new(CollectSink(Mutex::new(Vec::new()))),
1973 Arc::new(Deny),
1974 CancellationToken::new(),
1975 )
1976 .await
1977 .unwrap();
1978 assert!(matches!(
1979 history.get(2),
1980 Some(Message::Tool {
1981 content,
1982 is_error: true,
1983 ..
1984 }) if content.contains("denied")
1985 ));
1986 }
1987
1988 #[tokio::test]
1989 async fn cancellation_during_approval_rolls_back_the_active_tool_group() {
1990 let provider = Arc::new(ScriptedProvider {
1991 responses: Mutex::new(VecDeque::from([AssistantResponse {
1992 content: String::new(),
1993 tool_calls: vec![ToolCall {
1994 id: "call-cancel".into(),
1995 name: "echo".into(),
1996 arguments: serde_json::json!({"value":"hello"}),
1997 }],
1998 usage: Usage::default(),
1999 }])),
2000 });
2001 let mut registry = ToolRegistry::default();
2002 registry.register(Arc::new(EchoTool)).unwrap();
2003 let runtime = AgentRuntime::new(
2004 provider,
2005 Arc::new(registry),
2006 Arc::new(BudgetContextPolicy::new(ContextConfig::default()).unwrap()),
2007 AgentConfig {
2008 system_prompt: "test".into(),
2009 max_steps: 2,
2010 history_limits: HistoryLimits::default(),
2011 },
2012 PathBuf::from("/tmp"),
2013 );
2014 let before = vec![
2015 Message::user("previous"),
2016 Message::Assistant {
2017 content: "answer".into(),
2018 tool_calls: Vec::new(),
2019 },
2020 ];
2021 let mut history = before.clone();
2022 let cancellation = CancellationToken::new();
2023 let cancel = cancellation.clone();
2024 let entered = Arc::new(Notify::new());
2025 let wait = Arc::clone(&entered);
2026 let run = runtime.run_turn(
2027 &mut history,
2028 "new turn",
2029 Arc::new(CollectSink(Mutex::new(Vec::new()))),
2030 Arc::new(WaitForCancellation { entered }),
2031 cancellation,
2032 );
2033 let cancel_when_waiting = async move {
2034 wait.notified().await;
2035 cancel.cancel();
2036 };
2037 let (result, ()) = tokio::join!(run, cancel_when_waiting);
2038 assert!(matches!(result, Err(AgentError::Cancelled)));
2039 assert_eq!(history, before);
2040 }
2041
2042 #[tokio::test]
2043 async fn stops_after_the_configured_maximum_step() {
2044 let provider = Arc::new(ScriptedProvider {
2045 responses: Mutex::new(VecDeque::from([AssistantResponse {
2046 content: String::new(),
2047 tool_calls: vec![ToolCall {
2048 id: "call-1".into(),
2049 name: "echo".into(),
2050 arguments: serde_json::json!({"value":"one"}),
2051 }],
2052 usage: Usage::default(),
2053 }])),
2054 });
2055 let mut registry = ToolRegistry::default();
2056 registry.register(Arc::new(EchoTool)).unwrap();
2057 let runtime = AgentRuntime::new(
2058 provider,
2059 Arc::new(registry),
2060 Arc::new(BudgetContextPolicy::new(ContextConfig::default()).unwrap()),
2061 AgentConfig {
2062 system_prompt: "test".into(),
2063 max_steps: 1,
2064 history_limits: HistoryLimits::default(),
2065 },
2066 PathBuf::from("/tmp"),
2067 );
2068 let result = runtime
2069 .run_turn(
2070 &mut Vec::new(),
2071 "start",
2072 Arc::new(CollectSink(Mutex::new(Vec::new()))),
2073 Arc::new(Allow),
2074 CancellationToken::new(),
2075 )
2076 .await;
2077 assert!(matches!(result, Err(AgentError::StepLimit)));
2078 }
2079
2080 #[test]
2081 fn duplicate_tool_registration_does_not_replace_the_original() {
2082 let mut registry = ToolRegistry::default();
2083 registry.register(Arc::new(EchoTool)).unwrap();
2084 assert!(registry.register(Arc::new(EchoTool)).is_err());
2085 assert_eq!(registry.tools.len(), 1);
2086 assert!(registry.get("echo").is_some());
2087 }
2088}