substrate_core/ports.rs
1//! The five port traits — the only seams adapters may implement.
2//!
3//! Ports are expressed as `async_trait` object-safe traits returning
4//! [`crate::error::Result`]. Core defines them; adapter crates implement them;
5//! the application layer is generic over them. Core never implements a port.
6
7use async_trait::async_trait;
8
9use crate::domain::{
10 ConversationDump, EngineCapabilities, Mailbox, Message, RoutingDecision, StructuredResult, Task,
11};
12use crate::error::Result;
13
14/// Drives a concrete agent engine (a CLI such as `forge`, or an SDK).
15///
16/// Lifecycle: [`start`](EnginePort::start) launches a run and yields a
17/// [`Session`]. [`dump`](EnginePort::dump) exports the raw conversation, which
18/// [`extract_result`](EnginePort::extract_result) normalizes into a
19/// [`StructuredResult`].
20#[async_trait]
21pub trait EnginePort: Send + Sync {
22 /// Start a new run for `task`, returning a live session handle.
23 async fn start(&self, task: &Task) -> Result<crate::domain::Session>;
24
25 /// Resume an existing conversation with a follow-up prompt.
26 async fn resume(&self, conv_id: &str, prompt: &str) -> Result<crate::domain::Session>;
27
28 /// Export the raw conversation for `conv_id`.
29 async fn dump(&self, conv_id: &str) -> Result<ConversationDump>;
30
31 /// Cancel a running conversation.
32 async fn cancel(&self, conv_id: &str) -> Result<()>;
33
34 /// Attach a mailbox so the engine can emit/consume A2A messages.
35 async fn wire_mailbox(&self, conv_id: &str, mailbox: &Mailbox) -> Result<()>;
36
37 /// Normalize a raw dump into a [`StructuredResult`]. Pure transform;
38 /// implementations must not perform IO here.
39 fn extract_result(&self, dump: &ConversationDump) -> Result<StructuredResult>;
40
41 /// Advertise static capabilities.
42 fn capabilities(&self) -> EngineCapabilities;
43}
44
45/// Selects an engine/agent target for a task.
46///
47/// Adapters may delegate to [`crate::routing_port::RoutingSuperset`] for
48/// load-balancing strategies, per-target circuit breakers, and weighted fallback.
49#[async_trait]
50pub trait RoutingPort: Send + Sync {
51 /// Return the engine name that should handle `task`.
52 async fn route(&self, task: &Task) -> Result<String> {
53 // Backwards-compatible default: delegate to the structured decision.
54 Ok(self.route_decision(task).await?.engine)
55 }
56
57 /// Return a full [`RoutingDecision`] (engine + model + rationale).
58 ///
59 /// Phase 1 introduces the structured decision so adapters (e.g. the
60 /// `routing-phenotype-router` adapter) can route to a specific
61 /// model/provider while preserving the engine target.
62 async fn route_decision(&self, task: &Task) -> Result<RoutingDecision>;
63}
64
65/// A message bus / mailbox transport.
66///
67/// # Atomic claim contract
68///
69/// [`claim`](TransportPort::claim) MUST be atomic: at most one caller may
70/// successfully claim a given message. Implementations realize this via a
71/// compare-and-swap on a `state`/lease field (e.g. a lockfile + CAS), so that
72/// concurrent workers never double-process. A failed claim returns
73/// [`crate::error::SubstrateError::ClaimConflict`].
74#[async_trait]
75pub trait TransportPort: Send + Sync {
76 /// Publish `message` to its recipient's mailbox.
77 async fn publish(&self, message: &Message) -> Result<()>;
78
79 /// Return all messages currently addressed to `owner`.
80 async fn subscribe(&self, owner: &str) -> Result<Vec<Message>>;
81
82 /// Atomically claim `message_id` for exclusive processing (CAS-lease).
83 /// Returns the claimed message, or `ClaimConflict` if already held.
84 async fn claim(&self, owner: &str, message_id: &uuid::Uuid) -> Result<Message>;
85
86 /// Snapshot the full mailbox for `owner`.
87 async fn mailbox(&self, owner: &str) -> Result<Mailbox>;
88}
89
90/// Durable persistence for tasks and results.
91///
92/// [`claim_atomic`](StorePort::claim_atomic) provides the same CAS-lease
93/// guarantee as the transport: at most one worker may move a task out of its
94/// claimable state.
95#[async_trait]
96pub trait StorePort: Send + Sync {
97 /// Persist (insert or update) a task.
98 async fn persist(&self, task: &Task) -> Result<()>;
99
100 /// Load a task by id.
101 async fn load(&self, id: &uuid::Uuid) -> Result<Task>;
102
103 /// Persist a normalized result for a task.
104 async fn persist_result(&self, task_id: &uuid::Uuid, result: &StructuredResult) -> Result<()>;
105
106 /// Atomically claim a task for exclusive work (CAS on lifecycle state).
107 /// Returns `ClaimConflict` if another worker already holds it.
108 async fn claim_atomic(&self, id: &uuid::Uuid) -> Result<Task>;
109}
110
111/// The inbound application API (driving side of the hexagon).
112#[async_trait]
113pub trait DispatchApi: Send + Sync {
114 /// Dispatch a task end-to-end and return its normalized result.
115 async fn dispatch(&self, task: Task) -> Result<StructuredResult>;
116
117 /// Fetch a previously dispatched task.
118 async fn get(&self, id: &uuid::Uuid) -> Result<Task>;
119
120 /// Cancel a previously dispatched task.
121 async fn cancel(&self, id: &uuid::Uuid) -> Result<()>;
122}