Skip to main content

markon_core/chat/
agent.rs

1//! Agent loop: drive provider stream, execute tool_use, append tool_result,
2//! repeat until the provider returns a stop_reason other than `tool_use`.
3
4use 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/// Streaming events emitted to the SSE client.
13#[derive(Debug, Clone, serde::Serialize)]
14#[serde(tag = "type", rename_all = "snake_case")]
15pub enum AgentEvent {
16    /// Sent first, once we know which thread we're posting to (creating a new
17    /// one if the request didn't supply an id).
18    ThreadAssigned { thread_id: String, title: String },
19    /// Assistant text delta.
20    Text { delta: String },
21    /// Tool call has begun (after the model finishes producing its `input`).
22    ToolStart {
23        id: String,
24        name: String,
25        input: serde_json::Value,
26    },
27    /// Tool produced a result (or error) — the agent is about to send it back
28    /// to the model.
29    ToolEnd {
30        id: String,
31        output: String,
32        is_error: bool,
33    },
34    /// `edit_file` tool has stashed a proposal and is now blocked waiting on
35    /// the user's accept/reject. The client should render a diff card and a
36    /// pending bottom-bar; resolution comes via `POST /:ws/_/chat/edits/{id}/{apply|reject}`.
37    EditPending {
38        /// Pending-edit id, matches the URL segment of the resolve endpoint.
39        id: String,
40        /// Tool-use id from the originating `tool_use` block, so the client
41        /// can correlate this card with the right turn.
42        tool_use_id: String,
43        /// Workspace-relative path the model proposed editing.
44        path: String,
45        /// 1-based line number of `old_string`'s first character at propose-time.
46        line: usize,
47        old_string: String,
48        new_string: String,
49    },
50    /// One full provider turn ended; agent may loop again with tool results.
51    TurnEnd { stop_reason: String, usage: Usage },
52    /// The whole agent loop has ended for this user turn.
53    Done {
54        total_usage: Usage,
55        final_message_seq: Option<i64>,
56    },
57    /// Fatal error; stream is about to close.
58    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    /// Full prior history (already persisted).
68    pub history: Vec<Message>,
69    /// The new user turn we just appended (already persisted).
70    pub user_message: Message,
71    pub system: Vec<SystemBlock>,
72    pub model: String,
73    pub max_steps: u8,
74    pub max_tokens: u32,
75    /// Pending-edit store the `edit_file` tool stashes proposals into. Cloned
76    /// from the parent workspace so HTTP `apply`/`reject` handlers can find
77    /// the awaiting tool.
78    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    /// Run the agent loop and forward events on `sink`. Returns once the loop
101    /// terminates (success, error, or sink closed).
102    pub(crate) async fn run(&self, request: AgentRequest, sink: mpsc::Sender<AgentEvent>) {
103        // Tell the client which thread we landed in *before* we burn API budget.
104        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            // Short-circuit if the SSE client already went away — no point
135            // burning another provider call (and its prompt-cache state) on
136            // events nobody will read.
137            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            // Race each provider chunk against `sink.closed()` so the moment
166            // the SSE client disconnects we drop the stream and stop billing
167            // tokens, rather than draining the rest of the provider response
168            // (which can keep going for a long time on a `tool_use` turn).
169            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                        // Final tool input arrives in `MessageEnd.content`; we
199                        // dispatch from there in one place to keep ordering
200                        // deterministic relative to text blocks.
201                    }
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            // Persist the assistant turn now (before tool execution) so the
231            // record exists even if a tool blows up mid-loop.
232            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            // Decide whether to loop. Anthropic uses "tool_use"; OpenAI we
256            // already mapped to the same string in the provider layer.
257            if stop_reason != "tool_use" {
258                break;
259            }
260
261            // Collect all tool_uses in the order they appeared.
262            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                // Provider claimed tool_use but emitted none — bail rather than
274                // spin forever.
275                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            // Persist the synthetic user-turn carrying tool results, then add
308            // it to the in-memory history for the next provider call.
309            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            // Last iteration — prevent runaway.
327            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/// Helper: convert a slice of [`ContentBlock`] into a single string for
350/// places that need a flat representation (titles, summaries).
351#[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
365/// Generate a thread title from the first user message — first sentence,
366/// trimmed to 64 chars. Plain heuristic; the user can rename.
367pub(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}