agents-kit 0.0.1

Rust agent control Kit for Codex, OpenCode, and provider-backed Custom runtimes
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
//! Durable conversation facts, exclusive delivery, and caller interaction resolutions.
//!
//! Prompt output stays on the prompt return path. Passive items are acknowledged
//! by advancing delivery; actionable interactions require an explicit resolution.
//! Receiver drop is not acknowledgment while the conversation is reopenable.
//!
use std::path::PathBuf;
use std::pin::Pin;
use std::time::SystemTime;

use futures_util::{Stream, StreamExt};

use crate::backends::custom::CustomNotificationReceiver;
use crate::core::notification_store::NotificationReceiver;
use crate::{KitError, NotificationId, WorkspaceContext};
use storage_kit::{DatabaseBackend, StateStoreBackend};

#[derive(Clone, Debug, PartialEq)]
/// One durable notification delivered for a conversation.
///
/// # Examples
///
/// ```
/// use agents_kit::notification::{ConversationNotification, ConversationNotificationKind};
/// let id = "notification_00000000000000000000000000000000".parse()?;
/// let notification = ConversationNotification::new(
///     id, std::time::SystemTime::UNIX_EPOCH,
///     ConversationNotificationKind::Message { text: "Ready".into() },
/// )?;
/// assert!(notification.id().as_str().starts_with("notification_"));
/// assert_eq!(notification.inserted_at(), std::time::SystemTime::UNIX_EPOCH);
/// assert!(matches!(notification.kind(), ConversationNotificationKind::Message { text } if text == "Ready"));
/// # Ok::<(), agents_kit::KitError>(())
/// ```
pub struct ConversationNotification {
    id: NotificationId,
    inserted_at: SystemTime,
    kind: ConversationNotificationKind,
}

impl ConversationNotification {
    /// Validates and constructs one portable notification.
    ///
    /// # Errors
    ///
    /// Returns `InvalidInput` for invalid content: blank required text, invalid relative paths, padded labels/titles, or a non-object interaction schema. This value constructor does not persist or publish a notification; the owning conversation validates the full schema before publication.
    ///
    /// # Examples
    ///
    /// ```
    /// use agents_kit::notification::{ConversationNotification, ConversationNotificationKind};
    /// let id = "notification_00000000000000000000000000000000".parse()?;
    /// let notification = ConversationNotification::new(
    ///     id, std::time::SystemTime::UNIX_EPOCH,
    ///     ConversationNotificationKind::Message { text: "Ready".into() },
    /// )?;
    /// assert!(notification.id().as_str().starts_with("notification_"));
    /// assert_eq!(notification.inserted_at(), std::time::SystemTime::UNIX_EPOCH);
    /// assert!(matches!(notification.kind(), ConversationNotificationKind::Message { text } if text == "Ready"));
    /// # Ok::<(), agents_kit::KitError>(())
    /// ```
    pub fn new(
        id: NotificationId,
        inserted_at: SystemTime,
        kind: ConversationNotificationKind,
    ) -> Result<Self, KitError> {
        kind.validate()?;
        Ok(Self {
            id,
            inserted_at,
            kind,
        })
    }

    /// Returns the durable notification identity.
    ///
    /// # Examples
    ///
    /// ```
    /// use agents_kit::notification::{ConversationNotification, ConversationNotificationKind};
    /// let id = "notification_00000000000000000000000000000000".parse()?;
    /// let notification = ConversationNotification::new(
    ///     id, std::time::SystemTime::UNIX_EPOCH,
    ///     ConversationNotificationKind::Message { text: "Ready".into() },
    /// )?;
    /// assert!(notification.id().as_str().starts_with("notification_"));
    /// assert_eq!(notification.inserted_at(), std::time::SystemTime::UNIX_EPOCH);
    /// assert!(matches!(notification.kind(), ConversationNotificationKind::Message { text } if text == "Ready"));
    /// # Ok::<(), agents_kit::KitError>(())
    /// ```
    pub fn id(&self) -> &NotificationId {
        &self.id
    }

    /// Returns when the notification entered durable delivery.
    ///
    /// # Examples
    ///
    /// ```
    /// use agents_kit::notification::{ConversationNotification, ConversationNotificationKind};
    /// let id = "notification_00000000000000000000000000000000".parse()?;
    /// let notification = ConversationNotification::new(
    ///     id, std::time::SystemTime::UNIX_EPOCH,
    ///     ConversationNotificationKind::Message { text: "Ready".into() },
    /// )?;
    /// assert!(notification.id().as_str().starts_with("notification_"));
    /// assert_eq!(notification.inserted_at(), std::time::SystemTime::UNIX_EPOCH);
    /// assert!(matches!(notification.kind(), ConversationNotificationKind::Message { text } if text == "Ready"));
    /// # Ok::<(), agents_kit::KitError>(())
    /// ```
    pub fn inserted_at(&self) -> SystemTime {
        self.inserted_at
    }

    /// Returns the portable notification content.
    ///
    /// # Examples
    ///
    /// ```
    /// use agents_kit::notification::{ConversationNotification, ConversationNotificationKind};
    /// let id = "notification_00000000000000000000000000000000".parse()?;
    /// let notification = ConversationNotification::new(
    ///     id, std::time::SystemTime::UNIX_EPOCH,
    ///     ConversationNotificationKind::Message { text: "Ready".into() },
    /// )?;
    /// assert!(notification.id().as_str().starts_with("notification_"));
    /// assert_eq!(notification.inserted_at(), std::time::SystemTime::UNIX_EPOCH);
    /// assert!(matches!(notification.kind(), ConversationNotificationKind::Message { text } if text == "Ready"));
    /// # Ok::<(), agents_kit::KitError>(())
    /// ```
    pub fn kind(&self) -> &ConversationNotificationKind {
        &self.kind
    }
}

/// Portable lifecycle state for one caller-visible backend-host process.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ProcessState {
    /// The process is currently running.
    Running,
    /// The process exited with an optional portable exit code.
    Exited {
        /// Native exit status when one is available without leaking platform details.
        exit_code: Option<i32>,
    },
}

#[derive(Clone, Debug, PartialEq)]
/// Portable content delivered through the conversation notification stream.
pub enum ConversationNotificationKind {
    /// Reports a passive text message.
    Message {
        /// Message text.
        text: String,
    },
    /// Reports that a workspace-relative file changed.
    FileChanged {
        /// Validated workspace-relative path, preserving its original spelling.
        path: PathBuf,
        /// Optional source-neutral change summary.
        summary: Option<String>,
    },
    /// Reports a safe caller-visible process lifecycle fact.
    Process {
        /// Source-neutral process label.
        label: String,
        /// Current portable process state.
        state: ProcessState,
    },
    /// Reports passive progress without completing a prompt.
    Progress {
        /// Short progress label.
        label: String,
        /// Optional progress detail.
        detail: Option<String>,
    },
    /// Reports a portable warning.
    Warning {
        /// Warning message.
        message: String,
    },
    /// Reports a portable asynchronous error.
    Error {
        /// Error message.
        message: String,
    },
    /// Requests one caller decision through the portable interaction path.
    Interaction {
        /// Caller-facing interaction title.
        title: String,
        /// Caller-facing interaction body.
        body: String,
        /// Optional JSON Schema describing a submitted response.
        response_schema: Option<serde_json::Value>,
        /// Whether the caller may dismiss the interaction.
        dismissible: bool,
    },
}

impl ConversationNotificationKind {
    fn validate(&self) -> Result<(), KitError> {
        match self {
            Self::Message { text }
            | Self::Warning { message: text }
            | Self::Error { message: text }
                if text.trim().is_empty() =>
            {
                Err(KitError::invalid_input(
                    "notification message must be nonblank",
                ))
            }
            Self::FileChanged { path, summary } => {
                WorkspaceContext::normalized_relative(path)?;
                if summary
                    .as_ref()
                    .is_some_and(|summary| summary.trim().is_empty())
                {
                    return Err(KitError::invalid_input(
                        "file notification summary must be absent or nonblank",
                    ));
                }
                Ok(())
            }
            Self::Process { label, .. }
                if label.is_empty()
                    || label.trim() != label
                    || label.chars().any(char::is_control) =>
            {
                Err(KitError::invalid_input(
                    "process notification label must be nonempty, unpadded, and control-free",
                ))
            }
            Self::Progress { label, detail } => {
                if label.is_empty() || label.trim() != label {
                    return Err(KitError::invalid_input(
                        "progress notification label must be nonempty and unpadded",
                    ));
                }
                if detail
                    .as_ref()
                    .is_some_and(|detail| detail.trim().is_empty())
                {
                    return Err(KitError::invalid_input(
                        "progress notification detail must be absent or nonblank",
                    ));
                }
                Ok(())
            }
            Self::Interaction {
                title,
                body,
                response_schema,
                ..
            } => {
                if title.is_empty() || title.trim() != title {
                    return Err(KitError::invalid_input(
                        "interaction title must be nonempty and unpadded",
                    ));
                }
                if body.trim().is_empty() {
                    return Err(KitError::invalid_input("interaction body must be nonblank"));
                }
                if response_schema
                    .as_ref()
                    .is_some_and(|schema| !schema.is_object())
                {
                    return Err(KitError::invalid_input(
                        "interaction response schema must be a JSON object",
                    ));
                }
                Ok(())
            }
            Self::Message { .. }
            | Self::Process { .. }
            | Self::Warning { .. }
            | Self::Error { .. } => Ok(()),
        }
    }
}

#[derive(Clone, Debug, PartialEq)]
/// Resolves one pending portable interaction.
pub enum NotificationResolution {
    /// Allows the requested action.
    Allow,
    /// Denies the requested action.
    Deny {
        /// Nonempty caller explanation.
        reason: String,
    },
    /// Submits structured interaction content.
    Submit {
        /// JSON value validated against the interaction schema when present.
        content: serde_json::Value,
    },
    /// Dismisses a dismissible interaction.
    Dismiss {
        /// Nonempty caller explanation.
        reason: String,
    },
}

impl NotificationResolution {
    pub(crate) fn validate(&self) -> Result<(), KitError> {
        match self {
            Self::Deny { reason } | Self::Dismiss { reason }
                if reason.is_empty() || reason.trim() != reason =>
            {
                Err(KitError::invalid_input(
                    "notification resolution reason must be nonempty and unpadded",
                ))
            }
            _ => Ok(()),
        }
    }
}

/// Exclusively receives durable notifications for one conversation.
///
/// Built-in conversations deliver in durable, at-least-once order. The next receive
/// acknowledges the previous passive item; interactions require a successful
/// `ConversationBackend::resolve_notification` instead. Dropping a receiver while
/// the conversation is reopenable releases its lease without acknowledging items.
///
/// Conversation close makes an existing receiver drain-only. Parent workspace close
/// or Agent shutdown preserves backlog for reopen; it does not acknowledge it.
///
/// # Examples
///
/// A finite caller source illustrates wrapper delivery and fused end-of-stream.
/// It does not itself implement durable storage or interaction acknowledgment.
///
/// ```
/// use agents_kit::notification::{ConversationNotification, ConversationNotificationKind, ConversationNotificationReceiver};
/// let runtime = tokio::runtime::Builder::new_current_thread().build()?;
/// runtime.block_on(async {
///     let notification = ConversationNotification::new(
///         "notification_00000000000000000000000000000000".parse()?,
///         std::time::SystemTime::UNIX_EPOCH,
///         ConversationNotificationKind::Message { text: "Ready".into() },
///     )?;
///     let id = notification.id().clone();
///     let mut receiver = ConversationNotificationReceiver::from_stream(
///         futures_util::stream::iter([Ok(notification)]),
///     );
///     assert_eq!(receiver.recv().await.transpose()?.unwrap().id(), &id);
///     assert!(receiver.recv().await.is_none());
///     assert!(receiver.recv().await.is_none());
///     Ok::<(), agents_kit::KitError>(())
/// })?;
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
pub struct ConversationNotificationReceiver {
    inner: Pin<Box<dyn Stream<Item = Result<ConversationNotification, KitError>> + Send + 'static>>,
    terminal: bool,
}

impl ConversationNotificationReceiver {
    pub(crate) fn shared<S: StateStoreBackend>(inner: NotificationReceiver<S>) -> Self {
        Self::from_stream(futures_util::stream::unfold(
            inner,
            |mut receiver| async move { receiver.recv().await.map(|item| (item, receiver)) },
        ))
    }

    pub(crate) fn custom<S: StateStoreBackend, D: DatabaseBackend>(
        inner: CustomNotificationReceiver<S, D>,
    ) -> Self {
        Self::from_stream(futures_util::stream::unfold(
            inner,
            |mut receiver| async move { receiver.recv().await.map(|item| (item, receiver)) },
        ))
    }

    /// Adapts a downstream portable notification stream into the unique receiver wrapper.
    ///
    /// The caller source owns durable ordering, acknowledgment, cancellation safety and cleanup. The wrapper supplies portable delivery and terminal fusion, not a new journal.
    ///
    /// # Examples
    ///
    /// ```
    /// use agents_kit::notification::{ConversationNotification, ConversationNotificationKind, ConversationNotificationReceiver};
    /// let runtime = tokio::runtime::Builder::new_current_thread().build()?;
    /// runtime.block_on(async {
    ///     let notification = ConversationNotification::new(
    ///         "notification_00000000000000000000000000000000".parse()?,
    ///         std::time::SystemTime::UNIX_EPOCH,
    ///         ConversationNotificationKind::Message { text: "Ready".into() },
    ///     )?;
    ///     let id = notification.id().clone();
    ///     let mut receiver = ConversationNotificationReceiver::from_stream(
    ///         futures_util::stream::iter([Ok(notification)]),
    ///     );
    ///     assert_eq!(receiver.recv().await.transpose()?.unwrap().id(), &id);
    ///     assert!(receiver.recv().await.is_none());
    ///     assert!(receiver.recv().await.is_none());
    ///     Ok::<(), agents_kit::KitError>(())
    /// })?;
    /// # Ok::<(), Box<dyn std::error::Error>>(())
    /// ```
    pub fn from_stream<S>(stream: S) -> Self
    where
        S: Stream<Item = Result<ConversationNotification, KitError>> + Send + 'static,
    {
        Self {
            inner: Box::pin(stream),
            terminal: false,
        }
    }

    /// Waits for the next delivery, error, or normal end of delivery.
    ///
    /// # Errors
    ///
    /// Yields source errors with notification/publication context. `Timeout` and
    /// `Integration` permit a later receive. `Conflict`, `Closed`, `Unsupported`,
    /// `NotYetImplemented`, `Protocol` and `InvalidState` are yielded once and fuse
    /// this receiver; subsequent calls return `None`. A source EOF also fuses it.
    /// Built-in delivery retains admitted storage work if one awaiting receive is cancelled.
    ///
    /// # Examples
    ///
    /// ```
    /// use agents_kit::notification::{ConversationNotification, ConversationNotificationKind, ConversationNotificationReceiver};
    /// let runtime = tokio::runtime::Builder::new_current_thread().build()?;
    /// runtime.block_on(async {
    ///     let notification = ConversationNotification::new(
    ///         "notification_00000000000000000000000000000000".parse()?,
    ///         std::time::SystemTime::UNIX_EPOCH,
    ///         ConversationNotificationKind::Message { text: "Ready".into() },
    ///     )?;
    ///     let id = notification.id().clone();
    ///     let mut receiver = ConversationNotificationReceiver::from_stream(
    ///         futures_util::stream::iter([Ok(notification)]),
    ///     );
    ///     assert_eq!(receiver.recv().await.transpose()?.unwrap().id(), &id);
    ///     assert!(receiver.recv().await.is_none());
    ///     assert!(receiver.recv().await.is_none());
    ///     Ok::<(), agents_kit::KitError>(())
    /// })?;
    /// # Ok::<(), Box<dyn std::error::Error>>(())
    /// ```
    pub async fn recv(&mut self) -> Option<Result<ConversationNotification, KitError>> {
        if self.terminal {
            return None;
        }
        let next = self.inner.next().await.map(|result| {
            result.map_err(|error| {
                error
                    .at_operation(crate::error::Operation::NotificationStream)
                    .in_phase(crate::error::ErrorPhase::Publication)
            })
        });
        if next.is_none()
            || matches!(
                &next, Some(Err(error)) if matches!(error.kind(),
                    crate::error::ErrorKind::Conflict | crate::error::ErrorKind::Closed |
                    crate::error::ErrorKind::Unsupported | crate::error::ErrorKind::NotYetImplemented |
                    crate::error::ErrorKind::Protocol | crate::error::ErrorKind::InvalidState)
            )
        {
            self.terminal = true;
        }
        next
    }
}