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