Skip to main content

apl_cpex/
route_handler.rs

1// Location: ./crates/apl-cpex/src/route_handler.rs
2// Copyright 2025
3// SPDX-License-Identifier: Apache-2.0
4// Authors: Teryl Taylor, Fred Araujo
5//
6// `AplRouteHandler` — synthetic plugin that drives APL evaluation when
7// cpex-core's `filter_entries_by_route` matches an annotated route. Each
8// instance is bound to ONE phase (Pre or Post) so the unified-config
9// `cmf.tool_pre_invoke` and `cmf.tool_post_invoke` hooks can carry
10// distinct handler logic without an in-handler hook-name discriminator.
11//
12// # Why a phase-bound handler
13//
14// The CPEX manager's annotation table is keyed on
15// `(entity_type, entity_name, scope, hook_name)`. The visitor registers
16// one handler per route per phase; the manager picks the right one based
17// on the dispatching hook name. Inside `invoke`, no hook-name plumbing is
18// needed — the handler already knows which phase it's running.
19//
20// # Lifetime / weak manager handle
21//
22// The handler holds `Weak<PluginManager>` because the manager owns the
23// snapshot that owns the annotation that owns the handler — a strong
24// reference would create a cycle. Each `invoke` upgrades to `Arc` for
25// the duration of the call. If the upgrade fails (manager has been
26// dropped) the call returns a configuration error.
27
28use std::sync::{Arc, Weak};
29
30use async_trait::async_trait;
31use serde_json::Value;
32
33use cpex_core::cmf::MessagePayload;
34use cpex_core::context::PluginContext;
35use cpex_core::error::{PluginError, PluginViolation};
36use cpex_core::executor::ErasedResultFields;
37use cpex_core::extensions::Extensions;
38use cpex_core::hooks::PluginPayload;
39use cpex_core::manager::PluginManager;
40use cpex_core::plugin::{Plugin, PluginConfig};
41use cpex_core::registry::AnyHookHandler;
42
43use apl_cmf::constants::{DETAIL_HTTP_BODY, DETAIL_HTTP_HEADERS, DETAIL_HTTP_STATUS};
44use apl_cmf::{extract_args, extract_result, BagBuilder};
45use apl_core::evaluator::Decision;
46use apl_core::plugin_decl::PluginRegistry;
47use apl_core::route::{evaluate_post, evaluate_pre, RoutePayload};
48use apl_core::rules::{CompiledRoute, DenyResponse};
49use apl_core::step::PdpResolver;
50
51use crate::cmf_invoker::CmfPluginInvoker;
52use crate::delegation_invoker::DelegationPluginInvoker;
53use crate::dispatch_plan::DispatchCache;
54use crate::pdp_router::PdpRouter;
55use crate::session_store::SessionStore;
56
57/// Which APL phase this handler runs. Pre covers `args` + `policy`; Post
58/// covers `result` + `post_invocation`. Set once at construction and never
59/// changes.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum Phase {
62    Pre,
63    Post,
64}
65
66/// Synthetic plugin that drives APL evaluation for one route + one phase.
67///
68/// Implements `Plugin` (so cpex-core treats it like any other plugin —
69/// mode/capabilities/on_error come from the `PluginConfig` the visitor
70/// supplied at `annotate_route` time) and `AnyHookHandler` (so the
71/// executor dispatches into it through the normal type-erased path).
72pub struct AplRouteHandler {
73    config: PluginConfig,
74    route: Arc<CompiledRoute>,
75    phase: Phase,
76    plugin_registry: Arc<PluginRegistry>,
77    dispatch_cache: Arc<DispatchCache>,
78    session_store: Arc<dyn SessionStore>,
79    /// Weak handle to the manager so we can resolve plugin entries +
80    /// dispatch into them by-name. `Weak` avoids the
81    /// manager↔snapshot↔annotation↔handler cycle.
82    manager: Weak<PluginManager>,
83    /// PDP resolver. APL routes that don't use `pdp(...)` steps never
84    /// touch this. Default is an empty [`PdpRouter`] — any `pdp(...)`
85    /// step against an unregistered dialect returns
86    /// `PdpError::NoResolver`. Hosts that need Cedar, OPA, NeMo, etc.
87    /// install resolvers via [`Self::with_pdp`] or
88    /// [`Self::with_pdp_router`].
89    pdp: Arc<dyn PdpResolver>,
90}
91
92impl AplRouteHandler {
93    /// Build a handler. Visitor calls this twice per route — once for
94    /// each phase — and passes the resulting `Arc` to `annotate_route`.
95    pub fn new(
96        config: PluginConfig,
97        route: Arc<CompiledRoute>,
98        phase: Phase,
99        plugin_registry: Arc<PluginRegistry>,
100        dispatch_cache: Arc<DispatchCache>,
101        session_store: Arc<dyn SessionStore>,
102        manager: Weak<PluginManager>,
103    ) -> Self {
104        Self {
105            config,
106            route,
107            phase,
108            plugin_registry,
109            dispatch_cache,
110            session_store,
111            manager,
112            pdp: Arc::new(PdpRouter::new()),
113        }
114    }
115
116    /// Install a `PdpResolver`. Pass a [`PdpRouter`] when the host needs
117    /// to support multiple dialects (Cedar + OPA + NeMo) on the same
118    /// route — the router dispatches each `pdp(...)` step by dialect.
119    /// Pass a single resolver when only one dialect is in use; APL
120    /// steps for any other dialect will then return
121    /// `PdpError::NoResolver` at evaluation time.
122    pub fn with_pdp(mut self, pdp: Arc<dyn PdpResolver>) -> Self {
123        self.pdp = pdp;
124        self
125    }
126
127    /// Sugar for the common "register many resolvers" path. Builds a
128    /// [`PdpRouter`], registers each resolver into it, then installs the
129    /// router. Equivalent to constructing a `PdpRouter` by hand and
130    /// passing it to [`Self::with_pdp`].
131    pub fn with_pdp_router(
132        mut self,
133        resolvers: impl IntoIterator<Item = Arc<dyn PdpResolver>>,
134    ) -> Self {
135        let mut router = PdpRouter::new();
136        for r in resolvers {
137            router.register(r);
138        }
139        self.pdp = Arc::new(router);
140        self
141    }
142}
143
144#[async_trait]
145impl Plugin for AplRouteHandler {
146    fn config(&self) -> &PluginConfig {
147        &self.config
148    }
149}
150
151#[async_trait]
152impl AnyHookHandler for AplRouteHandler {
153    async fn invoke(
154        &self,
155        payload: &dyn PluginPayload,
156        extensions: &Extensions,
157        _ctx: &mut PluginContext,
158    ) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
159        // Downcast to the CMF payload — this handler only registers for
160        // cmf.* hook names, so the executor should always hand us a
161        // MessagePayload. A mismatch indicates a framework wiring bug.
162        let msg_payload = payload
163            .as_any()
164            .downcast_ref::<MessagePayload>()
165            .ok_or_else(|| {
166                Box::new(PluginError::Config {
167                    message: format!(
168                        "AplRouteHandler '{}': payload was not MessagePayload",
169                        self.route.route_key
170                    ),
171                })
172            })?;
173
174        let manager = self.manager.upgrade().ok_or_else(|| {
175            Box::new(PluginError::Config {
176                message: format!(
177                    "AplRouteHandler '{}': PluginManager dropped before invoke",
178                    self.route.route_key
179                ),
180            })
181        })?;
182
183        // Build (or reuse) the dispatch plan for this route. Cache keyed
184        // by `(route_key, manager.config_generation())` — if the manager
185        // has reloaded since the last invoke, the next lookup rebuilds.
186        let plan = self
187            .dispatch_cache
188            .get_or_build(&self.route, &self.plugin_registry, &manager)
189            .await;
190
191        // CmfPluginInvoker carries the request-scoped payload + extensions
192        // under interior mutability so successive plugin calls accumulate
193        // mutations. Hydration + persistence are no-ops when there's no
194        // session id (the common case for the first request in a session).
195        // Wrapped in Arc so it can be erased to `Arc<dyn PluginInvoker>`
196        // for the apl-core entry points (which take `&Arc<dyn PluginInvoker>`
197        // so `dispatch_parallel` can clone an owned, 'static reference into
198        // each spawned branch). Inherent-method calls on `CmfPluginInvoker`
199        // (e.g. `extensions_arc`, `persist_session`) deref through the Arc.
200        // Hydration loads accumulated session labels. A store failure
201        // here happens *before* any policy decision, so we fail the
202        // request closed immediately: deny with a
203        // distinguished violation rather than proceeding as if the
204        // session carried no taint. Sessionless traffic never reaches
205        // the store, so this only denies session-bearing requests.
206        let invoker = match CmfPluginInvoker::for_request(
207            Arc::clone(&manager),
208            extensions.clone(),
209            msg_payload.clone(),
210            plan,
211            Arc::clone(&self.session_store),
212        )
213        .await
214        {
215            Ok(inv) => Arc::new(inv),
216            Err(e) => {
217                tracing::error!(
218                    alarm = "session_store_failure",
219                    op = "load",
220                    route = %self.route.route_key,
221                    error = %e,
222                    "session label load failed; failing request closed"
223                );
224                let mut v = PluginViolation::new(
225                    "session.load_failed",
226                    "session state could not be loaded",
227                );
228                decorate_denial_response(&mut v, self.route.response.as_ref());
229                return Ok(Box::new(ErasedResultFields {
230                    continue_processing: false,
231                    modified_payload: None,
232                    modified_extensions: None,
233                    violation: Some(v),
234                }));
235            },
236        };
237
238        // Build the attribute bag. APL predicates read flat keys; the
239        // BagBuilder bridges typed CPEX extensions into that namespace.
240        // `route.key` lets default/policy-bundle predicates branch on
241        // which route they're attached to.
242        let post_extensions = invoker.current_extensions().await;
243        let mut bag = BagBuilder::new()
244            .with_extensions(&post_extensions)
245            .with_route_key(&self.route.route_key)
246            .build();
247
248        // Build `RoutePayload.args` from the message. Per-content shape:
249        //   * ToolCall      → arguments map (JSON Object)
250        //   * PromptRequest → arguments map (JSON Object)
251        //   * Text-only     → JSON String of concatenated text content
252        //
253        // Field pipelines operate on `args.<name>` paths. Result starts
254        // as Null on Pre (no upstream response yet); the Post phase
255        // would extract from a ToolResult / PromptResult — deferred
256        // until result-side handling lands.
257        let args_value = extract_args_from_message(&msg_payload.message);
258        let mut route_payload = match self.phase {
259            Phase::Pre => RoutePayload::new(args_value),
260            Phase::Post => {
261                // Pull the upstream result out of the message so APL
262                // `result.<field>` predicates and the `result:`
263                // pipeline have something to operate on. Falls back to
264                // `Value::Null` when the message has no ToolResult /
265                // PromptResult / Resource content (e.g. for hooks that
266                // fire on entities without a structured result).
267                let result_value = extract_result_from_message(&msg_payload.message);
268                RoutePayload::with_result(args_value, result_value)
269            },
270        };
271
272        // Flatten the call args into the bag under `args.<path>`. APL's
273        // own args pipelines read from `route_payload.args` directly,
274        // but PDP steps and predicates that reference `${args.X}` /
275        // `args.X` resolve through the bag. Mirroring the args here
276        // makes both consumers see the same vocabulary the
277        // `MessageView` exposes. (Bag-mutation via redact during the
278        // args pipeline isn't reflected back into the bag; that's fine
279        // — args predicates today read from `route_payload.args`, and
280        // the cedar substitution snapshots the pre-args view, which is
281        // what an author writing `cedar:(resource.id: ${args.X})` would
282        // expect.)
283        extract_args(&route_payload.args, &mut bag);
284        // Post phase: also project the upstream result into the bag
285        // under `result.<path>`. This is what enables predicates like
286        // `redact(result.ssn) when !perm.view_ssn` and `require(...)`
287        // gates that branch on the result. Pre phases skip this — the
288        // result is `None` by construction.
289        if matches!(self.phase, Phase::Post) {
290            if let Some(result_value) = route_payload.result.as_ref() {
291                extract_result(result_value, &mut bag);
292            }
293        }
294
295        // Slice B: real delegation invoker, sharing the CMF invoker's
296        // extensions Mutex so a `delegate(...)` step's writes to
297        // raw_credentials / delegation are visible to downstream CMF
298        // plugins and to the post phase. Routes that don't declare
299        // any `Step::Delegate` won't have entries in the plan's
300        // `delegation_entries` map; if such a route accidentally hits
301        // `delegate(...)`, the invoker returns `NotFound` and the
302        // evaluator translates it via the step's `on_error`.
303        let delegations = Arc::new(DelegationPluginInvoker::new(
304            Arc::clone(&manager),
305            invoker.extensions_arc(),
306            invoker.plan_arc(),
307        ));
308
309        // Unsized coercion: `Arc<ConcreteType>` → `Arc<dyn Trait>`. The
310        // erased forms get borrowed into `evaluate_pre`/`evaluate_post`;
311        // `dispatch_parallel` can then `Arc::clone` an owned 'static
312        // reference into each branch closure.
313        let invoker_dyn: Arc<dyn apl_core::step::PluginInvoker> = invoker.clone();
314        let delegations_dyn: Arc<dyn apl_core::step::DelegationInvoker> = delegations.clone();
315
316        let decision = match self.phase {
317            Phase::Pre => {
318                evaluate_pre(
319                    &self.route,
320                    &mut bag,
321                    &mut route_payload,
322                    &self.pdp,
323                    &invoker_dyn,
324                    &delegations_dyn,
325                )
326                .await
327            },
328            Phase::Post => {
329                evaluate_post(
330                    &self.route,
331                    &mut bag,
332                    &mut route_payload,
333                    &self.pdp,
334                    &invoker_dyn,
335                    &delegations_dyn,
336                )
337                .await
338            },
339        };
340
341        // Drain Session-scoped taints (from `taint(label, session)` /
342        // pipeline `Stage::Taint`) into `extensions.security.labels`
343        // so the existing label-diff flow inside `persist_session`
344        // picks them up. Message-scoped taints are filtered out by
345        // `apply_session_taints` — they need their own destination
346        // (see TS2). No-op when no taints emitted.
347        invoker.apply_session_taints(&decision.taints).await;
348
349        // Commit any session-scoped labels accumulated during this
350        // request. No-op when there was no session id. The result is
351        // folded into the decision below — captured here because
352        // `continue_processing`/`violation` are computed after persist.
353        let persist_result = invoker.persist_session().await;
354
355        // Surface the final mutated payload + extensions back into the
356        // PipelineResult the executor returns to the host. The host's
357        // body re-serialization picks up edits made by APL pipelines
358        // (e.g. a redact stage that rewrote args.text).
359        let final_payload = invoker.current_payload().await;
360        let final_extensions = invoker.current_extensions().await;
361
362        // Detect whether the args pipeline mutated the payload by
363        // re-extracting from the pre-eval message (msg_payload is
364        // still borrowed) and comparing against the post-eval
365        // route_payload.args. Re-extraction allocates but mirrors the
366        // surrounding pattern and avoids holding a pre-eval clone.
367        let pre_args = extract_args_from_message(&msg_payload.message);
368        // For Post phase, also detect result mutations from `result:`
369        // pipelines. Pre routes don't carry a result so this is None.
370        let pre_result = match self.phase {
371            Phase::Pre => None,
372            Phase::Post => Some(extract_result_from_message(&msg_payload.message)),
373        };
374        let modified_payload: Option<Box<dyn PluginPayload>> = if route_payload.args != pre_args {
375            // An args pipeline (Pre) rewrote a field. Fold the new
376            // args back into a fresh MessagePayload so downstream
377            // readers (the host's body re-serializer) see the
378            // change.
379            let mut updated = final_payload.clone();
380            write_args_back_to_message(&mut updated.message, &route_payload.args);
381            Some(Box::new(updated) as Box<dyn PluginPayload>)
382        } else if matches!(self.phase, Phase::Post)
383            && pre_result
384                .as_ref()
385                .zip(route_payload.result.as_ref())
386                .map(|(prev, current)| prev != current)
387                .unwrap_or(false)
388        {
389            // A `result:` pipeline rewrote a field in the upstream
390            // response. Fold the new result back into the message
391            // so the host's response body re-serializer can write
392            // it out before forwarding downstream.
393            let mut updated = final_payload.clone();
394            if let Some(result_value) = route_payload.result.as_ref() {
395                write_result_back_to_message(&mut updated.message, result_value);
396            }
397            Some(Box::new(updated) as Box<dyn PluginPayload>)
398        } else if msg_payload.message.get_text_content() != final_payload.message.get_text_content()
399        {
400            // A `pre_invocation:` plugin mutated the message directly via
401            // `modify_payload` (not through a field pipeline). Pass
402            // the invoker's view through unchanged.
403            Some(Box::new(final_payload) as Box<dyn PluginPayload>)
404        } else {
405            None
406        };
407
408        let modified_extensions = if extensions_changed(extensions, &final_extensions) {
409            Some(final_extensions.cow_copy())
410        } else {
411            None
412        };
413
414        // Attach the route's transpiled `denyWith` to a violation at each
415        // genuine-denial site (below) via `decorate_denial_response`, rather
416        // than blanket-decorating whatever `violation` is set. This keeps the
417        // custom response off any future non-denial signal (e.g. an
418        // elicitation/retry/confirm violation) that must reach the host with
419        // its own wire shape intact.
420        let (mut continue_processing, mut violation) = match decision.decision {
421            Decision::Allow => (true, None),
422            Decision::Deny {
423                reason,
424                rule_source,
425            } => {
426                let code = if rule_source.is_empty() {
427                    "policy.deny".to_string()
428                } else {
429                    rule_source
430                };
431                let reason = reason.unwrap_or_else(|| "access denied".to_string());
432                let mut v = PluginViolation::new(code, reason);
433                decorate_denial_response(&mut v, self.route.response.as_ref());
434                (false, Some(v))
435            },
436        };
437
438        // Append fail-closed with merge precedence:
439        //   - decision Allow + append Err → flip to Deny with a
440        //     distinguished `session.persist_failed` violation.
441        //   - decision Deny + append Err → keep the original policy
442        //     violation (preserve attribution); the request is already
443        //     denied. The append failure surfaces only as the alarm.
444        // The alarm/metric fires on every append failure regardless of
445        // decision, since the dangerous residual is a *selective*
446        // failure (append rejected while reads still succeed).
447        if let Err(e) = persist_result {
448            tracing::error!(
449                alarm = "session_store_failure",
450                op = "append",
451                route = %self.route.route_key,
452                decision_was_allow = continue_processing,
453                error = %e,
454                "session label persist failed; failing request closed"
455            );
456            if continue_processing {
457                continue_processing = false;
458                let mut v = PluginViolation::new(
459                    "session.persist_failed",
460                    "session state could not be persisted",
461                );
462                decorate_denial_response(&mut v, self.route.response.as_ref());
463                violation = Some(v);
464            }
465        }
466
467        Ok(Box::new(ErasedResultFields {
468            continue_processing,
469            modified_payload,
470            modified_extensions,
471            violation,
472        }))
473    }
474
475    fn hook_type_name(&self) -> &'static str {
476        // CmfHook::NAME — kept as a literal here to avoid pulling in the
477        // HookTypeDef trait just for the constant.
478        "cmf"
479    }
480}
481
482// =====================================================================
483// Helpers
484// =====================================================================
485
486/// Attach a route's transpiled `denyWith` (status/body/headers) to a
487/// denial `violation`'s `details` map so the host can render a custom HTTP
488/// denial response. Carried via `details` (not new violation fields) to
489/// keep the violation type stable. `None` response leaves the host default.
490///
491/// Call this only from genuine-denial sites — never blanket-apply it to
492/// whatever violation happens to be set, or a non-denial signal (e.g. an
493/// elicitation/retry/confirm) would get stamped with a `403`-shaped
494/// response the host would render instead of the intended wire signal.
495fn decorate_denial_response(violation: &mut PluginViolation, response: Option<&DenyResponse>) {
496    let Some(resp) = response else {
497        return;
498    };
499    if let Some(status) = resp.status {
500        violation
501            .details
502            .insert(DETAIL_HTTP_STATUS.to_string(), serde_json::json!(status));
503    }
504    if let Some(body) = &resp.body {
505        violation
506            .details
507            .insert(DETAIL_HTTP_BODY.to_string(), serde_json::json!(body));
508    }
509    if !resp.headers.is_empty() {
510        violation.details.insert(
511            DETAIL_HTTP_HEADERS.to_string(),
512            serde_json::json!(resp.headers),
513        );
514    }
515}
516
517/// Rewrite the first text part of `msg` with `new_text`. If there is no
518/// text part, append one. Mirrors what `MessagePayload`'s normal
519/// modify-path does for single-view v0.
520fn rewrite_message_text(msg: &mut cpex_core::cmf::Message, new_text: &str) {
521    for part in msg.content.iter_mut() {
522        if let cpex_core::cmf::ContentPart::Text { text } = part {
523            *text = new_text.to_string();
524            return;
525        }
526    }
527    msg.content.push(cpex_core::cmf::ContentPart::Text {
528        text: new_text.to_string(),
529    });
530}
531
532/// Extract `RoutePayload.args` from a CMF message. v0 maps:
533///   * First `ContentPart::ToolCall`      → `arguments` map (Object)
534///   * First `ContentPart::PromptRequest` → `arguments` map (Object)
535///   * Else (text / no entity parts)      → JSON String of text content
536///
537/// `args.<field>` APL paths target tool / prompt arguments directly.
538/// For text-only messages we fall back to the v0 "args = whole text"
539/// shape so `args.text` predicates keep working.
540fn extract_args_from_message(msg: &cpex_core::cmf::Message) -> Value {
541    use cpex_core::cmf::ContentPart;
542    for part in &msg.content {
543        match part {
544            ContentPart::ToolCall { content } => {
545                return Value::Object(
546                    content
547                        .arguments
548                        .iter()
549                        .map(|(k, v)| (k.clone(), v.clone()))
550                        .collect(),
551                );
552            },
553            ContentPart::PromptRequest { content } => {
554                return Value::Object(
555                    content
556                        .arguments
557                        .iter()
558                        .map(|(k, v)| (k.clone(), v.clone()))
559                        .collect(),
560                );
561            },
562            _ => {},
563        }
564    }
565    Value::String(msg.get_text_content())
566}
567
568/// Inverse of [`extract_args_from_message`]: write `args` back into
569/// `msg`'s first ToolCall / PromptRequest argument map, or — for
570/// text payloads — into the first text part.
571///
572/// Silently no-ops when the args shape doesn't match the message
573/// content shape (e.g. operator pipeline produced a String for what
574/// was originally a ToolCall). The mismatch path is recoverable —
575/// the upstream just sees the original unmodified content rather
576/// than a malformed rewrite.
577fn write_args_back_to_message(msg: &mut cpex_core::cmf::Message, args: &Value) {
578    use cpex_core::cmf::ContentPart;
579    for part in msg.content.iter_mut() {
580        match part {
581            ContentPart::ToolCall { content } => {
582                if let Some(obj) = args.as_object() {
583                    content.arguments = obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
584                }
585                return;
586            },
587            ContentPart::PromptRequest { content } => {
588                if let Some(obj) = args.as_object() {
589                    content.arguments = obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
590                }
591                return;
592            },
593            _ => {},
594        }
595    }
596    // Fall through: no structured entity part — treat as text.
597    if let Some(text) = args.as_str() {
598        rewrite_message_text(msg, text);
599    }
600}
601
602/// Extract `RoutePayload.result` from a CMF message. Mirror of
603/// [`extract_args_from_message`] for the Post phase. v0 maps:
604///   * First `ContentPart::ToolResult` → its `content` JSON value
605///   * Else (text / no structured result part) → JSON String of text
606///
607/// `result.<field>` APL paths target the structured result directly.
608fn extract_result_from_message(msg: &cpex_core::cmf::Message) -> Value {
609    use cpex_core::cmf::ContentPart;
610    for part in &msg.content {
611        if let ContentPart::ToolResult { content } = part {
612            return content.content.clone();
613        }
614    }
615    Value::String(msg.get_text_content())
616}
617
618/// Inverse of [`extract_result_from_message`]: write a mutated
619/// `result` back into the message's first `ContentPart::ToolResult.content`,
620/// or — for text-only messages — into the first text part. The praxis
621/// filter's response-body re-serializer then lifts the new content
622/// out of the ContentPart and folds it back into the JSON-RPC
623/// `result.content[*].text` payload.
624fn write_result_back_to_message(msg: &mut cpex_core::cmf::Message, result: &Value) {
625    use cpex_core::cmf::ContentPart;
626    for part in msg.content.iter_mut() {
627        if let ContentPart::ToolResult { content } = part {
628            content.content = result.clone();
629            return;
630        }
631    }
632    if let Some(text) = result.as_str() {
633        rewrite_message_text(msg, text);
634    }
635}
636
637/// Cheap pointer-equality check across the few mutable extension slots
638/// the executor would care about. False positives (claiming a change
639/// when there isn't one) are cheap — the executor re-validates anyway.
640fn extensions_changed(before: &Extensions, after: &Extensions) -> bool {
641    let security_changed = match (before.security.as_ref(), after.security.as_ref()) {
642        (Some(a), Some(b)) => !Arc::ptr_eq(a, b),
643        (None, None) => false,
644        _ => true,
645    };
646    let delegation_changed = match (before.delegation.as_ref(), after.delegation.as_ref()) {
647        (Some(a), Some(b)) => !Arc::ptr_eq(a, b),
648        (None, None) => false,
649        _ => true,
650    };
651    // `delegate(...)` steps write minted tokens into
652    // `raw_credentials.delegated_tokens` via the shared Mutex —
653    // without this check, a route whose only Extensions mutation is
654    // a delegate (no security / delegation chain edit) looks
655    // unchanged, so the executor never merges the minted token back
656    // and downstream readers (our HttpFilter attaching the token to
657    // the upstream request) see nothing.
658    let raw_creds_changed = match (
659        before.raw_credentials.as_ref(),
660        after.raw_credentials.as_ref(),
661    ) {
662        (Some(a), Some(b)) => !Arc::ptr_eq(a, b),
663        (None, None) => false,
664        _ => true,
665    };
666    security_changed || delegation_changed || raw_creds_changed
667}