1use crate::chat::message::{ContentBlock, Message, Role, Usage};
5use crate::chat::provider::{ChatRequest, Provider, ProviderEvent, SystemBlock};
6use crate::chat::storage::ChatStorage;
7use crate::chat::tools::{ToolContext, ToolError, ToolRegistry};
8use futures::StreamExt;
9use std::sync::Arc;
10use tokio::sync::mpsc;
11
12#[derive(Debug, Clone, serde::Serialize)]
14#[serde(tag = "type", rename_all = "snake_case")]
15pub enum AgentEvent {
16 ThreadAssigned { thread_id: String, title: String },
19 Text { delta: String },
21 ToolStart {
23 id: String,
24 name: String,
25 input: serde_json::Value,
26 },
27 ToolEnd {
30 id: String,
31 output: String,
32 is_error: bool,
33 },
34 EditPending {
38 id: String,
40 tool_use_id: String,
43 path: String,
45 line: usize,
47 old_string: String,
48 new_string: String,
49 },
50 TurnEnd { stop_reason: String, usage: Usage },
52 Done {
54 total_usage: Usage,
55 final_message_seq: Option<i64>,
56 },
57 Error { message: String },
59}
60
61pub(crate) struct AgentRequest {
62 pub thread_id: String,
63 pub thread_title: String,
64 #[allow(dead_code)]
65 pub workspace_id: String,
66 pub workspace_root: std::path::PathBuf,
67 pub history: Vec<Message>,
69 pub user_message: Message,
71 pub system: Vec<SystemBlock>,
72 pub model: String,
73 pub max_steps: u8,
74 pub max_tokens: u32,
75 pub pending_edits: Arc<crate::chat::edits::PendingEditStore>,
79}
80
81pub(crate) struct Agent {
82 pub provider: Arc<dyn Provider>,
83 pub tools: Arc<ToolRegistry>,
84 pub storage: ChatStorage,
85}
86
87impl Agent {
88 pub(crate) fn new(
89 provider: Arc<dyn Provider>,
90 tools: Arc<ToolRegistry>,
91 storage: ChatStorage,
92 ) -> Self {
93 Self {
94 provider,
95 tools,
96 storage,
97 }
98 }
99
100 pub(crate) async fn run(&self, request: AgentRequest, sink: mpsc::Sender<AgentEvent>) {
103 let _ = sink
105 .send(AgentEvent::ThreadAssigned {
106 thread_id: request.thread_id.clone(),
107 title: request.thread_title.clone(),
108 })
109 .await;
110
111 let mut messages = request.history.clone();
112 messages.push(request.user_message);
113 let tool_schemas = self.tools.schemas();
114 let tool_ctx = match ToolContext::new(&request.workspace_root) {
115 Ok(ctx) => ctx.with_chat_state(request.pending_edits.clone(), sink.clone()),
116 Err(e) => {
117 tracing::error!(
118 "failed to canonicalize workspace root {}: {e}",
119 request.workspace_root.display()
120 );
121 let _ = sink
122 .send(AgentEvent::Error {
123 message: format!("workspace root unavailable: {e}"),
124 })
125 .await;
126 return;
127 }
128 };
129
130 let mut total_usage = Usage::default();
131 let mut last_seq: Option<i64> = None;
132
133 for step in 0..request.max_steps {
134 if sink.is_closed() {
138 return;
139 }
140
141 let chat_req = ChatRequest {
142 model: request.model.clone(),
143 system: request.system.clone(),
144 messages: messages.clone(),
145 tools: tool_schemas.clone(),
146 max_tokens: request.max_tokens,
147 };
148
149 let mut stream = match self.provider.stream(chat_req).await {
150 Ok(s) => s,
151 Err(e) => {
152 let _ = sink
153 .send(AgentEvent::Error {
154 message: format!("provider: {e}"),
155 })
156 .await;
157 return;
158 }
159 };
160
161 let mut turn_content: Vec<ContentBlock> = Vec::new();
162 let mut turn_usage = Usage::default();
163 let mut stop_reason = String::from("end_turn");
164
165 loop {
170 let ev = tokio::select! {
171 biased;
172 () = sink.closed() => return,
173 next = stream.next() => match next {
174 Some(ev) => ev,
175 None => break,
176 },
177 };
178 match ev {
179 Ok(ProviderEvent::TextDelta(text)) => {
180 if sink.send(AgentEvent::Text { delta: text }).await.is_err() {
181 return;
182 }
183 }
184 Ok(ProviderEvent::ToolUseStart { id, name }) => {
185 if sink
186 .send(AgentEvent::ToolStart {
187 id,
188 name,
189 input: serde_json::Value::Null,
190 })
191 .await
192 .is_err()
193 {
194 return;
195 }
196 }
197 Ok(ProviderEvent::ToolUseEnd { .. }) => {
198 }
202 Ok(ProviderEvent::MessageEnd {
203 stop_reason: reason,
204 usage,
205 content,
206 }) => {
207 turn_content = content;
208 turn_usage = usage;
209 stop_reason = reason;
210 }
211 Err(e) => {
212 let _ = sink
213 .send(AgentEvent::Error {
214 message: format!("provider stream: {e}"),
215 })
216 .await;
217 return;
218 }
219 }
220 }
221
222 total_usage.add(&turn_usage);
223 let _ = sink
224 .send(AgentEvent::TurnEnd {
225 stop_reason: stop_reason.clone(),
226 usage: turn_usage,
227 })
228 .await;
229
230 match self
233 .storage
234 .append_message(&request.thread_id, Role::Assistant, &turn_content)
235 .await
236 {
237 Ok(stored) => {
238 last_seq = Some(stored.seq);
239 }
240 Err(e) => {
241 let _ = sink
242 .send(AgentEvent::Error {
243 message: format!("persist assistant: {e}"),
244 })
245 .await;
246 return;
247 }
248 }
249
250 messages.push(Message {
251 role: Role::Assistant,
252 content: turn_content.clone(),
253 });
254
255 if stop_reason != "tool_use" {
258 break;
259 }
260
261 let tool_uses: Vec<(String, String, serde_json::Value)> = turn_content
263 .iter()
264 .filter_map(|b| match b {
265 ContentBlock::ToolUse { id, name, input } => {
266 Some((id.clone(), name.clone(), input.clone()))
267 }
268 _ => None,
269 })
270 .collect();
271
272 if tool_uses.is_empty() {
273 break;
276 }
277
278 let mut tool_results: Vec<ContentBlock> = Vec::new();
279 for (id, name, input) in tool_uses {
280 let (output, is_error) =
281 match self.tools.dispatch(&tool_ctx, &name, input.clone()).await {
282 Ok(out) => (out, false),
283 Err(ToolError::NotFound(p)) => (format!("not found: {p}"), true),
284 Err(e) => (e.to_tool_message(), true),
285 };
286 let _ = sink
287 .send(AgentEvent::ToolStart {
288 id: id.clone(),
289 name: name.clone(),
290 input,
291 })
292 .await;
293 let _ = sink
294 .send(AgentEvent::ToolEnd {
295 id: id.clone(),
296 output: output.clone(),
297 is_error,
298 })
299 .await;
300 tool_results.push(ContentBlock::ToolResult {
301 tool_use_id: id,
302 content: output,
303 is_error,
304 });
305 }
306
307 if let Err(e) = self
310 .storage
311 .append_message(&request.thread_id, Role::User, &tool_results)
312 .await
313 {
314 let _ = sink
315 .send(AgentEvent::Error {
316 message: format!("persist tool results: {e}"),
317 })
318 .await;
319 return;
320 }
321 messages.push(Message {
322 role: Role::User,
323 content: tool_results,
324 });
325
326 if step + 1 == request.max_steps {
328 let _ = sink
329 .send(AgentEvent::Error {
330 message: format!(
331 "agent reached max_steps={} without finishing — stopping",
332 request.max_steps
333 ),
334 })
335 .await;
336 return;
337 }
338 }
339
340 let _ = sink
341 .send(AgentEvent::Done {
342 total_usage,
343 final_message_seq: last_seq,
344 })
345 .await;
346 }
347}
348
349#[allow(dead_code)]
352pub(crate) fn flatten_text(blocks: &[ContentBlock]) -> String {
353 let mut out = String::new();
354 for b in blocks {
355 if let ContentBlock::Text { text } = b {
356 if !out.is_empty() {
357 out.push('\n');
358 }
359 out.push_str(text);
360 }
361 }
362 out
363}
364
365pub(crate) fn auto_title(text: &str) -> String {
368 let trimmed = text.trim();
369 let first_line = trimmed.lines().next().unwrap_or("").trim();
370 let first_sentence = first_line
371 .split(['.', '。', '!', '?', '\n'])
372 .next()
373 .unwrap_or("");
374 let take = 64;
375 let mut out: String = first_sentence.chars().take(take).collect();
376 if first_sentence.chars().count() > take {
377 out.push('…');
378 }
379 if out.is_empty() {
380 "New chat".to_string()
381 } else {
382 out
383 }
384}
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389
390 #[test]
391 fn auto_title_takes_first_sentence() {
392 assert_eq!(auto_title("Hello there. Second sentence."), "Hello there");
393 assert_eq!(auto_title("中文标题。多余的内容"), "中文标题");
394 assert_eq!(auto_title(""), "New chat");
395 }
396
397 #[test]
398 fn flatten_text_concatenates_text_blocks() {
399 let blocks = vec![
400 ContentBlock::Text {
401 text: "first".into(),
402 },
403 ContentBlock::ToolUse {
404 id: "x".into(),
405 name: "grep".into(),
406 input: serde_json::json!({}),
407 },
408 ContentBlock::Text {
409 text: "second".into(),
410 },
411 ];
412 assert_eq!(flatten_text(&blocks), "first\nsecond");
413 }
414}