1#![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#[derive(Debug, Clone)]
60pub struct RuntimeConfig {
61 pub max_iterations: usize,
63 pub max_tool_concurrency: usize,
65 pub tool_execution_strategy: ToolExecutionStrategy,
67 pub tool_timeout: Duration,
69 pub provider_timeout: Duration,
71 pub continue_on_tool_failure: bool,
73 pub retry_on_provider_error: bool,
75 pub max_retries: usize,
77 pub approval_policy: ApprovalPolicy,
79 pub enable_compaction: bool,
81 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#[derive(Debug, Clone)]
105pub struct RunOutput {
106 pub run_id: RunId,
108 pub session_id: String,
110 pub iterations: usize,
112 pub finish_reason: FinishReason,
114 pub total_usage: TokenUsage,
116 pub final_message: Option<Message>,
118}
119
120pub 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 #[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 #[must_use]
154 pub fn with_config(mut self, config: RuntimeConfig) -> Self {
155 self.config = config;
156 self
157 }
158
159 #[must_use]
161 pub fn with_approval_gate(mut self, gate: Arc<ApprovalGate>) -> Self {
162 self.approval_gate = gate;
163 self
164 }
165
166 pub fn add_hook(&mut self, hook: Box<dyn behest_event::Hook>) {
168 self.hooks.push(hook);
169 }
170
171 #[must_use]
173 pub fn subscribe(&self) -> broadcast::Receiver<AgentEvent> {
174 self.event_tx.subscribe()
175 }
176
177 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 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 if let Ok(history) = self.memory.load(session_id).await
214 && !history.is_empty()
215 {
216 debug!(count = history.len(), "Loaded conversation history");
217 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 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 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 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 let response = self
270 .call_model_with_retry(provider, &request, &cancel)
271 .await?;
272
273 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 if !response.message.tool_calls().is_empty() {
295 let calls = response.message.tool_calls().to_vec();
296
297 let tool_results = self
299 .execute_tools(&calls, &run_id, session_id, user_id, &sink, &cancel)
300 .await;
301
302 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 final_message = Some(assistant_msg);
320 break;
321 }
322
323 if let Err(e) = self.memory.append(session_id, messages.clone()).await {
325 warn!(error = %e, "Failed to persist conversation");
326 }
327 }
328
329 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 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 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(_)) => {} 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 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 async fn emit_event(&self, event: &AgentEvent, run_id: &RunId, session_id: &str) {
517 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 let _ = self.event_tx.send(event.clone());
535 }
536}
537
538#[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 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 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 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 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 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 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}