| 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 | /// Sub-agents run unbounded rounds like the main agent (pi's runLoop). The |
| 16 | /// wall-clock deadline and mid-stream cancel checks bound runaway work; context |
| 17 | /// growth is handled by the same threshold/overflow compaction path as the |
| 18 | /// main agent instead of a hard tool-call cap that cut reports mid-flight. |
| 19 | const MAX_SUBAGENT_DEPTH: usize |
| 20 | /// Hard wall-clock cap on a single sub-agent run. Per round the LLM call has |
| 21 | /// its own read timeout, a per-call wall cap and mid-stream cancel checks |
| 22 | /// (see `llm.rs`), so a provider that keeps the socket alive can no longer |
| 23 | /// stall a round forever; this deadline bounds the chain of rounds and |
| 24 | /// guarantees the run terminates and its outcome lands as the task tool |
| 25 | /// result. Deliberately generous: slow "thinking" models legitimately take |
| 26 | /// minutes per reply, and the task node shows the sub-agent's live progress |
| 27 | /// meanwhile. |
| 28 | const SUBAGENT_HARD_DEADLINE: std |
| 29 | /// Upper bound on sub-agent LLM rounds. The wall-clock deadline alone cannot |
| 30 | /// stop a fast empty-response spin (each round is cheap and quick), so a |
| 31 | /// round cap bounds API spend when the model keeps returning no report. |
| 32 | const MAX_SUBAGENT_ROUNDS: usize |
| 33 | /// Upper bound on consecutive empty-response nudges before giving up instead |
| 34 | /// of retrying until the deadline. |
| 35 | const MAX_EMPTY_REPORT_NUDGES: usize |
| 36 | /// How long a sub-agent waits for the user to approve a mutating tool call in |
| 37 | /// accept-edits mode before it treats the call as declined and adapts, instead |
| 38 | /// of blocking forever on a confirmation the user never answered. |
| 39 | const SUBAGENT_CONFIRM_TIMEOUT: std |
| 40 | /// Bytes of mid-stream sub-agent text kept for a bail report (the parent LLM |
| 41 | /// gets a slice of what the sub-agent was saying before the cap/round limit). |
| 42 | const SUBAGENT_PARTIAL_CAP: usize |
| 43 | const SUBAGENT_PARTIAL_KEEP: usize |
| 44 | |
| 45 | /// Why a compaction is running — the TUI shows a pi-style status line with a |
| 46 | /// reason-specific message (manual `/compact` vs. recovered context overflow). |
| 47 | #[derive(Clone |
| 48 | pub enum CompactionReason |
| 49 | /// The user ran `/compact` (optionally with a focus). |
| 50 | Manual, |
| 51 | /// The provider rejected the request because the context no longer fits; |
| 52 | /// the agent folds older turns into a summary and retries (pi's overflow |
| 53 | /// auto-compaction). |
| 54 | Overflow, |
| 55 | /// The estimated context grew within `RESERVE_TOKENS` of the provider's |
| 56 | /// context window; the agent compacts proactively before the next request |
| 57 | /// (pi's threshold auto-compaction). |
| 58 | Threshold, |
| 59 | } |
| 60 | |
| 61 | pub enum AgentEvent |
| 62 | Delta(String), |
| 63 | ToolStart { |
| 64 | name: String |
| 65 | args: Value |
| 66 | }, |
| 67 | ToolResult { |
| 68 | name: String |
| 69 | result: String |
| 70 | is_error: bool |
| 71 | }, |
| 72 | Retry(String), |
| 73 | Failed(String), |
| 74 | /// A queued steering message was folded into the conversation and will be |
| 75 | /// seen by the model on the next LLM call. |
| 76 | SteerDelivered { |
| 77 | text: String |
| 78 | }, |
| 79 | /// `/compact` (or auto-compaction) started. |
| 80 | CompactionStarted { |
| 81 | reason: CompactionReason |
| 82 | }, |
| 83 | /// `/compact` finished: the older turns were folded into a summary. |
| 84 | CompactionDone { |
| 85 | tokens_before: usize |
| 86 | dropped: usize |
| 87 | kept: usize |
| 88 | }, |
| 89 | CompactionFailed(String), |
| 90 | /// Tool needs a yes/no confirmation from the user. The agent thread blocks |
| 91 | /// on `reply` until the TUI sends `true`/`false` (or the channel closes). |
| 92 | Confirm { |
| 93 | message: String |
| 94 | /// Short button label, e.g. "delete". |
| 95 | confirm_label: String |
| 96 | reply: std::sync |
| 97 | }, |
| 98 | /// A slash-command subtask (opencode's `subtask: true`, e.g. `/review`) |
| 99 | /// finished. `is_error` flips the banner's ✓/✗ marker. Sent by the |
| 100 | /// subtask runner just before `Finished`; the main agent never emits it. |
| 101 | SubtaskFinished { |
| 102 | is_error: bool |
| 103 | }, |
| 104 | Finished, |
| 105 | } |
| 106 | |
| 107 | /// A steering message queued while the agent is running. Empty text + empty |
| 108 | /// images = null steer (folded into the prompt to influence the next turn). |
| 109 | pub type SteerItem |
| 110 | |
| 111 | pub fn run( |
| 112 | conn: &Connection |
| 113 | session_id: i64 |
| 114 | cfg: &ProviderConfig |
| 115 | model: &str |
| 116 | user_input: &str |
| 117 | on_event: Option |
| 118 | ) -> Result<String |
| 119 | let cancel = |
| 120 | run_cancellable( |
| 121 | conn, |
| 122 | session_id, |
| 123 | cfg, |
| 124 | model, |
| 125 | user_input, |
| 126 | &[], |
| 127 | None, |
| 128 | on_event, |
| 129 | &cancel, |
| 130 | ) |
| 131 | } |
| 132 | |
| 133 | /// `/continue` applies exactly when the newest message is a `failed` row: a |
| 134 | /// finished turn has nothing to resume, and a user-cancelled turn persists |
| 135 | /// nothing at all (cancellations don't leave a row behind). |
| 136 | pub fn can_continue(messages |
| 137 | messages.last().is_some_and |
| 138 | } |
| 139 | |
| 140 | /// In-memory-only nudge prepended to a continued run's requests. It is never |
| 141 | /// persisted, so it doesn't flood the transcript — each round's request is |
| 142 | /// rebuilt from storage plus this one line, telling the model why it is being |
| 143 | /// asked to pick up mid-task with no fresh user instruction. |
| 144 | pub const CONTINUATION_REMINDER: |
| 145 | an error before it could finish. Continue from where you left off and complete the original \ |
| 146 | request. Do not mention or acknowledge this reminder.</system-reminder>"; |
| 147 | |
| 148 | /// True when `e` represents a user/provider cancellation rather than a model |
| 149 | /// failure. Only known cancel shapes match: the internal `bail!("cancelled")`, |
| 150 | /// `Retry cancelled`, tool-interrupt notes, and explicit user-cancel phrases. |
| 151 | /// Provider remote errors containing "cancel" (e.g. "cancelled by remote", |
| 152 | /// "cannot cancel subscription") are failures and must leave a `failed` row. |
| 153 | pub fn is_cancel_error(e |
| 154 | is_cancel_msg(&format! |
| 155 | } |
| 156 | |
| 157 | /// String variant of [`is_cancel_error`] for call sites that only have a |
| 158 | /// rendered message. |
| 159 | pub fn is_cancel_msg(msg |
| 160 | let m = |
| 161 | // Exact internal bail. |
| 162 | if m.trim() |
| 163 | return true; |
| 164 | } |
| 165 | m.contains("retry cancelled |
| 166 | || m.contains( |
| 167 | || m.contains( |
| 168 | || m.contains( |
| 169 | || m.contains( |
| 170 | || m.contains( |
| 171 | || m.contains( |
| 172 | || m.contains( |
| 173 | } |
| 174 | |
| 175 | #[allow(clippy:: |
| 176 | fn persist_run_failure(conn: |
| 177 | let text = |
| 178 | let _ = |
| 179 | conn, |
| 180 | session_id, |
| 181 | "failed", |
| 182 | Some(&text |
| 183 | None, |
| 184 | None, |
| 185 | None, |
| 186 | None, |
| 187 | None, |
| 188 | None, |
| 189 | ); |
| 190 | crate::events::error( |
| 191 | } |
| 192 | |
| 193 | #[allow(clippy:: |
| 194 | pub fn run_cancellable( |
| 195 | conn: &Connection |
| 196 | session_id: i64 |
| 197 | cfg: &ProviderConfig |
| 198 | model: &str |
| 199 | user_input: &str |
| 200 | images: &[ImagePart |
| 201 | steer: Option |
| 202 | on_event: Option |
| 203 | cancel: &AtomicBool |
| 204 | ) -> Result<String |
| 205 | // A run that dies leaves a `failed` row in the session so a reloaded |
| 206 | // session shows why it ended instead of going silent. User-initiated |
| 207 | // cancellations are not failures and persist nothing. |
| 208 | match run_cancellable_inner( |
| 209 | conn, |
| 210 | session_id, |
| 211 | cfg, |
| 212 | model, |
| 213 | user_input, |
| 214 | images, |
| 215 | steer, |
| 216 | false, |
| 217 | on_event, |
| 218 | cancel, |
| 219 | ) { |
| 220 | Err(e) |
| 221 | persist_run_failure(conn, session_id |
| 222 | Err(e) |
| 223 | } |
| 224 | other => other, |
| 225 | } |
| 226 | } |
| 227 | |
| 228 | /// Re-runs the agent on an existing session without adding a new user message |
| 229 | /// (`/continue`): resumes a turn that died on a transient error. The stored |
| 230 | /// history — which ends in a `failed` row — is the whole prompt, so no |
| 231 | /// "continue" filler accumulates in context. Same failure contract as |
| 232 | /// [`run_cancellable`]: a dying continuation leaves a fresh `failed` row. |
| 233 | #[allow(clippy:: |
| 234 | pub fn continue_run_cancellable( |
| 235 | conn: &Connection |
| 236 | session_id: i64 |
| 237 | cfg: &ProviderConfig |
| 238 | model: &str |
| 239 | steer: Option |
| 240 | on_event: Option |
| 241 | cancel: &AtomicBool |
| 242 | ) -> Result<String |
| 243 | match run_cancellable_inner( |
| 244 | conn, |
| 245 | session_id, |
| 246 | cfg, |
| 247 | model, |
| 248 | "", |
| 249 | &[], |
| 250 | steer, |
| 251 | true, |
| 252 | on_event, |
| 253 | cancel, |
| 254 | ) { |
| 255 | Err(e) |
| 256 | persist_run_failure(conn, session_id |
| 257 | Err(e) |
| 258 | } |
| 259 | other => other, |
| 260 | } |
| 261 | } |
| 262 | |
| 263 | #[allow(clippy:: |
| 264 | fn run_cancellable_inner( |
| 265 | conn: &Connection |
| 266 | session_id: i64 |
| 267 | cfg: &ProviderConfig |
| 268 | model: &str |
| 269 | user_input: &str |
| 270 | images: &[ImagePart |
| 271 | steer: Option |
| 272 | continuation: bool |
| 273 | mut on_event: Option |
| 274 | cancel: &AtomicBool |
| 275 | ) -> Result<String |
| 276 | // Session-global thinking effort (set via `/effort`, `min`/`max` resolve |
| 277 | // like pi) is applied to every request this run makes. |
| 278 | let mut cfg |
| 279 | if let Some |
| 280 | cfg.effort = Some |
| 281 | } |
| 282 | // Pi parity for the OpenCode zen/go gateway (pi #4847): stamp the stable |
| 283 | // per-conversation routing id so inference routes to one backend (and the |
| 284 | // free tier stops answering 400). Sub-agents inherit this `cfg`, so the |
| 285 | // whole conversation shares one value; paths that never stamp fall back |
| 286 | // to a per-process id inside `llm`. |
| 287 | let session_uuid = |
| 288 | .ok() |
| 289 | .flatten() |
| 290 | .map(|s| |
| 291 | .unwrap_or_default(); |
| 292 | cfg = llm:: |
| 293 | let images_json = |
| 294 | None |
| 295 | } else { |
| 296 | Some(serde_json:: |
| 297 | }; |
| 298 | // A continuation (`/continue`) adds no user row: the stored history is the |
| 299 | // prompt, and a synthetic reminder (below) tells the model to resume. |
| 300 | if !continuation { |
| 301 | db::add_message( |
| 302 | conn, |
| 303 | session_id, |
| 304 | "user", |
| 305 | Some(user_input), |
| 306 | None, |
| 307 | None, |
| 308 | None, |
| 309 | images_json.as_deref(), |
| 310 | None, |
| 311 | None, |
| 312 | )?; |
| 313 | if let Some |
| 314 | && s.name.is_empty |
| 315 | { |
| 316 | db::set_session_name(conn |
| 317 | } |
| 318 | } |
| 319 | db::touch_session(conn |
| 320 | |
| 321 | let streaming = |
| 322 | let mut noop |
| 323 | let sink: & |
| 324 | Some(f) |
| 325 | None => &mut |
| 326 | }; |
| 327 | // All event emission goes through this shared cell so the tool-confirmation |
| 328 | // closure can also emit while the rest of the loop calls sink directly. |
| 329 | let sink_cell = |
| 330 | // Confirmation bridge (Grok Build): sensitive tools (delete_chats, …) ask |
| 331 | // the user through the UI. Emits a Confirm event and blocks until the TUI |
| 332 | // sends a yes/no reply. Non-interactive runs never block — they decline. |
| 333 | let mut ask |
| 334 | let (reply_tx, |
| 335 | if streaming { |
| 336 | sink_cell.borrow_mut()(AgentEvent |
| 337 | message: message.to_string |
| 338 | confirm_label: label.to_string |
| 339 | reply: reply_tx, |
| 340 | }); |
| 341 | reply_rx.recv().unwrap_or |
| 342 | } else { |
| 343 | false |
| 344 | } |
| 345 | }; |
| 346 | // True after one overflow-triggered auto-compaction: pi bounds recovery at |
| 347 | // one compact-and-retry attempt per conversational input, so a second |
| 348 | // overflow inside the same run fails instead of looping forever. |
| 349 | let mut overflow_recovered |
| 350 | // The session's mode decides the system prompt, which tools the model is |
| 351 | // offered, and which tool calls must be approved first. Tools are computed |
| 352 | // per round so `todoread` only appears once a todo list actually exists |
| 353 | // (the model otherwise burns its opening round reading an empty list). |
| 354 | let mode = |
| 355 | let system = |
| 356 | |
| 357 | // Unbounded like pi's `runLoop`: the model decides when the turn ends — |
| 358 | // each round executes its tool calls, and the loop exits only when a |
| 359 | // response carries no tool calls. There is no round cap to bail on. |
| 360 | loop { |
| 361 | if cancel.load( |
| 362 | bail!("cancelled"); |
| 363 | } |
| 364 | let tools = |
| 365 | // Fold any queued steering messages into the conversation so the model |
| 366 | // sees them on this round. They persist as user messages, so a later |
| 367 | // relaunch after interruption keeps them in history. |
| 368 | if let Some |
| 369 | let pending: Vec |
| 370 | for (text, imgs |
| 371 | if text.is_empty() |
| 372 | continue; |
| 373 | } |
| 374 | let images_json = |
| 375 | None |
| 376 | } else { |
| 377 | Some(serde_json:: |
| 378 | }; |
| 379 | db::add_message( |
| 380 | conn, |
| 381 | session_id, |
| 382 | "user", |
| 383 | Some(&text |
| 384 | None, |
| 385 | None, |
| 386 | None, |
| 387 | images_json.as_deref(), |
| 388 | None, |
| 389 | None, |
| 390 | )?; |
| 391 | sink_cell.borrow_mut()(AgentEvent |
| 392 | } |
| 393 | } |
| 394 | let stored = |
| 395 | // Pi's threshold auto-compaction: when the estimated context has grown |
| 396 | // within RESERVE_TOKENS of the provider's window, compact proactively |
| 397 | // instead of waiting for the provider to reject the request. Unlike |
| 398 | // overflow recovery nothing failed here, so there is no retry — the |
| 399 | // round simply restarts on the smaller context. |
| 400 | // |
| 401 | // Pi's boundary guard (`prepareCompaction` rejects when the newest |
| 402 | // entry is already a `compaction` row): never auto-compact twice in a |
| 403 | // row. Without it, an estimate that stays over threshold after a |
| 404 | // compaction would fail with "Already compacted" every round and spam |
| 405 | // the UI in an unbounded loop. |
| 406 | let already_compacted = |
| 407 | if !already_compacted |
| 408 | && compact::should_compact( |
| 409 | compact::estimate_llm_context_tokens(&resolve_compaction |
| 410 | cfg.context_window as usize |
| 411 | ) |
| 412 | { |
| 413 | if streaming { |
| 414 | sink_cell.borrow_mut()(AgentEvent |
| 415 | reason: CompactionReason |
| 416 | }); |
| 417 | } else { |
| 418 | eprintln!(" ⟳ context near threshold — compacting history…"); |
| 419 | } |
| 420 | match compact::run( |
| 421 | Ok(r) |
| 422 | sink_cell.borrow_mut()(AgentEvent |
| 423 | tokens_before: r.tokens_before |
| 424 | dropped: r.dropped |
| 425 | kept: r.kept |
| 426 | }); |
| 427 | // Restart the round on the compacted history. |
| 428 | continue; |
| 429 | } |
| 430 | Err(ce) |
| 431 | // Never retry a failed compaction: the round proceeds with |
| 432 | // the unchanged history and the request goes out as-is |
| 433 | // (pi's threshold path has no retry either). |
| 434 | sink_cell.borrow_mut()(AgentEvent |
| 435 | } |
| 436 | } |
| 437 | } |
| 438 | // bound_history trims for size; sanitize_tool_pairs repairs any |
| 439 | // assistant tool_calls that lack matching tool results (cancel mid- |
| 440 | // tools, crash, or a bad cut). Providers like OpenAI/OpenCode reject |
| 441 | // the whole request with HTTP 400 if a tool_call_id is unanswered. |
| 442 | let mut messages |
| 443 | sanitize_tool_pairs(bound_history(resolve_compaction |
| 444 | // Defense in depth: a tool like delete_chats can wipe the session |
| 445 | // mid-turn, leaving an empty (or orphan-tool) history. Providers reject |
| 446 | // an empty message list with HTTP 400, so fall back to the current |
| 447 | // prompt as the sole user message. |
| 448 | if messages.is_empty() |
| 449 | || messages |
| 450 | .iter() |
| 451 | .find(|m| |
| 452 | .is_none_or(|m| |
| 453 | { |
| 454 | if continuation { |
| 455 | bail!("nothing to continue — the session has no user turn"); |
| 456 | } |
| 457 | messages.insert( |
| 458 | 0, |
| 459 | LlmMessage { |
| 460 | role: "user". |
| 461 | content: Some |
| 462 | tool_call_id: None |
| 463 | name: None |
| 464 | tool_calls: vec! |
| 465 | reasoning: None |
| 466 | images: vec! |
| 467 | }, |
| 468 | ); |
| 469 | } |
| 470 | // A continuation sends the stored history as-is (the trailing `failed` |
| 471 | // row is dropped by every wire serializer) plus one unpersisted nudge |
| 472 | // telling the model to pick up where it stopped. |
| 473 | if continuation { |
| 474 | messages.push(LlmMessage |
| 475 | role: "user". |
| 476 | content: Some |
| 477 | tool_call_id: None |
| 478 | name: None |
| 479 | tool_calls: vec! |
| 480 | reasoning: None |
| 481 | images: vec! |
| 482 | }); |
| 483 | } |
| 484 | let resp: LlmResponse |
| 485 | let call = |
| 486 | let mut llm_sink |
| 487 | sink_cell.borrow_mut()(AgentEvent |
| 488 | }; |
| 489 | let mut retry_sink |
| 490 | sink_cell.borrow_mut()(AgentEvent |
| 491 | "Retrying request · waiting {}s · attempt {}/{}" |
| 492 | wait_ms / 1000, |
| 493 | attempt, |
| 494 | max |
| 495 | ))); |
| 496 | }; |
| 497 | llm::chat_with_retries( |
| 498 | &cfg, |
| 499 | model, |
| 500 | &system, |
| 501 | &messages, |
| 502 | &tools, |
| 503 | Some(&mut |
| 504 | Some(&mut |
| 505 | cancel, |
| 506 | ) |
| 507 | } else { |
| 508 | llm::chat_with_retries(&cfg |
| 509 | }; |
| 510 | match call { |
| 511 | Ok(r) |
| 512 | Err(e) |
| 513 | && (llm::is_context_overflow |
| 514 | { |
| 515 | // The provider rejected the request because the history no |
| 516 | // longer fits its context window — or returned zero output |
| 517 | // with `finish_reason=length` (the input filled the window, |
| 518 | // leaving no room for an answer, e.g. MiMo). Both are |
| 519 | // folded into a summary (pi's overflow auto-compaction) and |
| 520 | // retried once. Nothing from the failed call was persisted, |
| 521 | // so the DB is intact; the next round re-reads the |
| 522 | // compacted history via resolve_compaction. |
| 523 | overflow_recovered = true; |
| 524 | if streaming { |
| 525 | sink_cell.borrow_mut()(AgentEvent |
| 526 | "Context overflow · compacting history · retrying".to_string(), |
| 527 | )); |
| 528 | sink_cell.borrow_mut()(AgentEvent |
| 529 | reason: CompactionReason |
| 530 | }); |
| 531 | } else { |
| 532 | eprintln!(" ⟳ context overflow — compacting history and retrying…"); |
| 533 | } |
| 534 | match compact::run( |
| 535 | Ok(r) |
| 536 | sink_cell.borrow_mut()(AgentEvent |
| 537 | tokens_before: r.tokens_before |
| 538 | dropped: r.dropped |
| 539 | kept: r.kept |
| 540 | }); |
| 541 | continue; |
| 542 | } |
| 543 | Err(ce) |
| 544 | sink_cell.borrow_mut()(AgentEvent |
| 545 | return Err(e |
| 546 | } |
| 547 | } |
| 548 | } |
| 549 | Err(e) |
| 550 | } |
| 551 | }; |
| 552 | if cancel.load( |
| 553 | bail!("cancelled"); |
| 554 | } |
| 555 | |
| 556 | let calls_json = |
| 557 | None |
| 558 | } else { |
| 559 | Some(serde_json:: |
| 560 | }; |
| 561 | // Persist the call's token usage so a future `estimate_context_tokens` |
| 562 | // can blend real counts with the chars/4 heuristic (pi blends |
| 563 | // `usage.input + usage.cacheRead` into its estimates). |
| 564 | let usage_json = |
| 565 | .usage |
| 566 | .as_ref() |
| 567 | .map(serde_json::to_string |
| 568 | .transpose()?; |
| 569 | db::add_message( |
| 570 | conn, |
| 571 | session_id, |
| 572 | "assistant", |
| 573 | resp.content.as_deref(), |
| 574 | None, |
| 575 | None, |
| 576 | calls_json.as_deref(), |
| 577 | None, |
| 578 | resp.reasoning.as_deref(), |
| 579 | usage_json.as_deref(), |
| 580 | )?; |
| 581 | |
| 582 | if resp.tool_calls.is_empty |
| 583 | return Ok(resp |
| 584 | } |
| 585 | |
| 586 | for tc in & |
| 587 | // Always record a tool result for every tool_call_id — even on |
| 588 | // cancel — so the next LLM request never sees an open tool_calls |
| 589 | // message (the HTTP 400 we hit after interrupted writes). |
| 590 | if cancel.load( |
| 591 | let result = |
| 592 | db::add_message( |
| 593 | conn, |
| 594 | session_id, |
| 595 | "tool", |
| 596 | Some(result), |
| 597 | Some(&tc |
| 598 | Some(&tc |
| 599 | None, |
| 600 | None, |
| 601 | None, |
| 602 | None, |
| 603 | )?; |
| 604 | if streaming { |
| 605 | sink_cell.borrow_mut()(AgentEvent |
| 606 | name: tc.name |
| 607 | result: result.to_string |
| 608 | is_error: true, |
| 609 | }); |
| 610 | } |
| 611 | continue; |
| 612 | } |
| 613 | if streaming { |
| 614 | sink_cell.borrow_mut()(AgentEvent |
| 615 | name: tc.name |
| 616 | args: tc.arguments |
| 617 | }); |
| 618 | } else { |
| 619 | eprintln!(" → {} |
| 620 | } |
| 621 | let (result, |
| 622 | (blocked_tool_result(&mode, |
| 623 | } else if tc |
| 624 | let t = |
| 625 | // Plan mode only ever delegates to read-only sub-agents. When |
| 626 | // the mode forces a different type, the prompt is annotated so |
| 627 | // the sub-agent's task matches what it can actually do instead |
| 628 | // of stalling on an impossible one. |
| 629 | let (subagent_type, |
| 630 | enforce_and_annotate(&mode, |
| 631 | let mut sub_sink |
| 632 | forward_subagent_event(&sink_cell, |
| 633 | }; |
| 634 | let parent_images: Vec |
| 635 | messages.iter().flat_map |
| 636 | match run_subagent_cancellable( |
| 637 | conn, |
| 638 | session_id, |
| 639 | &cfg, |
| 640 | model, |
| 641 | &mode, |
| 642 | &subagent_type, |
| 643 | &sub_prompt, |
| 644 | &parent_images, |
| 645 | 1, |
| 646 | Some(&mut |
| 647 | cancel, |
| 648 | ) { |
| 649 | Ok(r) |
| 650 | Err(e) |
| 651 | } |
| 652 | } else { |
| 653 | execute_with_approval(conn, session_id |
| 654 | }; |
| 655 | if streaming { |
| 656 | sink_cell.borrow_mut()(AgentEvent |
| 657 | name: tc.name |
| 658 | result: result.clone |
| 659 | is_error, |
| 660 | }); |
| 661 | } else { |
| 662 | eprintln!( |
| 663 | " → {} {} => {}", |
| 664 | tc.name, |
| 665 | tc.arguments, |
| 666 | truncate(&result, |
| 667 | ); |
| 668 | } |
| 669 | db::add_message( |
| 670 | conn, |
| 671 | session_id, |
| 672 | "tool", |
| 673 | Some(&result |
| 674 | Some(&tc |
| 675 | Some(&tc |
| 676 | None, |
| 677 | None, |
| 678 | None, |
| 679 | None, |
| 680 | )?; |
| 681 | } |
| 682 | } |
| 683 | } |
| 684 | |
| 685 | struct TaskArgs { |
| 686 | subagent_type: String |
| 687 | prompt: String |
| 688 | } |
| 689 | |
| 690 | fn parse_task(args: |
| 691 | let subagent_type = |
| 692 | .get("subagent_type") |
| 693 | .and_then(|v| |
| 694 | .unwrap_or("general") |
| 695 | .to_string(); |
| 696 | let prompt = |
| 697 | .get("prompt") |
| 698 | .and_then(|v| |
| 699 | .unwrap_or("") |
| 700 | .to_string(); |
| 701 | TaskArgs { |
| 702 | subagent_type, |
| 703 | prompt, |
| 704 | } |
| 705 | } |
| 706 | |
| 707 | /// Forward a sub-agent event into the parent stream **as the same event |
| 708 | /// types the main agent uses** (`Delta` / `ToolStart` / `ToolResult` / |
| 709 | /// `Retry`). The TUI routes these into the open task node so tool bars and |
| 710 | /// text match the main chat exactly. |
| 711 | fn forward_subagent_event( |
| 712 | sink: &RefCell |
| 713 | ev: AgentEvent |
| 714 | ) { |
| 715 | match ev { |
| 716 | AgentEvent::Confirm |
| 717 | | AgentEvent::Delta |
| 718 | | AgentEvent::ToolStart |
| 719 | | AgentEvent::ToolResult |
| 720 | | AgentEvent::Retry |
| 721 | sink.borrow_mut()(ev |
| 722 | } |
| 723 | // Finished/Failed/etc. belong to the parent run, not the task node. |
| 724 | _ => {} |
| 725 | } |
| 726 | } |
| 727 | |
| 728 | /// Coerce the sub-agent type for the active mode and, when the mode forces a |
| 729 | /// different (read-only) type, annotate the prompt so the sub-agent's task |
| 730 | /// matches what it can actually do instead of stalling on an impossible one. |
| 731 | fn enforce_and_annotate(mode: |
| 732 | let enforced = |
| 733 | if enforced == subagent_type |
| 734 | (enforced, prompt. |
| 735 | } else { |
| 736 | ( |
| 737 | enforced.clone(), |
| 738 | format!( |
| 739 | "{prompt}\n\nNote: you are running as a read-only 'explore' sub-agent because \ |
| 740 | the session is in plan mode. You cannot modify files or run shell commands. \ |
| 741 | Do the research, then report back with a concrete, actionable plan the main \ |
| 742 | agent can execute." |
| 743 | ), |
| 744 | ) |
| 745 | } |
| 746 | } |
| 747 | |
| 748 | /// The mode a session runs in, falling back to auto when the row is missing or |
| 749 | /// holds an unknown value (e.g. sessions created before modes existed). |
| 750 | pub(crate) fn session_mode |
| 751 | db::get_session_mode(conn |
| 752 | .ok() |
| 753 | .and_then(|s| |
| 754 | .unwrap_or(AnyMode::Builtin |
| 755 | } |
| 756 | |
| 757 | /// Tools for one LLM round: the mode's filter over the built-in definitions, |
| 758 | /// minus `todoread` while the session has no todo list. A fresh session would |
| 759 | /// otherwise waste its opening round calling `todoread` on an empty list; once |
| 760 | /// a `todowrite` creates items, `todoread` reappears on the next round. |
| 761 | fn round_tools(conn: |
| 762 | strip_todoread_if_empty(conn, session_id |
| 763 | } |
| 764 | |
| 765 | /// Hide `todoread` while the session has no todo list (shared by the main loop |
| 766 | /// and sub-agents, so nobody burns a round reading an empty list). |
| 767 | fn strip_todoread_if_empty( |
| 768 | conn: &Connection |
| 769 | session_id: i64 |
| 770 | mut tools: Vec |
| 771 | ) -> Vec<tools |
| 772 | if db::get_todos( |
| 773 | .map(|t| |
| 774 | .unwrap_or(false) |
| 775 | { |
| 776 | tools.retain(|t |
| 777 | } |
| 778 | tools |
| 779 | } |
| 780 | |
| 781 | /// opencode-style execution gate: a tool the active mode does not allow is |
| 782 | /// blocked before it runs. The model sees an error result (like opencode's |
| 783 | /// `Permission.assert` deny → `BlockedError`) so it adapts without the tool. |
| 784 | fn blocked_tool_result(mode: |
| 785 | let why = |
| 786 | AnyMode::Builtin( |
| 787 | "plan mode is read-only: no file changes or shell commands" |
| 788 | } |
| 789 | AnyMode::Builtin( |
| 790 | _ => "this mode only allows its listed tools" |
| 791 | }; |
| 792 | format!( |
| 793 | "Error: the '{name}' tool is blocked in {} mode ({ |
| 794 | mode.slug(), |
| 795 | ) |
| 796 | } |
| 797 | |
| 798 | /// Run one non-subagent tool, pausing for user approval first when the mode |
| 799 | /// requires it (accept-edits mode). A declined call returns an error result so |
| 800 | /// the model sees the rejection and adapts instead of silently proceeding. |
| 801 | fn execute_with_approval( |
| 802 | conn: &Connection |
| 803 | session_id: i64 |
| 804 | mode: &AnyMode |
| 805 | tc: &llm:: |
| 806 | ask: &mut |
| 807 | ) -> (String, |
| 808 | if mode.requires_approval( |
| 809 | let (message, |
| 810 | if !ask(&message |
| 811 | let verb = |
| 812 | return ( |
| 813 | format!( |
| 814 | "The user declined the {verb}. No change was made. Adjust your approach and continue." |
| 815 | ), |
| 816 | true, |
| 817 | ); |
| 818 | } |
| 819 | } |
| 820 | match tools::execute( |
| 821 | Ok(r) |
| 822 | Err(e) |
| 823 | } |
| 824 | } |
| 825 | |
| 826 | fn approval_message(tc: |
| 827 | let path = |
| 828 | .arguments |
| 829 | .get("path") |
| 830 | .and_then(|v| |
| 831 | .unwrap_or("?") |
| 832 | .to_string(); |
| 833 | match tc.name.as_str |
| 834 | "write_file" => (format! |
| 835 | "edit_file" => (format! |
| 836 | "delete_file" => (format! |
| 837 | other => (format! |
| 838 | } |
| 839 | } |
| 840 | |
| 841 | /// Shown to a sub-agent that finishes a round with no tool calls and no text: |
| 842 | /// it must not silently hand the parent an empty report. |
| 843 | const SUBAGENT_NO_REPORT_NUDGE: &str |
| 844 | Always end with a report: a clear summary of what you found, the exact files and line \ |
| 845 | numbers involved, and how to verify it. If you are blocked, say precisely what is \ |
| 846 | blocking you. Produce your final report now."; |
| 847 | |
| 848 | fn subagent_system(subagent_type: |
| 849 | match subagent_type { |
| 850 | "explore" | "scout" |
| 851 | 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."# |
| 852 | } |
| 853 | _ => { |
| 854 | 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."# |
| 855 | } |
| 856 | } |
| 857 | } |
| 858 | |
| 859 | #[allow(clippy:: |
| 860 | pub fn run_subagent_cancellable( |
| 861 | conn: &Connection |
| 862 | session_id: i64 |
| 863 | cfg: &ProviderConfig |
| 864 | model: &str |
| 865 | mode: &AnyMode |
| 866 | subagent_type: &str |
| 867 | prompt: &str |
| 868 | parent_images: &[ImagePart |
| 869 | depth: usize |
| 870 | mut on_event: Option |
| 871 | cancel: &AtomicBool |
| 872 | ) -> Result<String |
| 873 | if depth > MAX_SUBAGENT_DEPTH |
| 874 | bail!("sub-agent nesting too deep (max depth {MAX_SUBAGENT_DEPTH |
| 875 | } |
| 876 | if prompt.trim() |
| 877 | bail!("task tool requires a 'prompt'"); |
| 878 | } |
| 879 | if cancel.load( |
| 880 | bail!("cancelled"); |
| 881 | } |
| 882 | let deadline = |
| 883 | let system = |
| 884 | // Same rule as the main loop: no `todoread` until a todo list exists. |
| 885 | let tools = |
| 886 | let mut messages |
| 887 | role: "user". |
| 888 | content: Some |
| 889 | tool_call_id: None |
| 890 | name: None |
| 891 | tool_calls: vec![], |
| 892 | reasoning: None |
| 893 | // Paste the parent conversation's images into the sub-agent's first |
| 894 | // message so its chat call materializes them (text-only models get |
| 895 | // vision descriptions, vision models get real pixels) instead of the |
| 896 | // sub-agent only seeing bare `[Image #N]` chips — "I don't see any |
| 897 | // image attached" was the failure when images lived only in the parent. |
| 898 | images: parent_images.to_vec |
| 899 | }]; |
| 900 | let streaming = |
| 901 | let mut noop |
| 902 | let sink: & |
| 903 | Some(f) |
| 904 | None => &mut |
| 905 | }; |
| 906 | let sink_cell = |
| 907 | let mut ask |
| 908 | let (reply_tx, |
| 909 | if streaming { |
| 910 | sink_cell.borrow_mut()(AgentEvent |
| 911 | message: message.to_string |
| 912 | confirm_label: label.to_string |
| 913 | reply: reply_tx, |
| 914 | }); |
| 915 | // Never block forever: an unanswered confirmation is treated as a |
| 916 | // decline so the sub-agent adapts instead of stalling. |
| 917 | reply_rx |
| 918 | .recv_timeout(SUBAGENT_CONFIRM_TIMEOUT) |
| 919 | .unwrap_or(false) |
| 920 | } else { |
| 921 | false |
| 922 | } |
| 923 | }; |
| 924 | let mut last_content |
| 925 | // Text streamed during the in-flight LLM call, so a bail (deadline, |
| 926 | // cancel) still reports what the sub-agent was in the middle of saying |
| 927 | // instead of handing the parent a bare error. |
| 928 | let mut partial_stream |
| 929 | // Ring of the most recent tool calls, so a deadline bail can say what |
| 930 | // the sub-agent was doing instead of a (usually empty) "Last output". |
| 931 | let mut recent_tools |
| 932 | // After an in-memory compaction, skip the threshold check for one round |
| 933 | // so a still-high estimate cannot re-compact forever (mirrors the main |
| 934 | // loop's "already compacted" guard). |
| 935 | let mut just_compacted |
| 936 | // One overflow-triggered compact-and-retry per sub-agent run. |
| 937 | let mut overflow_recovered |
| 938 | let mut rounds |
| 939 | let mut empty_nudges |
| 940 | |
| 941 | loop { |
| 942 | rounds = rounds. |
| 943 | if rounds > MAX_SUBAGENT_ROUNDS |
| 944 | bail!(subagent_bail_message( |
| 945 | &recent_tools, |
| 946 | &last_content, |
| 947 | &partial_stream, |
| 948 | &format!("exceeded { |
| 949 | )); |
| 950 | } |
| 951 | if cancel.load( |
| 952 | bail!("cancelled"); |
| 953 | } |
| 954 | if std::time |
| 955 | bail!(subagent_bail_message( |
| 956 | &recent_tools, |
| 957 | &last_content, |
| 958 | &partial_stream, |
| 959 | &format!( |
| 960 | "did not finish within {} minutes", |
| 961 | SUBAGENT_HARD_DEADLINE.as_secs() / |
| 962 | ), |
| 963 | )); |
| 964 | } |
| 965 | // Threshold auto-compaction: same rule as the main agent, but over |
| 966 | // the sub-agent's in-memory transcript (no session DB write). Clone |
| 967 | // on the way in so a failed compact leaves history intact. |
| 968 | if !just_compacted |
| 969 | && compact::should_compact( |
| 970 | compact::estimate_llm_context_tokens(&messages |
| 971 | cfg.context_window as usize |
| 972 | ) |
| 973 | { |
| 974 | if streaming { |
| 975 | sink_cell.borrow_mut()(AgentEvent |
| 976 | "context near threshold — compacting…".into(), |
| 977 | )); |
| 978 | } else { |
| 979 | eprintln!(" ⟳ sub-agent context near threshold — compacting…"); |
| 980 | } |
| 981 | match compact::compact_messages( |
| 982 | Ok((compacted, |
| 983 | messages = compacted; |
| 984 | just_compacted = true; |
| 985 | if streaming { |
| 986 | sink_cell.borrow_mut()(AgentEvent |
| 987 | "compacted ({} → {} kept msgs)", |
| 988 | r.dropped + r |
| 989 | r.kept |
| 990 | ))); |
| 991 | } |
| 992 | continue; |
| 993 | } |
| 994 | Err(ce) |
| 995 | // Proceed with the uncompacted history (pi's threshold |
| 996 | // path has no retry either). |
| 997 | if streaming { |
| 998 | sink_cell.borrow_mut()(AgentEvent |
| 999 | "compaction skipped: {ce:#}" |
| 1000 | ))); |
| 1001 | } |
| 1002 | } |
| 1003 | } |
| 1004 | } |
| 1005 | just_compacted = false; |
| 1006 | messages = sanitize_tool_pairs( |
| 1007 | let call = |
| 1008 | let mut llm_sink |
| 1009 | partial_stream.push_str(chunk |
| 1010 | if partial_stream.len() |
| 1011 | // Trim to the tail, on a char boundary (byte-draining could |
| 1012 | // split a multi-byte char and panic). |
| 1013 | let from = |
| 1014 | .char_indices() |
| 1015 | .map(|(i |
| 1016 | .find(|&i |
| 1017 | .unwrap_or(0); |
| 1018 | partial_stream.drain(..from |
| 1019 | } |
| 1020 | sink_cell.borrow_mut()(AgentEvent |
| 1021 | }; |
| 1022 | let mut retry_sink |
| 1023 | sink_cell.borrow_mut()(AgentEvent |
| 1024 | "Retrying ({attempt}/{max} |
| 1025 | wait_ms / 1000 |
| 1026 | ))); |
| 1027 | }; |
| 1028 | llm::chat_with_retries( |
| 1029 | cfg, |
| 1030 | model, |
| 1031 | system, |
| 1032 | &messages, |
| 1033 | &tools, |
| 1034 | Some(&mut |
| 1035 | Some(&mut |
| 1036 | cancel, |
| 1037 | ) |
| 1038 | } else { |
| 1039 | llm::chat_with_retries(cfg |
| 1040 | }; |
| 1041 | let resp: LlmResponse |
| 1042 | Ok(r) |
| 1043 | Err(e) |
| 1044 | if !overflow_recovered |
| 1045 | && (llm::is_context_overflow |
| 1046 | { |
| 1047 | if streaming { |
| 1048 | sink_cell.borrow_mut()(AgentEvent |
| 1049 | "context overflow — compacting and retrying…".into(), |
| 1050 | )); |
| 1051 | } else { |
| 1052 | eprintln!(" ⟳ sub-agent context overflow — compacting…"); |
| 1053 | } |
| 1054 | match compact::compact_messages( |
| 1055 | Ok((compacted, |
| 1056 | messages = compacted; |
| 1057 | overflow_recovered = true; |
| 1058 | just_compacted = true; |
| 1059 | continue; |
| 1060 | } |
| 1061 | Err(ce) |
| 1062 | bail!( |
| 1063 | "sub-agent context overflow and compaction failed: {ce:#} (original: {e:# |
| 1064 | ); |
| 1065 | } |
| 1066 | } |
| 1067 | } |
| 1068 | Err(e) |
| 1069 | }; |
| 1070 | last_content = resp. |
| 1071 | messages.push(LlmMessage |
| 1072 | role: "assistant". |
| 1073 | content: resp.content |
| 1074 | tool_call_id: None |
| 1075 | name: None |
| 1076 | tool_calls: resp.tool_calls |
| 1077 | reasoning: None |
| 1078 | images: vec! |
| 1079 | }); |
| 1080 | if resp.tool_calls.is_empty |
| 1081 | let content = |
| 1082 | if content.trim() |
| 1083 | // A sub-agent that "finishes" with no text is not a report: nudge |
| 1084 | // it to produce one instead of handing the parent an empty result |
| 1085 | // the user would read as "no report". Bounded: after repeated |
| 1086 | // empty replies bail instead of spinning until the deadline. |
| 1087 | empty_nudges = empty_nudges. |
| 1088 | if empty_nudges >= MAX_EMPTY_REPORT_NUDGES |
| 1089 | bail!(subagent_bail_message( |
| 1090 | &recent_tools, |
| 1091 | &last_content, |
| 1092 | &partial_stream, |
| 1093 | &format!( |
| 1094 | "produced no report after {MAX_EMPTY_REPORT_NUDGES} nudges" |
| 1095 | ), |
| 1096 | )); |
| 1097 | } |
| 1098 | messages.push(LlmMessage |
| 1099 | role: "user". |
| 1100 | content: Some |
| 1101 | tool_call_id: None |
| 1102 | name: None |
| 1103 | tool_calls: vec! |
| 1104 | reasoning: None |
| 1105 | images: vec! |
| 1106 | }); |
| 1107 | last_content.clear(); |
| 1108 | continue; |
| 1109 | } |
| 1110 | return Ok(content |
| 1111 | } |
| 1112 | // Tool calls are progress: reset the consecutive-empty counter. |
| 1113 | empty_nudges = 0; |
| 1114 | for tc in & |
| 1115 | if cancel.load( |
| 1116 | messages.push(LlmMessage |
| 1117 | role: "tool". |
| 1118 | content: Some |
| 1119 | tool_call_id: Some |
| 1120 | name: Some |
| 1121 | tool_calls: vec! |
| 1122 | reasoning: None |
| 1123 | images: vec! |
| 1124 | }); |
| 1125 | if streaming { |
| 1126 | sink_cell.borrow_mut()(AgentEvent |
| 1127 | name: tc.name |
| 1128 | result: "Error: cancelled before tool completed". |
| 1129 | is_error: true, |
| 1130 | }); |
| 1131 | } |
| 1132 | continue; |
| 1133 | } |
| 1134 | if streaming { |
| 1135 | sink_cell.borrow_mut()(AgentEvent |
| 1136 | name: tc.name |
| 1137 | args: tc.arguments |
| 1138 | }); |
| 1139 | } else { |
| 1140 | eprintln!(" → {} |
| 1141 | } |
| 1142 | if recent_tools.len() |
| 1143 | recent_tools.pop_front(); |
| 1144 | } |
| 1145 | recent_tools.push_back(format! |
| 1146 | "{}({})", |
| 1147 | tc.name, |
| 1148 | truncate(&tc.arguments |
| 1149 | )); |
| 1150 | let (result, |
| 1151 | (blocked_tool_result(mode, |
| 1152 | } else if tc |
| 1153 | let t = |
| 1154 | // Plan mode only ever delegates to read-only sub-agents. The |
| 1155 | // prompt is annotated when the type is coerced so the task |
| 1156 | // matches what the nested sub-agent can actually do. |
| 1157 | let (subagent_type, |
| 1158 | enforce_and_annotate(mode, & |
| 1159 | let mut sub_sink |
| 1160 | forward_subagent_event(&sink_cell, |
| 1161 | }; |
| 1162 | let sub_parent_images: Vec |
| 1163 | messages.iter().flat_map |
| 1164 | match run_subagent_cancellable( |
| 1165 | conn, |
| 1166 | session_id, |
| 1167 | cfg, |
| 1168 | model, |
| 1169 | mode, |
| 1170 | &subagent_type, |
| 1171 | &sub_prompt, |
| 1172 | &sub_parent_images, |
| 1173 | depth + 1, |
| 1174 | Some(&mut |
| 1175 | cancel, |
| 1176 | ) { |
| 1177 | Ok(r) |
| 1178 | Err(e) |
| 1179 | } |
| 1180 | } else { |
| 1181 | execute_with_approval(conn, session_id |
| 1182 | }; |
| 1183 | if streaming { |
| 1184 | sink_cell.borrow_mut()(AgentEvent |
| 1185 | name: tc.name |
| 1186 | result: result.clone |
| 1187 | is_error, |
| 1188 | }); |
| 1189 | } else { |
| 1190 | eprintln!(" {} |
| 1191 | } |
| 1192 | messages.push(LlmMessage |
| 1193 | role: "tool". |
| 1194 | content: Some |
| 1195 | tool_call_id: Some |
| 1196 | name: Some |
| 1197 | tool_calls: vec! |
| 1198 | reasoning: None |
| 1199 | images: vec! |
| 1200 | }); |
| 1201 | } |
| 1202 | } |
| 1203 | } |
| 1204 | |
| 1205 | /// The "stopped without finishing" error shown to the parent agent. `why` |
| 1206 | /// names the stopping reason (round cap, wall-clock deadline); `activity` names |
| 1207 | /// the most recent tools the sub-agent ran and `last_content`/`partial` carry |
| 1208 | /// what it was saying, so the parent sees a useful report instead of a bare |
| 1209 | /// (usually empty) "Last output". |
| 1210 | fn subagent_bail_message( |
| 1211 | recent_tools: &VecDeque |
| 1212 | last_content: &str |
| 1213 | partial: &str |
| 1214 | why: &str |
| 1215 | ) -> String { |
| 1216 | let activity = |
| 1217 | "no tool calls were made".to_string() |
| 1218 | } else { |
| 1219 | format!( |
| 1220 | "last tools: {}", |
| 1221 | recent_tools.iter().cloned |
| 1222 | ) |
| 1223 | }; |
| 1224 | let last = |
| 1225 | String::new() |
| 1226 | } else { |
| 1227 | format!(". Last output: {}" |
| 1228 | }; |
| 1229 | let partial_txt = |
| 1230 | String::new() |
| 1231 | } else { |
| 1232 | format!(". Partial report so far: {}" |
| 1233 | }; |
| 1234 | format!( |
| 1235 | "sub-agent {why} ({activity} |
| 1236 | ) |
| 1237 | } |
| 1238 | |
| 1239 | fn truncate(s: |
| 1240 | if s.chars() |
| 1241 | s.to_string() |
| 1242 | } else { |
| 1243 | let t: String |
| 1244 | format!("{t} |
| 1245 | } |
| 1246 | } |
| 1247 | |
| 1248 | /// Ensure every assistant `tool_calls` entry has a following `tool` message |
| 1249 | /// for each `tool_call_id`. Providers (OpenAI-compatible, OpenCode zen, …) |
| 1250 | /// reject the request with: |
| 1251 | /// "An assistant message with 'tool_calls' must be followed by tool messages |
| 1252 | /// responding to each 'tool_call_id'" |
| 1253 | /// when history is incomplete — e.g. the process was cancelled after the |
| 1254 | /// assistant row was saved but before every tool result landed, or history |
| 1255 | /// trimming cut mid-round. Missing results are filled with a synthetic error |
| 1256 | /// so the next turn can continue; orphan tool rows (no preceding call) drop. |
| 1257 | fn sanitize_tool_pairs(messages: |
| 1258 | let mut out |
| 1259 | let mut i |
| 1260 | while i < messages |
| 1261 | let m = |
| 1262 | if m.role == |
| 1263 | out.push(m |
| 1264 | i += 1; |
| 1265 | let mut pending |
| 1266 | .tool_calls |
| 1267 | .iter() |
| 1268 | .map(|tc| |
| 1269 | .collect(); |
| 1270 | let expected: Vec |
| 1271 | while i < messages |
| 1272 | let id = |
| 1273 | // Only consume tool rows that belong to this assistant call. |
| 1274 | // Orphans (wrong/missing ids) break out so they can be dropped. |
| 1275 | if !expected.iter |
| 1276 | break; |
| 1277 | } |
| 1278 | pending.retain(|( |
| 1279 | out.push(messages |
| 1280 | i += 1; |
| 1281 | } |
| 1282 | for (id, name |
| 1283 | out.push(LlmMessage |
| 1284 | role: "tool". |
| 1285 | content: Some |
| 1286 | "Error: tool call interrupted before a result was recorded.".into(), |
| 1287 | ), |
| 1288 | tool_call_id: Some |
| 1289 | name: Some |
| 1290 | tool_calls: vec! |
| 1291 | reasoning: None |
| 1292 | images: vec! |
| 1293 | }); |
| 1294 | } |
| 1295 | continue; |
| 1296 | } |
| 1297 | // Drop orphan tool rows that aren't attached to an assistant call. |
| 1298 | if m.role == |
| 1299 | i += 1; |
| 1300 | continue; |
| 1301 | } |
| 1302 | out.push(m |
| 1303 | i += 1; |
| 1304 | } |
| 1305 | out |
| 1306 | } |
| 1307 | |
| 1308 | /// Builds the LLM message list from stored history, honoring compaction: |
| 1309 | /// when the session was compacted, the latest `compaction` row's summary is |
| 1310 | /// injected as a user message (pi's `COMPACTION_SUMMARY_PREFIX`/`_SUFFIX` |
| 1311 | /// wrapper) and every message before its `cut_msg_id` is dropped from context. |
| 1312 | /// Older compaction rows and already-summarized turns simply don't resurface. |
| 1313 | fn resolve_compaction(messages: |
| 1314 | let Some( |
| 1315 | return messages.into_iter() |
| 1316 | }; |
| 1317 | let comp = |
| 1318 | let cut_id = |
| 1319 | let mut out |
| 1320 | out.push(LlmMessage |
| 1321 | role: "user". |
| 1322 | content: Some |
| 1323 | "{COMPACTION_SUMMARY_PREFIX}{}{COMPACTION_SUMMARY_SUFFIX}", |
| 1324 | comp.content.as_deref() |
| 1325 | )), |
| 1326 | tool_call_id: None |
| 1327 | name: None |
| 1328 | tool_calls: vec! |
| 1329 | reasoning: None |
| 1330 | images: vec![], |
| 1331 | }); |
| 1332 | for m in messages |
| 1333 | if m.role == |
| 1334 | continue; |
| 1335 | } |
| 1336 | if m.id >= |
| 1337 | out.push(m |
| 1338 | } |
| 1339 | } |
| 1340 | out |
| 1341 | } |
| 1342 | |
| 1343 | /// Bounds the message history sent to the model each round so long, tool-heavy |
| 1344 | /// runs don't overflow the provider's context window (which would otherwise |
| 1345 | /// fail the whole run with a non-transient error). Keeps the leading user |
| 1346 | /// message (the goal) plus the most recent messages that fit a byte budget, |
| 1347 | /// cut at a round boundary so an assistant tool-call is never orphaned from |
| 1348 | /// its results. |
| 1349 | fn bound_history(messages: |
| 1350 | const MAX_MSGS: usize |
| 1351 | const MAX_BYTES: usize |
| 1352 | if messages.len() |
| 1353 | return messages; |
| 1354 | } |
| 1355 | let mut tail |
| 1356 | let mut used |
| 1357 | let mut i |
| 1358 | while i > 0 |
| 1359 | i -= 1; |
| 1360 | let sz = |
| 1361 | if used + sz |
| 1362 | break; |
| 1363 | } |
| 1364 | used += sz; |
| 1365 | tail.push(messages |
| 1366 | } |
| 1367 | // Never let the bounded history begin with a "tool" message whose |
| 1368 | // assistant tool-call got cut off before it. |
| 1369 | while i > 0 |
| 1370 | i -= 1; |
| 1371 | tail.push(messages |
| 1372 | } |
| 1373 | tail.reverse(); |
| 1374 | let mut out |
| 1375 | if let Some |
| 1376 | out.push(first |
| 1377 | if tail.first() |
| 1378 | tail.remove(0); |
| 1379 | } |
| 1380 | } |
| 1381 | out.extend(tail |
| 1382 | out |
| 1383 | } |
| 1384 | |
| 1385 | fn msg_bytes(m: |
| 1386 | m.content.as_deref() |
| 1387 | + m.name.as_deref |
| 1388 | + m.tool_call_id.as_deref |
| 1389 | + m.tool_calls |
| 1390 | .iter() |
| 1391 | .map(|t| |
| 1392 | .sum::<usize |
| 1393 | + m.images |
| 1394 | .iter() |
| 1395 | .map(|i| |
| 1396 | .sum::<usize |
| 1397 | } |
| 1398 | |
| 1399 | fn auto_title(s: |
| 1400 | const LIMIT: usize |
| 1401 | let s: String |
| 1402 | if s.chars() |
| 1403 | return s; |
| 1404 | } |
| 1405 | let bytes: Vec |
| 1406 | let trimmed = |
| 1407 | if trimmed.chars() |
| 1408 | && let Some |
| 1409 | { |
| 1410 | return format!("{} |
| 1411 | } |
| 1412 | format!("{trimmed} |
| 1413 | } |
| 1414 | |
| 1415 | #[cfg(test)] |
| 1416 | mod tests { |
| 1417 | use super::*; |
| 1418 | use serde_json::json; |
| 1419 | |
| 1420 | fn user(content: |
| 1421 | LlmMessage { |
| 1422 | role: "user". |
| 1423 | content: Some |
| 1424 | tool_call_id: None |
| 1425 | name: None |
| 1426 | tool_calls: vec! |
| 1427 | reasoning: None |
| 1428 | images: vec! |
| 1429 | } |
| 1430 | } |
| 1431 | |
| 1432 | fn assistant(content: |
| 1433 | LlmMessage { |
| 1434 | role: "assistant". |
| 1435 | content: Some |
| 1436 | tool_call_id: None |
| 1437 | name: None |
| 1438 | tool_calls: vec! |
| 1439 | reasoning: None |
| 1440 | images: vec! |
| 1441 | } |
| 1442 | } |
| 1443 | |
| 1444 | fn assistant_call(id: |
| 1445 | LlmMessage { |
| 1446 | role: "assistant". |
| 1447 | content: None |
| 1448 | tool_call_id: None |
| 1449 | name: None |
| 1450 | tool_calls: vec! |
| 1451 | id: id.into |
| 1452 | name: name.into |
| 1453 | arguments: json! |
| 1454 | }], |
| 1455 | reasoning: None |
| 1456 | images: vec! |
| 1457 | } |
| 1458 | } |
| 1459 | |
| 1460 | fn tool(id: |
| 1461 | LlmMessage { |
| 1462 | role: "tool". |
| 1463 | content: Some |
| 1464 | tool_call_id: Some |
| 1465 | name: Some |
| 1466 | tool_calls: vec! |
| 1467 | reasoning: None |
| 1468 | images: vec! |
| 1469 | } |
| 1470 | } |
| 1471 | |
| 1472 | #[test] |
| 1473 | fn bound_history_keeps_short_history() { |
| 1474 | let msgs = |
| 1475 | let out = |
| 1476 | assert_eq!(out.len |
| 1477 | assert_eq!(out[0 |
| 1478 | } |
| 1479 | |
| 1480 | #[test] |
| 1481 | fn bound_history_keeps_goal_and_trims_middle() { |
| 1482 | let mut msgs |
| 1483 | for i in 0 |
| 1484 | msgs.push(assistant_call |
| 1485 | msgs.push(tool |
| 1486 | } |
| 1487 | let out = |
| 1488 | assert!(out.len |
| 1489 | assert_eq!(out[0 |
| 1490 | assert_eq!(out.last |
| 1491 | } |
| 1492 | |
| 1493 | #[test] |
| 1494 | fn bound_history_never_orphans_tool_result() { |
| 1495 | let mut msgs |
| 1496 | for i in 0 |
| 1497 | msgs.push(assistant_call |
| 1498 | msgs.push(tool |
| 1499 | } |
| 1500 | let out = |
| 1501 | for (i, m |
| 1502 | if m.role == |
| 1503 | let prev = |
| 1504 | assert_eq!(prev. |
| 1505 | assert_eq!( |
| 1506 | prev.tool_calls.first() |
| 1507 | m.tool_call_id.as_deref() |
| 1508 | ); |
| 1509 | } |
| 1510 | } |
| 1511 | } |
| 1512 | |
| 1513 | #[test] |
| 1514 | fn bound_history_cuts_mid_round_extends_back() { |
| 1515 | // If the budget cut falls between an assistant tool-call and its tool |
| 1516 | // result, the tool result must not become the leading message. |
| 1517 | let mut msgs |
| 1518 | for i in 0 |
| 1519 | msgs.push(assistant_call |
| 1520 | msgs.push(tool |
| 1521 | } |
| 1522 | let out = |
| 1523 | assert_ne!(out[0 |
| 1524 | assert_eq!(out[0 |
| 1525 | } |
| 1526 | |
| 1527 | #[test] |
| 1528 | fn sanitize_fills_missing_tool_results_after_assistant_calls() { |
| 1529 | // Simulates cancel after the assistant tool_calls row was persisted |
| 1530 | // but before tool results — the exact HTTP 400 shape from OpenCode. |
| 1531 | let msgs = |
| 1532 | user("write a readme"), |
| 1533 | assistant_call("call_1", |
| 1534 | // no tool result for call_1 |
| 1535 | user("continue"), |
| 1536 | ]; |
| 1537 | let out = |
| 1538 | assert_eq!(out.len |
| 1539 | assert_eq!(out[1 |
| 1540 | assert_eq!(out[2 |
| 1541 | assert_eq!(out[2 |
| 1542 | assert!( |
| 1543 | out[2] |
| 1544 | .content |
| 1545 | .as_deref() |
| 1546 | .unwrap_or("") |
| 1547 | .contains("interrupted"), |
| 1548 | "{}", |
| 1549 | out[2].content. |
| 1550 | ); |
| 1551 | assert_eq!(out[3 |
| 1552 | } |
| 1553 | |
| 1554 | #[test] |
| 1555 | fn sanitize_keeps_complete_pairs_and_drops_orphan_tools() { |
| 1556 | let msgs = |
| 1557 | user("goal"), |
| 1558 | assistant_call("c1", |
| 1559 | tool("c1"), |
| 1560 | tool("orphan"), |
| 1561 | assistant("done"), |
| 1562 | ]; |
| 1563 | let out = |
| 1564 | assert_eq!(out.len |
| 1565 | assert_eq!(out[1 |
| 1566 | assert_eq!(out[2 |
| 1567 | assert_eq!(out[3 |
| 1568 | } |
| 1569 | |
| 1570 | #[test] |
| 1571 | fn sanitize_fills_only_the_missing_ids_in_a_batch() { |
| 1572 | let mut call |
| 1573 | call.tool_calls.push( |
| 1574 | id: "b". |
| 1575 | name: "write_file". |
| 1576 | arguments: json! |
| 1577 | }); |
| 1578 | let msgs = |
| 1579 | let out = |
| 1580 | let tool_ids: Vec |
| 1581 | .iter() |
| 1582 | .filter(|m| |
| 1583 | .map(|m| |
| 1584 | .collect(); |
| 1585 | assert_eq!(tool_ids, |
| 1586 | } |
| 1587 | |
| 1588 | #[test] |
| 1589 | fn bound_history_byte_budget_trims_big_tool_results() { |
| 1590 | // Several big file reads must not all be kept: the byte budget drops |
| 1591 | // the oldest rounds once the window is full. |
| 1592 | let mut msgs |
| 1593 | let big = |
| 1594 | for i in 0 |
| 1595 | msgs.push(assistant_call |
| 1596 | msgs.push(LlmMessage |
| 1597 | role: "tool". |
| 1598 | content: Some |
| 1599 | tool_call_id: Some |
| 1600 | name: Some |
| 1601 | tool_calls: vec! |
| 1602 | reasoning: None |
| 1603 | images: vec! |
| 1604 | }); |
| 1605 | } |
| 1606 | let out = |
| 1607 | let total: usize |
| 1608 | assert!(total <= |
| 1609 | assert_eq!(out[0 |
| 1610 | } |
| 1611 | |
| 1612 | fn stored(id: |
| 1613 | db::StoredMessage |
| 1614 | id, |
| 1615 | session_id: 1, |
| 1616 | role: role.into |
| 1617 | content: Some |
| 1618 | tool_call_id: None |
| 1619 | name: None |
| 1620 | tool_calls: None |
| 1621 | reasoning: None |
| 1622 | images: None |
| 1623 | usage: None |
| 1624 | cut_msg_id: cut, |
| 1625 | created_at: id, |
| 1626 | } |
| 1627 | } |
| 1628 | |
| 1629 | #[test] |
| 1630 | fn resolve_compaction_replaces_old_turns_with_summary() { |
| 1631 | let msgs = |
| 1632 | stored(1, "user |
| 1633 | stored(2, "assistant |
| 1634 | stored(3, "user |
| 1635 | stored(4, "assistant |
| 1636 | stored(5, "compaction |
| 1637 | stored(6, "user |
| 1638 | stored(7, "assistant |
| 1639 | ]; |
| 1640 | let out = |
| 1641 | // summary first, then only messages with id >= cut (3); compaction row excluded |
| 1642 | assert_eq!(out.len |
| 1643 | assert_eq!(out[0 |
| 1644 | assert!( |
| 1645 | out[0] |
| 1646 | .content |
| 1647 | .as_deref() |
| 1648 | .unwrap() |
| 1649 | .contains("## Goal\n |
| 1650 | "{}", |
| 1651 | out[0].content. |
| 1652 | ); |
| 1653 | assert_eq!(out[1 |
| 1654 | assert_eq!(out[2 |
| 1655 | assert_eq!(out[3 |
| 1656 | assert_eq!(out[4 |
| 1657 | assert!(!out. |
| 1658 | } |
| 1659 | |
| 1660 | #[test] |
| 1661 | fn resolve_compaction_noop_without_compaction() { |
| 1662 | let msgs = |
| 1663 | stored(1, "user |
| 1664 | stored(2, "assistant |
| 1665 | ]; |
| 1666 | let out = |
| 1667 | assert_eq!(out.len |
| 1668 | assert_eq!(out[0 |
| 1669 | assert_eq!(out[1 |
| 1670 | } |
| 1671 | |
| 1672 | #[test] |
| 1673 | fn threshold_estimate_after_compaction_uses_resolved_history() { |
| 1674 | // Raw rows: a huge folded history + a compaction row that cuts at id |
| 1675 | // 100. The raw estimate must stay high (that was the bug — it kept the |
| 1676 | // session re-compacting every round), while the resolved payload — what |
| 1677 | // actually goes to the provider — must be small enough that a fresh |
| 1678 | // read or two don't immediately re-trigger threshold compaction. |
| 1679 | let big: String |
| 1680 | let mut msgs |
| 1681 | for i in 1 |
| 1682 | msgs.push(stored |
| 1683 | } |
| 1684 | msgs.push(stored |
| 1685 | msgs.push(stored |
| 1686 | |
| 1687 | let raw = |
| 1688 | let resolved = |
| 1689 | assert!(raw > |
| 1690 | assert!( |
| 1691 | resolved < 10_000, |
| 1692 | "resolved estimate must not include folded rows: {resolved}" |
| 1693 | ); |
| 1694 | } |
| 1695 | |
| 1696 | #[test] |
| 1697 | fn blocked_tool_result_names_mode_and_tool() { |
| 1698 | let plan = |
| 1699 | let msg = |
| 1700 | assert!(msg.contains |
| 1701 | assert!(msg.contains |
| 1702 | assert!(msg.contains |
| 1703 | |
| 1704 | let manual = |
| 1705 | let msg = |
| 1706 | assert!(msg.contains |
| 1707 | assert!(msg.contains |
| 1708 | |
| 1709 | let custom = |
| 1710 | name: "review". |
| 1711 | tools: vec! |
| 1712 | ..crate::mode::CustomMode |
| 1713 | }); |
| 1714 | let msg = |
| 1715 | assert!(msg.contains |
| 1716 | assert!(msg.contains |
| 1717 | } |
| 1718 | |
| 1719 | #[test] |
| 1720 | fn plan_mode_coerces_general_subagent_and_annotates_prompt() { |
| 1721 | let plan = |
| 1722 | let (kind, |
| 1723 | assert_eq!(kind, |
| 1724 | assert!(prompt.contains |
| 1725 | assert!(prompt.contains |
| 1726 | assert!(prompt.contains |
| 1727 | } |
| 1728 | |
| 1729 | #[test] |
| 1730 | fn plan_mode_leaves_explore_subagent_untouched() { |
| 1731 | let plan = |
| 1732 | let (kind, |
| 1733 | assert_eq!(kind, |
| 1734 | assert_eq!(prompt, |
| 1735 | } |
| 1736 | |
| 1737 | #[test] |
| 1738 | fn auto_mode_passes_general_subagent_through_unchanged() { |
| 1739 | let auto = |
| 1740 | let (kind, |
| 1741 | assert_eq!(kind, |
| 1742 | assert_eq!(prompt, |
| 1743 | } |
| 1744 | |
| 1745 | #[test] |
| 1746 | fn forward_subagent_event_forwards_raw_main_agent_events() { |
| 1747 | // Sub-agent progress uses the same event types as the main agent so |
| 1748 | // the TUI can render identical tool bars and text streams. |
| 1749 | let mut kinds |
| 1750 | { |
| 1751 | let mut sink |
| 1752 | AgentEvent::Delta( |
| 1753 | AgentEvent::ToolStart |
| 1754 | AgentEvent::ToolResult |
| 1755 | AgentEvent::Retry( |
| 1756 | AgentEvent::Confirm |
| 1757 | _ => kinds. |
| 1758 | }; |
| 1759 | let cell = |
| 1760 | forward_subagent_event(&cell, |
| 1761 | forward_subagent_event( |
| 1762 | &cell, |
| 1763 | AgentEvent::ToolStart |
| 1764 | name: "search". |
| 1765 | args: serde_json::json! |
| 1766 | }, |
| 1767 | ); |
| 1768 | forward_subagent_event( |
| 1769 | &cell, |
| 1770 | AgentEvent::ToolResult |
| 1771 | name: "search". |
| 1772 | result: "1 match". |
| 1773 | is_error: false, |
| 1774 | }, |
| 1775 | ); |
| 1776 | forward_subagent_event(&cell, |
| 1777 | forward_subagent_event(&cell, |
| 1778 | } |
| 1779 | assert_eq!( |
| 1780 | kinds, |
| 1781 | vec!["Delta", |
| 1782 | ); |
| 1783 | } |
| 1784 | |
| 1785 | #[test] |
| 1786 | fn subagent_bail_message_lists_recent_tools_when_content_is_empty() { |
| 1787 | let tools: VecDeque |
| 1788 | "read_file(\"src/tui.rs\")" |
| 1789 | "search(\"banner\")" |
| 1790 | "read_file(\"src/tui.rs\")" |
| 1791 | ] |
| 1792 | .into_iter() |
| 1793 | .collect(); |
| 1794 | let why = |
| 1795 | let msg = |
| 1796 | assert!(msg.contains |
| 1797 | assert!(msg.contains |
| 1798 | "read_file(\"src/tui.rs\") → search(\ |
| 1799 | )); |
| 1800 | assert!(msg.contains |
| 1801 | assert!( |
| 1802 | !msg.contains(" |
| 1803 | "no phantom empty 'Last output:'\n{msg}" |
| 1804 | ); |
| 1805 | assert!( |
| 1806 | !msg.contains(" |
| 1807 | "no phantom empty partial report\n{msg}" |
| 1808 | ); |
| 1809 | } |
| 1810 | |
| 1811 | #[test] |
| 1812 | fn subagent_bail_message_without_tools_but_with_output() { |
| 1813 | let why = |
| 1814 | let msg = |
| 1815 | assert!(msg.contains |
| 1816 | assert!(!msg. |
| 1817 | |
| 1818 | let msg = |
| 1819 | assert!(msg.contains |
| 1820 | assert!(msg.contains |
| 1821 | } |
| 1822 | |
| 1823 | #[test] |
| 1824 | fn subagent_bail_message_includes_mid_stream_partial_text() { |
| 1825 | let why = |
| 1826 | let msg = |
| 1827 | assert!(msg.contains |
| 1828 | assert!(msg.contains |
| 1829 | assert!(!msg. |
| 1830 | } |
| 1831 | |
| 1832 | #[test] |
| 1833 | fn subagent_bail_message_truncates_long_output() { |
| 1834 | let why = |
| 1835 | let long = |
| 1836 | let msg = |
| 1837 | assert!(msg.contains |
| 1838 | assert!( |
| 1839 | msg.len() < |
| 1840 | "long output is truncated in the bail message" |
| 1841 | ); |
| 1842 | } |
| 1843 | |
| 1844 | #[test] |
| 1845 | fn round_tools_hides_todoread_until_a_todo_exists() { |
| 1846 | let path = |
| 1847 | "wynba-agent-test-{}-round-tools.sqlite", |
| 1848 | std::process::id |
| 1849 | )); |
| 1850 | let _ = |
| 1851 | let conn = |
| 1852 | let (sid, |
| 1853 | let auto = |
| 1854 | |
| 1855 | // Fresh session, no todos: todoread is not offered at all, so the agent |
| 1856 | // cannot burn its opening round on an empty list. |
| 1857 | let tools = |
| 1858 | assert!( |
| 1859 | !tools.iter(). |
| 1860 | "todoread hidden with no todos\n{:?}", |
| 1861 | tools.iter().map |
| 1862 | ); |
| 1863 | assert!( |
| 1864 | tools.iter().any |
| 1865 | "todowrite stays available to create a list" |
| 1866 | ); |
| 1867 | |
| 1868 | // After a todowrite creates items, todoread returns on the next round. |
| 1869 | crate::db::set_todos( |
| 1870 | &conn, |
| 1871 | sid, |
| 1872 | &[crate::db::TodoItem |
| 1873 | content: "fix the banner". |
| 1874 | status: "pending". |
| 1875 | priority: "medium". |
| 1876 | }], |
| 1877 | ) |
| 1878 | .unwrap(); |
| 1879 | let tools = |
| 1880 | assert!( |
| 1881 | tools.iter().any |
| 1882 | "todoread returns once todos exist" |
| 1883 | ); |
| 1884 | |
| 1885 | let _ = |
| 1886 | } |
| 1887 | |
| 1888 | #[test] |
| 1889 | fn subagent_tools_also_hide_todoread_when_list_is_empty() { |
| 1890 | let path = |
| 1891 | "wynba-agent-test-{}-subagent-tools.sqlite", |
| 1892 | std::process::id |
| 1893 | )); |
| 1894 | let _ = |
| 1895 | let conn = |
| 1896 | let (sid, |
| 1897 | |
| 1898 | let tools = |
| 1899 | assert!( |
| 1900 | !tools.iter(). |
| 1901 | "sub-agent tools omit todoread with no todos" |
| 1902 | ); |
| 1903 | |
| 1904 | let _ = |
| 1905 | } |
| 1906 | } |