Skip to main content

submilli_engine/stdlib/agents/
mod.rs

1//! `submilli:agents` — hand work to a sub-agent from inside a Submilli program.
2//!
3//! An optional package: it exists only for an embedder that enables
4//! [`OptionalPackage::Agents`](crate::stdlib::OptionalPackage::Agents). The
5//! harness runs the agents through [`StoreData::agent_provider`]; a runtime with
6//! none refuses every op with a catchable error rather than inventing an answer.
7//!
8//! **One capability.** `run` and `list()` are both `agent.run`, filtered by
9//! `agent`. `list()` gates each candidate with the same filter, so a listing
10//! never offers an agent the caller would be denied — the `llm.models()` shape.
11//!
12//! **No input or output in metadata.** The filter context is the agent name
13//! only; the task and the answer reach the call log as payload bodies, never in
14//! an error or a policy context.
15
16pub mod declaration;
17
18use crate::runtime::effects;
19use std::sync::Arc;
20
21use wasmtime::{FuncType, HeapType, Linker, RefType, StructType, Val, ValType};
22
23use crate::runtime::StoreData;
24use crate::runtime::agents::{
25    AgentCallError, AgentInfo, AgentOutcome, AgentProvider, AgentRequest,
26};
27use crate::runtime::call_log::{ModelUsage, Payload, Side, record_payload, record_usage};
28use crate::runtime::decision::CallTicket;
29use crate::runtime::fuel;
30use crate::runtime::host::{
31    abi_arg, read_string_arg, register_host_value_async, write_submilli_string_struct,
32};
33use crate::runtime::intrinsic_types::build_intrinsic_types;
34use crate::stdlib::abi::{
35    self, HostView, backing_struct, install_field_getters, nullable_object_field, string_field,
36};
37use crate::stdlib::shared::{
38    check_security_call, permitted_candidates, preflight_listing, running_package,
39    sanitize_description, truncated,
40};
41
42pub use declaration::package_declaration;
43
44pub const MODULE_NAME: &str = crate::runtime::agents::AGENTS_MODULE_NAME;
45
46/// The capability gating every op in this package.
47pub const CAPABILITY: &str = "agent.run";
48
49// `$AgentInfoBacking` field indices (0 is the vtable).
50const A_NAME: usize = 1;
51const A_DESCRIPTION: usize = 2;
52
53const AGENT_INFO_VIEW: HostView = HostView {
54    module: MODULE_NAME,
55    interface: "AgentInfo",
56    backing: agent_info_backing_struct,
57    properties: &[("name", A_NAME), ("description", A_DESCRIPTION)],
58};
59
60/// The host-backed interfaces this package's values are.
61pub(crate) const HOST_VIEWS: &[&HostView] = &[&AGENT_INFO_VIEW];
62
63pub fn install(linker: &mut Linker<StoreData>) -> wasmtime::Result<()> {
64    let engine = linker.engine().clone();
65    let intr = build_intrinsic_types(&engine)?;
66    let string = ValType::Ref(RefType::new(
67        false,
68        HeapType::ConcreteStruct(intr.string.clone()),
69    ));
70    let nullable_object = ValType::Ref(RefType::new(
71        true,
72        HeapType::ConcreteStruct(intr.object.clone()),
73    ));
74    let array = ValType::Ref(RefType::new(
75        false,
76        HeapType::ConcreteStruct(intr.array.clone()),
77    ));
78
79    register_host_value_async(
80        linker,
81        MODULE_NAME,
82        crate::mangle::package_symbol(MODULE_NAME, "run"),
83        FuncType::new(
84            &engine,
85            [string.clone(), string.clone(), nullable_object.clone()],
86            // `run` is generic, so its result travels in the erased `T` slot.
87            [nullable_object.clone()],
88        ),
89        |caller, params| {
90            Box::pin(async move {
91                let agent =
92                    read_string_arg(&mut *caller, abi_arg(params, 0)?, "agents.run (agent)")?;
93                let input =
94                    read_string_arg(&mut *caller, abi_arg(params, 1)?, "agents.run (input)")?;
95                let schema = read_optional_string(caller, abi_arg(params, 2)?)?;
96                run(caller, agent, input, schema).await
97            })
98        },
99    )?;
100
101    register_host_value_async(
102        linker,
103        MODULE_NAME,
104        crate::mangle::package_symbol(MODULE_NAME, "list"),
105        FuncType::new(&engine, [], [array]),
106        |caller, _params| Box::pin(async move { list(caller).await }),
107    )?;
108
109    let receiver = ValType::Ref(RefType::new(
110        false,
111        HeapType::ConcreteStruct(intr.object.clone()),
112    ));
113    install_field_getters(linker, &engine, &receiver, &AGENT_INFO_VIEW)
114}
115
116/// One run: gate, charge, record what is sent, dispatch, then build the result
117/// without refusing for fuel, because the run has already happened.
118async fn run(
119    caller: &mut wasmtime::Caller<'_, StoreData>,
120    agent: String,
121    input: String,
122    schema: Option<String>,
123) -> wasmtime::Result<Val> {
124    let ticket = gate(caller, &agent)?;
125    let principal = running_package(caller).map_err(|error| error.into_denial(CAPABILITY))?;
126    let sent = (input.len() + schema.as_deref().map_or(0, str::len)) as u64;
127    record_payload(&*caller, ticket, Side::Request, || {
128        Payload::meta(serde_json::json!({ "op": "run", "agent": agent, "schema": schema }))
129            .with_owned_body(input.clone().into_bytes())
130            .with_size(sent)
131    });
132    let typed = schema.is_some();
133    let request = AgentRequest {
134        agent: agent.clone(),
135        input,
136        schema,
137        caller: principal,
138    };
139    let outcome = effects::execute(caller, "agents.run", request, move |caller, _, request| {
140        Box::pin(async move {
141            fuel::charge(&mut *caller, fuel::IO, sent)?;
142            let provider = provider(caller).map_err(|error| fail(caller, ticket, "run", &error))?;
143            let outcome = provider
144                .run(request)
145                .await
146                .map_err(|error| fail(caller, ticket, "run", &error))?;
147            record_outcome(caller, ticket, &outcome);
148            fuel::settle(&mut *caller, fuel::IO, outcome.text.len() as u64)?;
149            Ok(outcome)
150        })
151    })
152    .await?;
153    if typed {
154        crate::runtime::json::parse_json_as_unknown(
155            caller,
156            &outcome.text,
157            &format!(
158                "agents.run(\"{}\"): the agent's result is not JSON",
159                truncated(&agent)
160            ),
161        )
162    } else {
163        let text = write_submilli_string_struct(caller, &outcome.text)?;
164        Ok(Val::AnyRef(Some(text.to_anyref())))
165    }
166}
167
168/// Record `error` as the call's response and turn it into the guest's error.
169fn fail(
170    caller: &wasmtime::Caller<'_, StoreData>,
171    ticket: Option<CallTicket>,
172    op: &str,
173    error: &AgentCallError,
174) -> wasmtime::Error {
175    record_payload(caller, ticket, Side::Response, || {
176        Payload::meta(serde_json::json!({ "call_error": call_error_record(error) }))
177    });
178    throw(op, error)
179}
180
181fn record_outcome(
182    caller: &wasmtime::Caller<'_, StoreData>,
183    ticket: Option<CallTicket>,
184    outcome: &AgentOutcome,
185) {
186    let usage = outcome.usage.clone().unwrap_or_default();
187    record_payload(caller, ticket, Side::Response, || {
188        Payload::meta(serde_json::json!({
189            "usage": {
190                "input_tokens": usage.input_tokens,
191                "output_tokens": usage.output_tokens,
192            }
193        }))
194        .with_owned_body(outcome.text.clone().into_bytes())
195        .with_size(outcome.text.len() as u64)
196    });
197    record_usage(
198        caller,
199        ticket,
200        ModelUsage {
201            input_tokens: usage.input_tokens,
202            output_tokens: usage.output_tokens,
203        },
204    );
205}
206
207/// The capability check, run before anything reaches the harness. The context
208/// is the agent name only: a policy can choose which agents a caller reaches,
209/// not read the task.
210fn gate(
211    caller: &mut wasmtime::Caller<'_, StoreData>,
212    agent: &str,
213) -> wasmtime::Result<Option<CallTicket>> {
214    check_security_call(caller, CAPABILITY, serde_json::json!({ "agent": agent }))
215}
216
217/// `list()`: every agent the harness offers that the caller may run. A denied
218/// candidate is left out, never reported. Whether a provider is wired is not
219/// hidden here: a listing has no candidate to gate before asking it.
220async fn list(caller: &mut wasmtime::Caller<'_, StoreData>) -> wasmtime::Result<Val> {
221    preflight_listing(caller, CAPABILITY, &serde_json::json!({ "agent": "" }))?;
222    let candidates = effects::execute(caller, "agents.list", (), |caller, _, ()| {
223        Box::pin(async move {
224            provider(caller)
225                .map_err(|error| throw("list", &error))?
226                .agents()
227                .await
228                .map_err(|e| throw("list", &e))
229        })
230    })
231    .await?;
232    let visible = permitted_candidates(
233        caller,
234        candidates,
235        |agent| agent.name.as_str(),
236        |caller, name| gate(caller, name).map(|_| ()),
237    )?;
238    let mut built = Vec::with_capacity(visible.len());
239    for agent in visible {
240        built.push(build_agent_info(caller, agent)?);
241    }
242    abi::new_array(caller, &built)
243}
244
245/// Clone the provider out of the store before any `await`. `run` looks it up
246/// after the capability check, so a denied caller cannot learn whether a harness
247/// is wired.
248fn provider(
249    caller: &wasmtime::Caller<'_, StoreData>,
250) -> Result<Arc<dyn AgentProvider>, AgentCallError> {
251    caller
252        .data()
253        .agent_provider
254        .clone()
255        .ok_or(AgentCallError::NotConfigured)
256}
257
258fn throw(op: &str, error: &AgentCallError) -> wasmtime::Error {
259    wasmtime::Error::msg(format!("agents.{op}: {error}"))
260}
261
262/// A run's failure as the call log keeps it: a stable kind and the agent.
263fn call_error_record(error: &AgentCallError) -> serde_json::Value {
264    match error {
265        AgentCallError::NotConfigured => serde_json::json!({ "kind": "not-configured" }),
266        AgentCallError::NotFound { agent } => {
267            serde_json::json!({ "kind": "not-found", "agent": agent })
268        }
269        AgentCallError::Failed { agent, message } => {
270            serde_json::json!({ "kind": "failed", "agent": agent, "message": message })
271        }
272        AgentCallError::Cancelled { agent } => {
273            serde_json::json!({ "kind": "cancelled", "agent": agent })
274        }
275    }
276}
277
278/// Read the compiler-filled `schema` argument. Undefined means an untyped call.
279fn read_optional_string(
280    caller: &mut wasmtime::Caller<'_, StoreData>,
281    val: &Val,
282) -> wasmtime::Result<Option<String>> {
283    if crate::runtime::prelude::undefined::is_undefined(caller, val)? {
284        return Ok(None);
285    }
286    read_string_arg(caller, val, "agents.run (schema)").map(Some)
287}
288
289fn agent_info_backing_struct(engine: &wasmtime::Engine) -> wasmtime::Result<StructType> {
290    let intr = build_intrinsic_types(engine)?;
291    backing_struct(
292        engine,
293        &intr,
294        vec![
295            string_field(&intr),          // name
296            nullable_object_field(&intr), // description
297        ],
298    )
299}
300
301fn build_agent_info(
302    caller: &mut wasmtime::Caller<'_, StoreData>,
303    agent: AgentInfo,
304) -> wasmtime::Result<Val> {
305    let name = write_submilli_string_struct(caller, &agent.name)?;
306    let description = match agent.description.as_deref().and_then(sanitize_description) {
307        Some(text) => Val::AnyRef(Some(
308            write_submilli_string_struct(caller, &text)?.to_anyref(),
309        )),
310        None => crate::runtime::prelude::undefined::value(caller)?,
311    };
312    let ty = agent_info_backing_struct(caller.engine())?;
313    abi::new_backing(
314        caller,
315        ty,
316        &[Val::AnyRef(Some(name.to_anyref())), description],
317    )
318}