Skip to main content

act_runtime/
actor.rs

1//! The component actor: one task owning the store, fed typed requests
2//! over a channel. Also the audit envelope each call is wrapped in.
3
4use anyhow::Result;
5use std::collections::HashMap;
6use std::pin::Pin;
7use std::sync::{Arc, Mutex};
8use std::task::{Context, Poll};
9use tokio::sync::{mpsc, oneshot};
10use tracing::Instrument;
11use wasmtime::component::{Component, Linker, Source, StreamConsumer, StreamResult};
12use wasmtime::{Engine, Store, StoreContextMut};
13
14use crate::consent;
15use crate::info::{ComponentError, ComponentInfo};
16use crate::store::{HostState, create_store};
17use crate::{act, exports};
18use crate::{credentials, fs_policy, sessions};
19
20/// Identity of the running artifact, carried into every audit record.
21#[derive(Debug, Clone)]
22pub struct AuditContext {
23    pub component_ref: String,
24    pub digest: String,
25    pub transport: crate::audit::Transport,
26    /// Whether this run has any channel that can answer an interactive
27    /// `ask` prompt — a real TTY (`TtyPrompter`) or an MCP client offering
28    /// elicitation (`McpElicitationPrompter`). `false` for headless CLI
29    /// invocations and ACT-HTTP (`DenyPrompter`), where every `ask`
30    /// decision degrades to deny before a human is ever involved. Decided
31    /// once, at the same point the concrete prompter is chosen, and carried
32    /// here so `instantiate_component` never has to infer it from the
33    /// prompter's type.
34    pub has_prompt_channel: bool,
35    /// `--audit-args`: record full tool-argument values in the envelope
36    /// alongside the digest, instead of the digest alone. Never applies to
37    /// session args — those are carried only as `session_id` regardless of
38    /// this flag; see `args_as_json`, which this only gates.
39    pub record_args: bool,
40}
41/// Pull a well-known `std:` key out of decoded call metadata for the audit
42/// envelope. Only ids are ever read this way — session *args* carry auth and
43/// are never logged, only the session id they produced.
44pub(crate) fn meta_str(metadata: &[(String, String)], key: &str) -> Option<String> {
45    metadata
46        .iter()
47        .find(|(k, _)| k == key)
48        .map(|(_, v)| v.clone())
49        .filter(|v| !v.is_empty())
50}
51/// Decode the string-valued entries out of raw WIT call metadata
52/// (`list<tuple<string, list<u8>>>`, each value dCBOR-encoded) so `meta_str`
53/// can search them. The `std:*` correlation ids the envelope reads are always
54/// CBOR text strings; anything that doesn't decode to one is dropped rather
55/// than guessed at.
56pub(crate) fn decode_meta_strings(metadata: &[(String, Vec<u8>)]) -> Vec<(String, String)> {
57    metadata
58        .iter()
59        .filter_map(|(k, v)| {
60            let value = act_types::cbor::cbor_to_json(v).ok()?;
61            value.as_str().map(|s| (k.clone(), s.to_string()))
62        })
63        .collect()
64}
65/// Render tool-call arguments (dCBOR bytes) as a JSON string for the audit
66/// envelope, gated by `--audit-args`. Returns `None` when the flag is unset
67/// (the default — `args_sha256` is all that is ever recorded then) or when
68/// the arguments fail to decode; either way the call itself proceeds
69/// unaffected, since the audit trail must never influence enforcement.
70/// Session args never pass through this function — `open_session_for_call`
71/// only ever forwards a `session_id` into the envelope, never the args that
72/// produced it.
73fn args_as_json(arguments: &[u8], record_args: bool) -> Option<String> {
74    if !record_args {
75        return None;
76    }
77    let value = act_types::cbor::cbor_to_json(arguments).ok()?;
78    serde_json::to_string(&value).ok()
79}
80/// True if any event in a completed call's result signals a guest tool-level
81/// failure. `call-tool` never returns `result<tool-result, error>` — an early
82/// failure is encoded as a `tool-event::error` inside an otherwise `Ok`
83/// response (ACT-TOOLS §5.2), the same shape `rmcp_bridge`'s
84/// `fold_events_to_result` inspects to map a call to an MCP error response.
85/// The audit envelope has to look at the same signal, or every guest failure
86/// audits as `ok`.
87fn events_contain_error(events: &[act::tools::types::ToolEvent]) -> bool {
88    events
89        .iter()
90        .any(|e| matches!(e, act::tools::types::ToolEvent::Error(_)))
91}
92/// Width of the visible request-id's counter field, in bits. See
93/// `pack_visible_request_id` for why this trades off against
94/// `SALT_BITS` rather than being widened freely.
95const REQUEST_ID_COUNTER_BITS: u32 = 9; // 512 values
96/// Width of the visible request-id's salt field, in bits. Together with
97/// `COUNTER_BITS` this must sum to 24 (`render_rollup` shows the id's first
98/// 6 hex digits = 24 bits — the hard ceiling on how many values can ever be
99/// visually distinguishable, no matter how the id is built).
100const REQUEST_ID_SALT_BITS: u32 = 24 - REQUEST_ID_COUNTER_BITS; // 15 bits, 32768 values
101/// Pack a per-call counter and a per-process salt into the 24-bit value
102/// rendered as `new_request_id`'s leading 6 hex digits — the only part
103/// `render_rollup`'s 6-byte truncation shows an operator. A prior fix
104/// (`format!("act-{:x}", hash_of(pid, counter, time))`) spent 4 of those 6
105/// bytes on the literal `act-` and left only 2 hex digits (256 values) of
106/// real entropy visible; a standalone repro of that exact algorithm hit a
107/// birthday collision at call #23 of 40. This packs the full 24 visible
108/// bits productively instead, split so both properties the review asked
109/// for hold within that hard ceiling:
110///
111/// - the high `COUNTER_BITS` bits are the per-process call counter, so two
112///   different calls in the SAME process render deterministically distinct
113///   visible prefixes — not probabilistically, as long as fewer than
114///   `2^COUNTER_BITS` calls have been made in this process. A *fixed-width*
115///   bit field is what makes this a guarantee: a variable-width
116///   counter-then-salt string (e.g. `format!("{:x}{}", counter, salt)`)
117///   can have a short counter's digits absorbed into what looks like a
118///   longer counter's leading digits when the salt happens to repeat the
119///   right digit — confirmed a real instance by brute-force search rather
120///   than asserting it from intuition: `counter=1` and `counter=0x11`
121///   both render `"111111"` under that scheme at `salt=0x11111`. Bit
122///   packing can't do this — the counter occupies fixed bit positions no
123///   salt value can shift into.
124/// - the low `SALT_BITS` bits are a per-process random salt, so two
125///   different PROCESSES — the dominant real-world case, since most `act
126///   call` invocations make exactly one request and so always have
127///   counter == 0 — usually render different visible prefixes too.
128pub(crate) fn pack_visible_request_id(counter: u64, salt: u32) -> u32 {
129    // Truncation is the operation, not a hazard: the mask below keeps only
130    // `REQUEST_ID_COUNTER_BITS` anyway, so the discarded high bits were never
131    // going to reach the result.
132    #[allow(clippy::cast_possible_truncation)]
133    let counter_field = (counter as u32) & ((1 << REQUEST_ID_COUNTER_BITS) - 1);
134    let salt_field = salt & ((1 << REQUEST_ID_SALT_BITS) - 1);
135    (counter_field << REQUEST_ID_SALT_BITS) | salt_field
136}
137/// Host-generated correlation id, used when the caller supplied no
138/// `std:request-id`. Keeping this non-optional is what makes every audit line
139/// joinable to a client log line.
140///
141/// The visible (6-hex-digit) part comes from `pack_visible_request_id`; see
142/// its doc comment for why it's split into a counter field and a salt
143/// field. `salt` is drawn once per process, from `RandomState`'s
144/// OS-seeded-per-thread hasher (no new dependency); `counter` is the usual
145/// per-process monotonic count. The full, un-truncated counter is appended
146/// after the visible portion too, so the untruncated id (used verbatim as
147/// the `act.request.id` span attribute for OTLP export, never truncated
148/// there) stays globally unique for the lifetime of the process regardless
149/// of the 6-byte display ceiling.
150pub(crate) fn new_request_id() -> String {
151    use std::collections::hash_map::RandomState;
152    use std::hash::{BuildHasher, Hasher};
153    use std::sync::OnceLock;
154    use std::sync::atomic::{AtomicU64, Ordering};
155
156    static SALT: OnceLock<u32> = OnceLock::new();
157    let salt = *SALT.get_or_init(|| {
158        let mut hasher = RandomState::new().build_hasher();
159        hasher.write_u32(std::process::id());
160        // A 64-bit hash folded into a 32-bit salt. Truncating is how you
161        // narrow a hash; there is no value to preserve.
162        hasher.finish() as u32
163    });
164
165    static N: AtomicU64 = AtomicU64::new(0);
166    let n = N.fetch_add(1, Ordering::Relaxed);
167
168    format!("{:06x}-{n:x}", pack_visible_request_id(n, salt))
169}
170pub use act_types::Metadata;
171/// Requests that can be sent to the component actor.
172pub(crate) enum ComponentRequest {
173    ListTools {
174        metadata: Metadata,
175        reply: oneshot::Sender<Result<act::tools::types::ListToolsResponse, ComponentError>>,
176    },
177    CallTool {
178        name: String,
179        arguments: Vec<u8>,
180        metadata: Vec<(String, Vec<u8>)>,
181        reply: oneshot::Sender<Result<CallToolResult, ComponentError>>,
182        /// Where a capability gate firing during this call sends its consent
183        /// question. `None` for transports that prompt locally (TTY) or do not
184        /// prompt at all. See `runtime::elicit` for why the ask travels back to
185        /// the caller instead of the gate reaching for the peer itself.
186        consent: Option<consent::ConsentSink>,
187    },
188    /// Returns a JSON Schema string. A component with no `session-provider`
189    /// fails with `Internal`, not a `std:not-found` tool error: nothing ran.
190    GetOpenSessionArgsSchema {
191        metadata: Vec<(String, Vec<u8>)>,
192        reply: oneshot::Sender<Result<String, ComponentError>>,
193    },
194    /// A component with no `session-provider` fails with `Internal`.
195    OpenSession {
196        args: Vec<(String, Vec<u8>)>,
197        metadata: Vec<(String, Vec<u8>)>,
198        reply: oneshot::Sender<Result<sessions::Session, ComponentError>>,
199        /// Same routing as `CallTool::consent`. Bridges do their network I/O
200        /// while opening a session, so this is where their capability gate
201        /// usually fires.
202        consent: Option<consent::ConsentSink>,
203    },
204    /// A component with no `session-provider` fails with `Internal`. The
205    /// reply carries `()` so callers can wait for the close to complete.
206    CloseSession {
207        session_id: String,
208        reply: oneshot::Sender<Result<(), ComponentError>>,
209    },
210}
211/// Collected result from call-tool (stream already consumed).
212pub struct CallToolResult {
213    pub events: Vec<act::tools::types::ToolEvent>,
214}
215/// Handle to send requests to the component actor.
216#[derive(Clone)]
217pub struct ComponentHandle {
218    tx: mpsc::Sender<ComponentRequest>,
219    /// Compiled argument schemas, per session.
220    ///
221    /// Per session because a bridge's tool list is not fixed: `mcp-bridge` and
222    /// `openapi-bridge` expose the tools of whatever upstream a session opened,
223    /// so a schema cached without the session id would be the wrong component's.
224    ///
225    /// Populated by one `list-tools` the first time a session calls a tool, and
226    /// reused after — validating must not double the guest round trips.
227    schemas: Arc<Mutex<HashMap<Option<String>, Arc<ToolSchemas>>>>,
228}
229
230/// Tool name to its compiled schema, `None` where the component shipped one
231/// that could not be compiled (see `validate::Validator::compile`).
232type ToolSchemas = HashMap<String, Option<Arc<crate::validate::Validator>>>;
233
234impl ComponentHandle {
235    pub(crate) fn new(tx: mpsc::Sender<ComponentRequest>) -> Self {
236        Self {
237            tx,
238            schemas: Arc::new(Mutex::new(HashMap::new())),
239        }
240    }
241
242    /// A handle with no actor behind it: every call answers
243    /// `component actor unavailable`.
244    ///
245    /// For host tests that need to construct whatever holds a handle without
246    /// standing up a component. The request enum is private, so a host cannot
247    /// build one of these itself.
248    pub fn disconnected() -> Self {
249        let (tx, _rx) = mpsc::channel(1);
250        Self::new(tx)
251    }
252
253    /// Send one request and wait for its reply.
254    ///
255    /// Both ways the round trip can fail without the component ever running —
256    /// the actor gone before the send, the actor gone after it — are host
257    /// failures, not tool errors, and neither may be reported as something the
258    /// component said.
259    async fn round_trip<T>(
260        &self,
261        build: impl FnOnce(oneshot::Sender<Result<T, ComponentError>>) -> ComponentRequest,
262    ) -> Result<T, ComponentError> {
263        let (reply, answer) = oneshot::channel();
264        self.tx.send(build(reply)).await.map_err(|_| {
265            ComponentError::Internal(anyhow::anyhow!("component actor unavailable"))
266        })?;
267        answer.await.map_err(|_| {
268            ComponentError::Internal(anyhow::anyhow!("component actor dropped reply"))
269        })?
270    }
271
272    pub async fn list_tools(
273        &self,
274        metadata: &Metadata,
275    ) -> Result<act::tools::types::ListToolsResponse, ComponentError> {
276        self.round_trip(|reply| ComponentRequest::ListTools {
277            metadata: metadata.clone(),
278            reply,
279        })
280        .await
281    }
282
283    /// `consent` is where a capability gate firing *during this call* sends its
284    /// question. `None` for transports that prompt locally or not at all — see
285    /// [`crate::consent`] for why the ask travels back to the caller rather
286    /// than the gate reaching for a peer itself.
287    /// Call a tool, after checking its arguments against the schema the
288    /// component published for it (`ACT-SPEC.md` §6.4).
289    ///
290    /// The check happens here rather than in a transport so that every caller
291    /// gets it — `act call` on the command line as much as an agent over MCP.
292    /// A rejection is `ComponentError::Tool` with kind `std:invalid-args`, the
293    /// same shape the guest would have produced, and the guest is never
294    /// reached.
295    pub async fn call_tool(
296        &self,
297        name: &str,
298        arguments: Vec<u8>,
299        metadata: Vec<(String, Vec<u8>)>,
300        consent: Option<consent::ConsentSink>,
301    ) -> Result<CallToolResult, ComponentError> {
302        self.check_arguments(name, &arguments, &metadata).await?;
303        self.round_trip(|reply| ComponentRequest::CallTool {
304            name: name.to_string(),
305            arguments,
306            metadata,
307            reply,
308            consent,
309        })
310        .await
311    }
312
313    async fn check_arguments(
314        &self,
315        name: &str,
316        arguments: &[u8],
317        metadata: &[(String, Vec<u8>)],
318    ) -> Result<(), ComponentError> {
319        let session = meta_str(&decode_meta_strings(metadata), "std:session-id");
320        let schemas = self.tool_schemas(session, metadata).await?;
321
322        // A tool the listing does not mention is passed through: the component
323        // answers `std:not-found` itself, and a host inventing that answer
324        // would be wrong for a component whose list is genuinely dynamic.
325        let Some(Some(validator)) = schemas.get(name) else {
326            return Ok(());
327        };
328
329        let value = crate::validate::arguments_as_json(arguments)
330            .map_err(|e| ComponentError::Tool(crate::validate::invalid_args(e)))?;
331        validator.check(&value).map_err(|e| {
332            tracing::debug!(tool = %name, "arguments rejected before reaching the component");
333            ComponentError::Tool(crate::validate::invalid_args(format!(
334                "arguments do not match the schema for '{name}': {e}"
335            )))
336        })
337    }
338
339    /// The other half of the same rule, for `open-session` args
340    /// (`ACT-SESSIONS.md` §2.1).
341    ///
342    /// Not cached: a session is opened once, so a cache would hold a schema
343    /// exactly as long as it is useless. The tool path caches because a session
344    /// then makes many calls.
345    async fn check_session_args(
346        &self,
347        args: &[(String, Vec<u8>)],
348        metadata: &[(String, Vec<u8>)],
349    ) -> Result<(), ComponentError> {
350        let schema = self.open_session_args_schema(metadata.to_vec()).await?;
351        let Some(validator) = crate::validate::Validator::compile("open-session", &schema) else {
352            return Ok(());
353        };
354        let value = crate::validate::session_args_as_json(args)
355            .map_err(|e| ComponentError::Tool(crate::validate::invalid_args(e)))?;
356        validator.check(&value).map_err(|e| {
357            ComponentError::Tool(crate::validate::invalid_args(format!(
358                "session arguments do not match the component's schema: {e}"
359            )))
360        })
361    }
362
363    async fn tool_schemas(
364        &self,
365        session: Option<String>,
366        metadata: &[(String, Vec<u8>)],
367    ) -> Result<Arc<ToolSchemas>, ComponentError> {
368        if let Some(hit) = self
369            .schemas
370            .lock()
371            .unwrap_or_else(std::sync::PoisonError::into_inner)
372            .get(&session)
373        {
374            return Ok(hit.clone());
375        }
376
377        // Listed with the caller's own metadata, so a bridge sees the session
378        // whose tools it is being asked about. `list-tools` takes it decoded;
379        // a value that is not decodable CBOR is dropped rather than guessed at,
380        // the same rule `decode_meta_strings` follows.
381        let mut listing_meta = Metadata::new();
382        for (k, v) in metadata {
383            if let Ok(value) = act_types::cbor::cbor_to_json(v) {
384                listing_meta.insert(k.clone(), value);
385            }
386        }
387        let listed = self.list_tools(&listing_meta).await?;
388        let compiled: ToolSchemas = listed
389            .tools
390            .iter()
391            .map(|td| {
392                let v = crate::validate::Validator::compile(&td.name, &td.parameters_schema)
393                    .map(Arc::new);
394                (td.name.clone(), v)
395            })
396            .collect();
397        let compiled = Arc::new(compiled);
398        self.schemas
399            .lock()
400            .unwrap_or_else(std::sync::PoisonError::into_inner)
401            .insert(session, compiled.clone());
402        Ok(compiled)
403    }
404
405    /// A component that exports no session-provider fails with
406    /// [`ComponentError::Internal`] — the host could not make the call, as
407    /// opposed to the component declining it.
408    pub async fn open_session(
409        &self,
410        args: Vec<(String, Vec<u8>)>,
411        metadata: Vec<(String, Vec<u8>)>,
412        consent: Option<consent::ConsentSink>,
413    ) -> Result<sessions::Session, ComponentError> {
414        self.check_session_args(&args, &metadata).await?;
415        self.round_trip(|reply| ComponentRequest::OpenSession {
416            args,
417            metadata,
418            reply,
419            consent,
420        })
421        .await
422    }
423
424    pub async fn close_session(&self, session_id: String) -> Result<(), ComponentError> {
425        self.round_trip(|reply| ComponentRequest::CloseSession { session_id, reply })
426            .await
427    }
428
429    /// Send one request and wait for its reply, answering any consent question
430    /// the gate raises *while the call is running* through `answer`.
431    ///
432    /// The select loop lives here rather than in the host because the ordering
433    /// it encodes is a property of the runtime: the guest is blocked until the
434    /// answer lands, so a pending ask must be serviced before the reply is
435    /// polled — `biased` is load-bearing, not a preference. What a host
436    /// supplies is only how to put the question to a human.
437    async fn round_trip_servicing_consent<T, F, Fut>(
438        &self,
439        build: impl FnOnce(
440            oneshot::Sender<Result<T, ComponentError>>,
441            consent::ConsentSink,
442        ) -> ComponentRequest,
443        mut answer: F,
444    ) -> Result<T, ComponentError>
445    where
446        F: FnMut(String) -> Fut,
447        Fut: std::future::Future<Output = bool>,
448    {
449        let (reply, mut answer_rx) = oneshot::channel();
450        // Depth 1: the actor runs one call at a time and blocks on each answer.
451        let (consent_tx, mut consent_rx) = mpsc::channel::<consent::ConsentRequest>(1);
452
453        self.tx.send(build(reply, consent_tx)).await.map_err(|_| {
454            ComponentError::Internal(anyhow::anyhow!("component actor unavailable"))
455        })?;
456
457        let reply = loop {
458            tokio::select! {
459                biased;
460                Some(ask) = consent_rx.recv() => {
461                    let decision = answer(ask.message).await;
462                    let _ = ask.reply.send(decision);
463                }
464                reply = &mut answer_rx => break reply,
465            }
466        };
467        reply.map_err(|_| {
468            ComponentError::Internal(anyhow::anyhow!("component actor dropped reply"))
469        })?
470    }
471
472    /// [`Self::call_tool`], with consent questions routed back to `answer`
473    /// instead of denied. Transports with a back-channel to a human use this.
474    pub async fn call_tool_servicing_consent<F, Fut>(
475        &self,
476        name: &str,
477        arguments: Vec<u8>,
478        metadata: Vec<(String, Vec<u8>)>,
479        answer: F,
480    ) -> Result<CallToolResult, ComponentError>
481    where
482        F: FnMut(String) -> Fut,
483        Fut: std::future::Future<Output = bool>,
484    {
485        self.round_trip_servicing_consent(
486            |reply, consent| ComponentRequest::CallTool {
487                name: name.to_string(),
488                arguments,
489                metadata,
490                reply,
491                consent: Some(consent),
492            },
493            answer,
494        )
495        .await
496    }
497
498    /// [`Self::open_session`], with consent questions routed back to `answer`.
499    /// A bridge does its network I/O while opening a session, so this is where
500    /// its capability gate usually fires.
501    pub async fn open_session_servicing_consent<F, Fut>(
502        &self,
503        args: Vec<(String, Vec<u8>)>,
504        metadata: Vec<(String, Vec<u8>)>,
505        answer: F,
506    ) -> Result<sessions::Session, ComponentError>
507    where
508        F: FnMut(String) -> Fut,
509        Fut: std::future::Future<Output = bool>,
510    {
511        self.round_trip_servicing_consent(
512            |reply, consent| ComponentRequest::OpenSession {
513                args,
514                metadata,
515                reply,
516                consent: Some(consent),
517            },
518            answer,
519        )
520        .await
521    }
522
523    /// Returns a JSON Schema string. A component that exports no
524    /// session-provider fails with [`ComponentError::Internal`].
525    pub async fn open_session_args_schema(
526        &self,
527        metadata: Vec<(String, Vec<u8>)>,
528    ) -> Result<String, ComponentError> {
529        self.round_trip(|reply| ComponentRequest::GetOpenSessionArgsSchema { metadata, reply })
530            .await
531    }
532}
533/// The generated tool-provider guest — the always-present surface of every
534/// ACT component.
535pub use exports::act::tools::tool_provider::Guest as ToolProvider;
536/// Instantiate the component. Returns the tool-provider guest, an optional
537/// `SessionProvider` (present iff the component exports
538/// `act:sessions/session-provider`), and the store.
539///
540/// `act-world` declares both `tool-provider` and `session-provider` as
541/// exports, but the latter is opt-in. Rather than `ActWorldIndices::new`
542/// (which requires *every* declared export and would reject stateless
543/// components), each interface is bound through its own per-interface
544/// `GuestIndices`: tool-provider is mandatory, session-provider is looked up
545/// with `.ok()` so its absence yields `None`.
546///
547/// Component info is read from custom sections (no instantiation needed
548/// for that).
549#[allow(clippy::too_many_arguments)]
550pub async fn instantiate_component(
551    engine: &Engine,
552    component: &Component,
553    linker: &Linker<HostState>,
554    preopens: &[fs_policy::Preopen],
555    grant_policy: &act_policy::grant::GrantPolicy,
556    info: &ComponentInfo,
557    max_memory: Option<usize>,
558    prompter: Arc<dyn act_policy::consent::ConsentPrompter>,
559    cache: Arc<act_policy::consent::DecisionCache>,
560    credentials: Option<Arc<credentials::CredentialHost>>,
561    audit: &AuditContext,
562) -> Result<(
563    ToolProvider,
564    Option<sessions::SessionProvider>,
565    Store<HostState>,
566)> {
567    use exports::act::sessions::session_provider::GuestIndices as SessionGuestIndices;
568    use exports::act::tools::tool_provider::GuestIndices as ToolGuestIndices;
569
570    let (mut store, ceilings) = create_store(
571        engine,
572        preopens,
573        grant_policy,
574        info,
575        max_memory,
576        prompter,
577        cache,
578        credentials,
579        &audit.component_ref,
580    )
581    .await?;
582
583    let pre = linker
584        .instantiate_pre(component)
585        .map_err(|e| anyhow::anyhow!("failed to pre-instantiate component: {e}"))?;
586    // Resolve export indices before instantiation. tool-provider is required;
587    // session-provider is optional — a missing export makes `new` error, which
588    // we map to `None` (the component is simply stateless).
589    let tool_indices =
590        ToolGuestIndices::new(&pre).map_err(|e| anyhow::anyhow!("tool-provider indices: {e}"))?;
591    let session_indices = SessionGuestIndices::new(&pre).ok();
592
593    let instance = pre
594        .instantiate_async(&mut store)
595        .await
596        .map_err(|e| anyhow::anyhow!("failed to instantiate component: {e}"))?;
597
598    let tool_provider = tool_indices
599        .load(&mut store, &instance)
600        .map_err(|e| anyhow::anyhow!("failed to load tool-provider: {e}"))?;
601
602    let session_provider = match session_indices {
603        Some(idx) => {
604            let guest = idx
605                .load(&mut store, &instance)
606                .map_err(|e| anyhow::anyhow!("failed to load session-provider: {e}"))?;
607            Some(sessions::SessionProvider::from_guest(&guest))
608        }
609        None => None,
610    };
611
612    // Audit at instantiation: what is running, and under what modes. Modelled
613    // exactly like a tool call — a span with one event per capability class —
614    // so the same layer machinery renders it and OTLP gets queryable per-class
615    // attributes rather than a sentence.
616    let inst_span = crate::audit::instantiation_span(&audit.component_ref, &audit.digest);
617    {
618        let _g = inst_span.enter();
619        for (id, c) in &ceilings {
620            crate::audit::emit_ceiling_class(&crate::audit::CeilingClassRecord {
621                cap_id: id.clone(),
622                mode: c.effective_mode().to_string(),
623                declared: c.declared(),
624                has_prompt_channel: audit.has_prompt_channel,
625            });
626        }
627    }
628    // Dropping the span closes it; the layer renders the header line and, when
629    // a declared class resolved to deny, the declared-but-ungranted warning.
630    drop(inst_span);
631
632    Ok((tool_provider, session_provider, store))
633}
634/// Spawn the component actor task. Owns the Store, the tool-provider guest,
635/// and the optional `SessionProvider` (present iff the component supports
636/// `act:sessions/session-provider`).
637///
638/// Returns a handle for sending requests.
639pub fn spawn_component_actor(
640    tool_provider: ToolProvider,
641    session_provider: Option<sessions::SessionProvider>,
642    mut store: Store<HostState>,
643    current_consent: Arc<consent::CurrentConsentSink>,
644    audit: AuditContext,
645) -> ComponentHandle {
646    let (tx, mut rx) = mpsc::channel::<ComponentRequest>(32);
647
648    // Session-ids opened through this actor. Closed on actor shutdown
649    // per ACT-SESSIONS §2.5 ("host MUST call close-session for every
650    // still-open session before deinit").
651    let mut tracked_sessions: Vec<String> = Vec::new();
652
653    // The credential host, if this run has one. Taken from the store rather
654    // than passed in: it is already there, and reading it here keeps the two
655    // views of "which sessions are live" — this actor's `tracked_sessions`
656    // and the credential host's set — updated from the same three places.
657    // Every transport (MCP stdio, MCP over HTTP, `--session-args`) opens and
658    // closes sessions through these requests, so wiring it here covers all
659    // of them at once.
660    let credentials = store.data().credentials.clone();
661
662    tokio::spawn(async move {
663        while let Some(request) = rx.recv().await {
664            match request {
665                ComponentRequest::ListTools { metadata, reply } => {
666                    let provider = tool_provider.clone();
667                    let result = store
668                        .run_concurrent(async |accessor| {
669                            provider
670                                .call_list_tools(accessor, metadata.clone().into())
671                                .await
672                        })
673                        .await;
674                    let response = match result {
675                        Ok(Ok(Ok(list_response))) => Ok(list_response),
676                        Ok(Ok(Err(tool_error))) => Err(ComponentError::Tool(tool_error)),
677                        Ok(Err(e)) => Err(ComponentError::Internal(anyhow::anyhow!(
678                            "list-tools failed: {e}"
679                        ))),
680                        Err(e) => Err(ComponentError::Internal(anyhow::anyhow!(
681                            "run_concurrent failed: {e}"
682                        ))),
683                    };
684                    let _ = reply.send(response);
685                }
686                ComponentRequest::CallTool {
687                    name,
688                    arguments,
689                    metadata,
690                    reply,
691                    consent,
692                } => {
693                    // Point the consent slot at this call for the duration of
694                    // the guest execution. The actor serves one request at a
695                    // time, so a capability gate firing below always resolves
696                    // to the caller that is waiting for this reply.
697                    current_consent.set(consent);
698                    let provider = tool_provider.clone();
699
700                    let started = std::time::Instant::now();
701                    let meta_strings = decode_meta_strings(&metadata);
702                    let audit_span = crate::audit::tool_call_span(&crate::audit::ToolCallStart {
703                        component_ref: audit.component_ref.clone(),
704                        digest: audit.digest.clone(),
705                        tool: name.clone(),
706                        args_sha256: crate::audit::sha256_hex(&arguments),
707                        args_json: args_as_json(&arguments, audit.record_args),
708                        session_id: meta_str(&meta_strings, act_types::constants::META_SESSION_ID),
709                        agent_id: meta_str(&meta_strings, act_types::constants::META_AGENT_ID),
710                        request_id: meta_str(&meta_strings, act_types::constants::META_REQUEST_ID)
711                            .unwrap_or_else(new_request_id),
712                        traceparent: meta_str(
713                            &meta_strings,
714                            act_types::constants::META_TRACEPARENT,
715                        ),
716                        tracestate: meta_str(&meta_strings, act_types::constants::META_TRACESTATE),
717                        transport: audit.transport,
718                    });
719
720                    let collected: Arc<std::sync::Mutex<Vec<act::tools::types::ToolEvent>>> =
721                        Arc::new(std::sync::Mutex::new(Vec::new()));
722                    let collected2 = collected.clone();
723                    let (done_tx, done_rx) = oneshot::channel::<()>();
724
725                    let result = store
726                        .run_concurrent(async |accessor| {
727                            let tool_result = provider
728                                .call_call_tool(
729                                    accessor,
730                                    name.clone(),
731                                    arguments.clone(),
732                                    metadata.clone(),
733                                )
734                                .await?;
735
736                            accessor.with(|access| match tool_result {
737                                exports::act::tools::tool_provider::ToolResult::Streaming(
738                                    stream,
739                                ) => {
740                                    let consumer = CollectingConsumer {
741                                        collected,
742                                        done_tx: Some(done_tx),
743                                    };
744                                    let _ = stream.pipe(access, consumer);
745                                }
746                                exports::act::tools::tool_provider::ToolResult::Immediate(
747                                    events,
748                                ) => {
749                                    collected
750                                        .lock()
751                                        .unwrap_or_else(std::sync::PoisonError::into_inner)
752                                        .extend(events);
753                                    let _ = done_tx.send(());
754                                }
755                            });
756
757                            let _ = done_rx.await;
758
759                            Ok::<_, wasmtime::Error>(())
760                        })
761                        .instrument(audit_span.clone())
762                        .await;
763
764                    let response = match result {
765                        Ok(Ok(())) => {
766                            let events = collected2
767                                .lock()
768                                .unwrap_or_else(std::sync::PoisonError::into_inner)
769                                .drain(..)
770                                .collect();
771                            Ok(CallToolResult { events })
772                        }
773                        Ok(Err(e)) => Err(ComponentError::Internal(anyhow::anyhow!(
774                            "call-tool failed: {e}"
775                        ))),
776                        Err(e) => Err(ComponentError::Internal(anyhow::anyhow!(
777                            "run_concurrent failed: {e}"
778                        ))),
779                    };
780                    // Nothing is executing any more: drop the sink so a later
781                    // gate outside a call cannot answer through a stale caller.
782                    current_consent.set(None);
783                    let outcome = match &response {
784                        // `call-tool` reports a guest failure inside the event
785                        // list, not via the outer Result — see
786                        // `events_contain_error`.
787                        Ok(r) if events_contain_error(&r.events) => {
788                            crate::audit::Outcome::ToolError
789                        }
790                        Ok(_) => crate::audit::Outcome::Ok,
791                        Err(ComponentError::Tool(_)) => crate::audit::Outcome::ToolError,
792                        Err(_) => crate::audit::Outcome::HostError,
793                    };
794                    crate::audit::finish_tool_call(&audit_span, outcome, started.elapsed());
795                    let _ = reply.send(response);
796                }
797                ComponentRequest::GetOpenSessionArgsSchema { metadata, reply } => {
798                    let response = match &session_provider {
799                        Some(sp) => {
800                            let sp = sp.clone();
801                            let result = store
802                                .run_concurrent(async |accessor| {
803                                    sp.get_open_session_args_schema
804                                        .call_concurrent(&accessor, (metadata,))
805                                        .await
806                                })
807                                .await;
808                            session_call_to_response(result, |(r,)| r)
809                        }
810                        None => Err(ComponentError::Internal(anyhow::anyhow!(
811                            "component does not export act:sessions/session-provider"
812                        ))),
813                    };
814                    let _ = reply.send(response);
815                }
816
817                ComponentRequest::OpenSession {
818                    args,
819                    metadata,
820                    reply,
821                    consent,
822                } => {
823                    current_consent.set(consent);
824                    let response = match &session_provider {
825                        Some(sp) => {
826                            let sp = sp.clone();
827                            let result = store
828                                .run_concurrent(async |accessor| {
829                                    sp.open_session
830                                        .call_concurrent(&accessor, (args, metadata))
831                                        .await
832                                })
833                                .await;
834                            let inner = session_call_to_response(result, |(r,)| r);
835                            // Track open id so we can close on deinit.
836                            if let Ok(s) = &inner {
837                                tracked_sessions.push(s.id.clone());
838                                if let Some(c) = &credentials {
839                                    c.note_session_opened(&s.id);
840                                }
841                            }
842                            inner
843                        }
844                        None => Err(ComponentError::Internal(anyhow::anyhow!(
845                            "component does not export act:sessions/session-provider"
846                        ))),
847                    };
848                    current_consent.set(None);
849                    let _ = reply.send(response);
850                }
851
852                ComponentRequest::CloseSession { session_id, reply } => {
853                    let response: Result<(), ComponentError> = match &session_provider {
854                        Some(sp) => {
855                            let sp = sp.clone();
856                            let id = session_id.clone();
857                            let result = store
858                                .run_concurrent(async |accessor| {
859                                    sp.close_session.call_concurrent(&accessor, (id,)).await
860                                })
861                                .await;
862                            // Untrack regardless of error. Credentials stop
863                            // being served for this id at the same moment
864                            // (design §3.3: "after close-session the host stops
865                            // serving that id") — a close that the component
866                            // reported as failed still ends the session from
867                            // the host's side, so the two must agree.
868                            tracked_sessions.retain(|sid| sid != &session_id);
869                            if let Some(c) = &credentials {
870                                c.note_session_closed(&session_id);
871                            }
872                            match result {
873                                Ok(Ok(())) => Ok(()),
874                                Ok(Err(e)) => Err(ComponentError::Internal(anyhow::anyhow!(
875                                    "close-session failed: {e}"
876                                ))),
877                                Err(e) => Err(ComponentError::Internal(anyhow::anyhow!(
878                                    "run_concurrent failed: {e}"
879                                ))),
880                            }
881                        }
882                        None => Err(ComponentError::Internal(anyhow::anyhow!(
883                            "component does not export act:sessions/session-provider"
884                        ))),
885                    };
886                    let _ = reply.send(response);
887                }
888            }
889        }
890
891        // Actor channel closed → component is shutting down. Close any
892        // sessions we still track, best-effort. ACT-SESSIONS §2.5.
893        if let Some(sp) = &session_provider {
894            for id in std::mem::take(&mut tracked_sessions) {
895                if let Some(c) = &credentials {
896                    c.note_session_closed(&id);
897                }
898                let sp = sp.clone();
899                let _ = store
900                    .run_concurrent(async |accessor| {
901                        sp.close_session.call_concurrent(&accessor, (id,)).await
902                    })
903                    .await;
904            }
905        }
906    });
907
908    ComponentHandle::new(tx)
909}
910/// Helper for unwrapping `result<R, error>` returns from session-provider
911/// typed-func calls.
912fn session_call_to_response<R, F>(
913    raw: wasmtime::Result<wasmtime::Result<(Result<R, act::core::types::Error>,)>>,
914    extract: F,
915) -> Result<R, ComponentError>
916where
917    F: FnOnce((Result<R, act::core::types::Error>,)) -> Result<R, act::core::types::Error>,
918{
919    match raw {
920        Ok(Ok(tuple)) => match extract(tuple) {
921            Ok(r) => Ok(r),
922            Err(e) => Err(ComponentError::Tool(e)),
923        },
924        Ok(Err(e)) => Err(ComponentError::Internal(anyhow::anyhow!(
925            "session-provider call failed: {e}"
926        ))),
927        Err(e) => Err(ComponentError::Internal(anyhow::anyhow!(
928            "run_concurrent failed: {e}"
929        ))),
930    }
931}
932/// A `StreamConsumer` that collects all items into a Vec and signals completion.
933struct CollectingConsumer {
934    collected: Arc<std::sync::Mutex<Vec<act::tools::types::ToolEvent>>>,
935    done_tx: Option<oneshot::Sender<()>>,
936}
937impl StreamConsumer<HostState> for CollectingConsumer {
938    type Item = act::tools::types::ToolEvent;
939
940    fn poll_consume(
941        mut self: Pin<&mut Self>,
942        _cx: &mut Context<'_>,
943        store: StoreContextMut<HostState>,
944        mut source: Source<'_, Self::Item>,
945        finish: bool,
946    ) -> Poll<wasmtime::Result<StreamResult>> {
947        let mut buffer = Vec::with_capacity(64);
948        source.read(store, &mut buffer)?;
949
950        if !buffer.is_empty() {
951            self.collected
952                .lock()
953                .unwrap_or_else(std::sync::PoisonError::into_inner)
954                .extend(buffer);
955        }
956
957        if finish {
958            if let Some(tx) = self.done_tx.take() {
959                let _ = tx.send(());
960            }
961            Poll::Ready(Ok(StreamResult::Dropped))
962        } else {
963            Poll::Ready(Ok(StreamResult::Completed))
964        }
965    }
966}