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