Skip to main content

mobius/middleware/
mod.rs

1//! Ordered middleware and capability registration.
2
3use std::collections::BTreeSet;
4use std::sync::Arc;
5
6use serde::Serialize;
7use serde::Serializer;
8use serde::ser::SerializeMap;
9use serde_json::Value;
10
11use crate::BoxFuture;
12use crate::Error;
13use crate::Result;
14use crate::backend::checkpoint::{ExecutionOutcome, QueuedMessage as DurableQueuedMessage};
15use crate::backend::sandbox::Sandbox;
16use crate::protocol::EventMsg;
17use crate::protocol::FrontendActionListItem;
18use crate::protocol::FrontendBlock;
19use crate::protocol::FrontendBlockRole;
20use crate::protocol::FrontendContribution;
21use crate::protocol::FrontendEvent;
22use crate::protocol::FrontendSlot;
23use crate::protocol::FrontendTone;
24use crate::protocol::FrontendWidgetContent;
25use crate::protocol::RenderedBlock;
26use crate::protocol::ToolCallBeginEvent;
27use crate::protocol::ToolCallEndEvent;
28use crate::serialized_len;
29
30pub mod artifacts;
31pub mod attachments;
32pub mod compaction;
33pub mod computer_control;
34mod context;
35pub(crate) mod delivery_once;
36pub mod extensions;
37pub mod image_generation;
38pub mod instructions;
39pub mod manifest;
40pub mod messages;
41pub mod questions;
42pub mod scratchpad;
43pub mod sessions;
44pub mod subagents;
45pub mod tasks;
46pub mod tools;
47pub mod voice;
48
49pub use context::{
50    ActiveCommandContext, CompactContext, FrontendEventSink, MessageQueue, MessageRouteContext,
51    MessageSubmitContext, MiddlewareCommandContext, ModelContext, ModelRequestContext,
52    PermissionRequestContext, PostToolUseContext, PreToolUseContext, QueuedMessageSnapshot,
53    QueuedMessageView, RuntimeContext, SessionStartContext, SessionStartSource, StopContext,
54    SubmissionResult, ToolExposureContext, TurnEndContext, TurnIdentity,
55};
56pub(crate) use context::{MessageSubmitResult, PreparationNotices, PreparedMessage, StagedInput};
57use tools::Catalog;
58
59/// Configurable heuristic for text context budgets, not a model tokenizer.
60/// Observed provider token usage still takes precedence when it is larger.
61#[derive(Debug, Clone, Copy, PartialEq)]
62pub struct TokenEstimate {
63    bytes_per_token: f64,
64}
65
66impl Default for TokenEstimate {
67    fn default() -> Self {
68        #[derive(serde::Deserialize)]
69        struct Defaults {
70            bytes_per_token: f64,
71        }
72        static DEFAULTS: std::sync::LazyLock<Defaults> = std::sync::LazyLock::new(|| {
73            crate::config::embedded(include_str!("token_estimation.toml"))
74        });
75        Self::new(DEFAULTS.bytes_per_token)
76            .expect("bundled token estimate must be positive and finite")
77    }
78}
79
80impl TokenEstimate {
81    /// Creates a byte-based estimate suitable for the workload's language and models.
82    /// # Errors
83    /// Returns an error for a nonfinite or nonpositive ratio.
84    pub fn new(bytes_per_token: f64) -> Result<Self> {
85        if !bytes_per_token.is_finite() || bytes_per_token <= 0.0 {
86            return Err(Error::Config(
87                "bytes per token must be positive and finite".into(),
88            ));
89        }
90        Ok(Self { bytes_per_token })
91    }
92
93    /// Returns the configured UTF-8 byte ratio.
94    #[must_use]
95    pub const fn bytes_per_token(self) -> f64 {
96        self.bytes_per_token
97    }
98
99    /// Estimates tokens from serialized UTF-8 bytes, rounding up and saturating.
100    #[must_use]
101    pub fn tokens(self, bytes: usize) -> usize {
102        let tokens = (bytes as f64 / self.bytes_per_token).ceil();
103        if tokens >= usize::MAX as f64 {
104            return usize::MAX;
105        }
106        // The ratio is positive and finite; this branch is within usize's range.
107        tokens as usize
108    }
109
110    /// Estimates one public model item, retaining conservative image tile estimates.
111    #[must_use]
112    pub fn item_tokens(self, item: &Value) -> usize {
113        approximate_item_tokens(item, self)
114    }
115}
116
117/// Result of a middleware-owned frontend command.
118pub struct MiddlewareCommandOutput {
119    /// The events.
120    pub events: Vec<FrontendEvent>,
121}
122
123/// Read-only middleware UI surface consumed by a frontend shell.
124#[derive(Clone)]
125pub struct FrontendExtensions {
126    stack: MiddlewareStack,
127    session_id: Arc<str>,
128}
129
130impl FrontendExtensions {
131    pub(crate) fn new(stack: MiddlewareStack, session_id: impl Into<Arc<str>>) -> Result<Self> {
132        let session_id = session_id.into();
133        stack.frontend(&session_id)?;
134        Ok(Self { stack, session_id })
135    }
136
137    /// Returns command and widget manifests in capability order, with current session counts.
138    /// # Errors
139    ///
140    /// Returns an error if a middleware exports metadata for another capability.
141    pub fn contributions(&self) -> Result<Vec<FrontendContribution>> {
142        self.stack.declared_frontend(&self.session_id)
143    }
144
145    /// Returns the smallest input-token limit installed middleware enforce for a context window.
146    #[must_use]
147    pub fn context_limit(&self, context_window: i64) -> i64 {
148        self.stack
149            .entries
150            .iter()
151            .filter_map(|entry| entry.context_limit(context_window))
152            .min()
153            .unwrap_or(context_window)
154    }
155
156    /// Lets installed middleware render capability-specific events.
157    #[must_use]
158    pub fn render(&self, event: &EventMsg) -> Vec<RenderedBlock> {
159        event
160            .presentation()
161            .into_iter()
162            .chain(self.stack.entries.iter().filter_map(|entry| {
163                entry
164                    .render(event, &self.session_id)
165                    .map(|block| RenderedBlock {
166                        capability: entry.name().into(),
167                        block,
168                    })
169            }))
170            .collect()
171    }
172
173    /// Reports whether any installed middleware still runs work for this session.
174    /// # Errors
175    ///
176    /// Returns an error if a middleware cannot read its background state.
177    pub async fn has_background_work(&self) -> Result<bool> {
178        for entry in &self.stack.entries {
179            if entry.has_background_work(&self.session_id).await? {
180                return Ok(true);
181            }
182        }
183        Ok(false)
184    }
185}
186
187impl MiddlewareCommandOutput {
188    /// Returns UI updates without replacing the active session.
189    #[must_use]
190    pub fn events(events: Vec<FrontendEvent>) -> Self {
191        Self { events }
192    }
193
194    /// Returns one capability-scoped transcript block.
195    #[must_use]
196    pub fn render(
197        capability: impl Into<String>,
198        text: impl Into<String>,
199        tone: FrontendTone,
200    ) -> Self {
201        let title = text.into();
202        Self::events(vec![FrontendEvent::Render {
203            capability: capability.into(),
204            block: FrontendBlock {
205                role: FrontendBlockRole::Notice,
206                title,
207                format: crate::protocol::FrontendBlockFormat::PlainText,
208                tone,
209                ..Default::default()
210            },
211        }])
212    }
213}
214
215/// A capability contribution to the single ordered agent pipeline.
216pub trait Middleware: Send + Sync {
217    /// Stable ID used to reject duplicate registrations.
218    fn name(&self) -> &'static str;
219
220    /// Middleware IDs that cannot share a stack with this configured capability.
221    fn incompatible_middleware(&self) -> &'static [&'static str] {
222        &[]
223    }
224
225    /// Returns the input-token limit this capability enforces for a model context window.
226    fn context_limit(&self, _context_window: i64) -> Option<i64> {
227        None
228    }
229
230    /// Adds tools to the catalog while the agent is created.
231    /// # Errors
232    ///
233    /// Returns an error if validation or an operation required by this function fails.
234    fn register(&self, _catalog: &mut Catalog, _runtime: &RuntimeContext) -> Result<()> {
235        Ok(())
236    }
237
238    /// Contributes one immutable system-prompt section while the agent is created.
239    /// # Errors
240    ///
241    /// Returns an error if validation or an operation required by this function fails.
242    fn prompt_section(&self, _runtime: &RuntimeContext) -> Result<Option<PromptSection>> {
243        Ok(None)
244    }
245
246    /// Declares commands and status data that any frontend may render for one session.
247    fn frontend(&self, _session_id: &str) -> FrontendContribution {
248        FrontendContribution::default()
249    }
250
251    /// Renders an event owned by this capability for the destination session.
252    ///
253    /// Session-bound handles must only be exposed when they belong to `session_id`.
254    fn render(&self, _event: &EventMsg, _session_id: &str) -> Option<FrontendBlock> {
255        None
256    }
257
258    /// Handles a command declared by this middleware's frontend contribution.
259    fn command<'a>(
260        &'a self,
261        context: MiddlewareCommandContext<'a>,
262    ) -> BoxFuture<'a, Result<MiddlewareCommandOutput>> {
263        Box::pin(async move {
264            Err(Error::Unknown(format!(
265                "middleware command `{}/{}`",
266                self.name(),
267                context.command
268            )))
269        })
270    }
271
272    /// Starts or re-enters a session lifecycle.
273    fn session_start<'a>(
274        &'a self,
275        _context: &'a mut SessionStartContext<'_>,
276    ) -> BoxFuture<'a, Result<()>> {
277        Box::pin(async { Ok(()) })
278    }
279
280    /// Reports whether this middleware is the session's conversation-message preparer.
281    fn handles_messages(&self) -> bool {
282        false
283    }
284
285    /// Validates and prepares one conversation message for its lifecycle boundary.
286    /// # Errors
287    ///
288    /// Returns an error if validation or an operation required by this function fails.
289    fn route_message<'a>(
290        &'a self,
291        _context: &'a mut MessageRouteContext<'_>,
292    ) -> BoxFuture<'a, Result<SubmissionResult>> {
293        Box::pin(async move {
294            Err(Error::Config(format!(
295                "middleware `{}` claimed messages but did not prepare them",
296                self.name()
297            )))
298        })
299    }
300
301    /// Produces capability UI cleanup when one queued message reaches its boundary.
302    fn message_boundary_events(&self, _submission_id: &str) -> Vec<EventMsg> {
303        Vec::new()
304    }
305
306    /// Intercepts a prepared conversation message at its delivery boundary.
307    fn message_submit<'a>(
308        &'a self,
309        _context: &'a mut MessageSubmitContext<'_>,
310    ) -> BoxFuture<'a, Result<()>> {
311        Box::pin(async { Ok(()) })
312    }
313
314    /// Handles a capability command while a turn is active.
315    ///
316    /// The active model, tool, or hook future is not polled until this returns. Implementations
317    /// must keep work bounded and must not await a resource held by that active future. Return
318    /// `None` when the command is unavailable during the active turn.
319    fn active_command<'a>(
320        &'a self,
321        _context: &'a mut ActiveCommandContext<'_>,
322    ) -> BoxFuture<'a, Result<Option<SubmissionResult>>> {
323        Box::pin(async { Ok(None) })
324    }
325
326    /// Mutates durable context before the next model request is assembled.
327    fn pre_model<'a>(&'a self, _context: &'a mut ModelContext<'_>) -> BoxFuture<'a, Result<()>> {
328        Box::pin(async { Ok(()) })
329    }
330
331    /// Applies request-only context after every durable transform has completed.
332    fn model_request<'a>(
333        &'a self,
334        _context: &'a mut ModelRequestContext<'_>,
335    ) -> BoxFuture<'a, Result<()>> {
336        Box::pin(async { Ok(()) })
337    }
338
339    /// Hides registered tools whose capability is unavailable at this boundary.
340    fn tool_exposure<'a>(
341        &'a self,
342        _context: &'a mut ToolExposureContext<'_>,
343    ) -> BoxFuture<'a, Result<()>> {
344        Box::pin(async { Ok(()) })
345    }
346
347    /// Intercepts one normalized tool call before authorization and persistence.
348    fn pre_tool_use<'a>(
349        &'a self,
350        _context: &'a mut PreToolUseContext<'_>,
351    ) -> BoxFuture<'a, Result<()>> {
352        Box::pin(async { Ok(()) })
353    }
354
355    /// Decides whether an approval request should be deferred, allowed, or denied.
356    fn permission_request<'a>(
357        &'a self,
358        _context: &'a mut PermissionRequestContext<'_>,
359    ) -> BoxFuture<'a, Result<()>> {
360        Box::pin(async { Ok(()) })
361    }
362
363    /// Transforms model-visible feedback after a tool has executed.
364    fn post_tool_use<'a>(
365        &'a self,
366        _context: &'a mut PostToolUseContext<'_>,
367    ) -> BoxFuture<'a, Result<()>> {
368        Box::pin(async { Ok(()) })
369    }
370
371    /// Intercepts context immediately before compaction and may stop the active turn.
372    fn pre_compact<'a>(
373        &'a self,
374        _context: &'a mut CompactContext<'_>,
375    ) -> BoxFuture<'a, Result<()>> {
376        Box::pin(async { Ok(()) })
377    }
378
379    /// Repairs or discards capability-owned projections before compacted input is committed.
380    /// The original input is read-only; changes are validated before the rewrite is saved.
381    fn prepare_compacted_input(
382        &self,
383        _original: crate::backend::model::ModelInput<'_>,
384        _compacted: &mut Vec<Arc<Value>>,
385    ) {
386    }
387
388    /// Intercepts the committed compacted context and may stop the active turn.
389    fn post_compact<'a>(
390        &'a self,
391        _context: &'a mut CompactContext<'_>,
392    ) -> BoxFuture<'a, Result<()>> {
393        Box::pin(async { Ok(()) })
394    }
395
396    /// Decides whether a naturally stopped model should continue once more.
397    fn stop<'a>(&'a self, _context: &'a mut StopContext<'_>) -> BoxFuture<'a, Result<()>> {
398        Box::pin(async { Ok(()) })
399    }
400
401    /// Observes terminal bookkeeping for a completed or aborted turn.
402    fn turn_end<'a>(&'a self, _context: &'a mut TurnEndContext<'_>) -> BoxFuture<'a, Result<()>> {
403        Box::pin(async { Ok(()) })
404    }
405
406    /// Releases session-local state when the agent runtime stops.
407    fn session_end<'a>(&'a self, _runtime: &'a RuntimeContext) -> BoxFuture<'a, Result<()>> {
408        Box::pin(async { Ok(()) })
409    }
410
411    /// Keeps this capability out of child agents created from the root's configuration.
412    fn main_agent_only(&self) -> bool {
413        false
414    }
415
416    /// Reports work this capability still runs for the session outside an active turn.
417    fn has_background_work<'a>(&'a self, _session_id: &'a str) -> BoxFuture<'a, Result<bool>> {
418        Box::pin(async { Ok(false) })
419    }
420}
421
422/// One middleware-owned section of the assembled system prompt.
423#[derive(Debug, Clone, PartialEq, Eq)]
424pub struct PromptSection {
425    title: Option<&'static str>,
426    body: String,
427}
428
429impl PromptSection {
430    /// Uses the contributing middleware's stable name as the Markdown heading.
431    #[must_use]
432    pub fn new(body: impl Into<String>) -> Self {
433        Self {
434            title: None,
435            body: body.into(),
436        }
437    }
438
439    /// Uses an explicit Markdown heading when the middleware name is ambiguous.
440    #[must_use]
441    pub fn titled(title: &'static str, body: impl Into<String>) -> Self {
442        Self {
443            title: Some(title),
444            body: body.into(),
445        }
446    }
447}
448
449/// A validated, declaration-ordered middleware pipeline.
450///
451/// Registration, prompt sections, and forward hooks follow declaration order;
452/// only [`Middleware::session_end`] unwinds in reverse. All
453/// [`Middleware::pre_model`] hooks finish before any [`Middleware::model_request`]
454/// hook runs. Prompt sections are composed once at agent creation; dynamic
455/// durable state belongs in `pre_model`, request-only decoration in `model_request`.
456#[derive(Clone)]
457pub struct MiddlewareStack {
458    entries: Vec<Arc<dyn Middleware>>,
459}
460
461#[derive(Debug)]
462pub(crate) struct SessionStartResult {
463    pub(crate) stop_reason: Option<String>,
464    pub(crate) input_changed: bool,
465}
466
467impl MiddlewareStack {
468    /// Creates a stack and rejects duplicate or incompatible middleware.
469    /// # Errors
470    ///
471    /// Returns an error if configuration is invalid or a required resource cannot be initialized.
472    pub fn new(entries: Vec<Arc<dyn Middleware>>) -> Result<Self> {
473        let mut names = BTreeSet::new();
474        let mut message_handler = None;
475        for entry in &entries {
476            if !names.insert(entry.name()) {
477                return Err(Error::Duplicate(format!("middleware `{}`", entry.name())));
478            }
479            if entry.handles_messages()
480                && let Some(owner) = message_handler.replace(entry.name())
481            {
482                return Err(Error::Config(format!(
483                    "conversation messages are owned by both `{owner}` and `{}`",
484                    entry.name()
485                )));
486            }
487        }
488        for entry in &entries {
489            if let Some(other) = entry
490                .incompatible_middleware()
491                .iter()
492                .find(|name| names.contains(**name))
493            {
494                return Err(Error::Config(format!(
495                    "middleware `{}` is incompatible with `{other}`",
496                    entry.name()
497                )));
498            }
499        }
500        Ok(Self { entries })
501    }
502
503    pub(crate) fn for_child_agents(&self) -> Self {
504        // Removing entries from a validated stack cannot introduce a conflict.
505        Self {
506            entries: self
507                .entries
508                .iter()
509                .filter(|entry| !entry.main_agent_only())
510                .map(Arc::clone)
511                .collect(),
512        }
513    }
514
515    pub(crate) fn with_sandbox(mut self, sandbox: Arc<Sandbox>) -> Result<Self> {
516        self.entries.insert(0, sandbox);
517        Self::new(self.entries)
518    }
519
520    /// Builds the immutable tool catalog once.
521    /// # Errors
522    ///
523    /// Returns an error if validation or an operation required by this function fails.
524    pub fn catalog(&self, runtime: &RuntimeContext) -> Result<Catalog> {
525        let mut catalog = Catalog::default();
526        for entry in &self.entries {
527            let previous = catalog.registered_definitions();
528            let registered = previous
529                .iter()
530                .map(|definition| definition.name.as_str())
531                .collect::<BTreeSet<_>>();
532            entry.register(&mut catalog, runtime)?;
533            for definition in catalog
534                .registered_definitions()
535                .iter()
536                .filter(|definition| !registered.contains(definition.name.as_str()))
537            {
538                validate_tool_rendering(entry.as_ref(), &definition.name, &runtime.session_id)?;
539            }
540        }
541        catalog.finalize()?;
542        Ok(catalog)
543    }
544
545    pub(crate) fn system_prompt(&self, base: &str, runtime: &RuntimeContext) -> Result<String> {
546        let mut prompt = format!("**instructions**\n\n{}", base.trim());
547        for entry in &self.entries {
548            let Some(section) = entry.prompt_section(runtime)? else {
549                continue;
550            };
551            let body = section.body.trim();
552            if body.is_empty() {
553                return Err(Error::Config(format!(
554                    "middleware `{}` returned an empty prompt section",
555                    entry.name()
556                )));
557            }
558            let title = section.title.unwrap_or_else(|| entry.name()).trim();
559            if title.is_empty() || title.lines().count() != 1 {
560                return Err(Error::Config(format!(
561                    "middleware `{}` returned an invalid prompt section title",
562                    entry.name()
563                )));
564            }
565            prompt.push_str("\n\n**");
566            prompt.push_str(title);
567            prompt.push_str("**\n\n");
568            prompt.push_str(body);
569        }
570        Ok(prompt)
571    }
572
573    /// Builds and validates the frontend-neutral capability catalog.
574    /// # Errors
575    ///
576    /// Returns an error if validation or an operation required by this function fails.
577    pub fn frontend(&self, session_id: &str) -> Result<Vec<FrontendContribution>> {
578        let contributions = self.declared_frontend(session_id)?;
579        validate_frontend(&contributions)?;
580        Ok(contributions)
581    }
582
583    fn declared_frontend(&self, session_id: &str) -> Result<Vec<FrontendContribution>> {
584        let mut contributions = Vec::new();
585        for entry in &self.entries {
586            let contribution = entry.frontend(session_id);
587            if contribution.capability.is_empty()
588                && contribution.commands.is_empty()
589                && contribution.widgets.is_empty()
590                && contribution.references.is_empty()
591            {
592                continue;
593            }
594            if contribution.capability != entry.name() {
595                return Err(Error::Config(format!(
596                    "middleware `{}` exported frontend metadata for `{}`",
597                    entry.name(),
598                    contribution.capability
599                )));
600            }
601            contributions.push(contribution);
602        }
603        Ok(contributions)
604    }
605
606    fn message_handler(&self) -> Option<&Arc<dyn Middleware>> {
607        self.entries.iter().find(|entry| entry.handles_messages())
608    }
609
610    fn message_handler_required(&self) -> Result<&Arc<dyn Middleware>> {
611        self.message_handler()
612            .ok_or_else(|| Error::Config("conversation message middleware is not installed".into()))
613    }
614
615    pub(crate) fn require_message_handler(&self) -> Result<()> {
616        self.message_handler_required().map(|_| ())
617    }
618
619    pub(crate) fn validate_message_queue(&self, queued: &[DurableQueuedMessage]) -> Result<()> {
620        let owner = self.message_handler_required()?.name();
621        if queued.iter().all(|item| item.owner() == owner) {
622            Ok(())
623        } else {
624            Err(Error::Checkpoint(
625                "queued message is owned by unavailable middleware".into(),
626            ))
627        }
628    }
629
630    pub(crate) async fn route_message(
631        &self,
632        context: &mut MessageRouteContext<'_>,
633    ) -> Result<SubmissionResult> {
634        let entry = self.message_handler_required()?;
635        context.queued_messages.scope(entry.name());
636        entry.route_message(context).await
637    }
638
639    pub(crate) async fn active_command(
640        &self,
641        middleware: &str,
642        context: &mut ActiveCommandContext<'_>,
643    ) -> Result<Option<SubmissionResult>> {
644        let Some(entry) = self.entries.iter().find(|entry| entry.name() == middleware) else {
645            return Ok(None);
646        };
647        if entry.handles_messages() {
648            context.queued_messages.scope(entry.name());
649        }
650        entry.active_command(context).await
651    }
652
653    pub(crate) fn stage_model_messages(
654        &self,
655        queued_messages: &mut Vec<DurableQueuedMessage>,
656        turn_id: &str,
657    ) -> Result<Vec<PreparedMessage>> {
658        let entry = self.message_handler_required()?;
659        let mut queue = MessageQueue::new(queued_messages);
660        queue.scope(entry.name());
661        let mut messages = queue.stage_model_messages(turn_id)?;
662        for message in &mut messages {
663            message.boundary_events = entry.message_boundary_events(&message.submission_id);
664        }
665        Ok(messages)
666    }
667
668    pub(crate) fn messages_ready(
669        &self,
670        queued_messages: &[DurableQueuedMessage],
671        turn_id: &str,
672    ) -> Result<bool> {
673        let entry = self.message_handler_required()?;
674        Ok(queued_messages.iter().any(|item| {
675            item.owner() == entry.name()
676                && matches!(
677                    item.boundary(),
678                    crate::backend::checkpoint::QueuedMessageBoundary::Steer {
679                        turn_id: target
680                    } if target == turn_id
681                )
682        }))
683    }
684
685    pub(crate) fn next_turn(
686        &self,
687        queued_messages: &mut Vec<DurableQueuedMessage>,
688    ) -> Result<Option<PreparedMessage>> {
689        let entry = self.message_handler_required()?;
690        let mut queue = MessageQueue::new(queued_messages);
691        queue.scope(entry.name());
692        Ok(queue.next_turn()?.map(|mut message| {
693            message.boundary_events = entry.message_boundary_events(&message.submission_id);
694            message
695        }))
696    }
697
698    pub(crate) fn consume_next_turn(
699        &self,
700        queued_messages: &mut Vec<DurableQueuedMessage>,
701        submission_id: &str,
702    ) -> Result<(usize, DurableQueuedMessage)> {
703        let entry = self.message_handler_required()?;
704        let mut queue = MessageQueue::new(queued_messages);
705        queue.scope(entry.name());
706        queue.consume_next_turn(submission_id)
707    }
708
709    pub(crate) async fn message_submit(
710        &self,
711        turn: TurnIdentity<'_>,
712        message: &PreparedMessage,
713        events: &mut Vec<EventMsg>,
714        receipts: &delivery_once::Receipts,
715    ) -> Result<MessageSubmitResult> {
716        let event = message
717            .event
718            .message()
719            .ok_or_else(|| Error::Checkpoint("prepared event is not a message".into()))?;
720        let mut context = MessageSubmitContext {
721            turn,
722            author: &event.author,
723            message: &event.text,
724            attachments: &event.attachments,
725            events,
726            delivery_once: delivery_once::DeliveryOnce::new(receipts),
727            input: Vec::new(),
728            rejection: None,
729        };
730        for entry in &self.entries {
731            context.delivery_once.owner = entry.name();
732            entry.message_submit(&mut context).await?;
733        }
734        Ok(MessageSubmitResult {
735            input: context.input,
736            rejection: context.rejection,
737        })
738    }
739
740    pub(crate) fn finish_message_turn(
741        &self,
742        queued_messages: &mut Vec<DurableQueuedMessage>,
743        turn_id: &str,
744        outcome: ExecutionOutcome,
745    ) -> Result<()> {
746        let entry = self.message_handler_required()?;
747        if outcome == ExecutionOutcome::Completed {
748            return Ok(());
749        }
750        let mut queue = MessageQueue::new(queued_messages);
751        queue.scope(entry.name());
752        queue.promote_failed_turn(turn_id)
753    }
754
755    pub(crate) async fn session_start(
756        &self,
757        runtime: &RuntimeContext,
758        queued_messages: &[DurableQueuedMessage],
759        source: SessionStartSource,
760        input: &mut Vec<Arc<Value>>,
761        receipts: &mut delivery_once::Receipts,
762    ) -> Result<SessionStartResult> {
763        let compact_input = (source == SessionStartSource::Compact)
764            .then(|| input.iter().map(Arc::clone).collect::<Vec<_>>());
765        let mut context = SessionStartContext {
766            runtime,
767            delivery_once: delivery_once::DeliveryOnce::new(receipts),
768            source,
769            queued_messages: QueuedMessageSnapshot::default(),
770            input,
771            input_changed: false,
772            stop_reason: None,
773        };
774        for (index, entry) in self.entries.iter().enumerate() {
775            context.delivery_once.owner = entry.name();
776            context.queued_messages =
777                QueuedMessageSnapshot::for_owner(entry.name(), queued_messages);
778            if let Err(error) = entry.session_start(&mut context).await {
779                if let Some(input) = compact_input {
780                    *context.input = input;
781                    return Err(error);
782                }
783                let mut rollback_error = None;
784                for started in self.entries[..index].iter().rev() {
785                    if let Err(error) = started.session_end(runtime).await
786                        && rollback_error.is_none()
787                    {
788                        rollback_error = Some(error);
789                    }
790                }
791                return Err(match rollback_error {
792                    Some(rollback) => Error::Rollback {
793                        primary: Box::new(error),
794                        rollback: Box::new(rollback),
795                    },
796                    None => error,
797                });
798            }
799        }
800        let mut result = SessionStartResult {
801            stop_reason: context.stop_reason,
802            input_changed: context.input_changed,
803        };
804        result.input_changed |= delivery_once::record(receipts, input);
805        Ok(result)
806    }
807
808    pub(crate) async fn prepare_model(
809        &self,
810        mut context: ModelContext<'_>,
811    ) -> Result<Option<Vec<Value>>> {
812        let supports_tool_image_input =
813            context.model.supports_tool_image_input(context.provider)?;
814        self.resolve_tool_exposure(
815            context.session_id,
816            supports_tool_image_input,
817            context.durable_input.input(),
818            context.available_tools,
819        )
820        .await?;
821        for entry in &self.entries {
822            entry.pre_model(&mut context).await?;
823        }
824        if context.turn_stopped() {
825            return Ok(None);
826        }
827        let mut request_context = ModelRequestContext {
828            author: context.author,
829            role: &context.runtime.role,
830            model: context.model,
831            provider: context.provider,
832            session_id: context.session_id,
833            turn_id: context.turn_id,
834            model_step: context.model_step,
835            input: context.input(),
836            replacement: None,
837        };
838        for entry in &self.entries {
839            entry.model_request(&mut request_context).await?;
840        }
841        Ok(request_context.replacement)
842    }
843
844    pub(crate) async fn resolve_tool_exposure(
845        &self,
846        session_id: &str,
847        supports_tool_image_input: bool,
848        input: crate::backend::model::ModelInput<'_>,
849        available: &mut BTreeSet<String>,
850    ) -> Result<()> {
851        for entry in &self.entries {
852            entry
853                .tool_exposure(&mut ToolExposureContext {
854                    session_id,
855                    supports_tool_image_input,
856                    input,
857                    available,
858                })
859                .await?;
860        }
861        Ok(())
862    }
863
864    pub(crate) async fn pre_tool_use(&self, context: &mut PreToolUseContext<'_>) -> Result<()> {
865        for entry in &self.entries {
866            context.delivery_once.owner = entry.name();
867            entry.pre_tool_use(context).await?;
868        }
869        Ok(())
870    }
871
872    pub(crate) async fn permission_request(
873        &self,
874        context: &mut PermissionRequestContext<'_>,
875    ) -> Result<()> {
876        for entry in &self.entries {
877            entry.permission_request(context).await?;
878        }
879        Ok(())
880    }
881
882    pub(crate) async fn post_tool_use(&self, context: &mut PostToolUseContext<'_>) -> Result<()> {
883        for entry in &self.entries {
884            context.delivery_once.owner = entry.name();
885            entry.post_tool_use(context).await?;
886        }
887        Ok(())
888    }
889
890    pub(crate) async fn pre_compact(
891        &self,
892        mut context: CompactContext<'_>,
893    ) -> Result<Option<String>> {
894        for entry in &self.entries {
895            entry.pre_compact(&mut context).await?;
896        }
897        Ok(context.stop_reason)
898    }
899
900    pub(crate) fn prepare_compacted_input(
901        &self,
902        original: crate::backend::model::ModelInput<'_>,
903        compacted: &mut Vec<Arc<Value>>,
904    ) {
905        for entry in &self.entries {
906            entry.prepare_compacted_input(original, compacted);
907        }
908    }
909
910    pub(crate) async fn post_compact(
911        &self,
912        mut context: CompactContext<'_>,
913    ) -> Result<Option<String>> {
914        for entry in &self.entries {
915            entry.post_compact(&mut context).await?;
916        }
917        Ok(context.stop_reason)
918    }
919
920    pub(crate) async fn stop(&self, context: &mut StopContext<'_>) -> Result<()> {
921        for entry in &self.entries {
922            entry.stop(context).await?;
923        }
924        Ok(())
925    }
926
927    pub(crate) async fn turn_end(&self, mut context: TurnEndContext<'_>) -> Result<()> {
928        for entry in &self.entries {
929            context.owner = Some(entry.name());
930            entry.turn_end(&mut context).await?;
931        }
932        Ok(())
933    }
934
935    pub(crate) async fn session_end(&self, runtime: &RuntimeContext) -> Result<()> {
936        let mut first_error = None;
937        for entry in self.entries.iter().rev() {
938            if let Err(error) = entry.session_end(runtime).await
939                && first_error.is_none()
940            {
941                first_error = Some(error);
942            }
943        }
944        first_error.map_or(Ok(()), Err)
945    }
946
947    pub(crate) async fn command(
948        &self,
949        middleware: &str,
950        context: MiddlewareCommandContext<'_>,
951    ) -> Result<MiddlewareCommandOutput> {
952        let entry = self
953            .entries
954            .iter()
955            .find(|entry| entry.name() == middleware)
956            .ok_or_else(|| Error::Unknown(format!("middleware `{middleware}`")))?;
957        let declared = entry
958            .frontend(context.session_id)
959            .commands
960            .into_iter()
961            .any(|command| command.name == context.command);
962        if !declared {
963            return Err(Error::Unknown(format!(
964                "middleware command `{middleware}/{}`",
965                context.command
966            )));
967        }
968        entry.command(context).await
969    }
970}
971
972fn validate_tool_rendering(
973    middleware: &dyn Middleware,
974    tool_name: &str,
975    session_id: &str,
976) -> Result<()> {
977    let events = [
978        (
979            "ToolCallBegin",
980            EventMsg::ToolCallBegin(ToolCallBeginEvent {
981                turn_id: "validation".into(),
982                call_id: "validation".into(),
983                name: tool_name.into(),
984                arguments: serde_json::json!({}),
985            }),
986        ),
987        (
988            "successful ToolCallEnd",
989            EventMsg::ToolCallEnd(ToolCallEndEvent {
990                turn_id: "validation".into(),
991                call_id: "validation".into(),
992                name: tool_name.into(),
993                output: String::new().into(),
994                is_error: false,
995            }),
996        ),
997        (
998            "error ToolCallEnd",
999            EventMsg::ToolCallEnd(ToolCallEndEvent {
1000                turn_id: "validation".into(),
1001                call_id: "validation".into(),
1002                name: tool_name.into(),
1003                output: "validation error".into(),
1004                is_error: true,
1005            }),
1006        ),
1007    ];
1008    for (event_name, event) in events {
1009        if middleware.render(&event, session_id).is_none() {
1010            return Err(Error::Config(format!(
1011                "middleware `{}` registered tool `{tool_name}` but does not render `{event_name}`",
1012                middleware.name()
1013            )));
1014        }
1015    }
1016    Ok(())
1017}
1018
1019fn validate_frontend(contributions: &[FrontendContribution]) -> Result<()> {
1020    let mut commands = BTreeSet::new();
1021    let mut widgets = BTreeSet::new();
1022    let mut references = BTreeSet::new();
1023    for contribution in contributions {
1024        for command in &contribution.commands {
1025            if command.name.is_empty() || command.name.chars().any(char::is_whitespace) {
1026                return Err(Error::Config(format!(
1027                    "invalid frontend command `{}`",
1028                    command.name
1029                )));
1030            }
1031            if !commands.insert(command.name.as_str()) {
1032                return Err(Error::Duplicate(format!(
1033                    "frontend command `{}`",
1034                    command.name
1035                )));
1036            }
1037        }
1038        for item in &contribution.widgets {
1039            if item.id.is_empty()
1040                || !widgets.insert((contribution.capability.as_str(), item.id.as_str()))
1041            {
1042                return Err(Error::Duplicate(format!(
1043                    "frontend status `{}/{}`",
1044                    contribution.capability, item.id
1045                )));
1046            }
1047            if matches!(item.slot, FrontendSlot::Navigation | FrontendSlot::ChatMenu)
1048                && (item.text.trim().is_empty()
1049                    || (item.content.is_none() && item.action.is_none()))
1050            {
1051                return Err(Error::Config(format!(
1052                    "frontend surface `{}/{}` requires a label and content or action",
1053                    contribution.capability, item.id
1054                )));
1055            }
1056            if let Some(FrontendWidgetContent::ActionList {
1057                title,
1058                items,
1059                actions,
1060            }) = &item.content
1061            {
1062                validate_action_list(title, items)?;
1063                validate_actions(actions)?;
1064            }
1065        }
1066        for reference in &contribution.references {
1067            if reference.trigger.is_control()
1068                || reference.trigger.is_whitespace()
1069                || reference.value.is_empty()
1070                || reference.value.chars().any(char::is_whitespace)
1071            {
1072                return Err(Error::Config(format!(
1073                    "invalid frontend reference `{}{}`",
1074                    reference.trigger, reference.value
1075                )));
1076            }
1077            if !references.insert((reference.trigger, reference.value.as_str())) {
1078                return Err(Error::Duplicate(format!(
1079                    "frontend reference `{}{}`",
1080                    reference.trigger, reference.value
1081                )));
1082            }
1083        }
1084    }
1085    Ok(())
1086}
1087
1088fn validate_action_list(title: &str, items: &[FrontendActionListItem]) -> Result<()> {
1089    if title.trim().is_empty() {
1090        return Err(Error::Config("frontend action list title is empty".into()));
1091    }
1092    let mut item_ids = BTreeSet::new();
1093    for item in items {
1094        if item.id.trim().is_empty() || item.text.trim().is_empty() {
1095            return Err(Error::Config(
1096                "frontend action list item requires an ID and text".into(),
1097            ));
1098        }
1099        if !item_ids.insert(&item.id) {
1100            return Err(Error::Duplicate(format!(
1101                "frontend action list item `{}`",
1102                item.id
1103            )));
1104        }
1105        validate_actions(&item.actions)?;
1106    }
1107    Ok(())
1108}
1109
1110fn validate_actions(actions: &[crate::protocol::FrontendAction]) -> Result<()> {
1111    let mut action_ids = BTreeSet::new();
1112    for action in actions {
1113        if action.id.trim().is_empty()
1114            || action.label.trim().is_empty()
1115            || action.symbol.as_str().trim().is_empty()
1116            || (action.input_from_label
1117                && (action.editor.is_some()
1118                    || !matches!(
1119                        action.op,
1120                        crate::protocol::Op::CapabilityCommand { input: None, .. }
1121                    )))
1122            || action.editor.as_ref().is_some_and(|editor| {
1123                editor.title.trim().is_empty()
1124                    || editor.label.trim().is_empty()
1125                    || editor.submit_label.trim().is_empty()
1126            })
1127        {
1128            return Err(Error::Config(
1129                "frontend list action requires an ID, label, and symbol".into(),
1130            ));
1131        }
1132        if !action_ids.insert(&action.id) {
1133            return Err(Error::Duplicate(format!(
1134                "frontend list action `{}`",
1135                action.id
1136            )));
1137        }
1138    }
1139    Ok(())
1140}
1141
1142fn approximate_item_tokens(item: &Value, estimate: TokenEstimate) -> usize {
1143    let image_tokens = crate::protocol::content_parts(item)
1144        .into_iter()
1145        .flatten()
1146        .filter(|part| part.get("type").and_then(Value::as_str) == Some("input_image"))
1147        .fold(0usize, |total, part| {
1148            let width = part["image"]["width"].as_u64().unwrap_or(2048);
1149            let height = part["image"]["height"].as_u64().unwrap_or(2048);
1150            // Conservative tile estimate; observed provider usage also drives compaction.
1151            let tokens = width
1152                .div_ceil(512)
1153                .saturating_mul(height.div_ceil(512))
1154                .saturating_mul(256)
1155                .saturating_add(256);
1156            total.saturating_add(usize::try_from(tokens).unwrap_or(usize::MAX))
1157        });
1158    serialized_len(&PublicItem(item))
1159        .map_or(0, |bytes| estimate.tokens(bytes))
1160        .saturating_add(image_tokens)
1161        .max(1)
1162}
1163
1164struct PublicItem<'a>(&'a Value);
1165
1166impl Serialize for PublicItem<'_> {
1167    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1168    where
1169        S: Serializer,
1170    {
1171        let Some(fields) = self.0.as_object() else {
1172            return self.0.serialize(serializer);
1173        };
1174        let mut map = serializer.serialize_map(None)?;
1175        for (name, value) in fields.iter().filter(|(name, _)| !name.starts_with('_')) {
1176            if let Some(parts) = value.as_array()
1177                && crate::protocol::content_parts(self.0)
1178                    .is_some_and(|content| std::ptr::eq(parts, content))
1179            {
1180                map.serialize_entry(name, &ImageTokenContent(parts))?;
1181            } else {
1182                map.serialize_entry(name, value)?;
1183            }
1184        }
1185        map.end()
1186    }
1187}
1188
1189struct ImageTokenContent<'a>(&'a [Value]);
1190
1191impl Serialize for ImageTokenContent<'_> {
1192    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1193    where
1194        S: Serializer,
1195    {
1196        serializer.collect_seq(self.0.iter().map(|part| {
1197            (part.get("type").and_then(Value::as_str) != Some("input_image")).then_some(part)
1198        }))
1199    }
1200}
1201
1202#[cfg(test)]
1203mod tests;