tool/execution

Scheduling, completion modes, durable tool effects, and output projection for tools that need more than a simple inline ToolResult::ok(...).

Tool Execution Modes

When the model emits multiple tool calls in one assistant turn, the runtime can dispatch them concurrently or one-at-a-time. The choice is declared per tool via ToolDefinition::with_scheduling; the runtime groups the batch and runs each group accordingly.

ToolScheduling::Parallel (default)

The tool's execute() may run concurrently with other parallel tools from the same batch. The dispatcher schedules them through FuturesUnordered and collects results as each completes. Right for pure reads, network fetches, file scans: anything safe to interleave.

ToolScheduling::Serial

The tool blocks the batch: the runtime runs serial tools one at a time in the order the model emitted them, and only after all parallel tools in the batch have finished. Right for tools that mutate shared state (write to a file, update a database, advance a state machine) where overlapping calls would corrupt outcomes.

use lash::tools::{ToolDefinition, ToolScheduling};

ToolDefinition::raw(
    "tool:write_file",
    "write_file",
    "Replace a file's contents.",
    input_schema,
    output_schema,
)
.with_scheduling(ToolScheduling::Serial)

Mixing parallel and serial tools in one batch is supported and common: a read-heavy plan emitting five read_file calls plus one write_file gets the reads concurrently and the write afterward. There is no model-visible distinction; the model sees the same JSON tool list regardless of dispatch mode.

Active vs. Deferred Completion

A tool's execute() returns a ToolResult, and the variant it returns chooses how the call completes. Most tools finish inline; a tool that waits on out-of-band work parks the call and lets an external resolver deliver the result later.

ToolResult::Done (active await)

The result is available inline and the runtime finalizes the call immediately. Build it with ToolResult::ok, ToolResult::err, ToolResult::failure, and friends.

ToolResult::Pending (deferred completion)

The tool has launched out-of-band work (a webhook, a human approval, another service) and the real outcome is delivered later against a completion key. The runtime parks the call on a durable wait and resumes it when the key resolves.

Deferred completion has one hard rule: before returning ToolResult::Pending, the tool must first obtain a completion key by calling call.context.completion_key().await. That key names the durable wait and is what the external resolver later uses to deliver the result. Returning Pending without having taken a key fails the call with the internal error pending_tool_missing_completion_key.

use lash::tools::PendingCompletion;

// Take the completion key BEFORE returning Pending, then hand it to whatever
// will deliver the result out-of-band — a webhook, a job queue, a human.
let key = match call.context.completion_key().await {
    Ok(key) => key,
    Err(err) => return ToolResult::err_fmt(format_args!("{err}")),
};
enqueue_external_work(key);

// Returning Pending without first taking the key fails the call with
// `pending_tool_missing_completion_key`.
ToolResult::pending(PendingCompletion::new())

PendingCompletion configures the wait: an optional deadline, an on_timeout behavior (ErrorAsResult feeds a timeout result back to the model; FailTurn fails the whole turn), and an on_cancel hint (CancelExternalWork by default, or Ignore to leave the out-of-band work running). Adjust it with PendingCompletion::new().with_deadline(..) and .fail_turn_on_timeout(). The remote protocol carries tool grants only; execute tools through a normal ToolProvider at the host boundary.

Durable Tool Effects

Durable tool effects are for advanced in-process tools that need to do nondeterministic work inside the caller's existing effect log. The tool borrows the boundary with call.context.durable_effects()?; Lash records JSON steps and waits without exposing Restate, Temporal, or any workflow-native context.

requirements

durable_effects() succeeds only for a prepared tool call with a non-empty call id, running under an effect controller that advertises durable-effects support. Otherwise it returns a clear runtime error such as durable_effects_missing_call_id or durable_effects_unavailable.

journaled step

run_json(step_id, input, run) records one JSON step in the caller's effect log. step_id must be non-empty and unique within the tool invocation. The stable replay envelope includes the step id, the JSON input, and the invocation replay key, so replay can return the recorded value without re-running the local closure.

external wait

external_event_key(key) returns a signed custom AwaitEventKey. Store the whole returned key if an external route will resolve it later; the string you passed in is not enough. await_event_json(key) parks through the existing AwaitEvent effect path and resumes with Resolution::Ok's JSON value, or maps error, timeout, and cancellation resolutions into runtime errors.

process event

emit_process_event(event_type, payload) appends a typed process event when the tool is executing inside a durable process. Calls outside a process fail with durable_effect_process_event_unavailable; use context.process_events().emit(...) only when you do not need the durable-effects facade.

Journal a JSON Step

Use run_json around the side effect that must not double-run after replay. The closure receives the JSON input that was recorded in the envelope; return a JSON value that is safe to store in the effect log.

let durable = match call.context.durable_effects() {
    Ok(durable) => durable,
    Err(err) => return ToolResult::err_fmt(err),
};

let opened = match durable
    .run_json(
        "create-input-request",
        serde_json::json!({ "question": call.args["question"].clone() }),
        |input| async move {
            // Create the external record, enqueue work, or call a service.
            Ok(serde_json::json!({
                "request_id": "request-1",
                "question": input["question"].clone()
            }))
        },
    )
    .await
{
    Ok(value) => value,
    Err(err) => return ToolResult::err_fmt(err),
};

Wait On An External Resolver

Generate the key before publishing the request, persist or emit the full key, then await that same key. Hosts resolve it through their effect host; the tool stays local and does not receive workflow-engine context.

let key = match durable
    .external_event_key(format!("input-request:{}", opened["request_id"]))
    .await
{
    Ok(key) => key,
    Err(err) => return ToolResult::err_fmt(err),
};

// Store or emit the complete key. The resolver needs the signed key, not just
// the human-readable custom identity string used above.
if let Err(err) = publish_input_request(opened.clone(), key.clone()).await {
    return ToolResult::err_fmt(format_args!("{err}"));
}

let answer = match durable.await_event_json(key).await {
    Ok(value) => value,
    Err(err) => return ToolResult::err_fmt(err),
};

Emit A Domain Process Event

When the tool is running inside a Lashlang process, process event context is carried into the ToolContext. That lets ordinary tools append domain events from the same implementation path the model calls.

if let Err(err) = durable
    .emit_process_event(
        "work.input_request.opened",
        serde_json::json!({
            "request_id": opened["request_id"].clone(),
            "await_key_id": key.key_id,
        }),
    )
    .await
{
    return ToolResult::err_fmt(err);
}

Replay Rules

Tool-Output Budgeting

Large tool outputs blow up provider context, model context windows, and persistent history all at once. ToolOutputBudgetPluginFactory sits in the runtime_plugin_stack() by default and projects oversized outputs into shorter forms at three different sites, so the dispatch result, the model's next request, and the session graph each see an appropriately budgeted view.

use lash::plugins::{
    ToolOutputBudgetConfig, ToolOutputBudgetPluginFactory, runtime_plugin_stack,
};

let config = ToolOutputBudgetConfig {
    limit: 32 * 1024, // default: 16 * 1024 bytes
    max_lines: 800,   // default: 400
    ..ToolOutputBudgetConfig::default()
};

let plugins = runtime_plugin_stack().configure(|plugins| {
    plugins.replace(Arc::new(ToolOutputBudgetPluginFactory::new(config)));
});

Defaults are 16 KiB and 400 lines per tool output (the size cap is bytes by default; ToolOutputBudgetMode::Tokens switches it to an approximate token count). Outputs under both caps pass through untouched; outputs over either cap keep a preview window wrapped in a "...N bytes truncated..." marker; the unit reads bytes (or tokens) when the size cap binds, lines when the line cap binds.

The plugin registers a tool-result projector that applies at three points:

RLM mode print observations go through the same projector: a Lashlang program that prints a 200 KiB blob sees the same trimmed view its next observation would carry. Replace the factory rather than appending; reg.tool_results() is exclusive (one projector per session). Plugins that need different projection logic should build on ToolOutputBudgetConfig rather than fight it.

Where Next

This page owns advanced execution behavior. Use the tools overview for registration, context capabilities, and registry state.

read on ·