fix(delegation): résout idea_ask_agent quand la cible revient au prompt sans idea_reply
Introduit le seam TurnResolution : sur prompt-ready, après un délai de grâce, la délégation en attente est résolue de façon typée plutôt que de rester bloquée indéfiniment (jusquà 24h). Couvre domain (mailbox), infrastructure (mailbox/input) et application (orchestrator/error), avec tests étendus. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -191,6 +191,28 @@ impl Ticket {
|
||||
}
|
||||
}
|
||||
|
||||
/// The outcome of a delegated turn, delivered to the awaiting [`PendingReply`].
|
||||
///
|
||||
/// A turn ends in **exactly one** of two ways (besides the channel closing, which is
|
||||
/// [`MailboxError::Cancelled`]):
|
||||
/// - [`TurnResolution::Replied`] — the target rendered a result via `idea_reply`
|
||||
/// ([`AgentMailbox::resolve`] / [`AgentMailbox::resolve_ticket`]). The normal
|
||||
/// success: the caller receives the answer inline.
|
||||
/// - [`TurnResolution::ReturnedToPromptNoReply`] — the target **finished its turn and
|
||||
/// returned to its prompt without calling `idea_reply`**. The rendezvous can produce
|
||||
/// no payload, so instead of leaving the caller blocked until the long safety
|
||||
/// timeout, the mailbox wakes it with this variant
|
||||
/// ([`AgentMailbox::complete_without_reply`]) so the application layer can surface a
|
||||
/// clear, retryable error promptly.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum TurnResolution {
|
||||
/// The target rendered a result via `idea_reply`. Carries the answer.
|
||||
Replied(String),
|
||||
/// The target returned to its prompt (turn ended) **without** an `idea_reply`.
|
||||
/// No payload exists; the caller must surface a typed, retryable error.
|
||||
ReturnedToPromptNoReply,
|
||||
}
|
||||
|
||||
/// Errors raised by the [`AgentMailbox`] port.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum MailboxError {
|
||||
@ -206,28 +228,32 @@ pub enum MailboxError {
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// A handle the caller awaits to receive a target agent's reply.
|
||||
/// A handle the caller awaits to receive a target agent's turn outcome.
|
||||
///
|
||||
/// Returned by [`AgentMailbox::enqueue`]. Awaiting it yields the `String` result a
|
||||
/// later [`AgentMailbox::resolve`] feeds into this ticket's slot, or
|
||||
/// [`MailboxError::Cancelled`] if the reply channel closes first. The future is
|
||||
/// **opaque** (the adapter builds it from its one-shot receiver): the domain stays
|
||||
/// free of any concrete channel type, and the await point lives in the application
|
||||
/// layer's `ask_agent`.
|
||||
/// Returned by [`AgentMailbox::enqueue`]. Awaiting it yields a [`TurnResolution`] (a
|
||||
/// [`TurnResolution::Replied`] result fed by a later [`AgentMailbox::resolve`], or a
|
||||
/// [`TurnResolution::ReturnedToPromptNoReply`] fed by
|
||||
/// [`AgentMailbox::complete_without_reply`] when the target returns to its prompt
|
||||
/// without replying), or [`MailboxError::Cancelled`] if the reply channel closes
|
||||
/// first. The future is **opaque** (the adapter builds it from its one-shot
|
||||
/// receiver): the domain stays free of any concrete channel type, and the await point
|
||||
/// lives in the application layer's `ask_agent`.
|
||||
pub struct PendingReply {
|
||||
inner: Pin<Box<dyn Future<Output = Result<String, MailboxError>> + Send>>,
|
||||
inner: Pin<Box<dyn Future<Output = Result<TurnResolution, MailboxError>> + Send>>,
|
||||
}
|
||||
|
||||
impl PendingReply {
|
||||
/// Wraps a reply future built by the adapter (e.g. over a one-shot receiver).
|
||||
#[must_use]
|
||||
pub fn new(inner: Pin<Box<dyn Future<Output = Result<String, MailboxError>> + Send>>) -> Self {
|
||||
pub fn new(
|
||||
inner: Pin<Box<dyn Future<Output = Result<TurnResolution, MailboxError>> + Send>>,
|
||||
) -> Self {
|
||||
Self { inner }
|
||||
}
|
||||
}
|
||||
|
||||
impl Future for PendingReply {
|
||||
type Output = Result<String, MailboxError>;
|
||||
type Output = Result<TurnResolution, MailboxError>;
|
||||
|
||||
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
|
||||
self.inner.as_mut().poll(cx)
|
||||
@ -301,6 +327,28 @@ pub trait AgentMailbox: Send + Sync {
|
||||
/// A no-op when the head is a different ticket (the timed-out one was already
|
||||
/// resolved, or another caller's ticket is now in front) — idempotent and safe.
|
||||
fn cancel_head(&self, agent: AgentId, ticket_id: TicketId);
|
||||
|
||||
/// Completes the turn of the ticket `ticket_id` **without** a reply, waking its
|
||||
/// awaiting [`PendingReply`] with [`TurnResolution::ReturnedToPromptNoReply`].
|
||||
///
|
||||
/// Called when the target **returned to its prompt** (its turn ended) without
|
||||
/// rendering an `idea_reply`: the rendezvous can produce no payload, so rather than
|
||||
/// leaving the caller blocked until its long safety timeout, the head ticket is
|
||||
/// resolved with the "no reply" outcome so the caller errors out promptly.
|
||||
///
|
||||
/// Contract for implementations:
|
||||
/// - **head-only & idempotent**: act only if `ticket_id` is currently at the head
|
||||
/// (a no-op once it has been resolved/cancelled, or if another ticket is in
|
||||
/// front) — exactly like [`AgentMailbox::cancel_head`];
|
||||
/// - **preserve fire-and-forget**: if the awaiting receiver already went away
|
||||
/// (a human submit that is not awaited, or a caller that already timed out),
|
||||
/// skip without retiring the head — the existing cancel/timeout paths own it.
|
||||
///
|
||||
/// The default is a no-op: a mailbox that cannot wake a pending caller early simply
|
||||
/// falls back to the caller's timeout net (no behavioural change for it).
|
||||
fn complete_without_reply(&self, agent: AgentId, ticket_id: TicketId) {
|
||||
let _ = (agent, ticket_id);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
Reference in New Issue
Block a user