Skip to main content

a_agent/agent/
agent.rs

1use std::sync::{Arc, Mutex};
2
3use anyhow::{Context, Result};
4use tokio_util::sync::CancellationToken;
5
6use crate::model::{ContentBlock, ModelMessage, ModelRequest, Role, StreamEvent};
7use crate::provider::{EventSink, Provider};
8use crate::session::SessionStore;
9use crate::tools::runner::ToolRunner;
10
11pub struct Agent {
12    provider: Arc<dyn Provider>,
13    tools: Arc<ToolRunner>,
14    store: Arc<Mutex<SessionStore>>,
15    session_id: String,
16    system_prompt: String,
17    max_cycles: usize,
18    context_window: Option<u64>,
19    max_output_tokens: u64,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct AgentResult {
24    pub final_text: Option<String>,
25    pub cycles: usize,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub struct ContextStatus {
30    pub used_tokens: u64,
31    pub provider_tokens: Option<u64>,
32    pub estimated_tokens: u64,
33    pub context_window: Option<u64>,
34    pub compact_at: Option<u64>,
35    pub max_output_tokens: u64,
36}
37
38struct ContextEstimate {
39    total: u64,
40    provider: Option<u64>,
41    estimated: u64,
42}
43
44impl Agent {
45    pub fn new(
46        provider: Arc<dyn Provider>,
47        tools: Arc<ToolRunner>,
48        store: Arc<Mutex<SessionStore>>,
49        session_id: String,
50        system_prompt: String,
51        max_cycles: usize,
52    ) -> Self {
53        Self {
54            provider,
55            tools,
56            store,
57            session_id,
58            system_prompt,
59            max_cycles,
60            context_window: None,
61            max_output_tokens: 0,
62        }
63    }
64
65    pub fn with_context_budget(
66        mut self,
67        context_window: Option<u64>,
68        max_output_tokens: u64,
69    ) -> Self {
70        self.context_window = context_window;
71        self.max_output_tokens = max_output_tokens;
72        self
73    }
74
75    pub async fn submit(
76        &self,
77        prompt: &str,
78        events: EventSink,
79        cancel: CancellationToken,
80    ) -> Result<AgentResult> {
81        self.compact_if_needed(Some(prompt), &events, &cancel)
82            .await?;
83        // File snapshots are keyed by the turn that produced them, so a rewind to
84        // this checkpoint knows exactly which changes came after it.
85        let turn_item_id = self
86            .store
87            .lock()
88            .map_err(|_| anyhow::anyhow!("session store lock poisoned"))?
89            .append_item(
90                &self.session_id,
91                Role::User,
92                vec![ContentBlock::Text(prompt.into())],
93            )?
94            .id;
95
96        for cycle in 1..=self.max_cycles {
97            if cancel.is_cancelled() {
98                anyhow::bail!("agent turn cancelled");
99            }
100            self.compact_if_needed(None, &events, &cancel).await?;
101            let messages = self
102                .store
103                .lock()
104                .map_err(|_| anyhow::anyhow!("session store lock poisoned"))?
105                .active_branch(&self.session_id)?
106                .into_iter()
107                .map(|item| ModelMessage {
108                    role: item.role,
109                    blocks: item.blocks,
110                })
111                .collect();
112            let request = ModelRequest {
113                system_prompt: self.system_prompt.clone(),
114                messages,
115                include_tools: true,
116            };
117            events.emit(StreamEvent::GenerationStart);
118            let turn = self
119                .provider
120                .stream_turn(request, events.clone(), cancel.clone())
121                .await?;
122            if !turn.blocks.is_empty() {
123                self.store
124                    .lock()
125                    .map_err(|_| anyhow::anyhow!("session store lock poisoned"))?
126                    .append_assistant_item(&self.session_id, turn.blocks.clone(), turn.usage)?;
127            }
128            if let Some(state) = &turn.provider_state {
129                self.store
130                    .lock()
131                    .map_err(|_| anyhow::anyhow!("session store lock poisoned"))?
132                    .set_provider_state(&self.session_id, "continuation", state)?;
133            }
134            if turn.tool_calls.is_empty() {
135                events.emit(StreamEvent::Done);
136                return Ok(AgentResult {
137                    final_text: turn.final_text(),
138                    cycles: cycle,
139                });
140            }
141            for call in &turn.tool_calls {
142                events.emit(StreamEvent::ToolExecutionStart {
143                    id: call.id.clone(),
144                });
145            }
146            let results = self
147                .tools
148                .execute_with(turn.tool_calls, events.clone(), cancel.clone())
149                .await;
150            for outcome in results {
151                let result = outcome.result;
152                if !outcome.snapshots.is_empty() {
153                    self.store
154                        .lock()
155                        .map_err(|_| anyhow::anyhow!("session store lock poisoned"))?
156                        .record_file_snapshots(
157                            &self.session_id,
158                            &turn_item_id,
159                            &outcome.snapshots,
160                        )?;
161                }
162                events.emit(StreamEvent::ToolExecutionEnd {
163                    id: result.call_id.clone(),
164                    result: result.clone(),
165                });
166                self.store
167                    .lock()
168                    .map_err(|_| anyhow::anyhow!("session store lock poisoned"))?
169                    .append_item(
170                        &self.session_id,
171                        Role::Tool,
172                        vec![ContentBlock::ToolResult(result)],
173                    )?;
174            }
175        }
176        Err(anyhow::anyhow!(
177            "maximum agent cycles ({}) exceeded",
178            self.max_cycles
179        ))
180        .context("maximum agent cycles reached before a final response")
181    }
182
183    async fn compact_if_needed(
184        &self,
185        additional_prompt: Option<&str>,
186        events: &EventSink,
187        cancel: &CancellationToken,
188    ) -> Result<()> {
189        let Some(context_window) = self.context_window else {
190            return Ok(());
191        };
192        let threshold = context_window.saturating_sub(self.max_output_tokens);
193        let branch = self
194            .store
195            .lock()
196            .map_err(|_| anyhow::anyhow!("session store lock poisoned"))?
197            .active_branch(&self.session_id)?;
198        if let Some(summary_index) = branch.iter().rposition(is_compaction_summary)
199            && !branch[summary_index + 1..].iter().any(has_valid_usage)
200        {
201            return Ok(());
202        }
203        if branch.is_empty()
204            || estimate_context_tokens(&self.system_prompt, &branch, additional_prompt).total
205                <= threshold
206        {
207            return Ok(());
208        }
209        self.compact_branch(branch, events, cancel).await
210    }
211
212    async fn compact_branch(
213        &self,
214        branch: Vec<crate::model::ConversationItem>,
215        events: &EventSink,
216        cancel: &CancellationToken,
217    ) -> Result<()> {
218        let messages = branch
219            .iter()
220            .map(|item| ModelMessage {
221                role: item.role,
222                blocks: item.blocks.clone(),
223            })
224            .collect();
225        let request = ModelRequest {
226            system_prompt: "Summarize this coding-agent conversation for continuation. Preserve user goals, decisions, modified files, tool results, failures, unresolved work, and exact technical constraints. Return only the compact summary and do not call tools.".into(),
227            messages,
228            include_tools: false,
229        };
230        events.emit(StreamEvent::GenerationStart);
231        let result = self
232            .provider
233            .stream_turn(request, EventSink::default(), cancel.clone())
234            .await;
235        events.emit(StreamEvent::Done);
236        let turn = result.context("compact conversation")?;
237        if !turn.tool_calls.is_empty() {
238            anyhow::bail!("compaction model attempted to call tools");
239        }
240        let summary = turn
241            .final_text()
242            .context("compaction model returned no summary")?;
243        self.store
244            .lock()
245            .map_err(|_| anyhow::anyhow!("session store lock poisoned"))?
246            .replace_branch_with_summary(&self.session_id, &summary)?;
247        Ok(())
248    }
249
250    pub async fn compact(&self, events: EventSink, cancel: CancellationToken) -> Result<bool> {
251        let branch = self
252            .store
253            .lock()
254            .map_err(|_| anyhow::anyhow!("session store lock poisoned"))?
255            .active_branch(&self.session_id)?;
256        if branch.is_empty() {
257            return Ok(false);
258        }
259        self.compact_branch(branch, &events, &cancel).await?;
260        Ok(true)
261    }
262
263    pub fn context_status(&self) -> Result<ContextStatus> {
264        let branch = self
265            .store
266            .lock()
267            .map_err(|_| anyhow::anyhow!("session store lock poisoned"))?
268            .active_branch(&self.session_id)?;
269        let estimate = estimate_context_tokens(&self.system_prompt, &branch, None);
270        Ok(ContextStatus {
271            used_tokens: estimate.total,
272            provider_tokens: estimate.provider,
273            estimated_tokens: estimate.estimated,
274            context_window: self.context_window,
275            compact_at: self
276                .context_window
277                .map(|window| window.saturating_sub(self.max_output_tokens)),
278            max_output_tokens: self.max_output_tokens,
279        })
280    }
281
282    pub fn record_interruption(&self) -> Result<()> {
283        self.store
284            .lock()
285            .map_err(|_| anyhow::anyhow!("session store lock poisoned"))?
286            .append_turn_interrupted(&self.session_id)?;
287        Ok(())
288    }
289}
290
291fn is_compaction_summary(item: &crate::model::ConversationItem) -> bool {
292    item.blocks.iter().any(|block| {
293        matches!(block, ContentBlock::Text(text) if text.starts_with(crate::session::CONVERSATION_SUMMARY_PREFIX))
294    })
295}
296
297fn has_valid_usage(item: &crate::model::ConversationItem) -> bool {
298    item.role == Role::Assistant
299        && item
300            .usage
301            .and_then(|usage| usage.context_tokens())
302            .is_some_and(|tokens| tokens > 0)
303}
304
305fn estimate_context_tokens(
306    system_prompt: &str,
307    branch: &[crate::model::ConversationItem],
308    additional_prompt: Option<&str>,
309) -> ContextEstimate {
310    let usage_anchor = branch.iter().enumerate().rev().find_map(|(index, item)| {
311        (item.role == Role::Assistant)
312            .then_some(item.usage)
313            .flatten()
314            .and_then(|usage| usage.context_tokens())
315            .filter(|tokens| *tokens > 0)
316            .map(|tokens| (index, tokens))
317    });
318    let (start, provider, mut estimated) = usage_anchor.map_or_else(
319        || {
320            let tools =
321                serde_json::to_string(&crate::provider::tool_definitions()).unwrap_or_default();
322            (
323                0,
324                None,
325                estimate_text_tokens(system_prompt) + estimate_text_tokens(&tools),
326            )
327        },
328        |(index, tokens)| (index + 1, Some(tokens), 0),
329    );
330    for item in &branch[start..] {
331        estimated += estimate_blocks_tokens(&item.blocks);
332    }
333    if let Some(prompt) = additional_prompt {
334        estimated += estimate_text_tokens(prompt);
335    }
336    ContextEstimate {
337        total: provider.unwrap_or(0) + estimated,
338        provider,
339        estimated,
340    }
341}
342
343fn estimate_blocks_tokens(blocks: &[ContentBlock]) -> u64 {
344    let characters = blocks
345        .iter()
346        .map(|block| match block {
347            ContentBlock::Text(text) | ContentBlock::Reasoning(text) => text.chars().count(),
348            ContentBlock::ToolCall(call) => {
349                call.name.chars().count() + call.arguments.chars().count()
350            }
351            ContentBlock::ToolResult(result) => result.output.chars().count(),
352        })
353        .sum::<usize>();
354    characters.div_ceil(4) as u64
355}
356
357fn estimate_text_tokens(text: &str) -> u64 {
358    text.chars().count().div_ceil(4) as u64
359}