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 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}