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, 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::{self, read_type_argument};
35use crate::stdlib::json_schema;
36use crate::stdlib::shared::{
37    check_and_approve_call, check_candidate, permitted_candidates, preflight_listing,
38    running_package, 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
48pub fn install(linker: &mut Linker<StoreData>) -> wasmtime::Result<()> {
49    let engine = linker.engine().clone();
50    let intr = build_intrinsic_types(&engine)?;
51    let string = ValType::Ref(RefType::new(
52        false,
53        HeapType::ConcreteStruct(intr.string.clone()),
54    ));
55    let nullable_object = ValType::Ref(RefType::new(
56        true,
57        HeapType::ConcreteStruct(intr.object.clone()),
58    ));
59    let array = ValType::Ref(RefType::new(
60        false,
61        HeapType::ConcreteStruct(intr.array.clone()),
62    ));
63
64    // `runText` takes what a program writes, the optional `schema` arriving
65    // as `string | undefined`.
66    register_host_value_async(
67        linker,
68        MODULE_NAME,
69        crate::mangle::package_symbol(MODULE_NAME, "runText"),
70        FuncType::new(
71            &engine,
72            [string.clone(), string.clone(), nullable_object.clone()],
73            [string.clone()],
74        ),
75        |caller, params| {
76            Box::pin(async move {
77                let agent =
78                    read_string_arg(&mut *caller, abi_arg(params, 0)?, "agents.runText (agent)")?;
79                let input =
80                    read_string_arg(&mut *caller, abi_arg(params, 1)?, "agents.runText (input)")?;
81                let schema = json_schema::read_written_schema(
82                    caller,
83                    abi_arg(params, 2)?,
84                    "agents.runText",
85                )?;
86                let answer = run(caller, "runText", &agent, input, schema).await?;
87                let text = write_submilli_string_struct(caller, &answer)?;
88                Ok(Val::AnyRef(Some(text.to_anyref())))
89            })
90        },
91    )?;
92    // `run<T>` is generic: after what a program writes comes the `$types`
93    // environment every generic call passes, holding the `$TypeDescriptor` for
94    // `T`, whose schema the call sends.
95    let types = ValType::Ref(RefType::new(
96        false,
97        HeapType::ConcreteArray(intr.object_fields.clone()),
98    ));
99    register_host_value_async(
100        linker,
101        MODULE_NAME,
102        crate::mangle::package_symbol(MODULE_NAME, "run"),
103        FuncType::new(
104            &engine,
105            [string.clone(), string.clone(), types],
106            // `run` is generic, so its result travels in the erased `T` slot.
107            [nullable_object.clone()],
108        ),
109        |caller, params| {
110            Box::pin(async move {
111                let agent =
112                    read_string_arg(&mut *caller, abi_arg(params, 0)?, "agents.run (agent)")?;
113                let input =
114                    read_string_arg(&mut *caller, abi_arg(params, 1)?, "agents.run (input)")?;
115                let descriptor = read_type_argument(caller, abi_arg(params, 2)?, 0)?;
116                let schema = descriptor.schema_for("agents.run")?.to_string();
117                let answer = run(caller, "run", &agent, input, Some(schema)).await?;
118                let value = crate::runtime::json::parse_json_as_unknown(
119                    caller,
120                    &answer,
121                    &format!(
122                        "agents.run(\"{}\"): the agent's result is not JSON",
123                        truncated(&agent)
124                    ),
125                )?;
126                // The descriptor's predicate checks the answer against `T`. The
127                // message names the type, never the answer or a key in it.
128                if descriptor.accepts(caller, value).await? {
129                    return Ok(value);
130                }
131                Err(crate::runtime::host::type_error(format!(
132                    "agents.run(\"{}\"): the agent's answer does not match `{}`",
133                    truncated(&agent),
134                    descriptor.name
135                )))
136            })
137        },
138    )?;
139
140    register_host_value_async(
141        linker,
142        MODULE_NAME,
143        crate::mangle::package_symbol(MODULE_NAME, "list"),
144        FuncType::new(&engine, [], [array]),
145        |caller, _params| Box::pin(async move { list(caller).await }),
146    )?;
147
148    Ok(())
149}
150
151/// One run: gate, charge, record what is sent, dispatch, and return the
152/// agent's answer text. Each caller builds its result from it without
153/// refusing for fuel, because the run has already happened. `api` (`run` or
154/// `runText`) names the errors; the call log and the replay history record
155/// the run as `agents.run` whichever function made it.
156async fn run(
157    caller: &mut wasmtime::Caller<'_, StoreData>,
158    api: &'static str,
159    agent: &str,
160    input: String,
161    schema: Option<String>,
162) -> wasmtime::Result<String> {
163    let ticket = gate(caller, agent).await?;
164    let principal = running_package(caller).map_err(|error| error.into_denial(CAPABILITY))?;
165    let sent = (input.len() + schema.as_deref().map_or(0, str::len)) as u64;
166    record_payload(&*caller, ticket, Side::Request, || {
167        Payload::meta(serde_json::json!({ "op": "run", "agent": agent, "schema": schema }))
168            .with_owned_body(input.clone().into_bytes())
169            .with_size(sent)
170    });
171    let request = AgentRequest {
172        agent: agent.to_string(),
173        input,
174        schema,
175        caller: principal,
176    };
177    let outcome = effects::execute(caller, "agents.run", request, move |caller, _, request| {
178        Box::pin(async move {
179            fuel::charge(&mut *caller, fuel::IO, sent)?;
180            let provider = provider(caller).map_err(|error| fail(caller, ticket, api, &error))?;
181            let outcome = provider
182                .run(request)
183                .await
184                .map_err(|error| fail(caller, ticket, api, &error))?;
185            record_outcome(caller, ticket, &outcome);
186            fuel::settle(&mut *caller, fuel::IO, outcome.text.len() as u64)?;
187            Ok(outcome)
188        })
189    })
190    .await?;
191    Ok(outcome.text)
192}
193
194/// Record `error` as the call's response and turn it into the guest's error.
195fn fail(
196    caller: &wasmtime::Caller<'_, StoreData>,
197    ticket: Option<CallTicket>,
198    op: &str,
199    error: &AgentCallError,
200) -> wasmtime::Error {
201    record_payload(caller, ticket, Side::Response, || {
202        Payload::meta(serde_json::json!({ "call_error": call_error_record(error) }))
203    });
204    throw(op, error)
205}
206
207fn record_outcome(
208    caller: &wasmtime::Caller<'_, StoreData>,
209    ticket: Option<CallTicket>,
210    outcome: &AgentOutcome,
211) {
212    let usage = outcome.usage.clone().unwrap_or_default();
213    record_payload(caller, ticket, Side::Response, || {
214        Payload::meta(serde_json::json!({
215            "usage": {
216                "input_tokens": usage.input_tokens,
217                "output_tokens": usage.output_tokens,
218            }
219        }))
220        .with_owned_body(outcome.text.clone().into_bytes())
221        .with_size(outcome.text.len() as u64)
222    });
223    record_usage(
224        caller,
225        ticket,
226        ModelUsage {
227            input_tokens: usage.input_tokens,
228            output_tokens: usage.output_tokens,
229        },
230    );
231}
232
233/// The capability check, run before anything reaches the harness. The context
234/// is the agent name only: a policy can choose which agents a caller reaches,
235/// not read the task.
236async fn gate(
237    caller: &mut wasmtime::Caller<'_, StoreData>,
238    agent: &str,
239) -> wasmtime::Result<Option<CallTicket>> {
240    check_and_approve_call(caller, CAPABILITY, gate_context(agent)).await
241}
242
243fn gate_context(agent: &str) -> serde_json::Value {
244    serde_json::json!({ "agent": agent })
245}
246
247/// `list()`: every agent the harness offers that the caller may run. A denied
248/// candidate is left out, never reported. Whether a provider is wired is not
249/// hidden here: a listing has no candidate to gate before asking it.
250async fn list(caller: &mut wasmtime::Caller<'_, StoreData>) -> wasmtime::Result<Val> {
251    preflight_listing(caller, CAPABILITY, &serde_json::json!({ "agent": "" }))?;
252    let candidates = effects::execute(caller, "agents.list", (), |caller, _, ()| {
253        Box::pin(async move {
254            provider(caller)
255                .map_err(|error| throw("list", &error))?
256                .agents()
257                .await
258                .map_err(|e| throw("list", &e))
259        })
260    })
261    .await?;
262    let visible = permitted_candidates(
263        caller,
264        candidates,
265        |agent| agent.name.as_str(),
266        |caller, name| check_candidate(caller, CAPABILITY, gate_context(name)),
267    )?;
268    let mut built = Vec::with_capacity(visible.len());
269    for agent in visible {
270        built.push(build_agent_info(caller, agent)?);
271    }
272    abi::new_array(caller, &built)
273}
274
275/// Clone the provider out of the store before any `await`. `run` looks it up
276/// after the capability check, so a denied caller cannot learn whether a harness
277/// is wired.
278fn provider(
279    caller: &wasmtime::Caller<'_, StoreData>,
280) -> Result<Arc<dyn AgentProvider>, AgentCallError> {
281    caller
282        .data()
283        .agent_provider
284        .clone()
285        .ok_or(AgentCallError::NotConfigured)
286}
287
288fn throw(op: &str, error: &AgentCallError) -> wasmtime::Error {
289    wasmtime::Error::msg(format!("agents.{op}: {error}"))
290}
291
292/// A run's failure as the call log keeps it: a stable kind and the agent.
293fn call_error_record(error: &AgentCallError) -> serde_json::Value {
294    match error {
295        AgentCallError::NotConfigured => serde_json::json!({ "kind": "not-configured" }),
296        AgentCallError::NotFound { agent } => {
297            serde_json::json!({ "kind": "not-found", "agent": agent })
298        }
299        AgentCallError::Failed { agent, message } => {
300            serde_json::json!({ "kind": "failed", "agent": agent, "message": message })
301        }
302        AgentCallError::Cancelled { agent } => {
303            serde_json::json!({ "kind": "cancelled", "agent": agent })
304        }
305    }
306}
307
308fn build_agent_info(
309    caller: &mut wasmtime::Caller<'_, StoreData>,
310    agent: AgentInfo,
311) -> wasmtime::Result<Val> {
312    let name = write_submilli_string_struct(caller, &agent.name)?;
313    let description = match agent.description.as_deref().and_then(sanitize_description) {
314        Some(text) => Val::AnyRef(Some(
315            write_submilli_string_struct(caller, &text)?.to_anyref(),
316        )),
317        None => crate::runtime::prelude::undefined::value(caller)?,
318    };
319    abi::new_object(
320        caller,
321        std::collections::BTreeMap::from([
322            ("name".to_string(), Val::AnyRef(Some(name.to_anyref()))),
323            ("description".to_string(), description),
324        ]),
325    )
326}