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}