| 1 | use anyhow::{bail, Result}; |
| 2 | use rusqlite::Connection |
| 3 | use serde_json::Value |
| 4 | use std::cell::RefCell |
| 5 | use std::sync::atomic |
| 6 | |
| 7 | use crate::db; |
| 8 | use crate::llm::{self |
| 9 | use crate::tools; |
| 10 | |
| 11 | pub const SYSTEM_PROMPT: |
| 12 | |
| 13 | You have these tools: read_file, write_file, edit_file, delete_file, list_files, search, run_command, task, web_search, web_fetch. |
| 14 | |
| 15 | Guidelines: |
| 16 | 1. Explore first. Read files and search the codebase before making changes. |
| 17 | 2. Make small, focused changes. Prefer edit_file (exact substring replacement) over rewriting whole files. |
| 18 | 3. After meaningful changes, verify with run_command (e.g. cargo check, npm test). |
| 19 | 4. Report clearly what you changed and how to run or verify it. |
| 20 | 5. Never claim something works unless you verified it. |
| 21 | 6. Tool output may be truncated. Re-read files when you need the full picture. |
| 22 | 7. If a tool returns an error, fix your arguments and try again. |
| 23 | 8. Delegate with the task tool when a task is large or exploratory: use 'explore' for read-only research, 'general' for multi-step work. Provide a very detailed prompt to the sub-agent. |
| 24 | 9. Use web_search for current or external information (the current year is 2026), then web_fetch to read the pages behind promising results. |
| 25 | "#; |
| 26 | |
| 27 | const MAX_ROUNDS: usize |
| 28 | const MAX_SUBAGENT_DEPTH: usize |
| 29 | |
| 30 | pub enum AgentEvent |
| 31 | Delta(String), |
| 32 | ToolDelta(String), |
| 33 | ToolStart { name |
| 34 | ToolResult { result |
| 35 | Retry(String), |
| 36 | Failed(String), |
| 37 | Finished, |
| 38 | } |
| 39 | |
| 40 | pub fn run( |
| 41 | conn: &Connection |
| 42 | session_id: i64 |
| 43 | cfg: &ProviderConfig |
| 44 | model: &str |
| 45 | user_input: &str |
| 46 | on_event: Option |
| 47 | ) -> Result<String |
| 48 | let cancel = |
| 49 | run_cancellable(conn, session_id |
| 50 | } |
| 51 | |
| 52 | #[allow(clippy:: |
| 53 | pub fn run_cancellable( |
| 54 | conn: &Connection |
| 55 | session_id: i64 |
| 56 | cfg: &ProviderConfig |
| 57 | model: &str |
| 58 | user_input: &str |
| 59 | images: &[ImagePart |
| 60 | mut on_event: Option |
| 61 | cancel: &AtomicBool |
| 62 | ) -> Result<String |
| 63 | let images_json = |
| 64 | None |
| 65 | } else { |
| 66 | Some(serde_json:: |
| 67 | }; |
| 68 | db::add_message(conn |
| 69 | db::touch_session(conn |
| 70 | if let Some |
| 71 | && s.name.is_empty |
| 72 | db::set_session_name(conn |
| 73 | } |
| 74 | |
| 75 | let streaming = |
| 76 | let mut noop |
| 77 | let sink: & |
| 78 | Some(f) |
| 79 | None => &mut |
| 80 | }; |
| 81 | let mut last_content |
| 82 | |
| 83 | for _ in 0 |
| 84 | if cancel.load( |
| 85 | bail!("cancelled"); |
| 86 | } |
| 87 | let stored = |
| 88 | let messages: Vec |
| 89 | let resp: LlmResponse |
| 90 | let cell = |
| 91 | let mut llm_sink |
| 92 | let mut s |
| 93 | s(AgentEvent::Delta |
| 94 | }; |
| 95 | let mut retry_sink |
| 96 | let mut s |
| 97 | s(AgentEvent::Retry |
| 98 | "Retrying ({attempt}/{max} |
| 99 | wait_ms / 1000 |
| 100 | ))); |
| 101 | }; |
| 102 | llm::chat_with_retries( |
| 103 | cfg, |
| 104 | model, |
| 105 | SYSTEM_PROMPT, |
| 106 | &messages, |
| 107 | &tools::definitions(), |
| 108 | Some(&mut |
| 109 | Some(&mut |
| 110 | cancel, |
| 111 | )? |
| 112 | } else { |
| 113 | llm::chat_with_retries( |
| 114 | cfg, |
| 115 | model, |
| 116 | SYSTEM_PROMPT, |
| 117 | &messages, |
| 118 | &tools::definitions(), |
| 119 | None, |
| 120 | None, |
| 121 | cancel, |
| 122 | )? |
| 123 | }; |
| 124 | |
| 125 | let calls_json = |
| 126 | None |
| 127 | } else { |
| 128 | Some(serde_json:: |
| 129 | }; |
| 130 | last_content = resp. |
| 131 | db::add_message(conn |
| 132 | |
| 133 | if resp.tool_calls.is_empty |
| 134 | return Ok(resp |
| 135 | } |
| 136 | |
| 137 | for tc in & |
| 138 | if streaming { |
| 139 | (*sink)(AgentEvent |
| 140 | name: tc.name |
| 141 | args: tc.arguments |
| 142 | }); |
| 143 | } else { |
| 144 | eprintln!(" → {} |
| 145 | } |
| 146 | let (result, |
| 147 | let t = |
| 148 | let mut sub_sink |
| 149 | let line = |
| 150 | AgentEvent::Delta( |
| 151 | AgentEvent::ToolStart |
| 152 | AgentEvent::Retry( |
| 153 | _ => return, |
| 154 | }; |
| 155 | (*sink)(AgentEvent |
| 156 | }; |
| 157 | match run_subagent_cancellable( |
| 158 | conn, |
| 159 | session_id, |
| 160 | cfg, |
| 161 | model, |
| 162 | &t.subagent_type, |
| 163 | &t.prompt, |
| 164 | 1, |
| 165 | Some(&mut |
| 166 | cancel, |
| 167 | ) { |
| 168 | Ok(r) |
| 169 | Err(e) |
| 170 | } |
| 171 | } else { |
| 172 | match tools::execute( |
| 173 | Ok(r) |
| 174 | Err(e) |
| 175 | } |
| 176 | }; |
| 177 | if streaming { |
| 178 | (*sink)(AgentEvent |
| 179 | result: result.clone |
| 180 | is_error, |
| 181 | }); |
| 182 | } else { |
| 183 | eprintln!(" → {} |
| 184 | } |
| 185 | db::add_message(conn |
| 186 | } |
| 187 | } |
| 188 | bail!( |
| 189 | "Agent stopped after {MAX_ROUNDS} tool-call rounds without finishing. Last output: {}" |
| 190 | truncate(&last_content, |
| 191 | ) |
| 192 | } |
| 193 | |
| 194 | struct TaskArgs { |
| 195 | subagent_type: String |
| 196 | prompt: String |
| 197 | } |
| 198 | |
| 199 | fn parse_task(args: |
| 200 | let subagent_type = |
| 201 | .get("subagent_type") |
| 202 | .and_then(|v| |
| 203 | .unwrap_or("general") |
| 204 | .to_string(); |
| 205 | let prompt = |
| 206 | TaskArgs { subagent_type |
| 207 | } |
| 208 | |
| 209 | fn subagent_system(subagent_type: |
| 210 | match subagent_type { |
| 211 | "explore" | "scout" |
| 212 | _ => r#"You are a general-purpose agent. You can research complex questions, execute multi-step tasks, and make file changes. Use your tools to complete the task described by the user. Make small, focused changes, and verify them when possible. When you are done, provide a clear summary of what you did and how to verify it. |
| 213 | } |
| 214 | } |
| 215 | |
| 216 | #[allow(clippy:: |
| 217 | pub fn run_subagent_cancellable( |
| 218 | conn: &Connection |
| 219 | session_id: i64 |
| 220 | cfg: &ProviderConfig |
| 221 | model: &str |
| 222 | subagent_type: &str |
| 223 | prompt: &str |
| 224 | depth: usize |
| 225 | mut on_event: Option |
| 226 | cancel: &AtomicBool |
| 227 | ) -> Result<String |
| 228 | if depth > MAX_SUBAGENT_DEPTH |
| 229 | bail!("sub-agent nesting too deep (max depth {MAX_SUBAGENT_DEPTH |
| 230 | } |
| 231 | if prompt.trim() |
| 232 | bail!("task tool requires a 'prompt'"); |
| 233 | } |
| 234 | if cancel.load( |
| 235 | bail!("cancelled"); |
| 236 | } |
| 237 | let system = |
| 238 | let tools = |
| 239 | let mut messages |
| 240 | role: "user". |
| 241 | content: Some |
| 242 | tool_call_id: None |
| 243 | name: None |
| 244 | tool_calls: vec![], |
| 245 | images: vec![], |
| 246 | }]; |
| 247 | let streaming = |
| 248 | let mut noop |
| 249 | let sink: & |
| 250 | Some(f) |
| 251 | None => &mut |
| 252 | }; |
| 253 | let mut last_content |
| 254 | |
| 255 | for _ in 0 |
| 256 | if cancel.load( |
| 257 | bail!("cancelled"); |
| 258 | } |
| 259 | messages = bound_history( |
| 260 | let resp: LlmResponse |
| 261 | let cell = |
| 262 | let mut llm_sink |
| 263 | let mut s |
| 264 | s(AgentEvent::Delta |
| 265 | }; |
| 266 | let mut retry_sink |
| 267 | let mut s |
| 268 | s(AgentEvent::Retry |
| 269 | "Retrying ({attempt}/{max} |
| 270 | wait_ms / 1000 |
| 271 | ))); |
| 272 | }; |
| 273 | llm::chat_with_retries( |
| 274 | cfg, |
| 275 | model, |
| 276 | system, |
| 277 | &messages, |
| 278 | &tools, |
| 279 | Some(&mut |
| 280 | Some(&mut |
| 281 | cancel, |
| 282 | )? |
| 283 | } else { |
| 284 | llm::chat_with_retries(cfg |
| 285 | }; |
| 286 | last_content = resp. |
| 287 | messages.push(LlmMessage |
| 288 | role: "assistant". |
| 289 | content: resp.content |
| 290 | tool_call_id: None |
| 291 | name: None |
| 292 | tool_calls: resp.tool_calls |
| 293 | images: vec![], |
| 294 | }); |
| 295 | if resp.tool_calls.is_empty |
| 296 | return Ok(resp |
| 297 | } |
| 298 | for tc in & |
| 299 | if streaming { |
| 300 | (*sink)(AgentEvent |
| 301 | name: tc.name |
| 302 | args: tc.arguments |
| 303 | }); |
| 304 | } |
| 305 | let (result, |
| 306 | let t = |
| 307 | let mut sub_sink |
| 308 | let line = |
| 309 | AgentEvent::Delta( |
| 310 | AgentEvent::ToolStart |
| 311 | AgentEvent::Retry( |
| 312 | _ => return, |
| 313 | }; |
| 314 | (*sink)(AgentEvent |
| 315 | }; |
| 316 | match run_subagent_cancellable( |
| 317 | conn, |
| 318 | session_id, |
| 319 | cfg, |
| 320 | model, |
| 321 | &t.subagent_type, |
| 322 | &t.prompt, |
| 323 | depth + 1, |
| 324 | Some(&mut |
| 325 | cancel, |
| 326 | ) { |
| 327 | Ok(r) |
| 328 | Err(e) |
| 329 | } |
| 330 | } else { |
| 331 | match tools::execute( |
| 332 | Ok(r) |
| 333 | Err(e) |
| 334 | } |
| 335 | }; |
| 336 | if streaming { |
| 337 | (*sink)(AgentEvent |
| 338 | result: result.clone |
| 339 | is_error, |
| 340 | }); |
| 341 | } |
| 342 | messages.push(LlmMessage |
| 343 | role: "tool". |
| 344 | content: Some |
| 345 | tool_call_id: Some |
| 346 | name: Some |
| 347 | tool_calls: vec! |
| 348 | images: vec! |
| 349 | }); |
| 350 | } |
| 351 | } |
| 352 | bail!( |
| 353 | "sub-agent stopped after {MAX_ROUNDS} tool-call rounds without finishing. Last output: {}" |
| 354 | truncate(&last_content, |
| 355 | ) |
| 356 | } |
| 357 | |
| 358 | fn truncate(s: |
| 359 | if s.chars() |
| 360 | s.to_string() |
| 361 | } else { |
| 362 | let t: String |
| 363 | format!("{t} |
| 364 | } |
| 365 | } |
| 366 | |
| 367 | /// Bounds the message history sent to the model each round so long, tool-heavy |
| 368 | /// runs don't overflow the provider's context window (which would otherwise |
| 369 | /// fail the whole run with a non-transient error). Keeps the leading user |
| 370 | /// message (the goal) plus the most recent messages that fit a byte budget, |
| 371 | /// cut at a round boundary so an assistant tool-call is never orphaned from |
| 372 | /// its results. |
| 373 | fn bound_history(messages: |
| 374 | const MAX_MSGS: usize |
| 375 | const MAX_BYTES: usize |
| 376 | if messages.len() |
| 377 | && messages.iter() |
| 378 | { |
| 379 | return messages; |
| 380 | } |
| 381 | let mut tail |
| 382 | let mut used |
| 383 | let mut i |
| 384 | while i > 0 |
| 385 | i -= 1; |
| 386 | let sz = |
| 387 | if used + sz |
| 388 | break; |
| 389 | } |
| 390 | used += sz; |
| 391 | tail.push(messages |
| 392 | } |
| 393 | // Never let the bounded history begin with a "tool" message whose |
| 394 | // assistant tool-call got cut off before it. |
| 395 | while i > 0 |
| 396 | i -= 1; |
| 397 | tail.push(messages |
| 398 | } |
| 399 | tail.reverse(); |
| 400 | let mut out |
| 401 | if let Some |
| 402 | out.push(first |
| 403 | if tail.first() |
| 404 | tail.remove(0); |
| 405 | } |
| 406 | } |
| 407 | out.extend(tail |
| 408 | out |
| 409 | } |
| 410 | |
| 411 | fn msg_bytes(m: |
| 412 | m.content.as_deref() |
| 413 | + m.name.as_deref |
| 414 | + m.tool_call_id.as_deref |
| 415 | + m.tool_calls |
| 416 | .iter() |
| 417 | .map(|t| |
| 418 | .sum::<usize |
| 419 | + m.images |
| 420 | .iter() |
| 421 | .map(|i| |
| 422 | .sum::<usize |
| 423 | } |
| 424 | |
| 425 | fn auto_title(s: |
| 426 | const LIMIT: usize |
| 427 | let s: String |
| 428 | if s.chars() |
| 429 | return s; |
| 430 | } |
| 431 | let bytes: Vec |
| 432 | let trimmed = |
| 433 | if trimmed.chars() |
| 434 | && let Some |
| 435 | return format!("{} |
| 436 | } |
| 437 | format!("{trimmed} |
| 438 | } |
| 439 | |
| 440 | #[cfg(test)] |
| 441 | mod tests { |
| 442 | use super::*; |
| 443 | use serde_json::json; |
| 444 | |
| 445 | fn user(content: |
| 446 | LlmMessage { |
| 447 | role: "user". |
| 448 | content: Some |
| 449 | tool_call_id: None |
| 450 | name: None |
| 451 | tool_calls: vec! |
| 452 | images: vec![], |
| 453 | } |
| 454 | } |
| 455 | |
| 456 | fn assistant(content: |
| 457 | LlmMessage { |
| 458 | role: "assistant". |
| 459 | content: Some |
| 460 | tool_call_id: None |
| 461 | name: None |
| 462 | tool_calls: vec! |
| 463 | images: vec![], |
| 464 | } |
| 465 | } |
| 466 | |
| 467 | fn assistant_call(id: |
| 468 | LlmMessage { |
| 469 | role: "assistant". |
| 470 | content: None |
| 471 | tool_call_id: None |
| 472 | name: None |
| 473 | tool_calls: vec! |
| 474 | id: id.into |
| 475 | name: name.into |
| 476 | arguments: json! |
| 477 | }], |
| 478 | images: vec![], |
| 479 | } |
| 480 | } |
| 481 | |
| 482 | fn tool(id: |
| 483 | LlmMessage { |
| 484 | role: "tool". |
| 485 | content: Some |
| 486 | tool_call_id: Some |
| 487 | name: Some |
| 488 | tool_calls: vec! |
| 489 | images: vec![], |
| 490 | } |
| 491 | } |
| 492 | |
| 493 | #[test] |
| 494 | fn bound_history_keeps_short_history() { |
| 495 | let msgs = |
| 496 | let out = |
| 497 | assert_eq!(out.len |
| 498 | assert_eq!(out[0 |
| 499 | } |
| 500 | |
| 501 | #[test] |
| 502 | fn bound_history_keeps_goal_and_trims_middle() { |
| 503 | let mut msgs |
| 504 | for i in 0 |
| 505 | msgs.push(assistant_call |
| 506 | msgs.push(tool |
| 507 | } |
| 508 | let out = |
| 509 | assert!(out.len |
| 510 | assert_eq!(out[0 |
| 511 | assert_eq!(out.last |
| 512 | } |
| 513 | |
| 514 | #[test] |
| 515 | fn bound_history_never_orphans_tool_result() { |
| 516 | let mut msgs |
| 517 | for i in 0 |
| 518 | msgs.push(assistant_call |
| 519 | msgs.push(tool |
| 520 | } |
| 521 | let out = |
| 522 | for (i, m |
| 523 | if m.role == |
| 524 | let prev = |
| 525 | assert_eq!(prev. |
| 526 | assert_eq!(prev. |
| 527 | } |
| 528 | } |
| 529 | } |
| 530 | |
| 531 | #[test] |
| 532 | fn bound_history_cuts_mid_round_extends_back() { |
| 533 | // If the budget cut falls between an assistant tool-call and its tool |
| 534 | // result, the tool result must not become the leading message. |
| 535 | let mut msgs |
| 536 | for i in 0 |
| 537 | msgs.push(assistant_call |
| 538 | msgs.push(tool |
| 539 | } |
| 540 | let out = |
| 541 | assert_ne!(out[0 |
| 542 | assert_eq!(out[0 |
| 543 | } |
| 544 | |
| 545 | #[test] |
| 546 | fn bound_history_byte_budget_trims_big_tool_results() { |
| 547 | // Several big file reads must not all be kept: the byte budget drops |
| 548 | // the oldest rounds once the window is full. |
| 549 | let mut msgs |
| 550 | let big = |
| 551 | for i in 0 |
| 552 | msgs.push(assistant_call |
| 553 | msgs.push(LlmMessage |
| 554 | role: "tool". |
| 555 | content: Some |
| 556 | tool_call_id: Some |
| 557 | name: Some |
| 558 | tool_calls: vec! |
| 559 | images: vec! |
| 560 | }); |
| 561 | } |
| 562 | let out = |
| 563 | let total: usize |
| 564 | assert!(total <= |
| 565 | assert_eq!(out[0 |
| 566 | } |
| 567 | } |