Skip to main content

thndrs_lib/core/harness/
mod.rs

1//! Minimal UI-agnostic harness API for starting an agent turn.
2//!
3//! The harness starts a reusable agent run and exposes an event receiver
4//! plus a cooperative cancellation handle.
5
6use std::sync::mpsc::Receiver;
7
8use crate::agent::{RunHandle, ToolExecutionHook, ToolPermissionHook};
9use crate::app::AgentEvent;
10use crate::providers::ProviderMessage;
11use crate::tools::AgentRunConfig;
12
13use thndrs_agent::CancelToken;
14
15/// Handle returned when a harness turn has started.
16#[derive(Debug)]
17pub struct HarnessHandle {
18    /// Stream of semantic agent events.
19    pub events: Receiver<AgentEvent>,
20    /// Token that can be cancelled to stop the turn cooperatively.
21    pub cancel: CancelToken,
22}
23
24impl HarnessHandle {
25    /// Request cancellation for this turn.
26    pub fn request_cancel(&self) {
27        self.cancel.cancel();
28    }
29}
30
31/// A single UI-independent harness turn description built from a
32/// pre-constructed agent runtime handle.
33#[derive(Debug)]
34pub struct HarnessTurn {
35    handle: RunHandle,
36}
37
38impl HarnessTurn {
39    pub fn new(handle: RunHandle) -> Self {
40        Self { handle }
41    }
42
43    /// Build a provider-backed harness turn with optional steering input.
44    pub fn provider_with_steering(
45        config: AgentRunConfig, messages: Vec<ProviderMessage>, expects_write: bool, steering: Receiver<String>,
46    ) -> Self {
47        Self::new(RunHandle::provider_with_steering(
48            config,
49            messages,
50            expects_write,
51            steering,
52        ))
53    }
54
55    /// Build a provider-backed harness turn with optional steering and tool permission review.
56    pub fn provider_with_steering_and_permissions(
57        config: AgentRunConfig, messages: Vec<ProviderMessage>, expects_write: bool, steering: Receiver<String>,
58        permission_hook: ToolPermissionHook,
59    ) -> Self {
60        Self::new(
61            RunHandle::provider_with_steering(config, messages, expects_write, steering)
62                .with_permission_hook(permission_hook),
63        )
64    }
65
66    /// Build a provider-backed harness turn with permission review and custom tool execution.
67    pub fn provider_with_steering_permissions_and_execution(
68        config: AgentRunConfig, messages: Vec<ProviderMessage>, expects_write: bool, steering: Receiver<String>,
69        permission_hook: ToolPermissionHook, execution_hook: ToolExecutionHook,
70    ) -> Self {
71        Self::new(
72            RunHandle::provider_with_steering(config, messages, expects_write, steering)
73                .with_permission_hook(permission_hook)
74                .with_execution_hook(execution_hook),
75        )
76    }
77
78    /// Create and start the turn, returning the event stream and cancel handle.
79    pub fn start(self) -> HarnessHandle {
80        let cancel = self.handle.cancel.clone();
81        let events = self.handle.spawn();
82        HarnessHandle { events, cancel }
83    }
84
85    /// Create a deterministic fake-provider harness turn.
86    #[cfg(test)]
87    pub fn fake(config: AgentRunConfig, prompt: String) -> Self {
88        Self::new(RunHandle::fake(config, prompt))
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use std::path::PathBuf;
95
96    use super::*;
97    use crate::cli::WebSearchMode;
98    use crate::tools::AgentRunConfig;
99
100    #[test]
101    fn fake_turn_starts_and_finishes_without_app() {
102        let config = AgentRunConfig::new(
103            PathBuf::from("."),
104            String::from("fake-agent"),
105            WebSearchMode::DuckDuckGo,
106        );
107        let handle = HarnessTurn::fake(config, String::new()).start();
108        let mut events = Vec::new();
109
110        while let Ok(event) = handle.events.recv() {
111            events.push(event);
112        }
113
114        assert_eq!(events.first(), Some(&AgentEvent::Started));
115        assert_eq!(events.last(), Some(&AgentEvent::Finished));
116        assert!(
117            events
118                .iter()
119                .any(|event| matches!(event, AgentEvent::ReasoningDelta(_)))
120        );
121        assert!(
122            events
123                .iter()
124                .any(|event| matches!(event, AgentEvent::AssistantDelta(_)))
125        );
126        assert!(
127            events
128                .iter()
129                .any(|event| matches!(event, AgentEvent::ToolStarted { .. }))
130        );
131        assert!(
132            events
133                .iter()
134                .any(|event| matches!(event, AgentEvent::ToolFinished { .. }))
135        );
136    }
137}