Skip to main content

scv_core/
lib.rs

1//! SCV's provider-independent agent loop and extension traits.
2
3use 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        /// Images that come with the text, for a model that accepts image
24        /// input. History keeps their paths, not their bytes.
25        #[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    /// A user message of plain text.
46    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
66/// What one image costs in context, whatever its size: providers scale
67/// images down to a bounded number of tiles.
68pub const IMAGE_TOKENS: usize = 1600;
69
70/// An image file shown to the model with a user message.
71#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
72pub struct ImageInput {
73    /// Absolute path on the host; the provider reads it for each request, so
74    /// an image removed since is replaced by a note.
75    pub path: PathBuf,
76    /// MIME type, such as `image/png`.
77    pub mime: String,
78}
79
80/// The user's side of a turn: text, and images for a model that accepts them.
81#[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    /// Sends a request to a host outside the auto-approved set, whose URL can
124    /// carry data the model has read.
125    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    /// The risk named by [`ToolRisk::as_str`], such as one a nested SCV
140    /// reported for its own tool call.
141    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    /// Where the tool may report short status lines while it runs.
159    pub progress: ProgressSink,
160    /// The session's approval gate, for a tool relaying a nested agent's own
161    /// approval requests.
162    pub approvals: ToolApprovals,
163}
164
165impl ToolContext {
166    /// A context whose progress reports go nowhere and whose relayed
167    /// approval requests are denied.
168    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/// The session's approval gate as seen by one running tool call. A tool that
179/// drives a nested agent (such as another SCV) asks it on the nested agent's
180/// behalf, so the session's policy and its user decide every nested side
181/// effect too. Without a gate, every request is denied.
182#[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    /// Requests for the tool call `call_id`, decided by `gate`.
200    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    /// Ask the session's gate to approve a nested agent's tool call. The
212    /// request carries this tool call's ID; `name`, `risk`, and `summary`
213    /// describe the nested call.
214    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
239/// Longest progress line a tool can report; longer lines are cut.
240pub const MAX_PROGRESS_LINE_BYTES: usize = 200;
241/// Largest progress event: the newest lines reported since the previous
242/// event, with older ones dropped first.
243pub const MAX_PROGRESS_EVENT_BYTES: usize = 512;
244/// Minimum spacing of one call's progress events (at most two a second).
245pub const PROGRESS_INTERVAL: Duration = Duration::from_millis(500);
246
247/// Where a running tool reports short status lines, such as a delegated
248/// agent's commands. Each report becomes one bounded line; the runtime
249/// forwards the pending lines to the client at most twice a second and never
250/// adds them to the model's history. The default sink discards reports, so a
251/// tool may always report.
252#[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    /// A sink that keeps reports until the runtime takes them.
268    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    /// Report one status line. Control characters and line breaks become
279    /// spaces and the line is cut to `MAX_PROGRESS_LINE_BYTES`.
280    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    /// The lines reported since the previous call, as one event text of at
294    /// most `MAX_PROGRESS_EVENT_BYTES`, or `None` when nothing is pending.
295    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
304/// Marker for lines dropped from the front of an event.
305const PROGRESS_ELIDED: &str = "…";
306
307#[derive(Debug, Default)]
308struct PendingProgress {
309    lines: VecDeque<String>,
310    /// Joined length of `lines`, separators included.
311    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        // Leave room for the elision marker and its separator.
320        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
349/// One bounded display line: control characters become spaces, runs of
350/// whitespace collapse, and the result is cut on a character boundary.
351fn 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    /// Status lines a running tool reported, for display only.
537    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
1182/// Run a tool while forwarding what it reports to `sink`, at most one
1183/// `ToolProgress` event per `PROGRESS_INTERVAL`. Progress is best effort: a
1184/// sink error stops forwarding but never fails the tool.
1185async 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    // Lines reported since the last event, when sending them keeps the pace.
1213    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(&note, 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        // Older history without images reads back unchanged.
1361        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        // 2.2 seconds of work at two events a second: four ticks, plus at
1685        // most one final flush.
1686        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        // Lines reported within an interval of the last event are dropped
1701        // rather than breaking the pace; `ToolCompleted` follows at once.
1702        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}