use std::sync::Arc;
use std::time::{Duration, Instant};
use chat_engine_sdk::error::PluginError;
use chat_engine_sdk::models::{
CapabilityValue, LifecycleState, MessagePartInput, TenantId, UserId,
};
use chat_engine_sdk::plugin::{
MessagePluginCtx, PluginCallContext, PluginStream, SessionPluginCtx,
};
use futures::stream::{self, BoxStream, StreamExt};
use serde_json::Value as JsonValue;
use time::OffsetDateTime;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use toolkit_macros::domain_model;
use tracing::{debug, info, instrument, warn};
use uuid::Uuid;
use crate::domain::context::{
is_context_overflow_error, read_memory_strategy, validate_memory_strategy,
write_memory_strategy,
};
use crate::domain::error::{ChatEngineError, Result};
use crate::domain::memory_strategy::MemoryStrategy;
use crate::domain::message::{
Message, StreamingChunkEvent, StreamingCompleteEvent, StreamingErrorEvent, StreamingEvent,
StreamingStartEvent,
};
use crate::domain::ports::SessionRepo;
use crate::domain::ports::SessionTypeRepo;
use crate::domain::ports::StreamEventBuffer;
use crate::domain::ports::{
FinalizeOutcome, InsertedPair, MessageRepo, NewUserMessage, PartCitations,
};
use crate::domain::service::plugin_service::PluginService;
use crate::domain::service::session_service::{Identity, redact_session};
use crate::domain::service::webhook::{NoopWebhookEmitter, WebhookEmitter, WebhookEvent};
use crate::domain::session::Session;
use crate::domain::stream_delta::DeltaProjector;
pub const DEFAULT_STREAMING_BUFFER_SIZE: usize = 64;
pub const DEFAULT_PLUGIN_DEADLINE: Duration = Duration::from_mins(2);
pub const RESUME_BUFFER_TTL: time::Duration = time::Duration::minutes(10);
#[domain_model]
struct Emitter {
tx: mpsc::Sender<StreamingEvent>,
projector: DeltaProjector,
buffer: Option<Arc<dyn StreamEventBuffer>>,
message_id: Uuid,
expires_at: OffsetDateTime,
client_gone: bool,
}
impl Emitter {
async fn emit(&mut self, event: StreamingEvent) {
if let Some(buffer) = &self.buffer {
for wire in self.projector.project(event.clone()) {
let value = serde_json::to_value(&wire).unwrap_or(JsonValue::Null);
if let Err(err) = buffer
.append(self.message_id, wire.seq(), value, self.expires_at)
.await
{
warn!(error = %err, message_id = %self.message_id,
"resume-buffer append failed (stream continues)");
}
}
}
if !self.client_gone && self.tx.send(event).await.is_err() {
debug!(message_id = %self.message_id,
"client disconnected; driver continues to completion (resume via Last-Event-ID)");
self.client_gone = true;
}
}
}
#[domain_model]
#[derive(Debug, Clone)]
pub struct SendMessageRequest {
pub session_id: Uuid,
pub parts: Vec<MessagePartInput>,
pub file_ids: Vec<Uuid>,
pub parent_message_id: Option<Uuid>,
pub capabilities: Option<Vec<CapabilityValue>>,
}
pub type SendMessageStream = BoxStream<'static, StreamingEvent>;
#[domain_model]
#[derive(Debug, Clone)]
pub struct DeleteOutcome {
pub message_id: Uuid,
pub deleted_count: u64,
pub deleted_at: OffsetDateTime,
}
#[domain_model]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MessageEventKind {
New,
Recreate,
}
#[domain_model]
#[derive(Clone)]
pub struct MessageService {
sessions: Arc<dyn SessionRepo>,
session_types: Arc<dyn SessionTypeRepo>,
messages: Arc<dyn MessageRepo>,
plugins: PluginService,
streaming_buffer_size: usize,
plugin_deadline: Duration,
webhooks: Arc<dyn WebhookEmitter>,
stream_buffer: Option<Arc<dyn StreamEventBuffer>>,
}
impl MessageService {
#[must_use]
pub fn new(
sessions: Arc<dyn SessionRepo>,
session_types: Arc<dyn SessionTypeRepo>,
messages: Arc<dyn MessageRepo>,
plugins: PluginService,
) -> Self {
Self {
sessions,
session_types,
messages,
plugins,
streaming_buffer_size: DEFAULT_STREAMING_BUFFER_SIZE,
plugin_deadline: DEFAULT_PLUGIN_DEADLINE,
webhooks: Arc::new(NoopWebhookEmitter),
stream_buffer: None,
}
}
#[must_use]
pub fn with_stream_buffer(mut self, buffer: Arc<dyn StreamEventBuffer>) -> Self {
self.stream_buffer = Some(buffer);
self
}
#[must_use]
pub fn with_webhook_emitter(mut self, webhooks: Arc<dyn WebhookEmitter>) -> Self {
self.webhooks = webhooks;
self
}
#[must_use]
pub fn with_streaming_buffer_size(mut self, size: usize) -> Self {
self.streaming_buffer_size = size.max(1);
self
}
#[must_use]
pub fn with_plugin_deadline(mut self, deadline: Duration) -> Self {
self.plugin_deadline = deadline;
self
}
pub async fn resolve_owned_message(
&self,
identity: &Identity,
message_id: Uuid,
) -> Result<Message> {
let message = self
.messages
.find_message_by_id(message_id)
.await?
.ok_or_else(|| ChatEngineError::not_found("message", message_id))?;
self.sessions
.find_by_id(&identity.tenant_id, &identity.user_id, message.session_id)
.await?
.ok_or_else(|| ChatEngineError::not_found("message", message_id))?;
Ok(message)
}
pub async fn list_active_messages(
&self,
identity: &Identity,
session_id: Uuid,
parent_message_id: Option<Uuid>,
) -> Result<Vec<Message>> {
self.sessions
.find_by_id(&identity.tenant_id, &identity.user_id, session_id)
.await?
.ok_or_else(|| ChatEngineError::not_found("session", session_id))?;
let messages = self.messages.fetch_active_history(session_id, None).await?;
Ok(match parent_message_id {
Some(pid) => messages
.into_iter()
.filter(|m| m.parent_message_id == Some(pid))
.collect(),
None => messages,
})
}
#[instrument(
skip(self, req, identity, cancel),
fields(
session_id = %req.session_id,
user_id = %identity.user_id,
request_id,
assistant_message_id,
),
)]
pub async fn send_message(
&self,
req: SendMessageRequest,
identity: Identity,
cancel: CancellationToken,
) -> Result<SendMessageStream> {
let validated = self.validate_request(&req, &identity).await?;
let InsertedPair {
user_message_id,
assistant_message_id,
user_variant_index,
} = self.pre_persist_user_message(&req, &identity).await?;
tracing::Span::current().record(
"assistant_message_id",
tracing::field::display(assistant_message_id),
);
debug!(
user_message_id = %user_message_id,
assistant_message_id = %assistant_message_id,
user_variant_index,
"persisted user message + assistant stub"
);
let history = self
.messages
.fetch_active_history(req.session_id, None)
.await?;
let plugin = self.plugins.resolve(&validated.plugin_instance_id)?;
let plugin_config = self
.plugins
.load_config(&validated.plugin_instance_id, validated.session_type_id)
.await?;
let request_id = Uuid::new_v4();
tracing::Span::current().record("request_id", tracing::field::display(request_id));
let plugin_cancel = cancel.child_token();
let deadline = Instant::now() + self.plugin_deadline;
let call_ctx = PluginCallContext {
request_id,
tenant_id: TenantId::new(identity.tenant_id.as_str()),
user_id: UserId::new(identity.user_id.as_str()),
plugin_instance_id: validated.plugin_instance_id.clone(),
session_type_id: validated.session_type_id,
plugin_config,
enabled_capabilities: req.capabilities.clone(),
deadline: Some(deadline),
cancel: plugin_cancel.clone(),
};
let plugin_ctx = MessagePluginCtx {
session_id: req.session_id,
message_id: assistant_message_id,
messages: history,
call_ctx,
};
let plugin_stream = match plugin.on_message(plugin_ctx).await {
Ok(s) => s,
Err(err) => {
let finish_reason = finish_reason_for(&err);
self.messages
.finalize_assistant(
req.session_id,
assistant_message_id,
FinalizeOutcome::Errored {
text: String::new(),
error: err.to_string(),
finish_reason,
},
)
.await
.ok();
return Err(err.into());
}
};
let messages_repo = Arc::clone(&self.messages);
let overflow_ctx = OverflowDispatchCtx {
service: self.clone(),
sessions: Arc::clone(&self.sessions),
session_id: req.session_id,
tenant_id: identity.tenant_id.clone(),
user_id: identity.user_id.clone(),
};
let stream = self.spawn_driver(
req.session_id,
assistant_message_id,
plugin_stream,
messages_repo,
cancel,
plugin_cancel,
deadline,
Some(overflow_ctx),
);
info!(
request_id = %request_id,
assistant_message_id = %assistant_message_id,
"send_message dispatch successful \u{2014} streaming response"
);
Ok(stream)
}
pub async fn apply_memory_strategy(
&self,
session: &Session,
current_msg: &Message,
) -> Result<Vec<Message>> {
let meta_value = session.metadata.clone().unwrap_or(JsonValue::Null);
let strategy = read_memory_strategy(&meta_value);
let active = self.messages.list_active_path(session.session_id).await?;
let mut out: Vec<Message> = match &strategy {
MemoryStrategy::Full => active
.into_iter()
.filter(|m| !m.is_hidden_from_backend)
.collect(),
MemoryStrategy::SlidingWindow { window_size } => {
let visible: Vec<Message> = active
.into_iter()
.filter(|m| !m.is_hidden_from_backend)
.collect();
let n = *window_size as usize;
let start = visible.len().saturating_sub(n);
visible[start..].to_vec()
}
MemoryStrategy::Summarized {
recent_messages_to_keep,
} => {
let keep = *recent_messages_to_keep as usize;
let total = active.len();
let recent_start = total.saturating_sub(keep);
let mut acc: Vec<Message> = Vec::with_capacity(total);
for (idx, m) in active.iter().enumerate() {
let keep_recent = idx >= recent_start;
if keep_recent || !m.is_hidden_from_backend {
acc.push(m.clone());
}
}
acc
}
};
out.push(current_msg.clone());
Ok(out)
}
pub async fn handle_context_overflow(
&self,
tenant_id: &str,
user_id: &str,
session_id: Uuid,
current_strategy: &MemoryStrategy,
) -> Result<()> {
info!(
session_id = %session_id,
strategy_type = %strategy_type_label(current_strategy),
"context_overflow observed \u{2014} dispatching to overflow handler",
);
match current_strategy {
MemoryStrategy::Summarized { .. } => {
self.recover_via_session_summary(tenant_id, user_id, session_id)
.await
}
MemoryStrategy::Full | MemoryStrategy::SlidingWindow { .. } => {
Err(ChatEngineError::BackendUnavailable {
reason: "context_overflow: backend rejected request as oversized".to_string(),
retry_after: None,
source: None,
})
}
}
}
async fn recover_via_session_summary(
&self,
tenant_id: &str,
user_id: &str,
session_id: Uuid,
) -> Result<()> {
let Some(row) = self
.sessions
.find_by_id(tenant_id, user_id, session_id)
.await?
else {
warn!(
session_id = %session_id,
"context_overflow recovery skipped: session row not accessible \
under the calling identity's scope",
);
return Ok(());
};
let session_type_id =
row.session_type_id
.ok_or_else(|| ChatEngineError::BackendUnavailable {
reason: "context_overflow recovery: session has no session_type bound"
.to_string(),
retry_after: None,
source: None,
})?;
let st = self
.session_types
.find_by_id(session_type_id)
.await?
.ok_or_else(|| ChatEngineError::not_found("session_type", session_type_id))?;
let plugin_instance_id =
st.plugin_instance_id
.ok_or_else(|| ChatEngineError::BackendUnavailable {
reason: "context_overflow recovery: session_type has no plugin binding"
.to_string(),
retry_after: None,
source: None,
})?;
let plugin = self.plugins.resolve(&plugin_instance_id)?;
let plugin_config = self
.plugins
.load_config(&plugin_instance_id, session_type_id)
.await?;
let cancel = CancellationToken::new();
let deadline = Instant::now() + self.plugin_deadline;
let call_ctx = PluginCallContext {
request_id: Uuid::new_v4(),
tenant_id: TenantId::new(row.tenant_id.as_str()),
user_id: UserId::new(row.user_id.as_str()),
plugin_instance_id: plugin_instance_id.clone(),
session_type_id,
plugin_config,
enabled_capabilities: None,
deadline: Some(deadline),
cancel: cancel.clone(),
};
let plugin_ctx = SessionPluginCtx {
session_type_id,
session_id: Some(session_id),
call_ctx,
};
let mut summary_stream = plugin.on_session_summary(plugin_ctx).await?;
let _cancel_guard = cancel;
let mut accumulator = String::new();
let mut metadata: Option<JsonValue> = None;
let mut summarized_ids: Vec<Uuid> = Vec::new();
while let Some(item) = summary_stream.next().await {
match item {
Ok(StreamingEvent::Start(_)) => {}
Ok(StreamingEvent::Chunk(c)) => accumulator.push_str(&c.chunk),
Ok(StreamingEvent::Complete(c)) => {
if let Some(ref m) = c.metadata {
summarized_ids = extract_summarized_ids_from_meta(m);
}
metadata = c.metadata;
break;
}
Ok(StreamingEvent::Error(e)) => {
return Err(ChatEngineError::BackendUnavailable {
reason: format!(
"context_overflow recovery: on_session_summary errored: {}",
e.error
),
retry_after: None,
source: None,
});
}
Ok(_) => {}
Err(err) => {
return Err(err.into());
}
}
}
self.messages
.insert_summary_message(
session_id,
accumulator,
metadata,
summarized_ids,
Some(tenant_id.to_owned()),
)
.await?;
info!(
session_id = %session_id,
"context_overflow recovery installed (Phase 8): summary persisted",
);
Ok(())
}
pub async fn update_memory_strategy(
&self,
identity: &Identity,
session_id: Uuid,
strategy: MemoryStrategy,
) -> Result<Session> {
validate_memory_strategy(&strategy)?;
let row = self
.sessions
.find_by_id(&identity.tenant_id, &identity.user_id, session_id)
.await?
.ok_or_else(|| ChatEngineError::not_found("session", session_id))?;
let state = row.lifecycle_state;
if matches!(
state,
LifecycleState::SoftDeleted | LifecycleState::HardDeleted
) {
return Err(ChatEngineError::conflict(format!(
"session is {state} and cannot accept memory_strategy updates",
)));
}
let mut meta = row.metadata.clone().unwrap_or(JsonValue::Null);
write_memory_strategy(&mut meta, &strategy);
let updated = self
.sessions
.update_metadata(
&identity.tenant_id,
&identity.user_id,
session_id,
Some(meta),
)
.await?;
Ok(redact_session(updated))
}
pub async fn prepare_recreate_stub(
&self,
session_id: Uuid,
parent_message_id: Uuid,
tenant_id: Option<String>,
) -> Result<InsertedPair> {
self.messages
.insert_assistant_variant_stub(session_id, parent_message_id, tenant_id)
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn dispatch_to_plugin(
&self,
identity: &Identity,
session_id: Uuid,
session_type_id: Uuid,
plugin_instance_id: String,
assistant_message_id: Uuid,
history: Vec<Message>,
capabilities: Option<Vec<CapabilityValue>>,
event_kind: MessageEventKind,
cancel: CancellationToken,
) -> Result<SendMessageStream> {
let plugin = self.plugins.resolve(&plugin_instance_id)?;
let plugin_config = self
.plugins
.load_config(&plugin_instance_id, session_type_id)
.await?;
let request_id = Uuid::new_v4();
let plugin_cancel = cancel.child_token();
let deadline = Instant::now() + self.plugin_deadline;
let call_ctx = PluginCallContext {
request_id,
tenant_id: TenantId::new(identity.tenant_id.as_str()),
user_id: UserId::new(identity.user_id.as_str()),
plugin_instance_id: plugin_instance_id.clone(),
session_type_id,
plugin_config,
enabled_capabilities: capabilities,
deadline: Some(deadline),
cancel: plugin_cancel.clone(),
};
let plugin_ctx = MessagePluginCtx {
session_id,
message_id: assistant_message_id,
messages: history,
call_ctx,
};
let plugin_stream = match event_kind {
MessageEventKind::New => plugin.on_message(plugin_ctx).await,
MessageEventKind::Recreate => plugin.on_message_recreate(plugin_ctx).await,
};
let plugin_stream = match plugin_stream {
Ok(s) => s,
Err(err) => {
let finish_reason = finish_reason_for(&err);
self.messages
.finalize_assistant(
session_id,
assistant_message_id,
FinalizeOutcome::Errored {
text: String::new(),
error: err.to_string(),
finish_reason,
},
)
.await
.ok();
return Err(err.into());
}
};
let messages_repo = Arc::clone(&self.messages);
let overflow_ctx = OverflowDispatchCtx {
service: self.clone(),
sessions: Arc::clone(&self.sessions),
session_id,
tenant_id: identity.tenant_id.clone(),
user_id: identity.user_id.clone(),
};
let stream = self.spawn_driver(
session_id,
assistant_message_id,
plugin_stream,
messages_repo,
cancel,
plugin_cancel,
deadline,
Some(overflow_ctx),
);
info!(
request_id = %request_id,
assistant_message_id = %assistant_message_id,
event_kind = ?event_kind,
"dispatch_to_plugin successful \u{2014} streaming response"
);
Ok(stream)
}
pub fn cancel_streaming(cancel: &CancellationToken) {
cancel.cancel();
}
#[instrument(
skip(self, identity),
fields(
session_id = %session_id,
message_id = %message_id,
user_id = %identity.user_id,
deleted_count,
),
)]
pub async fn delete_message_cascade(
&self,
identity: &Identity,
session_id: Uuid,
message_id: Uuid,
) -> Result<DeleteOutcome> {
use crate::domain::ports::SessionScopeCheck;
match self
.sessions
.check_session_scope(&identity.tenant_id, &identity.user_id, session_id)
.await?
{
SessionScopeCheck::Owned(_) => {}
SessionScopeCheck::WrongTenant => {
return Err(ChatEngineError::forbidden(
"session belongs to a different tenant",
));
}
SessionScopeCheck::WrongUser | SessionScopeCheck::NotFound => {
return Err(ChatEngineError::not_found("session", session_id));
}
}
let target = self
.messages
.find_message_in_session(session_id, message_id)
.await?
.ok_or_else(|| ChatEngineError::not_found("message", message_id))?;
if target.parent_message_id.is_none() {
return Err(ChatEngineError::conflict(
"cannot delete the root message of a session",
));
}
let deleted_count = self
.messages
.delete_message_subtree(session_id, message_id)
.await?;
if deleted_count == 0 {
return Err(ChatEngineError::not_found("message", message_id));
}
let deleted_at = OffsetDateTime::now_utc();
tracing::Span::current().record("deleted_count", tracing::field::display(deleted_count));
info!(
session_id = %session_id,
message_id = %message_id,
deleted_count,
"message subtree deleted",
);
let webhooks = Arc::clone(&self.webhooks);
let event = WebhookEvent::MessageDeleted {
session_id,
message_id,
tenant_id: identity.tenant_id.clone(),
user_id: identity.user_id.clone(),
deleted_count,
deleted_at,
};
tokio::spawn(async move {
if let Err(err) = webhooks.emit(event).await {
debug!(
target: "chat_engine::message::delete",
error = %err,
"message.deleted webhook emission failed (swallowed)",
);
}
});
Ok(DeleteOutcome {
message_id,
deleted_count,
deleted_at,
})
}
#[instrument(skip(self, req, identity), fields(session_id = %req.session_id))]
async fn validate_request(
&self,
req: &SendMessageRequest,
identity: &Identity,
) -> Result<ValidatedRequest> {
if req.parts.is_empty() {
return Err(ChatEngineError::bad_request(
"message must have at least one part",
));
}
let session = self
.sessions
.find_by_id(&identity.tenant_id, &identity.user_id, req.session_id)
.await?
.ok_or_else(|| ChatEngineError::not_found("session", req.session_id))?;
let state = session.lifecycle_state;
if !matches!(state, LifecycleState::Active) {
return Err(ChatEngineError::conflict(format!(
"session is {state} and does not accept new messages",
)));
}
if let Some(parent_id) = req.parent_message_id {
let exists = self
.messages
.find_message_in_session(req.session_id, parent_id)
.await?;
if exists.is_none() {
return Err(ChatEngineError::bad_request(
"parent_message_id does not exist in this session",
));
}
}
if let Some(ref requested) = req.capabilities {
let allowed_names =
capability_names_from_session(session.enabled_capabilities.as_ref());
for cap in requested {
if !allowed_names.contains(&cap.name) {
return Err(ChatEngineError::bad_request(format!(
"capability '{}' is not enabled for this session",
cap.name
)));
}
}
}
let session_type_id = session.session_type_id.ok_or_else(|| {
ChatEngineError::bad_request(
"session has no session_type bound; messages cannot be routed",
)
})?;
let session_type = self
.session_types
.find_by_id(session_type_id)
.await?
.ok_or_else(|| ChatEngineError::not_found("session_type", session_type_id))?;
let plugin_instance_id = session_type.plugin_instance_id.ok_or_else(|| {
ChatEngineError::bad_request(
"session_type has no plugin_instance_id; messages cannot be routed",
)
})?;
Ok(ValidatedRequest {
session_type_id,
plugin_instance_id,
})
}
#[instrument(skip(self, req, identity), fields(session_id = %req.session_id))]
async fn pre_persist_user_message(
&self,
req: &SendMessageRequest,
identity: &Identity,
) -> Result<InsertedPair> {
let payload = NewUserMessage {
session_id: req.session_id,
tenant_id: Some(identity.tenant_id.clone()),
user_id: Some(identity.user_id.clone()),
parent_message_id: req.parent_message_id,
parts: req.parts.clone(),
file_ids: if req.file_ids.is_empty() {
None
} else {
Some(req.file_ids.clone())
},
metadata: None,
};
self.messages.insert_user_and_assistant_stub(payload).await
}
fn spawn_driver(
&self,
session_id: Uuid,
assistant_id: Uuid,
mut plugin_stream: PluginStream,
messages: Arc<dyn MessageRepo>,
cancel: CancellationToken,
plugin_cancel: CancellationToken,
deadline: Instant,
overflow_ctx: Option<OverflowDispatchCtx>,
) -> SendMessageStream {
let (tx, rx) = mpsc::channel::<StreamingEvent>(self.streaming_buffer_size);
let plugin_cancel_for_deadline = plugin_cancel.clone();
tokio::spawn(async move {
tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)).await;
plugin_cancel_for_deadline.cancel();
});
let tx_for_driver = tx.clone();
let stream_buffer = self.stream_buffer.clone();
let buffer_expires_at = OffsetDateTime::now_utc() + RESUME_BUFFER_TTL;
tokio::spawn(async move {
let mut emitter = Emitter {
tx: tx_for_driver,
projector: DeltaProjector::new(),
buffer: stream_buffer,
message_id: assistant_id,
expires_at: buffer_expires_at,
client_gone: false,
};
let start = StreamingEvent::Start(StreamingStartEvent {
message_id: assistant_id,
});
emitter.emit(start).await;
let mut accumulator = String::new();
let mut last_metadata: Option<JsonValue> = None;
let mut extra_parts: Vec<MessagePartInput> = Vec::new();
let mut text_citations = PartCitations::default();
let mut state_patch: Option<JsonValue> = None;
let mut tool_traces: Vec<JsonValue> = Vec::new();
let mut session_patch = serde_json::Map::new();
let session_persist = overflow_ctx.as_ref().map(|c| {
(
Arc::clone(&c.sessions),
c.tenant_id.clone(),
c.user_id.clone(),
c.session_id,
)
});
let mut outcome = DriverOutcome::CancelledByClient;
let mut overflow_observed = false;
loop {
tokio::select! {
biased;
_ = cancel.cancelled() => {
plugin_cancel.cancel();
break;
}
next = plugin_stream.next() => {
let Some(item) = next else {
outcome = DriverOutcome::Completed {
metadata: last_metadata.clone(),
citations: PartCitations::default(),
};
break;
};
match item {
Ok(StreamingEvent::Start(_)) => {
}
Ok(StreamingEvent::Chunk(c)) => {
accumulator.push_str(&c.chunk);
let evt = StreamingEvent::Chunk(StreamingChunkEvent {
message_id: assistant_id,
chunk: c.chunk,
});
emitter.emit(evt).await;
}
Ok(StreamingEvent::Status(s)) => {
emitter.emit(StreamingEvent::Status(s)).await;
}
Ok(StreamingEvent::Part(p)) => {
extra_parts.push(p.part.clone());
emitter.emit(StreamingEvent::Part(p)).await;
}
Ok(StreamingEvent::Citation(c)) => {
text_citations.file_citations.extend(c.file_citations.clone());
text_citations.link_citations.extend(c.link_citations.clone());
text_citations.references.extend(c.references.clone());
emitter.emit(StreamingEvent::Citation(c)).await;
}
Ok(StreamingEvent::State(s)) => {
state_patch = Some(s.state.clone());
emitter.emit(StreamingEvent::State(s)).await;
}
Ok(StreamingEvent::SessionMeta(s)) => {
if let Some(obj) = s.patch.as_object() {
for (k, v) in obj {
session_patch.insert(k.clone(), v.clone());
}
}
emitter.emit(StreamingEvent::SessionMeta(s)).await;
}
Ok(StreamingEvent::Tool(t)) => {
tool_traces.push(serde_json::json!({
"tool": t.tool, "payload": t.payload,
}));
emitter.emit(StreamingEvent::Tool(t)).await;
}
Ok(StreamingEvent::Complete(c)) => {
last_metadata = c.metadata.clone();
text_citations.file_citations.extend(c.file_citations.clone());
text_citations.link_citations.extend(c.link_citations.clone());
text_citations.references.extend(c.references.clone());
let evt = StreamingEvent::Complete(StreamingCompleteEvent {
message_id: assistant_id,
metadata: c.metadata,
file_citations: c.file_citations,
link_citations: c.link_citations,
references: c.references,
});
emitter.emit(evt).await;
outcome = DriverOutcome::Completed {
metadata: last_metadata.clone(),
citations: std::mem::take(&mut text_citations),
};
break;
}
Ok(StreamingEvent::Error(e)) => {
if is_context_overflow_error(&e.error) {
overflow_observed = true;
warn!(
assistant_message_id = %assistant_id,
error = %e.error,
"plugin emitted context_overflow streaming error",
);
}
let evt = StreamingEvent::Error(StreamingErrorEvent {
message_id: assistant_id,
error: e.error.clone(),
});
emitter.emit(evt).await;
outcome = DriverOutcome::Errored {
error: e.error,
finish_reason: "error",
};
break;
}
Err(err) => {
let error_str = err.to_string();
let finish_reason = finish_reason_for(&err);
let evt = StreamingEvent::Error(StreamingErrorEvent {
message_id: assistant_id,
error: error_str.clone(),
});
emitter.emit(evt).await;
outcome = DriverOutcome::Errored {
error: error_str,
finish_reason,
};
break;
}
}
}
}
}
let persist = match outcome {
DriverOutcome::Completed {
metadata,
citations,
} => {
let metadata = merge_stream_metadata(metadata, state_patch, tool_traces);
messages
.finalize_assistant(
session_id,
assistant_id,
FinalizeOutcome::Complete {
text: accumulator,
metadata,
citations,
extra_parts,
},
)
.await
}
DriverOutcome::CancelledByClient => {
messages
.finalize_assistant(
session_id,
assistant_id,
FinalizeOutcome::Cancelled { text: accumulator },
)
.await
}
DriverOutcome::Errored {
error,
finish_reason,
} => {
messages
.finalize_assistant(
session_id,
assistant_id,
FinalizeOutcome::Errored {
text: accumulator,
error,
finish_reason,
},
)
.await
}
};
if let Err(err) = persist {
warn!(
assistant_message_id = %assistant_id,
error = %err,
"failed to finalize assistant message after stream end"
);
}
if !session_patch.is_empty()
&& let Some((sessions, tenant, user, sid)) = session_persist
{
apply_session_meta_patch(sessions.as_ref(), &tenant, &user, sid, session_patch)
.await;
}
if overflow_observed && let Some(ctx) = overflow_ctx {
let svc = ctx.service.clone();
let sessions = ctx.sessions.clone();
let session_id = ctx.session_id;
let identity_tenant = ctx.tenant_id.clone();
let identity_user = ctx.user_id.clone();
tokio::spawn(async move {
match sessions
.find_by_id(&identity_tenant, &identity_user, session_id)
.await
{
Ok(Some(row)) => {
let meta = row.metadata.clone().unwrap_or(JsonValue::Null);
let strategy = read_memory_strategy(&meta);
let res = svc
.handle_context_overflow(
&identity_tenant,
&identity_user,
session_id,
&strategy,
)
.await;
if let Err(err) = res {
debug!(
session_id = %session_id,
strategy_type = %strategy_type_label(&strategy),
error = %err,
"context_overflow hook returned (Phase 7 dispatch \u{2014} Phase 8 owns retry)",
);
}
}
Ok(None) => {
debug!(
session_id = %session_id,
"context_overflow hook: session no longer accessible",
);
}
Err(err) => {
warn!(
session_id = %session_id,
error = %err,
"context_overflow hook: failed to load session row",
);
}
}
});
}
});
stream::unfold(
rx,
|mut rx| async move { rx.recv().await.map(|evt| (evt, rx)) },
)
.boxed()
}
}
#[domain_model]
#[derive(Clone)]
struct OverflowDispatchCtx {
service: MessageService,
sessions: Arc<dyn SessionRepo>,
session_id: Uuid,
tenant_id: String,
user_id: String,
}
fn merge_stream_metadata(
base: Option<JsonValue>,
state: Option<JsonValue>,
tools: Vec<JsonValue>,
) -> Option<JsonValue> {
if state.is_none() && tools.is_empty() {
return base;
}
let mut map = match base {
Some(JsonValue::Object(m)) => m,
Some(other) => {
let mut m = serde_json::Map::new();
m.insert("_meta".to_owned(), other);
m
}
None => serde_json::Map::new(),
};
if let Some(s) = state {
map.insert("state".to_owned(), s);
}
if !tools.is_empty() {
map.insert("tools".to_owned(), JsonValue::Array(tools));
}
Some(JsonValue::Object(map))
}
async fn apply_session_meta_patch(
sessions: &dyn SessionRepo,
tenant_id: &str,
user_id: &str,
session_id: Uuid,
patch: serde_json::Map<String, JsonValue>,
) {
let current = match sessions.find_by_id(tenant_id, user_id, session_id).await {
Ok(Some(row)) => row.metadata,
Ok(None) => return,
Err(err) => {
warn!(session_id = %session_id, error = %err,
"session.meta patch: failed to load session; skipping");
return;
}
};
let mut map = match current {
Some(JsonValue::Object(m)) => m,
_ => serde_json::Map::new(),
};
for (k, v) in patch {
map.insert(k, v);
}
if let Err(err) = sessions
.update_metadata(tenant_id, user_id, session_id, Some(JsonValue::Object(map)))
.await
{
warn!(session_id = %session_id, error = %err,
"session.meta patch: update_metadata failed");
}
}
#[domain_model]
#[derive(Debug, Clone)]
enum DriverOutcome {
Completed {
metadata: Option<JsonValue>,
citations: PartCitations,
},
CancelledByClient,
Errored {
error: String,
finish_reason: &'static str,
},
}
#[domain_model]
#[derive(Debug, Clone)]
struct ValidatedRequest {
session_type_id: Uuid,
plugin_instance_id: String,
}
fn capability_names_from_session(value: Option<&JsonValue>) -> Vec<String> {
let Some(JsonValue::Array(arr)) = value else {
return Vec::new();
};
arr.iter()
.filter_map(|entry| match entry {
JsonValue::Object(map) => map.get("name").and_then(|n| n.as_str()).map(str::to_owned),
_ => None,
})
.collect()
}
fn strategy_type_label(s: &MemoryStrategy) -> &'static str {
match s {
MemoryStrategy::Full => "full",
MemoryStrategy::SlidingWindow { .. } => "sliding_window",
MemoryStrategy::Summarized { .. } => "summarized",
}
}
fn extract_summarized_ids_from_meta(meta: &JsonValue) -> Vec<Uuid> {
let Some(arr) = meta
.get("summarized_message_ids")
.and_then(|v| v.as_array())
else {
return Vec::new();
};
arr.iter()
.filter_map(|v| v.as_str().and_then(|s| Uuid::parse_str(s).ok()))
.collect()
}
fn finish_reason_for(err: &PluginError) -> &'static str {
match err {
PluginError::Timeout { .. } => "timeout",
PluginError::Transient { .. } | PluginError::RateLimited { .. } => "interrupted",
_ => "error",
}
}
#[cfg(test)]
#[path = "message_service_tests.rs"]
mod message_service_tests;