Skip to main content

rpi_agent/
agent.rs

1//! Mirrors `packages/agent/src/agent.ts` — the stateful wrapper around the
2//! low-level agent loop. `Agent` owns the current transcript, emits lifecycle
3//! events to subscribers, executes tools, and exposes queueing APIs for
4//! steering and follow-up messages.
5//!
6//! The TS `Agent` class drives the loop via `runWithLifecycle` +
7//! `processEvents`. Rust models the same shape:
8//! - internal `Mutex<MutableAgentState>` for the transcript + runtime flags;
9//! - a `broadcast::Sender<AgentEvent>` so multiple subscribers each get their
10//!   own copy;
11//! - a state-reducing emitter ([`StatefulEmitter`]) that folds each event into
12//!   `MutableAgentState` FIRST, then broadcasts — the direct mirror of TS
13//!   `processEvents` ("reduce internal state, then await listeners");
14//! - a per-run `ActiveRun` (abort handle + completion notify) so `prompt`
15//!   blocks until the run settles and `abort`/`wait_for_idle` can act on it.
16
17use crate::agent_loop::{run_agent_loop, run_agent_loop_continue};
18use crate::events::{AgentEmitter, AgentEvent, BroadcastEmitter};
19use crate::hooks::{default_convert_to_llm_fn, AgentLoopConfig, ConvertToLlm};
20use crate::message::AgentMessage;
21use crate::queue::PendingMessageQueue;
22use crate::stream_fn::{get_default_stream_fn, StreamFn};
23use crate::types::{AgentContext, AgentState, QueueMode, ToolExecutionMode};
24
25use rpi_ai::types::{AssistantMessage, UserContent, UserMessage};
26use rpi_ai::Model;
27use std::collections::HashSet;
28use std::sync::{Arc, Mutex};
29use tokio::sync::{broadcast, Notify};
30use tokio_util::sync::CancellationToken;
31
32/// Options for constructing an [`Agent`]. Mirrors TS `AgentOptions`. Only
33/// `stream_fn` is required (or a default must have been installed process-wide
34/// via [`crate::stream_fn::set_default_stream_fn`]).
35#[derive(Default)]
36pub struct AgentOptions {
37    pub initial_state: Option<InitialState>,
38    pub convert_to_llm: Option<ConvertToLlm>,
39    pub stream_fn: Option<StreamFn>,
40    pub thinking_level: Option<rpi_ai::types::ThinkingLevel>,
41    pub queue_mode: Option<QueueMode>,
42    pub follow_up_mode: Option<QueueMode>,
43    pub tool_execution: Option<ToolExecutionMode>,
44    pub session_id: Option<String>,
45}
46
47/// Subset of `AgentState` settable at construction.
48#[derive(Default)]
49pub struct InitialState {
50    pub system_prompt: Option<String>,
51    pub model: Option<Model>,
52    pub thinking_level: Option<rpi_ai::types::ThinkingLevel>,
53    pub tools: Option<Vec<Arc<dyn crate::agent_tool::AgentTool>>>,
54    pub messages: Option<Vec<AgentMessage>>,
55}
56
57/// The owned mutable state. Mirrors TS `MutableAgentState`. Guarded by a mutex.
58struct MutableAgentState {
59    system_prompt: String,
60    model: Model,
61    thinking_level: rpi_ai::types::ThinkingLevel,
62    tools: Vec<Arc<dyn crate::agent_tool::AgentTool>>,
63    messages: Vec<AgentMessage>,
64    is_streaming: bool,
65    /// The in-flight assistant message. `Arc` so the per-delta `MessageUpdate`
66    /// does not deep-clone it into this field on every streaming chunk.
67    streaming_message: Option<Arc<AssistantMessage>>,
68    pending_tool_calls: HashSet<String>,
69    error_message: Option<String>,
70}
71
72impl MutableAgentState {
73    fn snapshot(&self) -> AgentState {
74        AgentState {
75            system_prompt: self.system_prompt.clone(),
76            model: self.model.clone(),
77            thinking_level: self.thinking_level,
78            tools: self.tools.clone(),
79            messages: self.messages.clone(),
80            is_streaming: self.is_streaming,
81            streaming_message: self.streaming_message.clone(),
82            pending_tool_calls: self.pending_tool_calls.clone(),
83            error_message: self.error_message.clone(),
84        }
85    }
86
87    /// Reduce an event into state. Mirrors the switch in TS `processEvents`.
88    fn reduce(&mut self, event: &AgentEvent) {
89        match event {
90            AgentEvent::MessageStart { message } => {
91                // One clone per message (not per delta): the state holds the
92                // partial as a shared snapshot from here on.
93                self.streaming_message = message
94                    .as_assistant()
95                    .map(|assistant| Arc::new(assistant.clone()));
96            }
97            AgentEvent::MessageUpdate { message, .. } => {
98                self.streaming_message = Some(Arc::clone(message));
99            }
100            AgentEvent::MessageEnd { message } => {
101                self.streaming_message = None;
102                self.messages.push(message.clone());
103            }
104            AgentEvent::ToolExecutionStart { tool_call_id, .. } => {
105                self.pending_tool_calls.insert(tool_call_id.clone());
106            }
107            AgentEvent::ToolExecutionEnd { tool_call_id, .. } => {
108                self.pending_tool_calls.remove(tool_call_id);
109            }
110            AgentEvent::TurnEnd { message, .. } => {
111                if let Some(am) = message.as_assistant() {
112                    if am.error_message.is_some() {
113                        self.error_message = am.error_message.clone();
114                    }
115                }
116            }
117            AgentEvent::AgentEnd { .. } => {
118                self.streaming_message = None;
119            }
120            _ => {}
121        }
122    }
123}
124
125/// Per-run handle. Replaces TS `ActiveRun`.
126struct ActiveRun {
127    abort: CancellationToken,
128    done: Arc<Notify>,
129}
130
131/// A state-reducing emitter: folds each event into `MutableAgentState` FIRST,
132/// then broadcasts to subscribers. The direct mirror of TS `processEvents`.
133struct StatefulEmitter {
134    state: Arc<Mutex<MutableAgentState>>,
135    broadcast: BroadcastEmitter,
136}
137
138impl AgentEmitter for StatefulEmitter {
139    fn emit(&self, event: AgentEvent) -> futures::future::BoxFuture<'static, ()> {
140        self.state.lock().expect("state lock").reduce(&event);
141        self.broadcast.try_emit(event);
142        Box::pin(async {})
143    }
144    fn try_emit(&self, event: AgentEvent) {
145        self.state.lock().expect("state lock").reduce(&event);
146        self.broadcast.try_emit(event);
147    }
148}
149
150/// Shared queue storage: `Arc<Mutex<PendingMessageQueue>>` so the
151/// `get_steering_messages` / `get_follow_up_messages` hook closures (which must
152/// be `'static + Send + Sync`) can capture a clone.
153type SharedQueue = Arc<Mutex<PendingMessageQueue>>;
154
155/// A stateful agent. Clone shares the same inner state + event channel (like
156/// holding a second reference to the TS `Agent` instance).
157#[derive(Clone)]
158pub struct Agent {
159    inner: Arc<Inner>,
160}
161
162struct Inner {
163    state: Arc<Mutex<MutableAgentState>>,
164    convert_to_llm: ConvertToLlm,
165    stream_fn: StreamFn,
166    steering_queue: SharedQueue,
167    follow_up_queue: SharedQueue,
168    session_id: Option<String>,
169    tool_execution: ToolExecutionMode,
170    event_tx: broadcast::Sender<AgentEvent>,
171    active_run: Mutex<Option<ActiveRun>>,
172}
173
174/// Builder for [`Agent`] with a fluent API. Mirrors the TS `new Agent(opts)`.
175pub struct AgentBuilder {
176    opts: AgentOptions,
177}
178
179impl AgentBuilder {
180    pub fn new() -> Self {
181        Self {
182            opts: AgentOptions::default(),
183        }
184    }
185
186    pub fn model(mut self, model: Model) -> Self {
187        self.opts
188            .initial_state
189            .get_or_insert_with(InitialState::default)
190            .model = Some(model);
191        self
192    }
193
194    pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
195        self.opts
196            .initial_state
197            .get_or_insert_with(InitialState::default)
198            .system_prompt = Some(prompt.into());
199        self
200    }
201
202    pub fn thinking_level(mut self, level: rpi_ai::types::ThinkingLevel) -> Self {
203        self.opts
204            .initial_state
205            .get_or_insert_with(InitialState::default)
206            .thinking_level = Some(level);
207        self
208    }
209
210    pub fn tools(mut self, tools: Vec<Arc<dyn crate::agent_tool::AgentTool>>) -> Self {
211        self.opts
212            .initial_state
213            .get_or_insert_with(InitialState::default)
214            .tools = Some(tools);
215        self
216    }
217
218    pub fn messages(mut self, messages: Vec<AgentMessage>) -> Self {
219        self.opts
220            .initial_state
221            .get_or_insert_with(InitialState::default)
222            .messages = Some(messages);
223        self
224    }
225
226    pub fn stream_fn(mut self, stream_fn: StreamFn) -> Self {
227        self.opts.stream_fn = Some(stream_fn);
228        self
229    }
230
231    pub fn convert_to_llm(mut self, f: ConvertToLlm) -> Self {
232        self.opts.convert_to_llm = Some(f);
233        self
234    }
235
236    pub fn queue_mode(mut self, mode: QueueMode) -> Self {
237        self.opts.queue_mode = Some(mode);
238        self
239    }
240
241    pub fn follow_up_mode(mut self, mode: QueueMode) -> Self {
242        self.opts.follow_up_mode = Some(mode);
243        self
244    }
245
246    pub fn tool_execution(mut self, mode: ToolExecutionMode) -> Self {
247        self.opts.tool_execution = Some(mode);
248        self
249    }
250
251    pub fn session_id(mut self, id: impl Into<String>) -> Self {
252        self.opts.session_id = Some(id.into());
253        self
254    }
255
256    /// Build the `Agent`. Errors if no `stream_fn` was supplied and no default
257    /// has been installed process-wide.
258    pub fn build(self) -> Result<Agent, crate::AgentError> {
259        let stream_fn = match self.opts.stream_fn {
260            Some(f) => f,
261            None => get_default_stream_fn()?,
262        };
263
264        let initial = self.opts.initial_state.unwrap_or_default();
265        let model = initial.model.unwrap_or_else(default_model);
266        let thinking_level = self
267            .opts
268            .thinking_level
269            .or(initial.thinking_level)
270            .unwrap_or(rpi_ai::types::ThinkingLevel::Off);
271        let convert_to_llm = self
272            .opts
273            .convert_to_llm
274            .unwrap_or_else(default_convert_to_llm_fn);
275
276        let state = MutableAgentState {
277            system_prompt: initial.system_prompt.unwrap_or_default(),
278            model: model.clone(),
279            thinking_level,
280            tools: initial.tools.unwrap_or_default(),
281            messages: initial.messages.unwrap_or_default(),
282            is_streaming: false,
283            streaming_message: None,
284            pending_tool_calls: HashSet::new(),
285            error_message: None,
286        };
287
288        let (event_tx, _) = broadcast::channel(256);
289
290        let queue_mode = self.opts.queue_mode.unwrap_or_default();
291        let follow_up_mode = self.opts.follow_up_mode.unwrap_or_default();
292
293        let inner = Inner {
294            state: Arc::new(Mutex::new(state)),
295            convert_to_llm,
296            stream_fn,
297            steering_queue: Arc::new(Mutex::new(PendingMessageQueue::new(queue_mode))),
298            follow_up_queue: Arc::new(Mutex::new(PendingMessageQueue::new(follow_up_mode))),
299            session_id: self.opts.session_id,
300            tool_execution: self.opts.tool_execution.unwrap_or_default(),
301            event_tx,
302            active_run: Mutex::new(None),
303        };
304
305        Ok(Agent {
306            inner: Arc::new(inner),
307        })
308    }
309}
310
311impl Default for AgentBuilder {
312    fn default() -> Self {
313        Self::new()
314    }
315}
316
317impl Agent {
318    /// Subscribe to agent lifecycle events. Returns a `broadcast::Receiver`.
319    /// Mirrors TS `subscribe`.
320    pub fn subscribe(&self) -> broadcast::Receiver<AgentEvent> {
321        self.inner.event_tx.subscribe()
322    }
323
324    /// Current agent state snapshot. Clones tools/messages so callers can't
325    /// mutate internal state. Mirrors TS `get state()`.
326    pub fn state(&self) -> AgentState {
327        self.inner.state.lock().expect("state lock").snapshot()
328    }
329
330    /// Queue a steering message (injected after the current turn's tool batch).
331    pub fn steer(&self, message: AgentMessage) {
332        self.inner
333            .steering_queue
334            .lock()
335            .expect("steer lock")
336            .enqueue(message);
337    }
338
339    /// Queue a follow-up message (injected when the agent would otherwise stop).
340    pub fn follow_up(&self, message: AgentMessage) {
341        self.inner
342            .follow_up_queue
343            .lock()
344            .expect("followup lock")
345            .enqueue(message);
346    }
347
348    /// True when either queue has pending messages.
349    pub fn has_queued_messages(&self) -> bool {
350        let s = self.inner.steering_queue.lock().expect("steer lock");
351        if !s.is_empty() {
352            return true;
353        }
354        let f = self.inner.follow_up_queue.lock().expect("followup lock");
355        !f.is_empty()
356    }
357
358    /// Abort the current run, if one is active. No-op otherwise.
359    pub fn abort(&self) {
360        if let Some(run) = self.inner.active_run.lock().expect("run lock").as_ref() {
361            run.abort.cancel();
362        }
363    }
364
365    /// Resolve when the current run finishes (or immediately if idle).
366    pub async fn wait_for_idle(&self) {
367        let notify = {
368            let guard = self.inner.active_run.lock().expect("run lock");
369            guard.as_ref().map(|r| Arc::clone(&r.done))
370        };
371        if let Some(n) = notify {
372            n.notified().await;
373        }
374    }
375
376    /// Start a new prompt from text. Convenience for `prompt_message`.
377    pub async fn prompt(&self, text: impl Into<String>) -> Result<(), crate::AgentError> {
378        let message = AgentMessage::User(UserMessage::new(
379            UserContent::Text(text.into()),
380            crate::clock::now_ms(),
381        ));
382        self.prompt_messages(vec![message]).await
383    }
384
385    /// Start a new prompt from a single `AgentMessage`.
386    pub async fn prompt_message(&self, message: AgentMessage) -> Result<(), crate::AgentError> {
387        self.prompt_messages(vec![message]).await
388    }
389
390    /// Start a new prompt from a batch of `AgentMessage`s.
391    pub async fn prompt_messages(
392        &self,
393        messages: Vec<AgentMessage>,
394    ) -> Result<(), crate::AgentError> {
395        self.start_active_run()?;
396        let run_done = self.current_done();
397        let outcome = self.run_prompt(messages).await;
398        self.finish_run();
399        if let Some(n) = run_done {
400            n.notify_waiters();
401        }
402        outcome
403    }
404
405    /// Continue from the current transcript. Errors if the agent is busy or
406    /// the last message is an assistant message with no queued steering/follow-up.
407    pub async fn continue_run(&self) -> Result<(), crate::AgentError> {
408        self.start_active_run()?;
409        let run_done = self.current_done();
410        let outcome = self.run_continue().await;
411        self.finish_run();
412        if let Some(n) = run_done {
413            n.notify_waiters();
414        }
415        outcome
416    }
417
418    /// Clear transcript + runtime state + queues. Errors if a run is active.
419    pub fn reset(&self) -> Result<(), crate::AgentError> {
420        let mut state = self.inner.state.lock().expect("state lock");
421        if self.inner.active_run.lock().expect("run lock").is_some() {
422            return Err(crate::AgentError::State(
423                "Agent is already processing. Wait for completion before resetting.".into(),
424            ));
425        }
426        state.messages.clear();
427        state.is_streaming = false;
428        state.streaming_message = None;
429        state.pending_tool_calls.clear();
430        state.error_message = None;
431        drop(state);
432        let _ = self
433            .inner
434            .steering_queue
435            .lock()
436            .expect("steer lock")
437            .try_drain();
438        let _ = self
439            .inner
440            .follow_up_queue
441            .lock()
442            .expect("followup lock")
443            .try_drain();
444        Ok(())
445    }
446
447    // ---- internals --------------------------------------------------------
448
449    fn start_active_run(&self) -> Result<(), crate::AgentError> {
450        let mut guard = self.inner.active_run.lock().expect("run lock");
451        if guard.is_some() {
452            return Err(crate::AgentError::State(
453                "Agent is already processing a prompt. Use steer() or followUp() to queue messages, or wait for completion.".into(),
454            ));
455        }
456        let abort = CancellationToken::new();
457        let done = Arc::new(Notify::new());
458        *guard = Some(ActiveRun {
459            abort: abort.clone(),
460            done: Arc::clone(&done),
461        });
462
463        let mut state = self.inner.state.lock().expect("state lock");
464        state.is_streaming = true;
465        state.streaming_message = None;
466        state.error_message = None;
467        drop(state);
468        Ok(())
469    }
470
471    fn finish_run(&self) {
472        {
473            let mut state = self.inner.state.lock().expect("state lock");
474            state.is_streaming = false;
475            state.streaming_message = None;
476            state.pending_tool_calls.clear();
477        }
478        let mut guard = self.inner.active_run.lock().expect("run lock");
479        *guard = None;
480    }
481
482    fn current_done(&self) -> Option<Arc<Notify>> {
483        self.inner
484            .active_run
485            .lock()
486            .expect("run lock")
487            .as_ref()
488            .map(|r| Arc::clone(&r.done))
489    }
490
491    fn abort_token(&self) -> CancellationToken {
492        self.inner
493            .active_run
494            .lock()
495            .expect("run lock")
496            .as_ref()
497            .map(|r| r.abort.clone())
498            .unwrap_or_else(CancellationToken::new)
499    }
500
501    fn context_snapshot(&self) -> AgentContext {
502        let state = self.inner.state.lock().expect("state lock");
503        AgentContext {
504            system_prompt: state.system_prompt.clone(),
505            messages: state.messages.clone(),
506            tools: state.tools.clone(),
507        }
508    }
509
510    fn build_config(&self, signal: CancellationToken) -> AgentLoopConfig {
511        let state = self.inner.state.lock().expect("state lock");
512        let steering = Arc::clone(&self.inner.steering_queue);
513        let follow_up = Arc::clone(&self.inner.follow_up_queue);
514        AgentLoopConfig {
515            model: state.model.clone(),
516            convert_to_llm: Arc::clone(&self.inner.convert_to_llm),
517            transform_context: None,
518            get_api_key: None,
519            should_stop_after_turn: None,
520            prepare_next_turn: None,
521            after_tool_results: None,
522            get_steering_messages: Some(Arc::new(move || {
523                let q = Arc::clone(&steering);
524                Box::pin(async move { q.lock().expect("steer lock").try_drain() })
525            })),
526            get_follow_up_messages: Some(Arc::new(move || {
527                let q = Arc::clone(&follow_up);
528                Box::pin(async move { q.lock().expect("followup lock").try_drain() })
529            })),
530            before_tool_call: None,
531            after_tool_call: None,
532            tool_execution: self.inner.tool_execution,
533            thinking_level: state.thinking_level,
534            api_key: None,
535            timeout: None,
536            max_retries: None,
537            max_retry_delay: None,
538            cache_retention: rpi_ai::provider::CacheRetention::default(),
539            session_id: self.inner.session_id.clone(),
540            signal,
541        }
542    }
543
544    async fn run_prompt(&self, messages: Vec<AgentMessage>) -> Result<(), crate::AgentError> {
545        let signal = self.abort_token();
546        let context = self.context_snapshot();
547        let config = self.build_config(signal);
548        let emit: Arc<dyn AgentEmitter> = Arc::new(StatefulEmitter {
549            state: Arc::clone(&self.inner.state),
550            broadcast: BroadcastEmitter::from_sender(self.inner.event_tx.clone()),
551        });
552        let stream_fn = Arc::clone(&self.inner.stream_fn);
553        run_agent_loop(messages, context, config, emit, stream_fn).await?;
554        Ok(())
555    }
556
557    async fn run_continue(&self) -> Result<(), crate::AgentError> {
558        let signal = self.abort_token();
559        let context = self.context_snapshot();
560        if context.messages.is_empty() {
561            return Err(crate::AgentError::State(
562                "No messages to continue from".into(),
563            ));
564        }
565        if context.messages.last().unwrap().is_assistant() {
566            return Err(crate::AgentError::State(
567                "Cannot continue from message role: assistant".into(),
568            ));
569        }
570        let config = self.build_config(signal);
571        let emit: Arc<dyn AgentEmitter> = Arc::new(StatefulEmitter {
572            state: Arc::clone(&self.inner.state),
573            broadcast: BroadcastEmitter::from_sender(self.inner.event_tx.clone()),
574        });
575        let stream_fn = Arc::clone(&self.inner.stream_fn);
576        run_agent_loop_continue(context, config, emit, stream_fn).await?;
577        Ok(())
578    }
579}
580
581fn default_model() -> Model {
582    rpi_ai::model::Model::new(
583        "unknown",
584        "unknown",
585        rpi_ai::types::Api::Other("unknown".into()),
586        "unknown",
587        "",
588    )
589}
590
591#[cfg(test)]
592mod tests {
593    use super::*;
594    use rpi_ai::event_stream::create_assistant_message_event_stream;
595    use rpi_ai::provider::Provider;
596    use rpi_ai::providers::faux::{FauxProvider, FauxScript};
597
598    fn faux_stream_fn(provider: Arc<FauxProvider>) -> StreamFn {
599        crate::stream_fn::stream_fn(move |model, ctx, opts| {
600            // The faux provider's stream_simple is async; StreamFn is sync-return.
601            // Bridge by spawning the producer ourselves.
602            let (mut prod, stream) = create_assistant_message_event_stream();
603            let p = Arc::clone(&provider);
604            let model = model.clone();
605            let ctx = ctx.clone();
606            let opts = opts.clone();
607            tokio::spawn(async move {
608                let mut s = p.stream_simple(&model, &ctx, &opts).await;
609                // Drain the real stream into our producer.
610                while let Some(ev) = s.next().await {
611                    if !prod.push(ev) {
612                        break;
613                    }
614                }
615            });
616            stream
617        })
618    }
619
620    #[tokio::test]
621    async fn builder_requires_stream_fn() {
622        let res = AgentBuilder::new().build();
623        assert!(res.is_err(), "build with no stream_fn should error");
624    }
625
626    #[tokio::test]
627    async fn prompt_with_faux_text_collects_events() {
628        let provider = FauxProvider::new(FauxScript::new().with_text("hello"));
629        let sf = faux_stream_fn(provider);
630        let agent = AgentBuilder::new().stream_fn(sf).build().unwrap();
631
632        let mut rx = agent.subscribe();
633        agent.prompt("hi").await.unwrap();
634
635        // Drain the broadcast until AgentEnd.
636        let mut saw_start = false;
637        let mut saw_end = false;
638        while let Ok(ev) = rx.try_recv() {
639            match ev {
640                AgentEvent::AgentStart => saw_start = true,
641                AgentEvent::AgentEnd { .. } => saw_end = true,
642                _ => {}
643            }
644        }
645        assert!(saw_start, "agent_start observed");
646        assert!(saw_end, "agent_end observed");
647        // State: 1 user prompt + 1 assistant reply.
648        assert_eq!(agent.state().messages.len(), 2);
649    }
650}