Standard tool call
The model emits a native tool call. It reports as ToolCallStarted → ToolCallCompleted with no graph_key and no parent_call_id — a top-level call.
Lash reports what a turn is doing on several channels at once, each with a different carrier type, audience, and stability contract. This page is the map: which channel to consume for which job, what fires during standard versus code-block execution, why every structured record carries the runtime tool name, and the schema-evolution rules that keep the channels stable.
Five reporting channels carry runtime activity. Pick the narrowest one that matches the boundary you are crossing and the guarantee you need.
| Channel | Carrier type | Role | Stability | Consume when |
|---|---|---|---|---|
SessionEvent |
lash_sansio::SessionEvent |
Low-level runtime/debug stream: text and reasoning deltas, tool start, LLM request/response, token usage. | Runtime-internal. Not an app contract; shape follows the runtime. | You render an in-process debug or TUI view and want raw runtime signals. |
TurnActivity / TurnEvent |
lash_core::TurnActivity (wraps TurnEvent) |
Stable, app-facing semantic signals with identity (id + correlation_id). |
Additive within the workspace; the exhaustive match is the drift guard. Not versioned, not #[non_exhaustive]. |
You drive an app UI or fold live turn activity. See Turns and streams. |
TraceSink / TraceRecord |
lash_trace::TraceRecord (TraceEvent) |
Durable diagnostics: every provider call, tool call, prompt build, usage delta, protocol step, and Lashlang graph update. | Versioned JSONL schema. TRACE_SCHEMA_VERSION = 9; version 5 added composition snapshots, version 6 added replay-drop evidence naming typed minting and serving LLM Provider routes, including endpoints, version 7 renamed the Lashlang execution records to language-tagged ones, version 8 typed the turn/effect/wait/timer outcomes, and version 9 promotes the four runtime exec diagnostics to first-class typed events with typed per-tool status while removing the redundant tool_call_count and terminal_finish_present fields. The retry attempt outcome remains free-form by explicit FIG-1827 carve-out and is tracked by FIG-1832. |
You need billing, audit, offline analysis, OpenTelemetry export, or a rendered trace. See Tracing. |
ProcessEventSink |
lash_core::ProcessEventSink |
Best-effort freshness push for durable background-process events, in per-process append order. | Never truth (ADR 0017). Terminal events do not ride it; reconcile from the durable event log. | You render a live process log. Await terminal state through the work driver, not the sink. |
RemoteTurnActivity |
lash_remote_protocol::RemoteTurnActivity |
Versioned wire mirror of TurnActivity, deliberately narrower than the in-process enum. |
External stability boundary. Envelope<RemoteTurnActivity> carries the single protocol_version == REMOTE_PROTOCOL_VERSION stamp (currently 47); the nested activity body carries none. Version 47 centralizes that stamp without otherwise changing the top-level wire object. Version 46 carries turn-cancel disposition and affected-input outcomes; version 45 adds the accept_turn_input runtime-effect kind for durable turn acceptance; version 44 derives status from the turn outcome and drops the in_progress status; version 43 rejects remote turn reports whose status disagrees with their outcome; version 42 distinguishes the signal-only resident_changed session-observation event from committed; version 41 adds the caller_departed process status and its matching filter; version 40 adds the AssistantResponseHooks runtime-effect kind; version 39 adds the emit_trigger tool-intent kind; version 38 adds the LanguageRuntimeValue runtime-effect kind. Version 37 carries endpoint-aware LLM provider replay routes and replay-drop evidence. Version 36 carries FIG-1291's typed tool-intent outcomes and replay attribution through activity events alongside version 35's sealed LLM attempt ledgers and provider execution evidence in turn results and semantic activity streams. Version 34 cuts generation-disposition vocabulary over to suppressed_protocol_owned and carries typed protocol-abort evidence; version 33 renames the agent-frame switch field from frame_id to frame_key; version 32 is allocated to the concurrent wake-coalesce wire change. Version 31 requires an explicit TurnBudget in process execution policies and uses process-env:v3 refs. Version 24 coordinated required session-turn definition_key values, process-env:v2 refs, and trigger definition/operation fingerprints. |
Activity crosses HTTP, queues, or another process. See Remote protocol. |
SessionEvent is the runtime's own low-level stream and follows the runtime; ProcessEventSink is a freshness overlay, never a source of truth. Apps build on TurnActivity in-process, on RemoteTurnActivity across a boundary, and read durable history from TraceRecord or the session read view — not from the two internal channels.
The relationship between the two app channels is deliberate: TurnEvent is the in-process source, and RemoteTurnEvent is its wire mirror. The mirror is narrower on purpose — canonical queued-input application has its own typed identity event, while three other host-internal variants collapse into one generic diagnostic envelope (see Schema evolution) so the external contract does not churn every time a runtime-internal signal is added.
A tool call reports the same way whether the model called it natively (standard mode) or a Lashlang code block called it (RLM / code-exec mode). One shared seam guarantees it.
Both modes route every tool call through the same tool-execution seam in lash-core. That seam emits exactly one ToolCallStarted and one ToolCallCompleted per call on both the TurnEvent channel and the TraceEvent channel, and the OpenTelemetry sink turns each pair into one lash.tool span. There is no per-protocol emission path to drift out of sync; the decision is recorded in docs/adr/0018-per-tool-telemetry-emits-from-one-shared-seam.md. What differs between the two modes is only the containment a call reports.
The model emits a native tool call. It reports as ToolCallStarted → ToolCallCompleted with no graph_key and no parent_call_id — a top-level call.
A Lashlang block reports as CodeBlockStarted → … → CodeBlockCompleted. The start carries a graph_key; the completion repeats it and lists the tool_call_ids that ran inside.
Each tool the block invokes reports the enclosing block's graph_key. A child of a batch dispatch also carries parent_call_id — the batch call's id.
graph_key answers "which code block was this tool call in?" and matches the enclosing CodeBlockStarted.graph_key. parent_call_id answers "which batch spawned it?". A UI groups rows from these two fields directly instead of guessing containment from the order events arrive. Both are Option, and both are omitted when absent.
Code-block execution writes typed phase events to the trace, including a per-tool completion roll-up.
On the trace channel, ExecCodeStarted, ExecCodeCompleted, ExecCodeFailed, and ObservationProjection are first-class TraceEvent variants. ExecCodeCompleted carries a typed tool_calls array of { call_id, name, duration_ms, status }; status uses the existing TraceToolCallStatus snake-case vocabulary. Consumers derive the count from the array and terminal presence from terminal_finish.
{
"type": "exec_code_completed",
"duration_ms": 43,
"output": "first result\nsecond result",
"output_chars": 26,
"observation_count": 2,
"observation_truncation": [],
"error": null,
"terminal_finish": null,
"tool_calls": [
{ "call_id": "call-3", "name": "exec_command", "duration_ms": 41, "status": "success" },
{ "call_id": "call-4", "name": "fetch_url", "duration_ms": 2, "status": "success" }
]
}
The OTel sink derives span names from the same typed events, so both modes produce comparable spans.
| Span name | Source event | Notes |
|---|---|---|
lash.tool | ToolCallStarted / ToolCallCompleted | One per tool call, standard and code-exec. Keyed by tool:<call_id>; carries lash.tool.name, lash.tool.status, lash.tool.duration_ms. |
lash.exec_code | ExecCodeStarted / ExecCodeCompleted / ExecCodeFailed | The three typed exec phases collapse into this one family; the precise event kind rides the lash.protocol.diagnostic_phase attribute. |
lash.observation_projection | ObservationProjection | The typed observation-projection event keeps its distinct name. |
lash.protocol_step | ProtocolStep | Host and plugin protocol steps retain the opaque escape hatch. |
The span-name and attribute mechanics are owned by Tracing → OpenTelemetry Export; this page only notes that per-tool spans and the lash.exec_code family exist in both modes.
Every structured channel carries a tool's runtime name. The surface name — the Lashlang call path — appears only inside code text.
A tool has two names. The runtime name (the second argument to ToolDefinition::raw) is what the LLM tool schema advertises and what the runtime dispatches on; it is the name field on TurnEvent::ToolCall*, TraceEvent::ToolCall*, the lash.tool.name OTel attribute, a host's [tool] <name> line, and the tool_calls diagnostic roll-up. The surface name is the Lashlang call path (module.operation) registered by with_tool_binding; it appears only in Lashlang source, tool descriptions, and the code payload of a CodeBlockStarted event.
A model writes await shell.exec({ cmd: "…" }), but the tool call it produces reports as name: "exec_command" on every channel. When you match, filter, or label tool activity, match the runtime name. Reach for the surface name only when you are reading or generating Lashlang source.
The built-in shell catalog is the canonical example of the split (crates/lash-tools/src/shell/mod.rs):
| Surface (Lashlang call path) | Runtime name (on every channel) | Aliases |
|---|---|---|
shell.exec | exec_command | shell, bash |
shell.start | start_command | long_running_command, pty |
shell.write | write_stdin | send_stdin, poll_command |
The mapping is not a central table in the codebase: each tool declares its runtime name and its Lashlang binding on its own ToolDefinition. The pattern — runtime name for dispatch and reporting, surface path for authoring — holds for every catalog, not just the shell.
Token counts have one canonical shape and two deliberate boundary copies, each fenced by a compile-forced drift guard.
The canonical counter is lash_sansio::TokenUsage: five buckets — uncached input_tokens, total output_tokens, cache_read_input_tokens, cache_write_input_tokens, and reasoning_output_tokens. Reasoning is a subset of output, not an additive total. UsageTotals wraps it with a derived total_tokens.
TraceTokenUsageThe trace channel's copy (lash-trace). The two converters that build it in lash-core destructure their source exhaustively (no ..), so adding a runtime bucket is a compile error until the trace mirror is extended.
RemoteUsageThe wire channel's copy (lash-remote-protocol). Its From converters destructure the core type exhaustively too, keeping the versioned wire format from silently dropping a new bucket.
The trace and remote schemas are separate stability boundaries, so they hold their own usage structs rather than serializing the internal type. The exhaustive destructure is the mechanism that stops the three from drifting: extend one bucket upstream and the build breaks until each boundary copy is extended too.
Each channel evolves under its own rule. The in-workspace channel leans on the compiler; the two external boundaries carry version numbers.
TurnEvent — additive, compiler-guarded#[non_exhaustive] so every in-repo consumer's exhaustive match — above all the From<TurnEvent> for RemoteTurnEvent conversion — fails to compile until it handles the new shape. The exhaustive match is the drift guard. There is no version number on TurnEvent; the compiler is the contract. Every Option field is skip_serializing_if = "Option::is_none", so absent fields never appear on the wire and adding one does not disturb existing consumers.RemoteTurnActivity — the external wire boundaryEnvelope<RemoteTurnActivity> carries protocol_version (equal to REMOTE_PROTOCOL_VERSION); Envelope::decode_json(...) rejects an unsupported version before the bare activity body is decoded. The wire enum is deliberately narrower than TurnEvent: QueuedInputAccepted maps to typed TurnInputApplied { applications } evidence carrying admission, turn, committed-message, and optional active-checkpoint identity without display text. Hosts reconcile a missed live application through the durable application read, not by parsing RuntimeDiagnostic or treating a pending-input snapshot as evidence. The other three host-internal variants — QueuedWorkStarted, PluginRuntime, and QueuedMessagesCommitted — collapse into RuntimeDiagnostic { kind, data }. Every other variant maps one to one, containment keys included. Bump REMOTE_PROTOCOL_VERSION for a breaking wire change.TraceRecord — the durable diagnostics boundaryTRACE_SCHEMA_VERSION governs the durable JSONL schema. Adding a TraceEvent variant is breaking for closed-enum readers and does bump the version; version 5 adds CompositionChanged, version 6 adds ProviderReplayDropped with typed minting and serving LLM Provider routes, version 7 renames the Lashlang execution records to language-tagged ones, version 8 types the turn/effect/wait/timer outcomes, and version 9 replaces the four opaque runtime exec-diagnostic envelopes with typed events, types the completed event's per-tool roll-up, and removes tool_call_count and terminal_finish_present. The retry attempt outcome remains free-form by explicit FIG-1827 carve-out and is tracked by FIG-1832. Adding an optional (skip-when-None) field is additive only for readers that ignore unknown fields. Renaming a field, removing a field, or changing the meaning of an existing field also bumps it. The free-form Custom { name, payload } and ProtocolStep { plugin_id, payload } payloads remain opaque JSON for host and plugin payloads.Inside the workspace, let the exhaustive match catch drift. At a boundary that outlives a single build — the wire, the durable trace — a version number does the job the compiler cannot, and additive-only changes stay off the version dial.
This page maps the channels. Follow each to its owning guide for the wiring recipe.