Skip to main content

agent_engine/engine/
reactor.rs

1//! Central event-drain and wake-action policy — mode-agnostic engine layer.
2//!
3//! `drain_event_queue` pops every pending event from the queue in priority
4//! order, formats it, and classifies it as Steered / Buffered / Injected.
5//! `wake_action` decides what the caller should do next.
6//!
7//! `event_payload_from_drained` builds the canonical wire-format payload
8//! (shared by RPC and server) from a `DrainedEvent`.
9
10use tokio::sync::mpsc::UnboundedSender;
11
12use crate::events::{EventQueue, format_event_for_agent};
13use crate::SharedMessage;
14
15// Re-export the shared wire payload so callers can use `engine::reactor::EventPayload`.
16pub use agent_core::core::rpc_protocol::EventPayload;
17
18/// Hard cap: how many consecutive auto-triggered model turns can fire before
19/// the engine parks and waits for real user input.
20pub const AUTO_TURN_CAP: u32 = 5;
21
22// ── Types ────────────────────────────────────────────────────────────────────
23
24/// What happened to a single event that was drained from the queue.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum EventDisposition {
27    /// Sent via the steering channel into an active stream.
28    Steered,
29    /// Buffered in `pending_events` (busy but no live steer channel).
30    Buffered,
31    /// Injected as a `role=user` message into `messages` (idle turn).
32    Injected,
33}
34
35/// A single event that was drained, together with its formatted payload and
36/// how it was handled.
37#[derive(Debug, Clone)]
38pub struct DrainedEvent {
39    pub event: crate::events::types::Event,
40    pub formatted: String,
41    pub disposition: EventDisposition,
42}
43
44/// What the caller's event-loop arm should do after `drain_event_queue` + `wake_action`.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum WakeAction {
47    /// Spawn a new model turn (all preconditions satisfied).
48    RunTurn,
49    /// Forward / present the drained events but do NOT spawn a turn
50    /// (already streaming, no events, cap reached, etc.).
51    Forward,
52    /// Nothing was drained — no work to do.
53    Nothing,
54}
55
56// ── Core functions ────────────────────────────────────────────────────────────
57
58/// Drain every event currently in `queue`.
59///
60/// For each event:
61/// * **busy** (streaming or compact running):
62///   - try to steer via `steer_tx`; on success → `Steered`
63///   - if no live channel → `Buffered` (pushed to `pending_events`)
64/// * **idle**: inject as a `role=user` `SharedMessage` → `Injected`
65///
66/// Formatting is always done via the existing `format_event_for_agent` so
67/// canonical output is produced in exactly one place.
68pub fn drain_event_queue(
69    queue: &EventQueue,
70    messages: &mut Vec<SharedMessage>,
71    pending_events: &mut Vec<String>,
72    busy: bool,
73    steer_tx: Option<&UnboundedSender<String>>,
74) -> Vec<DrainedEvent> {
75    let mut drained = Vec::new();
76
77    while let Some(event) = queue.pop() {
78        let formatted = format_event_for_agent(&event);
79
80        let disposition = if busy {
81            // Steer into active stream if the channel is live.
82            let steered = steer_tx
83                .map(|tx| tx.send(formatted.clone()).is_ok())
84                .unwrap_or(false);
85            if steered {
86                EventDisposition::Steered
87            } else {
88                pending_events.push(formatted.clone());
89                EventDisposition::Buffered
90            }
91        } else {
92            // Idle: inject directly into message history.
93            messages.push(std::sync::Arc::new(serde_json::json!({
94                "role": "user",
95                "content": formatted
96            })));
97            EventDisposition::Injected
98        };
99
100        drained.push(DrainedEvent { event, formatted, disposition });
101    }
102
103    drained
104}
105
106/// Decide what the caller should do after draining the queue.
107///
108/// Rules (in order):
109/// 1. Nothing drained → `Nothing`.
110/// 2. Any `Injected` event + idle + auto-turn enabled + last message is
111///    `role=user` + consecutive cap not reached → `RunTurn`.
112/// 3. Otherwise → `Forward` (show events; caller may still steer/display).
113pub fn wake_action(
114    drained: &[DrainedEvent],
115    messages: &[SharedMessage],
116    busy: bool,
117    auto_turn_enabled: bool,
118    consecutive_auto_turns: u32,
119) -> WakeAction {
120    if drained.is_empty() {
121        return WakeAction::Nothing;
122    }
123
124    let has_injected = drained.iter().any(|d| d.disposition == EventDisposition::Injected);
125
126    if has_injected
127        && !busy
128        && auto_turn_enabled
129        && consecutive_auto_turns < AUTO_TURN_CAP
130        && messages.last().map(|m| m["role"].as_str() == Some("user")).unwrap_or(false)
131    {
132        return WakeAction::RunTurn;
133    }
134
135    WakeAction::Forward
136}
137
138// ── Wire-payload constructor ──────────────────────────────────────────────────
139
140/// Build the canonical `EventPayload` from a `DrainedEvent`.
141///
142/// This is the **single canonical constructor** shared by RPC and server
143/// modes — both produce identical structured payloads from the same source.
144/// The `formatted` field carries the full XML-tagged, injection-safe string
145/// produced by `format_event_for_agent`.
146pub fn event_payload_from_drained(drained: &DrainedEvent) -> EventPayload {
147    let ev = &drained.event;
148    let severity = ev
149        .content
150        .severity
151        .as_ref()
152        .map(|s| s.as_str())
153        .unwrap_or("medium")
154        .to_string();
155    EventPayload {
156        id: ev.id.clone(),
157        source: ev.source.source_type.clone(),
158        severity,
159        content_type: ev.content.content_type.clone(),
160        text: ev.content.text.clone(),
161        timestamp: ev.timestamp.to_rfc3339(),
162        formatted: drained.formatted.clone(),
163    }
164}
165
166// ── Auto-turn cap helper ──────────────────────────────────────────────────────
167
168/// Centralised auto-turn claim gate.
169///
170/// **Semantics (single source of truth):**
171/// * `counter < AUTO_TURN_CAP` → allowed: increment `counter` and return `true`.
172/// * `counter >= AUTO_TURN_CAP` → denied: leave `counter` unchanged, return `false`.
173/// * User input resets `counter` to 0 (caller responsibility — not this function).
174///
175/// All paths that may fire an auto-triggered model turn (TUI event arm, TUI
176/// stream arm, chat EventWake, chat AutoTriggerEvents, server idle wake, server
177/// AutoTriggerEvents) **must** call this function and bail when it returns `false`
178/// instead of duplicating the `fetch_add / >= cap` pattern inline.
179///
180/// # Arguments
181/// * `counter` — mutable reference to the caller's `u32` counter.
182///
183/// # Returns
184/// `true` if a turn is allowed (counter was incremented), `false` if parked.
185#[inline]
186pub fn claim_auto_turn(counter: &mut u32) -> bool {
187    if *counter < AUTO_TURN_CAP {
188        *counter += 1;
189        true
190    } else {
191        false
192    }
193}
194
195// ── Helpers ───────────────────────────────────────────────────────────────────
196
197/// Returns `true` when the agent idle loop should park on `queue.notified()`
198/// instead of immediately nagging with a user message.
199///
200/// Invariant: exactly one waiter on `queue.notified()` exists in agent mode.
201/// The caller **must** drop any registry lock before awaiting the future.
202///
203/// Conditions to wait:
204/// * At least one child subagent is still running, **or**
205/// * The event queue already has items (drain them first on loop-top).
206#[inline]
207pub fn idle_should_wait(children_running: bool, queue_len: usize) -> bool {
208    children_running || queue_len > 0
209}
210
211// ─── terminal_flush seam for integration testing ─────────────────────────────
212
213/// Pure decision logic mirroring `terminal_flush` from `cmd/rpc.rs` for use in
214/// integration tests.
215///
216/// This seam allows tests to verify the `allow_chain` contract on the mutable
217/// state fields that `terminal_flush` operates on, **without** requiring a live
218/// `Runtime`, `Session`, or `Mutex<RpcState>`.
219///
220/// Rules (identical to `terminal_flush`):
221/// * Always: clear `auto_turn_pending`; take `pending_events` → inject into `api_messages`.
222/// * `allow_chain = false`: never reserve; return `None`; leave `counter` unchanged.
223/// * `allow_chain = true`: if all conditions met, increment `counter`, set
224///   `auto_turn_pending = true`, return `Some("auto:seam")`.
225pub fn terminal_flush_seam(
226    allow_chain: bool,
227    auto_turn_pending: &mut bool,
228    pending_events: &mut Vec<String>,
229    api_messages: &mut Vec<crate::SharedMessage>,
230    events_auto_turn: bool,
231    consecutive_auto_turns: &mut u32,
232) -> Option<String> {
233    *auto_turn_pending = false;
234    let to_inject = std::mem::take(pending_events);
235    let had_buffered = !to_inject.is_empty();
236    for formatted in to_inject {
237        api_messages.push(std::sync::Arc::new(
238            serde_json::json!({"role": "user", "content": formatted})
239        ));
240    }
241    if allow_chain
242        && had_buffered
243        && events_auto_turn
244        && *consecutive_auto_turns < AUTO_TURN_CAP
245        && api_messages.last().map(|m| m["role"].as_str() == Some("user")).unwrap_or(false)
246        && claim_auto_turn(consecutive_auto_turns)
247    {
248        *auto_turn_pending = true;
249        return Some("auto:seam".to_string());
250    }
251    None
252}
253
254// ─── spawn_prompt registration seam ─────────────────────────────────────────
255
256/// Pure decision logic for the `spawn_prompt` registration re-check (Bug 1 fix).
257///
258/// Models the guard that runs **inside** the registration lock just before
259/// `in_flight` is written. Returns `true` if it is safe to proceed with
260/// registration (caller should write `in_flight` and clear `auto_turn_pending`);
261/// returns `false` if the reservation was revoked by an intervening Abort and
262/// the caller must abort the spawned task (drop `start_tx`).
263///
264/// Rules (identical to the re-check in `spawn_prompt`):
265/// * Non-auto prompts (`!is_auto`) always return `true` — they are validated
266///   by `handle_prompt`'s `is_busy()` check before reaching here.
267/// * Auto prompts: `true` iff `auto_turn_pending && !in_flight_live`.
268/// * If returning `false`, the helper also defensively clears `auto_turn_pending`.
269///
270/// Used by `spawn_prompt` and by integration tests (not the replica scheduler).
271pub fn spawn_prompt_registration_check(
272    is_auto: bool,
273    auto_turn_pending: &mut bool,
274    in_flight_live: bool,
275) -> bool {
276    if !is_auto {
277        // A client prompt was checked before spawning, but an auto-turn or
278        // another prompt may have reserved/registered in the meantime.
279        return !*auto_turn_pending && !in_flight_live;
280    }
281    if *auto_turn_pending && !in_flight_live {
282        return true;
283    }
284    // Reservation revoked — clear pending defensively before returning false.
285    *auto_turn_pending = false;
286    false
287}
288
289// ── Tests ─────────────────────────────────────────────────────────────────────
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    use crate::events::{types::Severity, EventQueue};
295
296    fn make_queue_with(texts: &[(&str, Option<Severity>)]) -> EventQueue {
297        let q = EventQueue::new(64);
298        for (text, sev) in texts {
299            q.push(crate::events::types::Event::simple("test", text, sev.clone())).unwrap();
300        }
301        q
302    }
303
304    fn user_msg(text: &str) -> SharedMessage {
305        std::sync::Arc::new(serde_json::json!({"role": "user", "content": text}))
306    }
307
308    fn assistant_msg() -> SharedMessage {
309        std::sync::Arc::new(serde_json::json!({"role": "assistant", "content": "ok"}))
310    }
311
312    // ── drain_event_queue ────────────────────────────────────────────────
313
314    #[test]
315    fn idle_single_event_injected() {
316        let q = make_queue_with(&[("hello", Some(Severity::Medium))]);
317        let mut messages: Vec<SharedMessage> = Vec::new();
318        let mut pending: Vec<String> = Vec::new();
319
320        let drained = drain_event_queue(&q, &mut messages, &mut pending, false, None);
321
322        assert_eq!(drained.len(), 1);
323        assert_eq!(drained[0].disposition, EventDisposition::Injected);
324        assert!(drained[0].formatted.contains("hello"));
325        assert_eq!(messages.len(), 1);
326        assert_eq!(messages[0]["role"], "user");
327        assert!(messages[0]["content"].as_str().unwrap().contains("hello"));
328        assert!(pending.is_empty());
329        assert!(q.is_empty());
330    }
331
332    #[test]
333    fn idle_multiple_events_injected_in_order() {
334        let q = make_queue_with(&[
335            ("first", Some(Severity::Medium)),
336            ("second", Some(Severity::Medium)),
337            ("third", Some(Severity::Low)),
338        ]);
339        let mut messages: Vec<SharedMessage> = Vec::new();
340        let mut pending: Vec<String> = Vec::new();
341
342        let drained = drain_event_queue(&q, &mut messages, &mut pending, false, None);
343
344        assert_eq!(drained.len(), 3);
345        assert!(drained.iter().all(|d| d.disposition == EventDisposition::Injected));
346        // Canonical order preserved
347        assert!(drained[0].formatted.contains("first"));
348        assert!(drained[1].formatted.contains("second"));
349        assert!(drained[2].formatted.contains("third"));
350        assert_eq!(messages.len(), 3);
351    }
352
353    #[test]
354    fn busy_live_steer_tx_steers() {
355        let q = make_queue_with(&[("ev", Some(Severity::Medium))]);
356        let mut messages: Vec<SharedMessage> = Vec::new();
357        let mut pending: Vec<String> = Vec::new();
358        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<String>();
359
360        let drained = drain_event_queue(&q, &mut messages, &mut pending, true, Some(&tx));
361
362        assert_eq!(drained.len(), 1);
363        assert_eq!(drained[0].disposition, EventDisposition::Steered);
364        assert!(messages.is_empty());
365        assert!(pending.is_empty());
366        // The formatted payload was actually sent
367        let sent = rx.try_recv().expect("message should have been steered");
368        assert!(sent.contains("ev"));
369    }
370
371    #[test]
372    fn busy_dead_steer_tx_buffers() {
373        let q = make_queue_with(&[("ev", Some(Severity::Medium))]);
374        let mut messages: Vec<SharedMessage> = Vec::new();
375        let mut pending: Vec<String> = Vec::new();
376        // Create a tx whose rx is immediately dropped → send will fail
377        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<String>();
378        drop(rx);
379
380        let drained = drain_event_queue(&q, &mut messages, &mut pending, true, Some(&tx));
381
382        assert_eq!(drained.len(), 1);
383        assert_eq!(drained[0].disposition, EventDisposition::Buffered);
384        assert!(messages.is_empty());
385        assert_eq!(pending.len(), 1);
386        assert!(pending[0].contains("ev"));
387    }
388
389    #[test]
390    fn busy_no_steer_tx_buffers() {
391        let q = make_queue_with(&[("ev", Some(Severity::Medium))]);
392        let mut messages: Vec<SharedMessage> = Vec::new();
393        let mut pending: Vec<String> = Vec::new();
394
395        let drained = drain_event_queue(&q, &mut messages, &mut pending, true, None);
396
397        assert_eq!(drained.len(), 1);
398        assert_eq!(drained[0].disposition, EventDisposition::Buffered);
399        assert!(pending.len() == 1);
400    }
401
402    #[test]
403    fn empty_queue_returns_nothing() {
404        let q = EventQueue::new(10);
405        let mut messages: Vec<SharedMessage> = Vec::new();
406        let mut pending: Vec<String> = Vec::new();
407
408        let drained = drain_event_queue(&q, &mut messages, &mut pending, false, None);
409
410        assert!(drained.is_empty());
411        assert!(messages.is_empty());
412        assert!(pending.is_empty());
413    }
414
415    #[test]
416    fn canonical_format_contains_xml_event_tags() {
417        let q = make_queue_with(&[("ping", Some(Severity::High))]);
418        let mut messages: Vec<SharedMessage> = Vec::new();
419        let mut pending: Vec<String> = Vec::new();
420
421        let drained = drain_event_queue(&q, &mut messages, &mut pending, false, None);
422
423        assert!(drained[0].formatted.starts_with("<event "));
424        assert!(drained[0].formatted.ends_with("</event>"));
425        assert!(drained[0].formatted.contains("severity=\"high\""));
426    }
427
428    #[test]
429    fn priority_ordering_preserved_critical_first() {
430        // Push medium first, then critical — critical should be drained first
431        let q = EventQueue::new(10);
432        q.push(crate::events::types::Event::simple("test", "medium", Some(Severity::Medium))).unwrap();
433        q.push(crate::events::types::Event::simple("test", "critical", Some(Severity::Critical))).unwrap();
434
435        let mut messages: Vec<SharedMessage> = Vec::new();
436        let mut pending: Vec<String> = Vec::new();
437
438        let drained = drain_event_queue(&q, &mut messages, &mut pending, false, None);
439
440        assert_eq!(drained.len(), 2);
441        assert!(drained[0].formatted.contains("critical"));
442        assert!(drained[1].formatted.contains("medium"));
443    }
444
445    // ── wake_action truth table ──────────────────────────────────────────
446
447    fn injected_event() -> DrainedEvent {
448        let event = crate::events::types::Event::simple("test", "x", None);
449        DrainedEvent {
450            formatted: format_event_for_agent(&event),
451            event,
452            disposition: EventDisposition::Injected,
453        }
454    }
455
456    fn steered_event() -> DrainedEvent {
457        let event = crate::events::types::Event::simple("test", "x", None);
458        DrainedEvent {
459            formatted: format_event_for_agent(&event),
460            event,
461            disposition: EventDisposition::Steered,
462        }
463    }
464
465    fn buffered_event() -> DrainedEvent {
466        let event = crate::events::types::Event::simple("test", "x", None);
467        DrainedEvent {
468            formatted: format_event_for_agent(&event),
469            event,
470            disposition: EventDisposition::Buffered,
471        }
472    }
473
474    #[test]
475    fn wake_nothing_when_drained_empty() {
476        let result = wake_action(&[], &[], false, true, 0);
477        assert_eq!(result, WakeAction::Nothing);
478    }
479
480    #[test]
481    fn wake_run_turn_when_injected_idle_enabled_under_cap() {
482        let drained = vec![injected_event()];
483        let messages = vec![user_msg("hello")];
484        let result = wake_action(&drained, &messages, false, true, 0);
485        assert_eq!(result, WakeAction::RunTurn);
486    }
487
488    #[test]
489    fn wake_forward_when_busy() {
490        let drained = vec![injected_event()];
491        let messages = vec![user_msg("hello")];
492        let result = wake_action(&drained, &messages, true, true, 0);
493        assert_eq!(result, WakeAction::Forward);
494    }
495
496    #[test]
497    fn wake_forward_when_auto_turn_disabled() {
498        let drained = vec![injected_event()];
499        let messages = vec![user_msg("hello")];
500        let result = wake_action(&drained, &messages, false, false, 0);
501        assert_eq!(result, WakeAction::Forward);
502    }
503
504    #[test]
505    fn wake_forward_when_last_message_not_user() {
506        let drained = vec![injected_event()];
507        let messages = vec![assistant_msg()];
508        let result = wake_action(&drained, &messages, false, true, 0);
509        assert_eq!(result, WakeAction::Forward);
510    }
511
512    #[test]
513    fn wake_forward_when_messages_empty() {
514        let drained = vec![injected_event()];
515        let result = wake_action(&drained, &[], false, true, 0);
516        assert_eq!(result, WakeAction::Forward);
517    }
518
519    #[test]
520    fn wake_forward_when_cap_reached() {
521        let drained = vec![injected_event()];
522        let messages = vec![user_msg("hello")];
523        // Exactly at cap — should NOT run
524        let result = wake_action(&drained, &messages, false, true, AUTO_TURN_CAP);
525        assert_eq!(result, WakeAction::Forward);
526    }
527
528    #[test]
529    fn wake_run_turn_when_one_below_cap() {
530        let drained = vec![injected_event()];
531        let messages = vec![user_msg("hello")];
532        let result = wake_action(&drained, &messages, false, true, AUTO_TURN_CAP - 1);
533        assert_eq!(result, WakeAction::RunTurn);
534    }
535
536    #[test]
537    fn wake_forward_when_only_steered_events() {
538        // Steered events don't qualify for RunTurn
539        let drained = vec![steered_event()];
540        let messages = vec![user_msg("hello")];
541        let result = wake_action(&drained, &messages, false, true, 0);
542        assert_eq!(result, WakeAction::Forward);
543    }
544
545    #[test]
546    fn wake_forward_when_only_buffered_events() {
547        let drained = vec![buffered_event()];
548        let messages = vec![user_msg("hello")];
549        let result = wake_action(&drained, &messages, false, true, 0);
550        assert_eq!(result, WakeAction::Forward);
551    }
552
553    #[test]
554    fn wake_run_turn_with_mixed_injected_and_steered() {
555        // At least one Injected is enough
556        let drained = vec![steered_event(), injected_event()];
557        let messages = vec![user_msg("hello")];
558        let result = wake_action(&drained, &messages, false, true, 0);
559        assert_eq!(result, WakeAction::RunTurn);
560    }
561
562    // ── idle_should_wait truth table ─────────────────────────────────────────
563
564    #[test]
565    fn idle_should_wait_false_when_no_children_no_queue() {
566        assert!(!idle_should_wait(false, 0));
567    }
568
569    #[test]
570    fn idle_should_wait_true_when_children_running() {
571        assert!(idle_should_wait(true, 0));
572    }
573
574    #[test]
575    fn idle_should_wait_true_when_queue_nonempty() {
576        assert!(idle_should_wait(false, 1));
577    }
578
579    #[test]
580    fn idle_should_wait_true_when_both_children_and_queue() {
581        assert!(idle_should_wait(true, 3));
582    }
583
584    #[test]
585    fn idle_should_wait_true_when_large_queue() {
586        assert!(idle_should_wait(false, 999));
587    }
588
589    // ── claim_auto_turn boundary tests ─────────────────────────────────────
590
591    #[test]
592    fn claim_auto_turn_first_five_allowed() {
593        let mut counter: u32 = 0;
594        for _ in 0..AUTO_TURN_CAP {
595            assert!(claim_auto_turn(&mut counter), "turn within cap must be allowed");
596        }
597        assert_eq!(counter, AUTO_TURN_CAP, "counter must equal cap after 5 claims");
598    }
599
600    #[test]
601    fn claim_auto_turn_sixth_denied() {
602        let mut counter: u32 = AUTO_TURN_CAP;
603        assert!(!claim_auto_turn(&mut counter), "turn at cap must be denied");
604        assert_eq!(counter, AUTO_TURN_CAP, "counter must remain unchanged when denied");
605    }
606
607    #[test]
608    fn claim_auto_turn_remains_denied_until_reset() {
609        let mut counter: u32 = AUTO_TURN_CAP;
610        assert!(!claim_auto_turn(&mut counter));
611        assert!(!claim_auto_turn(&mut counter));
612        assert!(!claim_auto_turn(&mut counter));
613        assert_eq!(counter, AUTO_TURN_CAP, "counter must not change while denied");
614    }
615
616    #[test]
617    fn claim_auto_turn_reset_re_enables() {
618        let mut counter: u32 = AUTO_TURN_CAP;
619        assert!(!claim_auto_turn(&mut counter));
620        // Simulate user input reset
621        counter = 0;
622        assert!(claim_auto_turn(&mut counter), "after reset first turn must be allowed");
623        assert_eq!(counter, 1);
624    }
625
626    #[test]
627    fn claim_auto_turn_exact_boundary_sequence() {
628        // Exactly AUTO_TURN_CAP (5) allowed, 6th denied
629        let mut c: u32 = 0;
630        for i in 1..=AUTO_TURN_CAP {
631            assert!(claim_auto_turn(&mut c), "turn {i} must be allowed");
632            assert_eq!(c, i);
633        }
634        // 6th
635        assert!(!claim_auto_turn(&mut c));
636        assert_eq!(c, AUTO_TURN_CAP);
637    }
638}