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
//! Outbound reply transform pipeline.
//!
//! Every agent reply that the framework is about to publish on a
//! channel topic first runs through a chain of
//! [`OutboundReplyTransformer`]s. Each transformer receives the
//! current `OutboundReplyKind` + read-only context, and returns
//! either a replaced kind or passes through unchanged. The chain
//! is registered in order at boot — typical placement:
//!
//! 1. content-filter / DLP transformer (microapp).
//! 2. voice-mode TTS transformer (microapp).
//! 3. attachment / sticker decorators (microapp).
//!
//! The trait stays channel-agnostic: transformers don't know whether
//! the reply will end on whatsapp or telegram, only what the agent
//! produced. Channel plugins consume the final
//! `OutboundReplyKind` and map it to their native primitives.
//!
//! Errors short-circuit the chain. The reply is dropped (with a
//! warn log) and the operator dashboard surfaces a
//! `transform_failed` event so the lost reply is observable.
use std::sync::Arc;
use async_trait::async_trait;
use nexo_tool_meta::reply_kind::{OutboundReplyContext, OutboundReplyKind};
/// Errors a transformer can surface. Boxed so impls can wrap
/// their own error types without forcing a concrete std error
/// dependency in the trait.
#[derive(Debug, thiserror::Error)]
pub enum TransformError {
/// The transformer rejected the reply outright (rate-limit,
/// policy violation, etc.). Callers MUST drop the reply and
/// surface the reason.
#[error("rejected by `{transformer}`: {reason}")]
Rejected {
/// Identifier of the transformer that rejected.
transformer: String,
/// Operator-facing reason string.
reason: String,
},
/// Backend call (e.g. TTS provider) failed transiently. Caller
/// MUST drop this reply but the transformer chain itself
/// remains usable for the next reply.
#[error("transient failure in `{transformer}`: {source}")]
Transient {
/// Identifier of the transformer that failed.
transformer: String,
/// Underlying error.
#[source]
source: anyhow::Error,
},
/// Configuration / programming bug. Callers MUST log loud and
/// drop.
#[error("internal error in `{transformer}`: {source}")]
Internal {
/// Identifier of the transformer that errored.
transformer: String,
/// Underlying error.
#[source]
source: anyhow::Error,
},
}
/// Channel-agnostic hook the framework runs before dispatching the
/// agent's outbound reply. Implementations live in microapps,
/// extensions, or the daemon itself.
///
/// Implementations MUST be cheap on the no-op path because every
/// reply pays the chain cost — short-circuit early when the
/// transformer doesn't apply (e.g. voice-mode disabled for this
/// conversation).
#[async_trait]
pub trait OutboundReplyTransformer: Send + Sync + 'static {
/// Stable identifier surfaced in tracing + error wrapping.
/// Should be slug-shaped (`voice_mode`, `pii_redactor`).
fn id(&self) -> &str;
/// Inspect or rewrite the reply. Returning the input unchanged
/// is the no-op path. The framework hands ownership so impls
/// can mutate-and-return without cloning.
async fn transform(
&self,
ctx: &OutboundReplyContext,
reply: OutboundReplyKind,
) -> Result<OutboundReplyKind, TransformError>;
}
/// Ordered chain of transformers. Cheap to clone (`Arc`), cheap to
/// call (returns the input directly when empty). Wire it onto the
/// agent runtime via the boot-time registration helper exposed in
/// the daemon's setup code.
#[derive(Clone, Default)]
pub struct OutboundReplyTransformChain {
transformers: Arc<Vec<Arc<dyn OutboundReplyTransformer>>>,
}
impl std::fmt::Debug for OutboundReplyTransformChain {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OutboundReplyTransformChain")
.field(
"ids",
&self
.transformers
.iter()
.map(|t| t.id().to_string())
.collect::<Vec<_>>(),
)
.finish()
}
}
impl OutboundReplyTransformChain {
/// Empty chain — every reply passes through unchanged.
pub fn empty() -> Self {
Self::default()
}
/// Build from a pre-ordered list of transformers.
pub fn from_vec(transformers: Vec<Arc<dyn OutboundReplyTransformer>>) -> Self {
Self {
transformers: Arc::new(transformers),
}
}
/// `true` when no transformers are registered. Callers can use
/// this to skip the cloning of `OutboundReplyContext` on the
/// hot path.
pub fn is_empty(&self) -> bool {
self.transformers.is_empty()
}
/// Stable transformer ids in registration order — surfaced by
/// admin diagnostics so operators can see what's wired.
pub fn ids(&self) -> Vec<String> {
self.transformers
.iter()
.map(|t| t.id().to_string())
.collect()
}
/// Run the reply through each transformer in order. The first
/// `Err` short-circuits the chain.
pub async fn run(
&self,
ctx: &OutboundReplyContext,
mut reply: OutboundReplyKind,
) -> Result<OutboundReplyKind, TransformError> {
for t in self.transformers.iter() {
reply = t.transform(ctx, reply).await?;
}
Ok(reply)
}
}
#[cfg(test)]
mod tests {
use super::*;
struct UpperCaseText;
#[async_trait]
impl OutboundReplyTransformer for UpperCaseText {
fn id(&self) -> &str {
"uppercase_text"
}
async fn transform(
&self,
_ctx: &OutboundReplyContext,
reply: OutboundReplyKind,
) -> Result<OutboundReplyKind, TransformError> {
match reply {
OutboundReplyKind::Text { body } => Ok(OutboundReplyKind::Text {
body: body.to_uppercase(),
}),
other => Ok(other),
}
}
}
fn ctx() -> OutboundReplyContext {
OutboundReplyContext {
agent_id: "ana".into(),
session_id: "00000000-0000-0000-0000-000000000000".into(),
channel: "whatsapp".into(),
instance: Some("smoketest".into()),
recipient: Some("57300@s.whatsapp.net".into()),
tenant_id: None,
conversation_key: "ana:session:00000000-0000-0000-0000-000000000000".into(),
language: None,
}
}
#[tokio::test]
async fn empty_chain_passes_through() {
let chain = OutboundReplyTransformChain::empty();
let out = chain
.run(&ctx(), OutboundReplyKind::text("hola"))
.await
.unwrap();
assert_eq!(out, OutboundReplyKind::text("hola"));
}
#[tokio::test]
async fn chain_runs_in_order() {
let chain = OutboundReplyTransformChain::from_vec(vec![Arc::new(UpperCaseText)]);
let out = chain
.run(&ctx(), OutboundReplyKind::text("hola"))
.await
.unwrap();
assert_eq!(out, OutboundReplyKind::text("HOLA"));
}
}