Skip to main content

agent_sdk_core/application/
run_handle.rs

1//! Reconnectable run-handle helpers. Use this module when a host needs to wait for
2//! output, stream events from a cursor, replay journal frames, or request
3//! cancellation. Handle operations read or mutate the configured run-control store
4//! and may publish cancellation intent.
5//!
6use std::{
7    collections::BTreeMap,
8    sync::{Arc, Mutex},
9    time::Duration,
10};
11
12use crate::{
13    domain::{AgentError, AgentId, RunId},
14    event::{EventCursor, EventDeliverySemantics, EventKind},
15    event_bus::AgentEventStream,
16    journal::{JournalCursor, JournalRecord, JournalRecordPayload},
17    run::{RunResult, RunStatus},
18    subscription::RunSubscriptionSource,
19};
20
21#[derive(Clone)]
22/// Holds run handle application-layer state or configuration.
23/// Use it with the documented coordinator methods; run, journal, event, provider, or port effects are called out on those methods rather than on construction.
24pub struct RunHandle {
25    run_id: RunId,
26    control: Arc<dyn RunControlStore>,
27    subscriptions: Arc<dyn RunSubscriptionSource>,
28}
29
30impl RunHandle {
31    /// Creates a new application::run_handle value with explicit
32    /// caller-provided inputs. This constructor is data-only and
33    /// performs no I/O or external side effects.
34    pub fn new(
35        run_id: RunId,
36        control: Arc<dyn RunControlStore>,
37        subscriptions: Arc<dyn RunSubscriptionSource>,
38    ) -> Self {
39        Self {
40            run_id,
41            control,
42            subscriptions,
43        }
44    }
45
46    /// Returns run id for this application::run_handle value without
47    /// performing external I/O.
48    pub fn run_id(&self) -> &RunId {
49        &self.run_id
50    }
51
52    /// Returns the terminal result once the handle store, terminal event, and
53    /// journal agree. This reads run-control state and does not drive the run or
54    /// start new side effects.
55    pub fn wait(&self) -> Result<RunResult, AgentError> {
56        self.consistent_terminal_result()?.ok_or_else(|| {
57            AgentError::contract_violation(
58                "run is not terminal until journal, handle status, and terminal event agree",
59            )
60        })
61    }
62
63    /// Returns the terminal result if it is already available before the
64    /// timeout budget. This first-slice implementation is non-blocking and does
65    /// not cancel or drive the run.
66    pub fn wait_with_timeout(&self, _timeout: Duration) -> Result<Option<RunResult>, AgentError> {
67        self.consistent_terminal_result()
68    }
69
70    /// Returns the status currently held by this value.
71    /// This reads run-control status for the handle and does not change the run.
72    pub fn status(&self) -> Result<RunStatus, AgentError> {
73        self.control.status(&self.run_id)
74    }
75
76    /// Opens an event stream for this run from the supplied live-event cursor.
77    /// This delegates to the configured subscription port to create a read-only stream; it does
78    /// not drive the run, execute tools, or call the provider.
79    pub fn stream_from(&self, cursor: Option<EventCursor>) -> Result<AgentEventStream, AgentError> {
80        self.subscriptions
81            .subscribe_run(self.run_id.clone(), cursor)
82    }
83
84    /// Opens a replay-derived stream for this run from a journal cursor.
85    /// This delegates to the subscription port's journal replay path and does not mutate run
86    /// control or execute runtime work.
87    pub fn stream_from_journal(
88        &self,
89        cursor: JournalCursor,
90    ) -> Result<AgentEventStream, AgentError> {
91        self.subscriptions
92            .replay_run_from_cursor(self.run_id.clone(), cursor)
93    }
94
95    /// Cancel.
96    /// This forwards cancellation to run control; adapter/process cleanup remains owned by the
97    /// run coordinator.
98    pub fn cancel(&self) -> Result<(), AgentError> {
99        self.control.request_cancel(&self.run_id)
100    }
101
102    fn consistent_terminal_result(&self) -> Result<Option<RunResult>, AgentError> {
103        let Some(result) = self.control.terminal_result(&self.run_id)? else {
104            return Ok(None);
105        };
106        if !result.status.is_terminal() {
107            return Err(AgentError::contract_violation(
108                "terminal result carried non-terminal run status",
109            ));
110        }
111
112        let Some(frame) = self.subscriptions.latest_terminal_event(&self.run_id)? else {
113            return Ok(None);
114        };
115        let Some(event_status) = status_from_terminal_event_kind(&frame.event.envelope.event_kind)
116        else {
117            return Ok(None);
118        };
119        if event_status != result.status {
120            return Err(AgentError::contract_violation(
121                "terminal event status does not match sealed journal result",
122            ));
123        }
124        if !matches!(
125            frame.event.envelope.delivery_semantics,
126            EventDeliverySemantics::JournalBacked | EventDeliverySemantics::DerivedReplay
127        ) || frame.event.envelope.journal_cursor.is_none()
128        {
129            return Err(AgentError::contract_violation(
130                "terminal event must be journal-backed or derived from a journal cursor",
131            ));
132        }
133
134        Ok(Some(result))
135    }
136}
137
138impl core::fmt::Debug for RunHandle {
139    fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
140        formatter
141            .debug_struct("RunHandle")
142            .field("run_id", &self.run_id)
143            .finish_non_exhaustive()
144    }
145}
146
147/// Port or behavior contract for run control store. Implementors should
148/// preserve policy, redaction, idempotency, and replay expectations
149/// from the surrounding module. Implementations may perform side
150/// effects only as described by the trait methods.
151pub trait RunControlStore: Send + Sync {
152    /// Returns the status currently held by this value.
153    /// This reads run-control status for the handle and does not change the run.
154    fn status(&self, run_id: &RunId) -> Result<RunStatus, AgentError>;
155    /// Returns terminal result for callers that need to inspect the contract state.
156    /// Implementations read terminal result state for the run and do not change run status.
157    fn terminal_result(&self, run_id: &RunId) -> Result<Option<RunResult>, AgentError>;
158    /// Requests cancellation for a registered run.
159    /// Implementations may mutate run-control state to record the request; provider/tool cleanup
160    /// remains owned by the run coordinator that observes the cancellation.
161    fn request_cancel(&self, run_id: &RunId) -> Result<(), AgentError>;
162}
163
164#[derive(Clone, Debug, Default)]
165/// Holds in memory run control store application-layer state or configuration.
166/// Use it with the documented coordinator methods; run, journal, event, provider, or port effects are called out on those methods rather than on construction.
167pub struct InMemoryRunControlStore {
168    records: Arc<Mutex<BTreeMap<RunId, RunControlRecord>>>,
169}
170
171impl InMemoryRunControlStore {
172    /// Register run.
173    /// This inserts a run into in-memory test run-control state for deterministic handle tests.
174    pub fn register_run(&self, run_id: RunId, agent_id: AgentId) -> Result<(), AgentError> {
175        self.records
176            .lock()
177            .map_err(|_| AgentError::contract_violation("run control store lock poisoned"))?
178            .entry(run_id)
179            .or_insert_with(|| RunControlRecord::new(agent_id));
180        Ok(())
181    }
182
183    /// Mark visible output complete.
184    /// This records final visible output in in-memory test run-control state.
185    pub fn mark_visible_output_complete(
186        &self,
187        run_id: &RunId,
188        output: impl Into<String>,
189    ) -> Result<(), AgentError> {
190        let mut records = self
191            .records
192            .lock()
193            .map_err(|_| AgentError::contract_violation("run control store lock poisoned"))?;
194        let record = records
195            .get_mut(run_id)
196            .ok_or_else(|| AgentError::contract_violation("run is not registered"))?;
197        record.visible_output = Some(output.into());
198        Ok(())
199    }
200
201    /// Seals terminal run-control state from a journal terminal record.
202    /// This stores the derived `RunResult` in the in-memory run-control map; it does not append a
203    /// journal record, publish an event, or execute provider/tool work.
204    pub fn seal_terminal_result_from_journal(
205        &self,
206        record: &JournalRecord,
207        output: impl Into<String>,
208    ) -> Result<RunResult, AgentError> {
209        let status = match &record.payload {
210            JournalRecordPayload::TerminalResult(marker) => {
211                RunStatus::from_terminal_str(&marker.terminal_status).ok_or_else(|| {
212                    AgentError::contract_violation("journal terminal status is not recognized")
213                })?
214            }
215            _ => {
216                return Err(AgentError::contract_violation(
217                    "journal record is not a terminal result marker",
218                ));
219            }
220        };
221
222        let result = RunResult::new(record.run_id.clone(), status.clone(), output);
223        let mut records = self
224            .records
225            .lock()
226            .map_err(|_| AgentError::contract_violation("run control store lock poisoned"))?;
227        let stored = records
228            .entry(record.run_id.clone())
229            .or_insert_with(|| RunControlRecord::new(record.agent_id.clone()));
230
231        if let Some(existing) = stored.final_result.as_ref() {
232            if existing != &result {
233                return Err(AgentError::contract_violation(
234                    "journal terminal result conflicts with existing handle result",
235                ));
236            }
237            return Ok(existing.clone());
238        }
239
240        stored.status = status.clone();
241        stored.journal_terminal_status = Some(status);
242        stored.final_result = Some(result.clone());
243        Ok(result)
244    }
245
246    /// Returns the cancel request count currently held by this value.
247    /// This reads the number of cancellation requests recorded for the run.
248    pub fn cancel_request_count(&self, run_id: &RunId) -> Result<usize, AgentError> {
249        Ok(self
250            .records
251            .lock()
252            .map_err(|_| AgentError::contract_violation("run control store lock poisoned"))?
253            .get(run_id)
254            .map(|record| record.cancel_request_count)
255            .unwrap_or(0))
256    }
257
258    /// Returns visible output for callers that need to inspect the contract state.
259    /// This reads visible output state from run control and does not change run status or
260    /// output delivery.
261    pub fn visible_output(&self, run_id: &RunId) -> Result<Option<String>, AgentError> {
262        Ok(self
263            .records
264            .lock()
265            .map_err(|_| AgentError::contract_violation("run control store lock poisoned"))?
266            .get(run_id)
267            .and_then(|record| record.visible_output.clone()))
268    }
269}
270
271impl RunControlStore for InMemoryRunControlStore {
272    fn status(&self, run_id: &RunId) -> Result<RunStatus, AgentError> {
273        Ok(self
274            .records
275            .lock()
276            .map_err(|_| AgentError::contract_violation("run control store lock poisoned"))?
277            .get(run_id)
278            .map(|record| record.status.clone())
279            .unwrap_or(RunStatus::Pending))
280    }
281
282    fn terminal_result(&self, run_id: &RunId) -> Result<Option<RunResult>, AgentError> {
283        let records = self
284            .records
285            .lock()
286            .map_err(|_| AgentError::contract_violation("run control store lock poisoned"))?;
287        let Some(record) = records.get(run_id) else {
288            return Ok(None);
289        };
290        let Some(result) = record.final_result.clone() else {
291            return Ok(None);
292        };
293        if record.status != result.status
294            || record.journal_terminal_status.as_ref() != Some(&result.status)
295        {
296            return Err(AgentError::contract_violation(
297                "handle status and journal terminal record disagree",
298            ));
299        }
300        Ok(Some(result))
301    }
302
303    fn request_cancel(&self, run_id: &RunId) -> Result<(), AgentError> {
304        let mut records = self
305            .records
306            .lock()
307            .map_err(|_| AgentError::contract_violation("run control store lock poisoned"))?;
308        let record = records
309            .get_mut(run_id)
310            .ok_or_else(|| AgentError::contract_violation("run is not registered"))?;
311        if record.status.is_terminal() || record.cancel_requested {
312            return Ok(());
313        }
314        record.cancel_requested = true;
315        record.cancel_request_count += 1;
316        record.status = RunStatus::Cancelling;
317        Ok(())
318    }
319}
320
321#[derive(Clone, Debug)]
322struct RunControlRecord {
323    #[allow(dead_code)]
324    agent_id: AgentId,
325    status: RunStatus,
326    visible_output: Option<String>,
327    journal_terminal_status: Option<RunStatus>,
328    final_result: Option<RunResult>,
329    cancel_requested: bool,
330    cancel_request_count: usize,
331}
332
333impl RunControlRecord {
334    fn new(agent_id: AgentId) -> Self {
335        Self {
336            agent_id,
337            status: RunStatus::Running,
338            visible_output: None,
339            journal_terminal_status: None,
340            final_result: None,
341            cancel_requested: false,
342            cancel_request_count: 0,
343        }
344    }
345}
346
347fn status_from_terminal_event_kind(kind: &EventKind) -> Option<RunStatus> {
348    match kind {
349        EventKind::RunCompleted => Some(RunStatus::Completed),
350        EventKind::RunFailed => Some(RunStatus::Failed),
351        EventKind::RunCancelled => Some(RunStatus::Cancelled),
352        _ => None,
353    }
354}