| 1 | use anyhow::{Result, bail}; |
| 2 | use rusqlite::Connection |
| 3 | use serde_json::Value |
| 4 | use std::cell::RefCell |
| 5 | use std::collections:: |
| 6 | use std::sync::Mutex |
| 7 | use std::sync::atomic |
| 8 | |
| 9 | use crate::compact::{self |
| 10 | use crate::db; |
| 11 | use crate::llm::{self |
| 12 | use crate::mode::AnyMode |
| 13 | use crate::tools; |
| 14 | |
| 15 | /// Round cap for a sub-agent, deliberately higher than the main loop's: a |
| 16 | /// read-only research sub-agent on a large codebase needs many read/search |
| 17 | /// rounds before it can produce its report. The wall-clock deadline still |
| 18 | /// bounds runaway sub-agents. |
| 19 | const MAX_SUBAGENT_ROUNDS: usize |
| 20 | const MAX_SUBAGENT_DEPTH: usize |
| 21 | /// Hard wall-clock cap on a single sub-agent run. LLM calls have per-read |
| 22 | /// timeouts and retries are bounded, but a provider that keeps the connection |
| 23 | /// alive (or a chain of retrying rounds) could otherwise stall the sub-agent |
| 24 | /// indefinitely with no recorded result. The deadline guarantees the run |
| 25 | /// terminates and its outcome is recorded as the task tool result. |
| 26 | const SUBAGENT_DEADLINE: std |
| 27 | /// How long a sub-agent waits for the user to approve a mutating tool call in |
| 28 | /// accept-edits mode before it treats the call as declined and adapts, instead |
| 29 | /// of blocking forever on a confirmation the user never answered. |
| 30 | const SUBAGENT_CONFIRM_TIMEOUT: std |
| 31 | |
| 32 | /// Why a compaction is running — the TUI shows a pi-style status line with a |
| 33 | /// reason-specific message (manual `/compact` vs. recovered context overflow). |
| 34 | #[derive(Clone |
| 35 | pub enum CompactionReason |
| 36 | /// The user ran `/compact` (optionally with a focus). |
| 37 | Manual, |
| 38 | /// The provider rejected the request because the context no longer fits; |
| 39 | /// the agent folds older turns into a summary and retries (pi's overflow |
| 40 | /// auto-compaction). |
| 41 | Overflow, |
| 42 | /// The estimated context grew within `RESERVE_TOKENS` of the provider's |
| 43 | /// context window; the agent compacts proactively before the next request |
| 44 | /// (pi's threshold auto-compaction). |
| 45 | Threshold, |
| 46 | } |
| 47 | |
| 48 | pub enum AgentEvent |
| 49 | Delta(String), |
| 50 | ToolDelta(String), |
| 51 | ToolStart { |
| 52 | name: String |
| 53 | args: Value |
| 54 | }, |
| 55 | ToolResult { |
| 56 | name: String |
| 57 | result: String |
| 58 | is_error: bool |
| 59 | }, |
| 60 | Retry(String), |
| 61 | Failed(String), |
| 62 | /// A queued steering message was folded into the conversation and will be |
| 63 | /// seen by the model on the next LLM call. |
| 64 | SteerDelivered { |
| 65 | text: String |
| 66 | }, |
| 67 | /// `/compact` (or auto-compaction) started. |
| 68 | CompactionStarted { |
| 69 | reason: CompactionReason |
| 70 | }, |
| 71 | /// `/compact` finished: the older turns were folded into a summary. |
| 72 | CompactionDone { |
| 73 | tokens_before: usize |
| 74 | dropped: usize |
| 75 | kept: usize |
| 76 | }, |
| 77 | CompactionFailed(String), |
| 78 | /// Tool needs a yes/no confirmation from the user. The agent thread blocks |
| 79 | /// on `reply` until the TUI sends `true`/`false` (or the channel closes). |
| 80 | Confirm { |
| 81 | message: String |
| 82 | /// Short button label, e.g. "delete". |
| 83 | confirm_label: String |
| 84 | reply: std::sync |
| 85 | }, |
| 86 | Finished, |
| 87 | } |
| 88 | |
| 89 | /// A steering message queued while the agent is running. Empty text + empty |
| 90 | /// images = null steer (folded into the prompt to influence the next turn). |
| 91 | pub type SteerItem |
| 92 | |
| 93 | pub fn run( |
| 94 | conn: &Connection |
| 95 | session_id: i64 |
| 96 | cfg: &ProviderConfig |
| 97 | model: &str |
| 98 | user_input: &str |
| 99 | on_event: Option |
| 100 | ) -> Result<String |
| 101 | let cancel = |
| 102 | run_cancellable( |
| 103 | conn, |
| 104 | session_id, |
| 105 | cfg, |
| 106 | model, |
| 107 | user_input, |
| 108 | &[], |
| 109 | None, |
| 110 | on_event, |
| 111 | &cancel, |
| 112 | ) |
| 113 | } |
| 114 | |
| 115 | #[allow(clippy:: |
| 116 | pub fn run_cancellable( |
| 117 | conn: &Connection |
| 118 | session_id: i64 |
| 119 | cfg: &ProviderConfig |
| 120 | model: &str |
| 121 | user_input: &str |
| 122 | images: &[ImagePart |
| 123 | steer: Option |
| 124 | mut on_event: Option |
| 125 | cancel: &AtomicBool |
| 126 | ) -> Result<String |
| 127 | // Session-global thinking effort (set via `/effort`, `min`/`max` resolve |
| 128 | // like pi) is applied to every request this run makes. |
| 129 | let mut cfg |
| 130 | if let Some |
| 131 | cfg.effort = Some |
| 132 | } |
| 133 | let images_json = |
| 134 | None |
| 135 | } else { |
| 136 | Some(serde_json:: |
| 137 | }; |
| 138 | db::add_message( |
| 139 | conn, |
| 140 | session_id, |
| 141 | "user", |
| 142 | Some(user_input), |
| 143 | None, |
| 144 | None, |
| 145 | None, |
| 146 | images_json.as_deref(), |
| 147 | None, |
| 148 | None, |
| 149 | )?; |
| 150 | db::touch_session(conn |
| 151 | if let Some |
| 152 | && s.name.is_empty |
| 153 | { |
| 154 | db::set_session_name(conn |
| 155 | } |
| 156 | |
| 157 | let streaming = |
| 158 | let mut noop |
| 159 | let sink: & |
| 160 | Some(f) |
| 161 | None => &mut |
| 162 | }; |
| 163 | // All event emission goes through this shared cell so the tool-confirmation |
| 164 | // closure can also emit while the rest of the loop calls sink directly. |
| 165 | let sink_cell = |
| 166 | // Confirmation bridge (Grok Build): sensitive tools (delete_chats, …) ask |
| 167 | // the user through the UI. Emits a Confirm event and blocks until the TUI |
| 168 | // sends a yes/no reply. Non-interactive runs never block — they decline. |
| 169 | let mut ask |
| 170 | let (reply_tx, |
| 171 | if streaming { |
| 172 | sink_cell.borrow_mut()(AgentEvent |
| 173 | message: message.to_string |
| 174 | confirm_label: label.to_string |
| 175 | reply: reply_tx, |
| 176 | }); |
| 177 | reply_rx.recv().unwrap_or |
| 178 | } else { |
| 179 | false |
| 180 | } |
| 181 | }; |
| 182 | // True after one overflow-triggered auto-compaction: pi bounds recovery at |
| 183 | // one compact-and-retry attempt per conversational input, so a second |
| 184 | // overflow inside the same run fails instead of looping forever. |
| 185 | let mut overflow_recovered |
| 186 | // The session's mode decides the system prompt, which tools the model is |
| 187 | // offered, and which tool calls must be approved first. Tools are computed |
| 188 | // per round so `todoread` only appears once a todo list actually exists |
| 189 | // (the model otherwise burns its opening round reading an empty list). |
| 190 | let mode = |
| 191 | let system = |
| 192 | |
| 193 | // Unbounded like pi's `runLoop`: the model decides when the turn ends — |
| 194 | // each round executes its tool calls, and the loop exits only when a |
| 195 | // response carries no tool calls. There is no round cap to bail on. |
| 196 | loop { |
| 197 | if cancel.load( |
| 198 | bail!("cancelled"); |
| 199 | } |
| 200 | let tools = |
| 201 | // Fold any queued steering messages into the conversation so the model |
| 202 | // sees them on this round. They persist as user messages, so a later |
| 203 | // relaunch after interruption keeps them in history. |
| 204 | if let Some |
| 205 | let pending: Vec |
| 206 | for (text, imgs |
| 207 | if text.is_empty() |
| 208 | continue; |
| 209 | } |
| 210 | let images_json = |
| 211 | None |
| 212 | } else { |
| 213 | Some(serde_json:: |
| 214 | }; |
| 215 | db::add_message( |
| 216 | conn, |
| 217 | session_id, |
| 218 | "user", |
| 219 | Some(&text |
| 220 | None, |
| 221 | None, |
| 222 | None, |
| 223 | images_json.as_deref(), |
| 224 | None, |
| 225 | None, |
| 226 | )?; |
| 227 | sink_cell.borrow_mut()(AgentEvent |
| 228 | } |
| 229 | } |
| 230 | let stored = |
| 231 | // Pi's threshold auto-compaction: when the estimated context has grown |
| 232 | // within RESERVE_TOKENS of the provider's window, compact proactively |
| 233 | // instead of waiting for the provider to reject the request. Unlike |
| 234 | // overflow recovery nothing failed here, so there is no retry — the |
| 235 | // round simply restarts on the smaller context. |
| 236 | // |
| 237 | // Pi's boundary guard (`prepareCompaction` rejects when the newest |
| 238 | // entry is already a `compaction` row): never auto-compact twice in a |
| 239 | // row. Without it, an estimate that stays over threshold after a |
| 240 | // compaction would fail with "Already compacted" every round and spam |
| 241 | // the UI in an unbounded loop. |
| 242 | let already_compacted = |
| 243 | if !already_compacted |
| 244 | && compact::should_compact( |
| 245 | compact::estimate_context_tokens(&stored |
| 246 | cfg.context_window as usize |
| 247 | ) |
| 248 | { |
| 249 | if streaming { |
| 250 | sink_cell.borrow_mut()(AgentEvent |
| 251 | reason: CompactionReason |
| 252 | }); |
| 253 | } else { |
| 254 | eprintln!(" ⟳ context near threshold — compacting history…"); |
| 255 | } |
| 256 | match compact::run( |
| 257 | Ok(r) |
| 258 | sink_cell.borrow_mut()(AgentEvent |
| 259 | tokens_before: r.tokens_before |
| 260 | dropped: r.dropped |
| 261 | kept: r.kept |
| 262 | }); |
| 263 | // Restart the round on the compacted history. |
| 264 | continue; |
| 265 | } |
| 266 | Err(ce) |
| 267 | // Never retry a failed compaction: the round proceeds with |
| 268 | // the unchanged history and the request goes out as-is |
| 269 | // (pi's threshold path has no retry either). |
| 270 | sink_cell.borrow_mut()(AgentEvent |
| 271 | } |
| 272 | } |
| 273 | } |
| 274 | // bound_history trims for size; sanitize_tool_pairs repairs any |
| 275 | // assistant tool_calls that lack matching tool results (cancel mid- |
| 276 | // tools, crash, or a bad cut). Providers like OpenAI/OpenCode reject |
| 277 | // the whole request with HTTP 400 if a tool_call_id is unanswered. |
| 278 | let mut messages |
| 279 | sanitize_tool_pairs(bound_history(resolve_compaction |
| 280 | // Defense in depth: a tool like delete_chats can wipe the session |
| 281 | // mid-turn, leaving an empty (or orphan-tool) history. Providers reject |
| 282 | // an empty message list with HTTP 400, so fall back to the current |
| 283 | // prompt as the sole user message. |
| 284 | if messages.is_empty() |
| 285 | || messages |
| 286 | .iter() |
| 287 | .find(|m| |
| 288 | .is_none_or(|m| |
| 289 | { |
| 290 | messages.insert( |
| 291 | 0, |
| 292 | LlmMessage { |
| 293 | role: "user". |
| 294 | content: Some |
| 295 | tool_call_id: None |
| 296 | name: None |
| 297 | tool_calls: vec! |
| 298 | reasoning: None |
| 299 | images: vec! |
| 300 | }, |
| 301 | ); |
| 302 | } |
| 303 | let resp: LlmResponse |
| 304 | let call = |
| 305 | let mut llm_sink |
| 306 | sink_cell.borrow_mut()(AgentEvent |
| 307 | }; |
| 308 | let mut retry_sink |
| 309 | sink_cell.borrow_mut()(AgentEvent |
| 310 | "Retrying ({attempt}/{max} |
| 311 | wait_ms / 1000 |
| 312 | ))); |
| 313 | }; |
| 314 | llm::chat_with_retries( |
| 315 | &cfg, |
| 316 | model, |
| 317 | &system, |
| 318 | &messages, |
| 319 | &tools, |
| 320 | Some(&mut |
| 321 | Some(&mut |
| 322 | cancel, |
| 323 | ) |
| 324 | } else { |
| 325 | llm::chat_with_retries(&cfg |
| 326 | }; |
| 327 | match call { |
| 328 | Ok(r) |
| 329 | Err(e) |
| 330 | && (llm::is_context_overflow |
| 331 | { |
| 332 | // The provider rejected the request because the history no |
| 333 | // longer fits its context window — or returned zero output |
| 334 | // with `finish_reason=length` (the input filled the window, |
| 335 | // leaving no room for an answer, e.g. MiMo). Both are |
| 336 | // folded into a summary (pi's overflow auto-compaction) and |
| 337 | // retried once. Nothing from the failed call was persisted, |
| 338 | // so the DB is intact; the next round re-reads the |
| 339 | // compacted history via resolve_compaction. |
| 340 | overflow_recovered = true; |
| 341 | if streaming { |
| 342 | sink_cell.borrow_mut()(AgentEvent |
| 343 | "Context overflow detected — auto-compacting and retrying…".to_string(), |
| 344 | )); |
| 345 | sink_cell.borrow_mut()(AgentEvent |
| 346 | reason: CompactionReason |
| 347 | }); |
| 348 | } else { |
| 349 | eprintln!(" ⟳ context overflow — compacting history and retrying…"); |
| 350 | } |
| 351 | match compact::run( |
| 352 | Ok(r) |
| 353 | sink_cell.borrow_mut()(AgentEvent |
| 354 | tokens_before: r.tokens_before |
| 355 | dropped: r.dropped |
| 356 | kept: r.kept |
| 357 | }); |
| 358 | continue; |
| 359 | } |
| 360 | Err(ce) |
| 361 | sink_cell.borrow_mut()(AgentEvent |
| 362 | return Err(e |
| 363 | } |
| 364 | } |
| 365 | } |
| 366 | Err(e) |
| 367 | } |
| 368 | }; |
| 369 | if cancel.load( |
| 370 | bail!("cancelled"); |
| 371 | } |
| 372 | |
| 373 | let calls_json = |
| 374 | None |
| 375 | } else { |
| 376 | Some(serde_json:: |
| 377 | }; |
| 378 | // Persist the call's token usage so a future `estimate_context_tokens` |
| 379 | // can blend real counts with the chars/4 heuristic (pi blends |
| 380 | // `usage.input + usage.cacheRead` into its estimates). |
| 381 | let usage_json = |
| 382 | .usage |
| 383 | .as_ref() |
| 384 | .map(serde_json::to_string |
| 385 | .transpose()?; |
| 386 | db::add_message( |
| 387 | conn, |
| 388 | session_id, |
| 389 | "assistant", |
| 390 | resp.content.as_deref(), |
| 391 | None, |
| 392 | None, |
| 393 | calls_json.as_deref(), |
| 394 | None, |
| 395 | resp.reasoning.as_deref(), |
| 396 | usage_json.as_deref(), |
| 397 | )?; |
| 398 | |
| 399 | if resp.tool_calls.is_empty |
| 400 | return Ok(resp |
| 401 | } |
| 402 | |
| 403 | for tc in & |
| 404 | // Always record a tool result for every tool_call_id — even on |
| 405 | // cancel — so the next LLM request never sees an open tool_calls |
| 406 | // message (the HTTP 400 we hit after interrupted writes). |
| 407 | if cancel.load( |
| 408 | let result = |
| 409 | db::add_message( |
| 410 | conn, |
| 411 | session_id, |
| 412 | "tool", |
| 413 | Some(result), |
| 414 | Some(&tc |
| 415 | Some(&tc |
| 416 | None, |
| 417 | None, |
| 418 | None, |
| 419 | None, |
| 420 | )?; |
| 421 | if streaming { |
| 422 | sink_cell.borrow_mut()(AgentEvent |
| 423 | name: tc.name |
| 424 | result: result.to_string |
| 425 | is_error: true, |
| 426 | }); |
| 427 | } |
| 428 | continue; |
| 429 | } |
| 430 | if streaming { |
| 431 | sink_cell.borrow_mut()(AgentEvent |
| 432 | name: tc.name |
| 433 | args: tc.arguments |
| 434 | }); |
| 435 | } else { |
| 436 | eprintln!(" → {} |
| 437 | } |
| 438 | let (result, |
| 439 | (blocked_tool_result(&mode, |
| 440 | } else if tc |
| 441 | let t = |
| 442 | // Plan mode only ever delegates to read-only sub-agents. When |
| 443 | // the mode forces a different type, the prompt is annotated so |
| 444 | // the sub-agent's task matches what it can actually do instead |
| 445 | // of stalling on an impossible one. |
| 446 | let (subagent_type, |
| 447 | enforce_and_annotate(&mode, |
| 448 | let mut sub_sink |
| 449 | if matches!(ev |
| 450 | // Sub-agent confirmations bubble up to the user. |
| 451 | sink_cell.borrow_mut()(ev |
| 452 | return; |
| 453 | } |
| 454 | let line = |
| 455 | AgentEvent::Delta( |
| 456 | AgentEvent::ToolStart |
| 457 | AgentEvent::ToolResult |
| 458 | name, |
| 459 | result, |
| 460 | is_error, |
| 461 | } => sub_tool_result_line(& |
| 462 | AgentEvent::Retry( |
| 463 | _ => return, |
| 464 | }; |
| 465 | sink_cell.borrow_mut()(AgentEvent |
| 466 | }; |
| 467 | match run_subagent_cancellable( |
| 468 | conn, |
| 469 | session_id, |
| 470 | &cfg, |
| 471 | model, |
| 472 | &mode, |
| 473 | &subagent_type, |
| 474 | &sub_prompt, |
| 475 | 1, |
| 476 | Some(&mut |
| 477 | cancel, |
| 478 | ) { |
| 479 | Ok(r) |
| 480 | Err(e) |
| 481 | } |
| 482 | } else { |
| 483 | execute_with_approval(conn, session_id |
| 484 | }; |
| 485 | if streaming { |
| 486 | sink_cell.borrow_mut()(AgentEvent |
| 487 | name: tc.name |
| 488 | result: result.clone |
| 489 | is_error, |
| 490 | }); |
| 491 | } else { |
| 492 | eprintln!( |
| 493 | " → {} {} => {}", |
| 494 | tc.name, |
| 495 | tc.arguments, |
| 496 | truncate(&result, |
| 497 | ); |
| 498 | } |
| 499 | db::add_message( |
| 500 | conn, |
| 501 | session_id, |
| 502 | "tool", |
| 503 | Some(&result |
| 504 | Some(&tc |
| 505 | Some(&tc |
| 506 | None, |
| 507 | None, |
| 508 | None, |
| 509 | None, |
| 510 | )?; |
| 511 | } |
| 512 | } |
| 513 | } |
| 514 | |
| 515 | struct TaskArgs { |
| 516 | subagent_type: String |
| 517 | prompt: String |
| 518 | } |
| 519 | |
| 520 | fn parse_task(args: |
| 521 | let subagent_type = |
| 522 | .get("subagent_type") |
| 523 | .and_then(|v| |
| 524 | .unwrap_or("general") |
| 525 | .to_string(); |
| 526 | let prompt = |
| 527 | .get("prompt") |
| 528 | .and_then(|v| |
| 529 | .unwrap_or("") |
| 530 | .to_string(); |
| 531 | TaskArgs { |
| 532 | subagent_type, |
| 533 | prompt, |
| 534 | } |
| 535 | } |
| 536 | |
| 537 | /// Coerce the sub-agent type for the active mode and, when the mode forces a |
| 538 | /// different (read-only) type, annotate the prompt so the sub-agent's task |
| 539 | /// matches what it can actually do instead of stalling on an impossible one. |
| 540 | fn enforce_and_annotate(mode: |
| 541 | let enforced = |
| 542 | if enforced == subagent_type |
| 543 | (enforced, prompt. |
| 544 | } else { |
| 545 | ( |
| 546 | enforced.clone(), |
| 547 | format!( |
| 548 | "{prompt}\n\nNote: you are running as a read-only 'explore' sub-agent because \ |
| 549 | the session is in plan mode. You cannot modify files or run shell commands. \ |
| 550 | Do the research, then report back with a concrete, actionable plan the main \ |
| 551 | agent can execute." |
| 552 | ), |
| 553 | ) |
| 554 | } |
| 555 | } |
| 556 | |
| 557 | /// The mode a session runs in, falling back to auto when the row is missing or |
| 558 | /// holds an unknown value (e.g. sessions created before modes existed). |
| 559 | fn session_mode(conn: |
| 560 | db::get_session_mode(conn |
| 561 | .ok() |
| 562 | .and_then(|s| |
| 563 | .unwrap_or(AnyMode::Builtin |
| 564 | } |
| 565 | |
| 566 | /// Tools for one LLM round: the mode's filter over the built-in definitions, |
| 567 | /// minus `todoread` while the session has no todo list. A fresh session would |
| 568 | /// otherwise waste its opening round calling `todoread` on an empty list; once |
| 569 | /// a `todowrite` creates items, `todoread` reappears on the next round. |
| 570 | fn round_tools(conn: |
| 571 | strip_todoread_if_empty(conn, session_id |
| 572 | } |
| 573 | |
| 574 | /// Hide `todoread` while the session has no todo list (shared by the main loop |
| 575 | /// and sub-agents, so nobody burns a round reading an empty list). |
| 576 | fn strip_todoread_if_empty( |
| 577 | conn: &Connection |
| 578 | session_id: i64 |
| 579 | mut tools: Vec |
| 580 | ) -> Vec<tools |
| 581 | if db::get_todos( |
| 582 | .map(|t| |
| 583 | .unwrap_or(false) |
| 584 | { |
| 585 | tools.retain(|t |
| 586 | } |
| 587 | tools |
| 588 | } |
| 589 | |
| 590 | /// opencode-style execution gate: a tool the active mode does not allow is |
| 591 | /// blocked before it runs. The model sees an error result (like opencode's |
| 592 | /// `Permission.assert` deny → `BlockedError`) so it adapts without the tool. |
| 593 | fn blocked_tool_result(mode: |
| 594 | let why = |
| 595 | AnyMode::Builtin( |
| 596 | "plan mode is read-only: no file changes or shell commands" |
| 597 | } |
| 598 | AnyMode::Builtin( |
| 599 | _ => "this mode only allows its listed tools" |
| 600 | }; |
| 601 | format!( |
| 602 | "Error: the '{name}' tool is blocked in {} mode ({ |
| 603 | mode.slug(), |
| 604 | ) |
| 605 | } |
| 606 | |
| 607 | /// Run one non-subagent tool, pausing for user approval first when the mode |
| 608 | /// requires it (accept-edits mode). A declined call returns an error result so |
| 609 | /// the model sees the rejection and adapts instead of silently proceeding. |
| 610 | fn execute_with_approval( |
| 611 | conn: &Connection |
| 612 | session_id: i64 |
| 613 | mode: &AnyMode |
| 614 | tc: &llm:: |
| 615 | ask: &mut |
| 616 | ) -> (String, |
| 617 | if mode.requires_approval( |
| 618 | let (message, |
| 619 | if !ask(&message |
| 620 | let verb = |
| 621 | return ( |
| 622 | format!( |
| 623 | "The user declined the {verb}. No change was made. Adjust your approach and continue." |
| 624 | ), |
| 625 | true, |
| 626 | ); |
| 627 | } |
| 628 | } |
| 629 | match tools::execute( |
| 630 | Ok(r) |
| 631 | Err(e) |
| 632 | } |
| 633 | } |
| 634 | |
| 635 | fn approval_message(tc: |
| 636 | let path = |
| 637 | .arguments |
| 638 | .get("path") |
| 639 | .and_then(|v| |
| 640 | .unwrap_or("?") |
| 641 | .to_string(); |
| 642 | match tc.name.as_str |
| 643 | "write_file" => (format! |
| 644 | "edit_file" => (format! |
| 645 | "delete_file" => (format! |
| 646 | other => (format! |
| 647 | } |
| 648 | } |
| 649 | |
| 650 | /// Shown to a sub-agent that finishes a round with no tool calls and no text: |
| 651 | /// it must not silently hand the parent an empty report. |
| 652 | const SUBAGENT_NO_REPORT_NUDGE: &str |
| 653 | Always end with a report: a clear summary of what you found, the exact files and line \ |
| 654 | numbers involved, and how to verify it. If you are blocked, say precisely what is \ |
| 655 | blocking you. Produce your final report now."; |
| 656 | |
| 657 | fn subagent_system(subagent_type: |
| 658 | match subagent_type { |
| 659 | "explore" | "scout" |
| 660 | r#"You are a fast, read-only agent specialized for exploring codebases. You can find files by name or pattern, search code for keywords, and answer questions about the codebase. You cannot modify files. Use the read_file, list_files, and search tools to investigate. You MUST end with a report: the answer to the question with references to the exact files and line numbers, and how the main agent can verify it. Never finish with an empty response."# |
| 661 | } |
| 662 | _ => { |
| 663 | 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. You MUST end with a report: a clear summary of what you did, the exact files you changed, and how to verify it. Never finish with an empty response."# |
| 664 | } |
| 665 | } |
| 666 | } |
| 667 | |
| 668 | #[allow(clippy:: |
| 669 | pub fn run_subagent_cancellable( |
| 670 | conn: &Connection |
| 671 | session_id: i64 |
| 672 | cfg: &ProviderConfig |
| 673 | model: &str |
| 674 | mode: &AnyMode |
| 675 | subagent_type: &str |
| 676 | prompt: &str |
| 677 | depth: usize |
| 678 | mut on_event: Option |
| 679 | cancel: &AtomicBool |
| 680 | ) -> Result<String |
| 681 | if depth > MAX_SUBAGENT_DEPTH |
| 682 | bail!("sub-agent nesting too deep (max depth {MAX_SUBAGENT_DEPTH |
| 683 | } |
| 684 | if prompt.trim() |
| 685 | bail!("task tool requires a 'prompt'"); |
| 686 | } |
| 687 | if cancel.load( |
| 688 | bail!("cancelled"); |
| 689 | } |
| 690 | let deadline = |
| 691 | let system = |
| 692 | // Same rule as the main loop: no `todoread` until a todo list exists. |
| 693 | let tools = |
| 694 | let mut messages |
| 695 | role: "user". |
| 696 | content: Some |
| 697 | tool_call_id: None |
| 698 | name: None |
| 699 | tool_calls: vec![], |
| 700 | reasoning: None |
| 701 | images: vec![], |
| 702 | }]; |
| 703 | let streaming = |
| 704 | let mut noop |
| 705 | let sink: & |
| 706 | Some(f) |
| 707 | None => &mut |
| 708 | }; |
| 709 | let sink_cell = |
| 710 | let mut ask |
| 711 | let (reply_tx, |
| 712 | if streaming { |
| 713 | sink_cell.borrow_mut()(AgentEvent |
| 714 | message: message.to_string |
| 715 | confirm_label: label.to_string |
| 716 | reply: reply_tx, |
| 717 | }); |
| 718 | // Never block forever: an unanswered confirmation is treated as a |
| 719 | // decline so the sub-agent adapts instead of stalling. |
| 720 | reply_rx |
| 721 | .recv_timeout(SUBAGENT_CONFIRM_TIMEOUT) |
| 722 | .unwrap_or(false) |
| 723 | } else { |
| 724 | false |
| 725 | } |
| 726 | }; |
| 727 | let mut last_content |
| 728 | // Ring of the most recent tool calls, so a max-rounds bail can say what |
| 729 | // the sub-agent was doing instead of a (usually empty) "Last output". |
| 730 | let mut recent_tools |
| 731 | |
| 732 | for _ in 0 |
| 733 | if cancel.load( |
| 734 | bail!("cancelled"); |
| 735 | } |
| 736 | if std::time |
| 737 | bail!( |
| 738 | "sub-agent did not finish within {} minutes; the run was stopped so the main agent can continue", |
| 739 | SUBAGENT_DEADLINE.as_secs() / |
| 740 | ); |
| 741 | } |
| 742 | messages = sanitize_tool_pairs( |
| 743 | let resp: LlmResponse |
| 744 | let mut llm_sink |
| 745 | sink_cell.borrow_mut()(AgentEvent |
| 746 | }; |
| 747 | let mut retry_sink |
| 748 | sink_cell.borrow_mut()(AgentEvent |
| 749 | "Retrying ({attempt}/{max} |
| 750 | wait_ms / 1000 |
| 751 | ))); |
| 752 | }; |
| 753 | llm::chat_with_retries( |
| 754 | cfg, |
| 755 | model, |
| 756 | system, |
| 757 | &messages, |
| 758 | &tools, |
| 759 | Some(&mut |
| 760 | Some(&mut |
| 761 | cancel, |
| 762 | )? |
| 763 | } else { |
| 764 | llm::chat_with_retries(cfg |
| 765 | }; |
| 766 | last_content = resp. |
| 767 | messages.push(LlmMessage |
| 768 | role: "assistant". |
| 769 | content: resp.content |
| 770 | tool_call_id: None |
| 771 | name: None |
| 772 | tool_calls: resp.tool_calls |
| 773 | reasoning: None |
| 774 | images: vec![], |
| 775 | }); |
| 776 | if resp.tool_calls.is_empty |
| 777 | let content = |
| 778 | if content.trim() |
| 779 | // A sub-agent that "finishes" with no text is not a report: nudge |
| 780 | // it to produce one instead of handing the parent an empty result |
| 781 | // the user would read as "no report". |
| 782 | messages.push(LlmMessage |
| 783 | role: "user". |
| 784 | content: Some |
| 785 | tool_call_id: None |
| 786 | name: None |
| 787 | tool_calls: vec! |
| 788 | reasoning: None |
| 789 | images: vec! |
| 790 | }); |
| 791 | last_content.clear(); |
| 792 | continue; |
| 793 | } |
| 794 | return Ok(content |
| 795 | } |
| 796 | for tc in & |
| 797 | if cancel.load( |
| 798 | messages.push(LlmMessage |
| 799 | role: "tool". |
| 800 | content: Some |
| 801 | tool_call_id: Some |
| 802 | name: Some |
| 803 | tool_calls: vec! |
| 804 | reasoning: None |
| 805 | images: vec! |
| 806 | }); |
| 807 | if streaming { |
| 808 | sink_cell.borrow_mut()(AgentEvent |
| 809 | name: tc.name |
| 810 | result: "Error: cancelled before tool completed". |
| 811 | is_error: true, |
| 812 | }); |
| 813 | } |
| 814 | continue; |
| 815 | } |
| 816 | if streaming { |
| 817 | sink_cell.borrow_mut()(AgentEvent |
| 818 | name: tc.name |
| 819 | args: tc.arguments |
| 820 | }); |
| 821 | } |
| 822 | if recent_tools.len() |
| 823 | recent_tools.pop_front(); |
| 824 | } |
| 825 | recent_tools.push_back(format! |
| 826 | "{}({})", |
| 827 | tc.name, |
| 828 | truncate(&tc.arguments |
| 829 | )); |
| 830 | let (result, |
| 831 | (blocked_tool_result(mode, |
| 832 | } else if tc |
| 833 | let t = |
| 834 | // Plan mode only ever delegates to read-only sub-agents. The |
| 835 | // prompt is annotated when the type is coerced so the task |
| 836 | // matches what the nested sub-agent can actually do. |
| 837 | let (subagent_type, |
| 838 | enforce_and_annotate(mode, & |
| 839 | let mut sub_sink |
| 840 | if matches!(ev |
| 841 | sink_cell.borrow_mut()(ev |
| 842 | return; |
| 843 | } |
| 844 | let line = |
| 845 | AgentEvent::Delta( |
| 846 | AgentEvent::ToolStart |
| 847 | AgentEvent::ToolResult |
| 848 | name, |
| 849 | result, |
| 850 | is_error, |
| 851 | } => sub_tool_result_line(& |
| 852 | AgentEvent::Retry( |
| 853 | _ => return, |
| 854 | }; |
| 855 | sink_cell.borrow_mut()(AgentEvent |
| 856 | }; |
| 857 | match run_subagent_cancellable( |
| 858 | conn, |
| 859 | session_id, |
| 860 | cfg, |
| 861 | model, |
| 862 | mode, |
| 863 | &subagent_type, |
| 864 | &sub_prompt, |
| 865 | depth + 1, |
| 866 | Some(&mut |
| 867 | cancel, |
| 868 | ) { |
| 869 | Ok(r) |
| 870 | Err(e) |
| 871 | } |
| 872 | } else { |
| 873 | execute_with_approval(conn, session_id |
| 874 | }; |
| 875 | if streaming { |
| 876 | sink_cell.borrow_mut()(AgentEvent |
| 877 | name: tc.name |
| 878 | result: result.clone |
| 879 | is_error, |
| 880 | }); |
| 881 | } |
| 882 | messages.push(LlmMessage |
| 883 | role: "tool". |
| 884 | content: Some |
| 885 | tool_call_id: Some |
| 886 | name: Some |
| 887 | tool_calls: vec! |
| 888 | reasoning: None |
| 889 | images: vec! |
| 890 | }); |
| 891 | } |
| 892 | } |
| 893 | bail!("{}", |
| 894 | } |
| 895 | |
| 896 | /// The "stopped after N rounds" error shown to the parent agent. `activity` |
| 897 | /// names the most recent tools the sub-agent ran so the parent can see what it |
| 898 | /// was doing before it gave up, instead of a bare (usually empty) "Last output". |
| 899 | fn subagent_bail_message(recent_tools: |
| 900 | let activity = |
| 901 | "no tool calls were made".to_string() |
| 902 | } else { |
| 903 | format!( |
| 904 | "last tools: {}", |
| 905 | recent_tools.iter().cloned |
| 906 | ) |
| 907 | }; |
| 908 | let last = |
| 909 | String::new() |
| 910 | } else { |
| 911 | format!(". Last output: {}" |
| 912 | }; |
| 913 | format!( |
| 914 | "sub-agent stopped after {MAX_SUBAGENT_ROUNDS} tool-call rounds without finishing ({activity |
| 915 | ) |
| 916 | } |
| 917 | |
| 918 | fn truncate(s: |
| 919 | if s.chars() |
| 920 | s.to_string() |
| 921 | } else { |
| 922 | let t: String |
| 923 | format!("{t} |
| 924 | } |
| 925 | } |
| 926 | |
| 927 | /// One-line status for a sub-agent tool result, streamed into the parent node's |
| 928 | /// context. read/search/list results collapse to a Claude Code-style summary |
| 929 | /// line instead of a multi-line dump; every other tool keeps the truncated raw |
| 930 | /// result (so run_command output still shows). |
| 931 | fn sub_tool_result_line(name: |
| 932 | let mark = |
| 933 | if !is_error && |
| 934 | return format!("{ |
| 935 | } |
| 936 | format!("{mark}{} |
| 937 | } |
| 938 | |
| 939 | /// Compact summary of a read_file/search/list_files result, or None when the |
| 940 | /// result doesn't match the known formats. `read_file` counts its content |
| 941 | /// rows (Grok Build `N→` anchors, window markers excluded); `search` counts |
| 942 | /// the `path:line:` result lines; `list_files` counts its entry rows. |
| 943 | fn summarize_read_search_list(name: |
| 944 | match name { |
| 945 | "read_file" => { |
| 946 | let rows = |
| 947 | .lines() |
| 948 | .filter(|l| |
| 949 | let l = |
| 950 | !l.is_empty() |
| 951 | }) |
| 952 | .count(); |
| 953 | if rows > 0 |
| 954 | Some(format! |
| 955 | } else { |
| 956 | None |
| 957 | } |
| 958 | } |
| 959 | "search" => { |
| 960 | let count = |
| 961 | .lines() |
| 962 | .filter(|l| |
| 963 | .count(); |
| 964 | Some(format! |
| 965 | } |
| 966 | "list_files" => { |
| 967 | let count = |
| 968 | .lines() |
| 969 | .filter(|l| |
| 970 | let l = |
| 971 | !l.is_empty() |
| 972 | }) |
| 973 | .count(); |
| 974 | Some(format! |
| 975 | } |
| 976 | _ => None |
| 977 | } |
| 978 | } |
| 979 | |
| 980 | /// Ensure every assistant `tool_calls` entry has a following `tool` message |
| 981 | /// for each `tool_call_id`. Providers (OpenAI-compatible, OpenCode zen, …) |
| 982 | /// reject the request with: |
| 983 | /// "An assistant message with 'tool_calls' must be followed by tool messages |
| 984 | /// responding to each 'tool_call_id'" |
| 985 | /// when history is incomplete — e.g. the process was cancelled after the |
| 986 | /// assistant row was saved but before every tool result landed, or history |
| 987 | /// trimming cut mid-round. Missing results are filled with a synthetic error |
| 988 | /// so the next turn can continue; orphan tool rows (no preceding call) drop. |
| 989 | fn sanitize_tool_pairs(messages: |
| 990 | let mut out |
| 991 | let mut i |
| 992 | while i < messages |
| 993 | let m = |
| 994 | if m.role == |
| 995 | out.push(m |
| 996 | i += 1; |
| 997 | let mut pending |
| 998 | .tool_calls |
| 999 | .iter() |
| 1000 | .map(|tc| |
| 1001 | .collect(); |
| 1002 | let expected: Vec |
| 1003 | while i < messages |
| 1004 | let id = |
| 1005 | // Only consume tool rows that belong to this assistant call. |
| 1006 | // Orphans (wrong/missing ids) break out so they can be dropped. |
| 1007 | if !expected.iter |
| 1008 | break; |
| 1009 | } |
| 1010 | pending.retain(|( |
| 1011 | out.push(messages |
| 1012 | i += 1; |
| 1013 | } |
| 1014 | for (id, name |
| 1015 | out.push(LlmMessage |
| 1016 | role: "tool". |
| 1017 | content: Some |
| 1018 | "Error: tool call interrupted before a result was recorded.".into(), |
| 1019 | ), |
| 1020 | tool_call_id: Some |
| 1021 | name: Some |
| 1022 | tool_calls: vec! |
| 1023 | reasoning: None |
| 1024 | images: vec! |
| 1025 | }); |
| 1026 | } |
| 1027 | continue; |
| 1028 | } |
| 1029 | // Drop orphan tool rows that aren't attached to an assistant call. |
| 1030 | if m.role == |
| 1031 | i += 1; |
| 1032 | continue; |
| 1033 | } |
| 1034 | out.push(m |
| 1035 | i += 1; |
| 1036 | } |
| 1037 | out |
| 1038 | } |
| 1039 | |
| 1040 | /// Builds the LLM message list from stored history, honoring compaction: |
| 1041 | /// when the session was compacted, the latest `compaction` row's summary is |
| 1042 | /// injected as a user message (pi's `COMPACTION_SUMMARY_PREFIX`/`_SUFFIX` |
| 1043 | /// wrapper) and every message before its `cut_msg_id` is dropped from context. |
| 1044 | /// Older compaction rows and already-summarized turns simply don't resurface. |
| 1045 | fn resolve_compaction(messages: |
| 1046 | let Some( |
| 1047 | return messages.into_iter() |
| 1048 | }; |
| 1049 | let comp = |
| 1050 | let cut_id = |
| 1051 | let mut out |
| 1052 | out.push(LlmMessage |
| 1053 | role: "user". |
| 1054 | content: Some |
| 1055 | "{COMPACTION_SUMMARY_PREFIX}{}{COMPACTION_SUMMARY_SUFFIX}", |
| 1056 | comp.content.as_deref() |
| 1057 | )), |
| 1058 | tool_call_id: None |
| 1059 | name: None |
| 1060 | tool_calls: vec! |
| 1061 | reasoning: None |
| 1062 | images: vec![], |
| 1063 | }); |
| 1064 | for m in messages |
| 1065 | if m.role == |
| 1066 | continue; |
| 1067 | } |
| 1068 | if m.id >= |
| 1069 | out.push(m |
| 1070 | } |
| 1071 | } |
| 1072 | out |
| 1073 | } |
| 1074 | |
| 1075 | /// Bounds the message history sent to the model each round so long, tool-heavy |
| 1076 | /// runs don't overflow the provider's context window (which would otherwise |
| 1077 | /// fail the whole run with a non-transient error). Keeps the leading user |
| 1078 | /// message (the goal) plus the most recent messages that fit a byte budget, |
| 1079 | /// cut at a round boundary so an assistant tool-call is never orphaned from |
| 1080 | /// its results. |
| 1081 | fn bound_history(messages: |
| 1082 | const MAX_MSGS: usize |
| 1083 | const MAX_BYTES: usize |
| 1084 | if messages.len() |
| 1085 | return messages; |
| 1086 | } |
| 1087 | let mut tail |
| 1088 | let mut used |
| 1089 | let mut i |
| 1090 | while i > 0 |
| 1091 | i -= 1; |
| 1092 | let sz = |
| 1093 | if used + sz |
| 1094 | break; |
| 1095 | } |
| 1096 | used += sz; |
| 1097 | tail.push(messages |
| 1098 | } |
| 1099 | // Never let the bounded history begin with a "tool" message whose |
| 1100 | // assistant tool-call got cut off before it. |
| 1101 | while i > 0 |
| 1102 | i -= 1; |
| 1103 | tail.push(messages |
| 1104 | } |
| 1105 | tail.reverse(); |
| 1106 | let mut out |
| 1107 | if let Some |
| 1108 | out.push(first |
| 1109 | if tail.first() |
| 1110 | tail.remove(0); |
| 1111 | } |
| 1112 | } |
| 1113 | out.extend(tail |
| 1114 | out |
| 1115 | } |
| 1116 | |
| 1117 | fn msg_bytes(m: |
| 1118 | m.content.as_deref() |
| 1119 | + m.name.as_deref |
| 1120 | + m.tool_call_id.as_deref |
| 1121 | + m.tool_calls |
| 1122 | .iter() |
| 1123 | .map(|t| |
| 1124 | .sum::<usize |
| 1125 | + m.images |
| 1126 | .iter() |
| 1127 | .map(|i| |
| 1128 | .sum::<usize |
| 1129 | } |
| 1130 | |
| 1131 | fn auto_title(s: |
| 1132 | const LIMIT: usize |
| 1133 | let s: String |
| 1134 | if s.chars() |
| 1135 | return s; |
| 1136 | } |
| 1137 | let bytes: Vec |
| 1138 | let trimmed = |
| 1139 | if trimmed.chars() |
| 1140 | && let Some |
| 1141 | { |
| 1142 | return format!("{} |
| 1143 | } |
| 1144 | format!("{trimmed} |
| 1145 | } |
| 1146 | |
| 1147 | #[cfg(test)] |
| 1148 | mod tests { |
| 1149 | use super::*; |
| 1150 | use serde_json::json; |
| 1151 | |
| 1152 | fn user(content: |
| 1153 | LlmMessage { |
| 1154 | role: "user". |
| 1155 | content: Some |
| 1156 | tool_call_id: None |
| 1157 | name: None |
| 1158 | tool_calls: vec! |
| 1159 | reasoning: None |
| 1160 | images: vec! |
| 1161 | } |
| 1162 | } |
| 1163 | |
| 1164 | fn assistant(content: |
| 1165 | LlmMessage { |
| 1166 | role: "assistant". |
| 1167 | content: Some |
| 1168 | tool_call_id: None |
| 1169 | name: None |
| 1170 | tool_calls: vec! |
| 1171 | reasoning: None |
| 1172 | images: vec! |
| 1173 | } |
| 1174 | } |
| 1175 | |
| 1176 | fn assistant_call(id: |
| 1177 | LlmMessage { |
| 1178 | role: "assistant". |
| 1179 | content: None |
| 1180 | tool_call_id: None |
| 1181 | name: None |
| 1182 | tool_calls: vec! |
| 1183 | id: id.into |
| 1184 | name: name.into |
| 1185 | arguments: json! |
| 1186 | }], |
| 1187 | reasoning: None |
| 1188 | images: vec! |
| 1189 | } |
| 1190 | } |
| 1191 | |
| 1192 | fn tool(id: |
| 1193 | LlmMessage { |
| 1194 | role: "tool". |
| 1195 | content: Some |
| 1196 | tool_call_id: Some |
| 1197 | name: Some |
| 1198 | tool_calls: vec! |
| 1199 | reasoning: None |
| 1200 | images: vec! |
| 1201 | } |
| 1202 | } |
| 1203 | |
| 1204 | #[test] |
| 1205 | fn bound_history_keeps_short_history() { |
| 1206 | let msgs = |
| 1207 | let out = |
| 1208 | assert_eq!(out.len |
| 1209 | assert_eq!(out[0 |
| 1210 | } |
| 1211 | |
| 1212 | #[test] |
| 1213 | fn bound_history_keeps_goal_and_trims_middle() { |
| 1214 | let mut msgs |
| 1215 | for i in 0 |
| 1216 | msgs.push(assistant_call |
| 1217 | msgs.push(tool |
| 1218 | } |
| 1219 | let out = |
| 1220 | assert!(out.len |
| 1221 | assert_eq!(out[0 |
| 1222 | assert_eq!(out.last |
| 1223 | } |
| 1224 | |
| 1225 | #[test] |
| 1226 | fn bound_history_never_orphans_tool_result() { |
| 1227 | let mut msgs |
| 1228 | for i in 0 |
| 1229 | msgs.push(assistant_call |
| 1230 | msgs.push(tool |
| 1231 | } |
| 1232 | let out = |
| 1233 | for (i, m |
| 1234 | if m.role == |
| 1235 | let prev = |
| 1236 | assert_eq!(prev. |
| 1237 | assert_eq!( |
| 1238 | prev.tool_calls.first() |
| 1239 | m.tool_call_id.as_deref() |
| 1240 | ); |
| 1241 | } |
| 1242 | } |
| 1243 | } |
| 1244 | |
| 1245 | #[test] |
| 1246 | fn bound_history_cuts_mid_round_extends_back() { |
| 1247 | // If the budget cut falls between an assistant tool-call and its tool |
| 1248 | // result, the tool result must not become the leading message. |
| 1249 | let mut msgs |
| 1250 | for i in 0 |
| 1251 | msgs.push(assistant_call |
| 1252 | msgs.push(tool |
| 1253 | } |
| 1254 | let out = |
| 1255 | assert_ne!(out[0 |
| 1256 | assert_eq!(out[0 |
| 1257 | } |
| 1258 | |
| 1259 | #[test] |
| 1260 | fn sanitize_fills_missing_tool_results_after_assistant_calls() { |
| 1261 | // Simulates cancel after the assistant tool_calls row was persisted |
| 1262 | // but before tool results — the exact HTTP 400 shape from OpenCode. |
| 1263 | let msgs = |
| 1264 | user("write a readme"), |
| 1265 | assistant_call("call_1", |
| 1266 | // no tool result for call_1 |
| 1267 | user("continue"), |
| 1268 | ]; |
| 1269 | let out = |
| 1270 | assert_eq!(out.len |
| 1271 | assert_eq!(out[1 |
| 1272 | assert_eq!(out[2 |
| 1273 | assert_eq!(out[2 |
| 1274 | assert!( |
| 1275 | out[2] |
| 1276 | .content |
| 1277 | .as_deref() |
| 1278 | .unwrap_or("") |
| 1279 | .contains("interrupted"), |
| 1280 | "{}", |
| 1281 | out[2].content. |
| 1282 | ); |
| 1283 | assert_eq!(out[3 |
| 1284 | } |
| 1285 | |
| 1286 | #[test] |
| 1287 | fn sanitize_keeps_complete_pairs_and_drops_orphan_tools() { |
| 1288 | let msgs = |
| 1289 | user("goal"), |
| 1290 | assistant_call("c1", |
| 1291 | tool("c1"), |
| 1292 | tool("orphan"), |
| 1293 | assistant("done"), |
| 1294 | ]; |
| 1295 | let out = |
| 1296 | assert_eq!(out.len |
| 1297 | assert_eq!(out[1 |
| 1298 | assert_eq!(out[2 |
| 1299 | assert_eq!(out[3 |
| 1300 | } |
| 1301 | |
| 1302 | #[test] |
| 1303 | fn sanitize_fills_only_the_missing_ids_in_a_batch() { |
| 1304 | let mut call |
| 1305 | call.tool_calls.push( |
| 1306 | id: "b". |
| 1307 | name: "write_file". |
| 1308 | arguments: json! |
| 1309 | }); |
| 1310 | let msgs = |
| 1311 | let out = |
| 1312 | let tool_ids: Vec |
| 1313 | .iter() |
| 1314 | .filter(|m| |
| 1315 | .map(|m| |
| 1316 | .collect(); |
| 1317 | assert_eq!(tool_ids, |
| 1318 | } |
| 1319 | |
| 1320 | #[test] |
| 1321 | fn bound_history_byte_budget_trims_big_tool_results() { |
| 1322 | // Several big file reads must not all be kept: the byte budget drops |
| 1323 | // the oldest rounds once the window is full. |
| 1324 | let mut msgs |
| 1325 | let big = |
| 1326 | for i in 0 |
| 1327 | msgs.push(assistant_call |
| 1328 | msgs.push(LlmMessage |
| 1329 | role: "tool". |
| 1330 | content: Some |
| 1331 | tool_call_id: Some |
| 1332 | name: Some |
| 1333 | tool_calls: vec! |
| 1334 | reasoning: None |
| 1335 | images: vec! |
| 1336 | }); |
| 1337 | } |
| 1338 | let out = |
| 1339 | let total: usize |
| 1340 | assert!(total <= |
| 1341 | assert_eq!(out[0 |
| 1342 | } |
| 1343 | |
| 1344 | fn stored(id: |
| 1345 | db::StoredMessage |
| 1346 | id, |
| 1347 | session_id: 1, |
| 1348 | role: role.into |
| 1349 | content: Some |
| 1350 | tool_call_id: None |
| 1351 | name: None |
| 1352 | tool_calls: None |
| 1353 | reasoning: None |
| 1354 | images: None |
| 1355 | usage: None |
| 1356 | cut_msg_id: cut, |
| 1357 | created_at: id, |
| 1358 | } |
| 1359 | } |
| 1360 | |
| 1361 | #[test] |
| 1362 | fn resolve_compaction_replaces_old_turns_with_summary() { |
| 1363 | let msgs = |
| 1364 | stored(1, "user |
| 1365 | stored(2, "assistant |
| 1366 | stored(3, "user |
| 1367 | stored(4, "assistant |
| 1368 | stored(5, "compaction |
| 1369 | stored(6, "user |
| 1370 | stored(7, "assistant |
| 1371 | ]; |
| 1372 | let out = |
| 1373 | // summary first, then only messages with id >= cut (3); compaction row excluded |
| 1374 | assert_eq!(out.len |
| 1375 | assert_eq!(out[0 |
| 1376 | assert!( |
| 1377 | out[0] |
| 1378 | .content |
| 1379 | .as_deref() |
| 1380 | .unwrap() |
| 1381 | .contains("## Goal\n |
| 1382 | "{}", |
| 1383 | out[0].content. |
| 1384 | ); |
| 1385 | assert_eq!(out[1 |
| 1386 | assert_eq!(out[2 |
| 1387 | assert_eq!(out[3 |
| 1388 | assert_eq!(out[4 |
| 1389 | assert!(!out. |
| 1390 | } |
| 1391 | |
| 1392 | #[test] |
| 1393 | fn resolve_compaction_noop_without_compaction() { |
| 1394 | let msgs = |
| 1395 | stored(1, "user |
| 1396 | stored(2, "assistant |
| 1397 | ]; |
| 1398 | let out = |
| 1399 | assert_eq!(out.len |
| 1400 | assert_eq!(out[0 |
| 1401 | assert_eq!(out[1 |
| 1402 | } |
| 1403 | |
| 1404 | #[test] |
| 1405 | fn blocked_tool_result_names_mode_and_tool() { |
| 1406 | let plan = |
| 1407 | let msg = |
| 1408 | assert!(msg.contains |
| 1409 | assert!(msg.contains |
| 1410 | assert!(msg.contains |
| 1411 | |
| 1412 | let manual = |
| 1413 | let msg = |
| 1414 | assert!(msg.contains |
| 1415 | assert!(msg.contains |
| 1416 | |
| 1417 | let custom = |
| 1418 | name: "review". |
| 1419 | tools: vec! |
| 1420 | ..crate::mode::CustomMode |
| 1421 | }); |
| 1422 | let msg = |
| 1423 | assert!(msg.contains |
| 1424 | assert!(msg.contains |
| 1425 | } |
| 1426 | |
| 1427 | #[test] |
| 1428 | fn plan_mode_coerces_general_subagent_and_annotates_prompt() { |
| 1429 | let plan = |
| 1430 | let (kind, |
| 1431 | assert_eq!(kind, |
| 1432 | assert!(prompt.contains |
| 1433 | assert!(prompt.contains |
| 1434 | assert!(prompt.contains |
| 1435 | } |
| 1436 | |
| 1437 | #[test] |
| 1438 | fn plan_mode_leaves_explore_subagent_untouched() { |
| 1439 | let plan = |
| 1440 | let (kind, |
| 1441 | assert_eq!(kind, |
| 1442 | assert_eq!(prompt, |
| 1443 | } |
| 1444 | |
| 1445 | #[test] |
| 1446 | fn auto_mode_passes_general_subagent_through_unchanged() { |
| 1447 | let auto = |
| 1448 | let (kind, |
| 1449 | assert_eq!(kind, |
| 1450 | assert_eq!(prompt, |
| 1451 | } |
| 1452 | |
| 1453 | #[test] |
| 1454 | fn subagent_bail_message_lists_recent_tools_when_content_is_empty() { |
| 1455 | let tools: VecDeque |
| 1456 | "read_file(\"src/tui.rs\")" |
| 1457 | "search(\"banner\")" |
| 1458 | "read_file(\"src/tui.rs\")" |
| 1459 | ] |
| 1460 | .into_iter() |
| 1461 | .collect(); |
| 1462 | let msg = |
| 1463 | assert!(msg.contains |
| 1464 | assert!(msg.contains |
| 1465 | "read_file(\"src/tui.rs\") → search(\ |
| 1466 | )); |
| 1467 | assert!(msg.contains |
| 1468 | assert!( |
| 1469 | !msg.contains(" |
| 1470 | "no phantom empty 'Last output:'\n{msg}" |
| 1471 | ); |
| 1472 | } |
| 1473 | |
| 1474 | #[test] |
| 1475 | fn subagent_bail_message_without_tools_but_with_output() { |
| 1476 | let msg = |
| 1477 | assert!(msg.contains |
| 1478 | assert!(!msg. |
| 1479 | |
| 1480 | let msg = |
| 1481 | assert!(msg.contains |
| 1482 | assert!(msg.contains |
| 1483 | } |
| 1484 | |
| 1485 | #[test] |
| 1486 | fn subagent_bail_message_truncates_long_output() { |
| 1487 | let long = |
| 1488 | let msg = |
| 1489 | assert!(msg.contains |
| 1490 | assert!( |
| 1491 | msg.len() < |
| 1492 | "long output is truncated in the bail message" |
| 1493 | ); |
| 1494 | } |
| 1495 | |
| 1496 | #[test] |
| 1497 | fn round_tools_hides_todoread_until_a_todo_exists() { |
| 1498 | let path = |
| 1499 | "wynba-agent-test-{}-round-tools.sqlite", |
| 1500 | std::process::id |
| 1501 | )); |
| 1502 | let _ = |
| 1503 | let conn = |
| 1504 | let (sid, |
| 1505 | let auto = |
| 1506 | |
| 1507 | // Fresh session, no todos: todoread is not offered at all, so the agent |
| 1508 | // cannot burn its opening round on an empty list. |
| 1509 | let tools = |
| 1510 | assert!( |
| 1511 | !tools.iter(). |
| 1512 | "todoread hidden with no todos\n{:?}", |
| 1513 | tools.iter().map |
| 1514 | ); |
| 1515 | assert!( |
| 1516 | tools.iter().any |
| 1517 | "todowrite stays available to create a list" |
| 1518 | ); |
| 1519 | |
| 1520 | // After a todowrite creates items, todoread returns on the next round. |
| 1521 | crate::db::set_todos( |
| 1522 | &conn, |
| 1523 | sid, |
| 1524 | &[crate::db::TodoItem |
| 1525 | content: "fix the banner". |
| 1526 | status: "pending". |
| 1527 | priority: "medium". |
| 1528 | }], |
| 1529 | ) |
| 1530 | .unwrap(); |
| 1531 | let tools = |
| 1532 | assert!( |
| 1533 | tools.iter().any |
| 1534 | "todoread returns once todos exist" |
| 1535 | ); |
| 1536 | |
| 1537 | let _ = |
| 1538 | } |
| 1539 | |
| 1540 | #[test] |
| 1541 | fn subagent_tools_also_hide_todoread_when_list_is_empty() { |
| 1542 | let path = |
| 1543 | "wynba-agent-test-{}-subagent-tools.sqlite", |
| 1544 | std::process::id |
| 1545 | )); |
| 1546 | let _ = |
| 1547 | let conn = |
| 1548 | let (sid, |
| 1549 | |
| 1550 | let tools = |
| 1551 | assert!( |
| 1552 | !tools.iter(). |
| 1553 | "sub-agent tools omit todoread with no todos" |
| 1554 | ); |
| 1555 | |
| 1556 | let _ = |
| 1557 | } |
| 1558 | } |