Skip to main content

behest_runtime/
lib.rs

1//! Async runtime glue connecting the sans-IO state machine to real I/O.
2//!
3//! The [`Runtime`] struct orchestrates the full agent execution loop:
4//!
5//! 1. Receives a user message
6//! 2. Builds context (system prompt + history)
7//! 3. Drives the sans-IO state machine via [`transition`]
8//! 4. Executes actions: model calls, tool execution, memory compaction, approval
9//! 5. Emits events through the hook stack
10//! 6. Returns when the run finishes or is aborted
11//!
12//! # Architecture
13//!
14//! ```text
15//! User Message
16//!     │
17//!     ▼
18//! Runtime::run()
19//!     │
20//!     ├─► build context (system prompt + history + RAG)
21//!     ├─► transition(state, input) → (new_state, actions)
22//!     │       │
23//!     │       ├─► RequestModel  → provider.stream() → events → ModelCompleted
24//!     │       ├─► ExecuteTool   → tool_registry.execute() → ToolResultReceived
25//!     │       ├─► RequestToolApproval → approval_gate.request() → await decision
26//!     │       ├─► CompactMemory → memory compaction → MemoryCompactionCompleted
27//!     │       ├─► FinishRun     → return RunOutput
28//!     │       └─► AbortRun      → return error
29//!     │
30//!     └─► emit events → HookStack.dispatch() → EventActions
31//! ```
32
33#![forbid(unsafe_code)]
34#![deny(missing_docs)]
35#![deny(unreachable_pub)]
36
37use behest_approval::{ApprovalGate, ApprovalPolicy};
38use behest_context::{
39    EventSink, RunBudget, RunContextImpl, SessionContextImpl, SessionState, ToolContextImpl,
40};
41use behest_core::error::ProviderError;
42use behest_core::id::RunId;
43use behest_core::message::{ChatRequest, ContentPart, FinishReason, Message, TokenUsage};
44use behest_core::run::{RunConfig, RunInput, RunState, transition};
45use behest_core::tool_types::ToolCall;
46use behest_event::{AgentEvent, HookStack};
47use behest_memory::ConversationMemory;
48use behest_tool::{ToolExecutionStrategy, ToolRegistry};
49use std::sync::Arc;
50use std::time::Duration;
51use tokio::sync::broadcast;
52use tokio_util::sync::CancellationToken;
53use tracing::{debug, info, warn};
54
55mod error;
56pub use error::{RuntimeError, RuntimeResult};
57
58/// Configuration for the runtime.
59#[derive(Debug, Clone)]
60pub struct RuntimeConfig {
61    /// Maximum number of tool-calling iterations.
62    pub max_iterations: usize,
63    /// Maximum tool execution concurrency.
64    pub max_tool_concurrency: usize,
65    /// Tool execution strategy (sequential, parallel, auto).
66    pub tool_execution_strategy: ToolExecutionStrategy,
67    /// Timeout for individual tool executions.
68    pub tool_timeout: Duration,
69    /// Timeout for provider calls.
70    pub provider_timeout: Duration,
71    /// Whether to continue after tool failures.
72    pub continue_on_tool_failure: bool,
73    /// Whether to retry on provider errors.
74    pub retry_on_provider_error: bool,
75    /// Maximum number of retries for provider errors.
76    pub max_retries: usize,
77    /// Approval policy for tool calls.
78    pub approval_policy: ApprovalPolicy,
79    /// Whether to enable memory compaction.
80    pub enable_compaction: bool,
81    /// Event channel capacity.
82    pub event_channel_capacity: usize,
83}
84
85impl Default for RuntimeConfig {
86    fn default() -> Self {
87        Self {
88            max_iterations: 10,
89            max_tool_concurrency: 4,
90            tool_execution_strategy: ToolExecutionStrategy::Auto,
91            tool_timeout: Duration::from_secs(30),
92            provider_timeout: Duration::from_secs(60),
93            continue_on_tool_failure: true,
94            retry_on_provider_error: true,
95            max_retries: 2,
96            approval_policy: ApprovalPolicy::AutoForReadOnly,
97            enable_compaction: true,
98            event_channel_capacity: 256,
99        }
100    }
101}
102
103/// Output from a completed run.
104#[derive(Debug, Clone)]
105pub struct RunOutput {
106    /// The run identifier.
107    pub run_id: RunId,
108    /// The session identifier.
109    pub session_id: String,
110    /// Number of model-calling iterations completed.
111    pub iterations: usize,
112    /// Why the run finished.
113    pub finish_reason: FinishReason,
114    /// Total token usage across all model calls.
115    pub total_usage: TokenUsage,
116    /// The final assistant message.
117    pub final_message: Option<Message>,
118}
119
120/// The central runtime orchestrator.
121///
122/// Connects the sans-IO state machine to real I/O: model providers,
123/// tool execution, memory management, and approval workflows.
124pub struct Runtime {
125    config: RuntimeConfig,
126    tools: Arc<ToolRegistry>,
127    memory: Arc<dyn ConversationMemory>,
128    approval_gate: Arc<ApprovalGate>,
129    hooks: HookStack,
130    event_tx: broadcast::Sender<AgentEvent>,
131}
132
133impl Runtime {
134    /// Creates a new runtime with default configuration.
135    ///
136    /// The runtime is parameterized by:
137    /// - `tools`: registry of callable tools
138    /// - `memory`: conversation memory backend
139    #[must_use]
140    pub fn new(tools: Arc<ToolRegistry>, memory: Arc<dyn ConversationMemory>) -> Self {
141        let (event_tx, _) = broadcast::channel(256);
142        Self {
143            config: RuntimeConfig::default(),
144            tools,
145            memory,
146            approval_gate: Arc::new(ApprovalGate::new()),
147            hooks: HookStack::new(),
148            event_tx,
149        }
150    }
151
152    /// Sets the runtime configuration.
153    #[must_use]
154    pub fn with_config(mut self, config: RuntimeConfig) -> Self {
155        self.config = config;
156        self
157    }
158
159    /// Sets the approval gate.
160    #[must_use]
161    pub fn with_approval_gate(mut self, gate: Arc<ApprovalGate>) -> Self {
162        self.approval_gate = gate;
163        self
164    }
165
166    /// Adds a hook to the runtime.
167    pub fn add_hook(&mut self, hook: Box<dyn behest_event::Hook>) {
168        self.hooks.push(hook);
169    }
170
171    /// Subscribes to runtime events.
172    #[must_use]
173    pub fn subscribe(&self) -> broadcast::Receiver<AgentEvent> {
174        self.event_tx.subscribe()
175    }
176
177    /// Runs an agent execution loop.
178    ///
179    /// Takes a user message text, builds context, and drives the
180    /// sans-IO state machine until completion or error.
181    ///
182    /// # Errors
183    ///
184    /// Returns [`RuntimeError`] if the provider fails unrecoverably or
185    /// the run is aborted.
186    pub async fn run(
187        &self,
188        provider: &(dyn behest_provider::ChatProvider + Sync),
189        session_id: &str,
190        user_id: &str,
191        user_message: &str,
192        system_prompt: Option<&str>,
193    ) -> RuntimeResult<RunOutput> {
194        let run_id = RunId::new();
195        let cancel = CancellationToken::new();
196        let sink = EventSink::new();
197        let _budget = RunBudget::new(self.config.max_iterations.checked_mul(100_000));
198
199        info!(
200            run_id = %run_id,
201            session_id = %session_id,
202            "Starting agent run"
203        );
204
205        // Build initial messages
206        let mut messages: Vec<Message> = Vec::new();
207        if let Some(prompt) = system_prompt {
208            messages.push(Message::system_text(prompt));
209        }
210        messages.push(Message::user_text(user_message));
211
212        // Load conversation history
213        if let Ok(history) = self.memory.load(session_id).await
214            && !history.is_empty()
215        {
216            debug!(count = history.len(), "Loaded conversation history");
217            // Insert history before the current user message
218            let user_msg = messages.pop();
219            messages = history;
220            if let Some(msg) = user_msg {
221                messages.push(msg);
222            }
223        }
224
225        let tool_specs = self.tools.specs();
226
227        // State machine loop
228        let mut state = RunState::Idle;
229        let mut iterations: usize = 0;
230        let mut total_usage = TokenUsage::new(0, 0);
231        let mut final_message: Option<Message> = None;
232
233        loop {
234            iterations += 1;
235            if iterations > self.config.max_iterations {
236                warn!(iterations, "Exceeded max iterations");
237                break;
238            }
239
240            // Build context and create chat request
241            let model_name = provider.capabilities().max_output_tokens.map_or_else(
242                || behest_core::id::ModelName::new("default"),
243                |_| behest_core::id::ModelName::new("default"),
244            );
245
246            let mut request = ChatRequest::new(model_name);
247            for msg in &messages {
248                request = request.with_message(msg.clone());
249            }
250            for spec in &tool_specs {
251                request = request.with_tool(spec.clone());
252            }
253
254            // Transition: BuildingContext → CallingModel
255            let (new_state, _actions) = transition(
256                &state,
257                RunInput::UserMessageReceived {
258                    content: vec![ContentPart::text(user_message)],
259                },
260                &RunConfig {
261                    max_iterations: self.config.max_iterations,
262                    max_tokens: None,
263                    continue_on_tool_failure: self.config.continue_on_tool_failure,
264                },
265            );
266            state = new_state;
267
268            // Call model (with retry)
269            let response = self
270                .call_model_with_retry(provider, &request, &cancel)
271                .await?;
272
273            // Emit model completed event
274            let event = AgentEvent::ModelCompleted {
275                run_id,
276                usage: response.usage.unwrap_or(TokenUsage::new(0, 0)),
277            };
278            self.emit_event(&event, &run_id, session_id).await;
279
280            total_usage = TokenUsage::new(
281                total_usage.input_tokens
282                    + response.usage.unwrap_or(TokenUsage::new(0, 0)).input_tokens,
283                total_usage.output_tokens
284                    + response
285                        .usage
286                        .unwrap_or(TokenUsage::new(0, 0))
287                        .output_tokens,
288            );
289
290            let assistant_msg = response.message.clone();
291            messages.push(assistant_msg.clone());
292
293            // Check for tool calls
294            if !response.message.tool_calls().is_empty() {
295                let calls = response.message.tool_calls().to_vec();
296
297                // Execute tools
298                let tool_results = self
299                    .execute_tools(&calls, &run_id, session_id, user_id, &sink, &cancel)
300                    .await;
301
302                // Feed tool results back as messages
303                for (call, result) in calls.iter().zip(tool_results.iter()) {
304                    let result_text = match result {
305                        Ok(output) => output.value.to_string(),
306                        Err(e) => {
307                            warn!(tool = %call.name, error = %e, "Tool execution failed");
308                            format!("Error: {e}")
309                        }
310                    };
311                    messages.push(Message::Tool {
312                        tool_call_id: call.id.clone(),
313                        name: call.name.clone(),
314                        content: vec![ContentPart::text(&result_text)],
315                    });
316                }
317            } else {
318                // No tool calls — run is complete
319                final_message = Some(assistant_msg);
320                break;
321            }
322
323            // Persist to memory
324            if let Err(e) = self.memory.append(session_id, messages.clone()).await {
325                warn!(error = %e, "Failed to persist conversation");
326            }
327        }
328
329        // Emit finish event
330        let finish_event = AgentEvent::RunFinished {
331            run_id,
332            reason: FinishReason::Stop,
333            usage: total_usage,
334        };
335        self.emit_event(&finish_event, &run_id, session_id).await;
336
337        info!(
338            run_id = %run_id,
339            iterations,
340            input_tokens = total_usage.input_tokens,
341            output_tokens = total_usage.output_tokens,
342            "Agent run complete"
343        );
344
345        Ok(RunOutput {
346            run_id,
347            session_id: session_id.to_string(),
348            iterations,
349            finish_reason: FinishReason::Stop,
350            total_usage,
351            final_message,
352        })
353    }
354
355    /// Calls the model with retry logic.
356    async fn call_model_with_retry(
357        &self,
358        provider: &(dyn behest_provider::ChatProvider + Sync),
359        request: &ChatRequest,
360        cancel: &CancellationToken,
361    ) -> RuntimeResult<behest_provider::ChatResponse> {
362        let mut last_error = None;
363
364        for attempt in 0..=self.config.max_retries {
365            if cancel.is_cancelled() {
366                return Err(RuntimeError::Cancelled);
367            }
368
369            let result = tokio::time::timeout(
370                self.config.provider_timeout,
371                provider.complete(request.clone()),
372            )
373            .await;
374
375            match result {
376                Ok(Ok(response)) => return Ok(response),
377                Ok(Err(e)) => {
378                    if !self.config.retry_on_provider_error || !e.is_retryable() {
379                        return Err(RuntimeError::Provider(e));
380                    }
381                    warn!(attempt, error = %e, "Provider error, retrying");
382                    last_error = Some(e);
383                    tokio::time::sleep(Duration::from_millis(500 * 2_u64.pow(attempt as u32)))
384                        .await;
385                }
386                Err(_elapsed) => {
387                    last_error = Some(ProviderError::Timeout {
388                        provider: provider.id(),
389                    });
390                    if !self.config.retry_on_provider_error {
391                        return Err(RuntimeError::Provider(ProviderError::Timeout {
392                            provider: provider.id(),
393                        }));
394                    }
395                }
396            }
397        }
398
399        Err(RuntimeError::Provider(last_error.unwrap_or_else(|| {
400            ProviderError::Timeout {
401                provider: provider.id(),
402            }
403        })))
404    }
405
406    /// Executes a batch of tool calls.
407    async fn execute_tools(
408        &self,
409        calls: &[ToolCall],
410        run_id: &RunId,
411        session_id: &str,
412        user_id: &str,
413        sink: &EventSink,
414        cancel: &CancellationToken,
415    ) -> Vec<Result<behest_tool::ToolOutput, behest_core::error::ToolError>> {
416        use std::collections::HashMap;
417
418        let tool_arcs: Vec<_> = calls
419            .iter()
420            .filter_map(|c| self.tools.get(&c.name))
421            .collect();
422
423        let plan = self.config.tool_execution_strategy.plan(calls, &tool_arcs);
424
425        let mut results: HashMap<
426            String,
427            Result<behest_tool::ToolOutput, behest_core::error::ToolError>,
428        > = HashMap::new();
429
430        for group in &plan.groups {
431            if group.parallel && group.calls.len() > 1 {
432                let handles: Vec<_> = group
433                    .calls
434                    .iter()
435                    .map(|call| {
436                        let tools = Arc::clone(&self.tools);
437                        let call = call.clone();
438                        let run_id = *run_id;
439                        let session_id = session_id.to_string();
440                        let user_id = user_id.to_string();
441                        let sink = sink.clone();
442                        let cancel = cancel.clone();
443                        let timeout = self.config.tool_timeout;
444                        let approval_gate = Arc::clone(&self.approval_gate);
445                        let approval_policy = self.config.approval_policy.clone();
446
447                        tokio::spawn(async move {
448                            execute_single_tool(
449                                &tools,
450                                &call,
451                                &run_id,
452                                &session_id,
453                                &user_id,
454                                &sink,
455                                &cancel,
456                                timeout,
457                                &approval_gate,
458                                &approval_policy,
459                            )
460                            .await
461                        })
462                    })
463                    .collect();
464
465                for handle in handles {
466                    match handle.await {
467                        Ok(Ok((call_id, result))) => {
468                            results.insert(call_id, result);
469                        }
470                        Ok(Err(_)) => {} // Task failed, skip
471                        Err(e) => {
472                            warn!(error = %e, "Tool execution task panicked");
473                        }
474                    }
475                }
476            } else {
477                for call in &group.calls {
478                    if cancel.is_cancelled() {
479                        break;
480                    }
481                    if let Ok((call_id, result)) = execute_single_tool(
482                        &self.tools,
483                        call,
484                        run_id,
485                        session_id,
486                        user_id,
487                        sink,
488                        cancel,
489                        self.config.tool_timeout,
490                        &self.approval_gate,
491                        &self.config.approval_policy,
492                    )
493                    .await
494                    {
495                        results.insert(call_id, result);
496                    }
497                }
498            }
499        }
500
501        // Return results in original call order
502        calls
503            .iter()
504            .map(|c| {
505                results.remove(&c.id).unwrap_or_else(|| {
506                    Err(behest_core::error::ToolError::Execution {
507                        name: c.name.clone(),
508                        message: "Tool execution was skipped".to_string(),
509                    })
510                })
511            })
512            .collect()
513    }
514
515    /// Emits an event through the hook stack and broadcast channel.
516    async fn emit_event(&self, event: &AgentEvent, run_id: &RunId, session_id: &str) {
517        // Dispatch to hooks
518        let hook_ctx = behest_context::HookContextImpl {
519            app: behest_context::AppContext {
520                invocation_id: run_id.to_string(),
521                session_id: session_id.to_string(),
522                user_id: String::new(),
523                app_name: "behest".to_string(),
524            },
525            state: RunState::Idle,
526            run_id: *run_id,
527            iteration: 0,
528            tokens_used: 0,
529        };
530
531        let _hook_actions = self.hooks.dispatch(event, &hook_ctx);
532
533        // Broadcast to subscribers
534        let _ = self.event_tx.send(event.clone());
535    }
536}
537
538/// Executes a single tool call with timeout, approval check, and progress emission.
539#[allow(clippy::too_many_arguments)]
540async fn execute_single_tool(
541    tools: &ToolRegistry,
542    call: &ToolCall,
543    run_id: &RunId,
544    session_id: &str,
545    user_id: &str,
546    sink: &EventSink,
547    cancel: &CancellationToken,
548    timeout: Duration,
549    approval_gate: &ApprovalGate,
550    approval_policy: &ApprovalPolicy,
551) -> Result<
552    (
553        String,
554        Result<behest_tool::ToolOutput, behest_core::error::ToolError>,
555    ),
556    String,
557> {
558    let tool = match tools.get(&call.name) {
559        Some(t) => t,
560        None => {
561            return Ok((
562                call.id.clone(),
563                Err(behest_core::error::ToolError::NotFound {
564                    name: call.name.clone(),
565                }),
566            ));
567        }
568    };
569
570    // Check approval
571    if tool.requires_approval() && approval_policy.requires_approval(true, tool.is_read_only()) {
572        let reason = tool
573            .approval_reason()
574            .unwrap_or_else(|| format!("Tool '{}' requires approval", call.name));
575        approval_gate
576            .request(call.clone(), reason, Some(timeout))
577            .await;
578
579        // Wait for approval decision
580        let approval_timeout = timeout.min(Duration::from_secs(60));
581        let start = tokio::time::Instant::now();
582        loop {
583            if start.elapsed() > approval_timeout {
584                approval_gate.expire_timed_out().await;
585                return Ok((
586                    call.id.clone(),
587                    Err(behest_core::error::ToolError::Execution {
588                        name: call.name.clone(),
589                        message: "Approval timed out".to_string(),
590                    }),
591                ));
592            }
593            if approval_gate.is_approved(&call.id).await {
594                break;
595            }
596            if cancel.is_cancelled() {
597                return Err("cancelled".to_string());
598            }
599            tokio::time::sleep(Duration::from_millis(100)).await;
600        }
601    }
602
603    // Emit tool execution started
604    let start_event = AgentEvent::ToolExecutionStarted {
605        run_id: *run_id,
606        call_id: call.id.clone(),
607        name: call.name.clone(),
608    };
609    let _ = sink.emit(serde_json::to_value(&start_event).unwrap_or_default());
610
611    // Execute with timeout
612    let ctx = ToolContextImpl {
613        run: RunContextImpl {
614            session: SessionContextImpl {
615                app: behest_context::AppContext {
616                    invocation_id: run_id.to_string(),
617                    session_id: session_id.to_string(),
618                    user_id: user_id.to_string(),
619                    app_name: "behest".to_string(),
620                },
621                state: SessionState::new(),
622            },
623            run_id: *run_id,
624            cancel: cancel.clone(),
625            deadline: Some(tokio::time::Instant::now().into_std() + timeout),
626            sink: sink.clone(),
627            budget: RunBudget::new(None),
628        },
629        tool_call: call.clone(),
630    };
631
632    let result = tokio::time::timeout(timeout, tool.execute(&ctx, call.arguments.clone())).await;
633
634    match result {
635        Ok(Ok(output)) => {
636            let completed_event = AgentEvent::ToolExecutionCompleted {
637                run_id: *run_id,
638                call_id: call.id.clone(),
639                output: output.value.clone(),
640            };
641            let _ = sink.emit(serde_json::to_value(&completed_event).unwrap_or_default());
642            Ok((call.id.clone(), Ok(output)))
643        }
644        Ok(Err(e)) => {
645            let failed_event = AgentEvent::ToolExecutionFailed {
646                run_id: *run_id,
647                call_id: call.id.clone(),
648                error: e.to_string(),
649            };
650            let _ = sink.emit(serde_json::to_value(&failed_event).unwrap_or_default());
651            Ok((call.id.clone(), Err(e)))
652        }
653        Err(_elapsed) => Ok((
654            call.id.clone(),
655            Err(behest_core::error::ToolError::Execution {
656                name: call.name.clone(),
657                message: "Tool execution timed out".to_string(),
658            }),
659        )),
660    }
661}
662
663#[cfg(test)]
664#[allow(clippy::unwrap_used)]
665mod tests {
666    use super::*;
667
668    /// A mock provider for testing.
669    struct MockProvider {
670        responses: std::sync::Mutex<Vec<behest_provider::ChatResponse>>,
671    }
672
673    impl MockProvider {
674        fn new(responses: Vec<behest_provider::ChatResponse>) -> Self {
675            Self {
676                responses: std::sync::Mutex::new(responses),
677            }
678        }
679    }
680
681    #[async_trait::async_trait]
682    impl behest_provider::ChatProvider for MockProvider {
683        fn id(&self) -> behest_core::id::ProviderId {
684            behest_core::id::ProviderId::new("mock")
685        }
686
687        fn capabilities(&self) -> behest_core::capabilities::ProviderCapabilities {
688            behest_core::capabilities::ProviderCapabilities {
689                chat: true,
690                chat_stream: false,
691                tool_calling: true,
692                ..behest_core::capabilities::ProviderCapabilities::empty()
693            }
694        }
695
696        async fn complete(
697            &self,
698            _request: ChatRequest,
699        ) -> behest_provider::ProviderResult<behest_provider::ChatResponse> {
700            let mut responses = self.responses.lock().unwrap();
701            Ok(responses.remove(0))
702        }
703    }
704
705    #[tokio::test]
706    async fn runtime_simple_text_response() {
707        let tools = Arc::new(ToolRegistry::new());
708        let memory = Arc::new(behest_memory::InMemoryConversationMemory::new());
709
710        let provider = MockProvider::new(vec![behest_provider::ChatResponse {
711            provider: behest_core::id::ProviderId::new("mock"),
712            model: behest_core::id::ModelName::new("mock"),
713            message: Message::assistant_text("Hello! How can I help?"),
714            finish_reason: FinishReason::Stop,
715            usage: Some(TokenUsage::new(10, 5)),
716            raw: None,
717        }]);
718
719        let runtime = Runtime::new(tools, memory);
720
721        let output = runtime
722            .run(&provider, "test-session", "test-user", "Hi!", None)
723            .await
724            .unwrap();
725
726        assert_eq!(output.iterations, 1);
727        assert_eq!(output.total_usage.input_tokens, 10);
728        assert_eq!(output.total_usage.output_tokens, 5);
729    }
730
731    #[tokio::test]
732    async fn runtime_emits_events() {
733        let tools = Arc::new(ToolRegistry::new());
734        let memory = Arc::new(behest_memory::InMemoryConversationMemory::new());
735
736        let provider = MockProvider::new(vec![behest_provider::ChatResponse {
737            provider: behest_core::id::ProviderId::new("mock"),
738            model: behest_core::id::ModelName::new("mock"),
739            message: Message::assistant_text("Done"),
740            finish_reason: FinishReason::Stop,
741            usage: Some(TokenUsage::new(5, 2)),
742            raw: None,
743        }]);
744
745        let runtime = Runtime::new(tools, memory);
746        let mut rx = runtime.subscribe();
747
748        let _output = runtime
749            .run(&provider, "sess", "user", "Hello", None)
750            .await
751            .unwrap();
752
753        // Should receive at least ModelCompleted and RunFinished events
754        let mut events = Vec::new();
755        while let Ok(event) = rx.try_recv() {
756            events.push(event);
757        }
758        assert!(!events.is_empty(), "Should emit events");
759    }
760}