Skip to main content

harn_vm/llm/
resolved_dispatch.rs

1//! ResolvedDispatch — one self-contained record of what an LLM call actually
2//! dispatched, and what came back.
3//!
4//! WHY THIS EXISTS
5//!
6//! Answering "what provider/model/wire-format/thinking did this LLM call
7//! actually use, where did each of those come from, and what did it return?"
8//! used to require joining the `provider_call_request` and
9//! `provider_call_response` transcript events by `call_id`, cross-referencing
10//! `capabilities.toml` to learn the wire format, and reconstructing the
11//! provenance of each field by reading scattered resolution layers. The
12//! transcript needs to carry the *final resolved decision* so route debugging
13//! does not depend on reconstructing state from adjacent events.
14//!
15//! `resolved_dispatch` collapses that into ONE append-only transcript event
16//! per LLM call. It is:
17//!   - self-contained (no join needed),
18//!   - deterministic in its wire-format/base-url fields (derived from the
19//!     capability registry, the single source of truth), and
20//!   - provenance-bearing: each of provider/model/wire_format/thinking/
21//!     tool_format records WHERE it came from. The high-signal value is
22//!     `inherited_from_primary`, which flags silent route inheritance directly.
23//!
24//! This module is observability-only. It reads the request options + the
25//! result and emits a record; it never feeds back into request construction,
26//! so the model's next-turn payload is byte-identical with or without it.
27
28use super::api::{LlmCallOptions, ThinkingConfig};
29use super::capabilities::WireDialect;
30
31/// Where a single resolved dispatch field came from. Carried on
32/// [`LlmCallOptions::dispatch_provenance`], populated from the internal
33/// `_dispatch_provenance` channel by the pipeline resolver
34/// (Burin's smart-escalation / model-selection layers, threaded through the
35/// agent-loop options). `Unknown` is the default when no resolver annotated the
36/// call — e.g. a raw `harness.llm.call(...)` from script context.
37///
38/// The string values are a small, stable vocabulary so downstream tooling
39/// (the harness-debugger `dispatch_trace` MCP tool) can filter on them:
40/// - `operator_pin`: an explicit operator/env pin, such as
41///   `BURIN_EVAL_SMART_PROVIDER`.
42/// - `pipeline_input`: a `selected_*` field on the pipeline input.
43/// - `escalation_override`: chosen by the smart-escalation resolver.
44/// - `catalog_default`: filled from the provider catalog / capability registry.
45/// - `inherited_from_primary`: no pin/override existed, so the value fell
46///   through from the cheap primary model. THIS is the value that flags a
47///   silent-inheritance bug.
48#[derive(Debug, Clone, Default, PartialEq, Eq)]
49pub struct DispatchProvenance {
50    pub provider: Option<String>,
51    pub model: Option<String>,
52    pub wire_format: Option<String>,
53    pub thinking: Option<String>,
54    pub tool_format: Option<String>,
55}
56
57impl DispatchProvenance {
58    /// Canonical provenance origin for a value that fell through from the
59    /// primary model with no pin or override. Named as a constant so the
60    /// resolver and the tests reference the same smoking-gun literal.
61    pub const INHERITED_FROM_PRIMARY: &'static str = "inherited_from_primary";
62    pub const OPERATOR_PIN: &'static str = "operator_pin";
63    pub const ESCALATION_OVERRIDE: &'static str = "escalation_override";
64    pub const PIPELINE_INPUT: &'static str = "pipeline_input";
65    pub const CATALOG_DEFAULT: &'static str = "catalog_default";
66
67    /// Parse an internal `_dispatch_provenance` dict supplied by the pipeline
68    /// resolver into the typed provenance. Each entry is a per-field origin
69    /// string (`operator_pin`, `escalation_override`, `inherited_from_primary`,
70    /// ...); absent entries stay `None` and surface as `"unknown"` in the
71    /// record. Returns `None` when the value is absent or not a dict, so a
72    /// non-annotating caller pays nothing.
73    pub fn from_vm_value(value: &crate::value::VmValue) -> Option<Self> {
74        let dict = value.as_dict()?;
75        let field = |key: &str| -> Option<String> {
76            dict.get(key)
77                .map(|v| v.as_str_cow().into_owned())
78                .filter(|s| !s.is_empty())
79        };
80        Some(Self {
81            provider: field("provider"),
82            model: field("model"),
83            wire_format: field("wire_format"),
84            thinking: field("thinking"),
85            tool_format: field("tool_format"),
86        })
87    }
88
89    fn origin_or_unknown(value: &Option<String>) -> &str {
90        value.as_deref().unwrap_or("unknown")
91    }
92
93    fn to_json(&self) -> serde_json::Value {
94        serde_json::json!({
95            "provider": Self::origin_or_unknown(&self.provider),
96            "model": Self::origin_or_unknown(&self.model),
97            "wire_format": Self::origin_or_unknown(&self.wire_format),
98            "thinking": Self::origin_or_unknown(&self.thinking),
99            "tool_format": Self::origin_or_unknown(&self.tool_format),
100        })
101    }
102}
103
104/// The normalized outcome of a dispatched LLM call. Derived from the result (on
105/// success) or the thrown error (on failure) so a consumer never has to
106/// pattern-match raw error strings.
107///
108/// The `served` vs `empty_completion_transient_recovered` vs
109/// `empty_completion_terminal` split is the distinction the escalation guard
110/// hinges on: an empty response the runtime retried and recovered from is not a
111/// dead lane; only a terminal unrecovered empty is.
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub(crate) enum DispatchOutcome {
114    /// The call returned committed content / a tool call / thinking on the
115    /// first attempt.
116    Served {
117        completion_tokens: i64,
118        content_len: usize,
119    },
120    /// The provider emitted one or more empty completions that the runtime
121    /// retried and RECOVERED from — the call ultimately served. Not a dead
122    /// lane; the retry machinery did its job.
123    EmptyCompletionTransientRecovered {
124        completion_tokens: i64,
125        content_len: usize,
126        empty_retries: usize,
127    },
128    /// The provider billed output tokens but committed nothing, and the runtime
129    /// exhausted its retry budget (or surfaced the empty as a terminal error).
130    /// THIS is the "escalation served empty" dead-lane signal.
131    EmptyCompletionTerminal { completion_tokens: i64 },
132    /// The provider hit a usage / quota / rate limit.
133    UsageLimit,
134    /// Any other provider-side error, with a short class label.
135    ProviderError { class: String },
136}
137
138impl DispatchOutcome {
139    /// Classify a successful [`super::api::LlmResult`], given how many
140    /// empty-completion retries preceded this (recovered) result. An empty
141    /// committed message with billed output that survives to this point is
142    /// TERMINAL (the retry budget was exhausted and the loop is returning the
143    /// empty result unchanged); a served result after >0 empty retries is a
144    /// transient-recovered flake; a clean first-attempt serve is `served`.
145    pub(crate) fn from_result(result: &super::api::LlmResult, empty_retries: usize) -> Self {
146        let content_len = result.text.len();
147        // Trim-based: a whitespace-only / echoed-stop-sequence completion that
148        // billed tokens committed nothing usable and must book as an empty
149        // completion, not `served` (harn#4744). Shared with the retry predicate.
150        let committed_nothing = result.committed_nothing_usable();
151        if committed_nothing && result.output_tokens > 0 {
152            return DispatchOutcome::EmptyCompletionTerminal {
153                completion_tokens: result.output_tokens,
154            };
155        }
156        if empty_retries > 0 {
157            return DispatchOutcome::EmptyCompletionTransientRecovered {
158                completion_tokens: result.output_tokens,
159                content_len,
160                empty_retries,
161            };
162        }
163        DispatchOutcome::Served {
164            completion_tokens: result.output_tokens,
165            content_len,
166        }
167    }
168
169    /// Classify a thrown TERMINAL error message into the outcome vocabulary.
170    /// Only called on the surfaced (non-retryable) error, so an empty-completion
171    /// error here is by definition terminal. Keyed on the same stable substrings
172    /// the retry/skip classifiers use, so the record agrees with the runtime's
173    /// own routing decisions.
174    pub(crate) fn from_error_message(message: &str) -> Self {
175        let lower = message.to_lowercase();
176        if lower.contains("completion_tokens=")
177            && (lower.contains("delivered no content")
178                || (lower.contains("no dispatchable tool call or answer")
179                    && lower.contains("upstream contract violation")))
180        {
181            // The token count is embedded in the message but is not needed for
182            // the class; downstream consumers read `completion_tokens` from the
183            // sibling `provider_call_response` when they need the exact value.
184            // A surfaced empty-completion error is terminal by construction.
185            return DispatchOutcome::EmptyCompletionTerminal {
186                completion_tokens: 0,
187            };
188        }
189        if lower.contains("rate limit")
190            || lower.contains("quota")
191            || lower.contains("usage limit")
192            || lower.contains("429")
193        {
194            return DispatchOutcome::UsageLimit;
195        }
196        DispatchOutcome::ProviderError {
197            class: provider_error_class(&lower),
198        }
199    }
200
201    /// Stable machine label. `dispatch_trace` filters on these.
202    pub(crate) fn label(&self) -> &'static str {
203        match self {
204            DispatchOutcome::Served { .. } => "served",
205            DispatchOutcome::EmptyCompletionTransientRecovered { .. } => {
206                "empty_completion_transient_recovered"
207            }
208            DispatchOutcome::EmptyCompletionTerminal { .. } => "empty_completion_terminal",
209            DispatchOutcome::UsageLimit => "usage_limit",
210            DispatchOutcome::ProviderError { .. } => "provider_error",
211        }
212    }
213
214    fn to_json(&self) -> serde_json::Value {
215        match self {
216            DispatchOutcome::Served {
217                completion_tokens,
218                content_len,
219            } => serde_json::json!({
220                "kind": "served",
221                "completion_tokens": completion_tokens,
222                "content_len": content_len,
223            }),
224            DispatchOutcome::EmptyCompletionTransientRecovered {
225                completion_tokens,
226                content_len,
227                empty_retries,
228            } => serde_json::json!({
229                "kind": "empty_completion_transient_recovered",
230                "completion_tokens": completion_tokens,
231                "content_len": content_len,
232                "empty_retries": empty_retries,
233            }),
234            DispatchOutcome::EmptyCompletionTerminal { completion_tokens } => serde_json::json!({
235                "kind": "empty_completion_terminal",
236                "completion_tokens": completion_tokens,
237                "content_len": 0,
238            }),
239            DispatchOutcome::UsageLimit => serde_json::json!({
240                "kind": "usage_limit",
241            }),
242            DispatchOutcome::ProviderError { class } => serde_json::json!({
243                "kind": "provider_error",
244                "class": class,
245            }),
246        }
247    }
248}
249
250/// Coarse class label for a provider error message so `dispatch_trace` can
251/// bucket failures without exposing the full (potentially secret-bearing) text.
252fn provider_error_class(lower: &str) -> String {
253    for (needle, class) in [
254        ("api error", "api_error"),
255        ("timed out", "timeout"),
256        ("timeout", "timeout"),
257        ("connection", "connection"),
258        ("missing content array", "malformed_response"),
259        ("authentication", "auth"),
260        ("unauthorized", "auth"),
261        ("401", "auth"),
262        ("not found", "not_found"),
263        ("404", "not_found"),
264        ("overloaded", "overloaded"),
265        ("500", "server_error"),
266        ("502", "server_error"),
267        ("503", "server_error"),
268    ] {
269        if lower.contains(needle) {
270            return class.to_string();
271        }
272    }
273    "unknown".to_string()
274}
275
276/// The wire format an LLM call dispatched over, derived from the capability
277/// registry (the single source of truth). This is the field whose absence made
278/// the escalation incident hard to root-cause.
279pub fn wire_format_for(provider: &str, model: &str) -> &'static str {
280    match super::capabilities::lookup(provider, model).message_wire_format {
281        WireDialect::Anthropic => "anthropic_native",
282        WireDialect::OpenAiCompat => "openai_compat",
283        WireDialect::Ollama => "ollama",
284        WireDialect::Gemini => "gemini",
285    }
286}
287
288/// The host of the base URL the call went to (e.g. `api.anthropic.com`), so a
289/// misrouted call to a proxy / third-party rehost is visible at a glance. Falls
290/// back to the raw base URL when it has no parseable host.
291fn base_url_host(provider: &str) -> String {
292    let base_url = super::helpers::ResolvedProvider::resolve(provider).base_url;
293    base_url
294        .split("://")
295        .nth(1)
296        .and_then(|rest| rest.split('/').next())
297        .map(str::to_string)
298        .unwrap_or(base_url)
299}
300
301fn thinking_json(thinking: &ThinkingConfig) -> serde_json::Value {
302    match thinking {
303        ThinkingConfig::Disabled => serde_json::json!({"mode": "off", "enabled": false}),
304        ThinkingConfig::Enabled { budget_tokens } => serde_json::json!({
305            "mode": "enabled",
306            "enabled": true,
307            "budget_tokens": budget_tokens,
308        }),
309        ThinkingConfig::Adaptive => serde_json::json!({"mode": "adaptive", "enabled": true}),
310        ThinkingConfig::Effort { level } => serde_json::json!({
311            "mode": "effort",
312            "level": level.as_str(),
313            "enabled": !thinking.is_disabled(),
314        }),
315    }
316}
317
318/// Build the single self-contained `resolved_dispatch` transcript record for
319/// one LLM call. `iteration`/`call_id`/`span_id` correlate it with the sibling
320/// `provider_call_request` / `provider_call_response` events; the record itself
321/// carries everything a consumer needs to answer "what dispatched, from where,
322/// and what came back" without any join.
323pub(crate) fn build_record(
324    iteration: usize,
325    call_id: &str,
326    span_id: Option<u64>,
327    timestamp: String,
328    opts: &LlmCallOptions,
329    effective_tool_format: &str,
330    outcome: &DispatchOutcome,
331) -> serde_json::Value {
332    let provenance = opts.dispatch_provenance.clone().unwrap_or_default();
333    serde_json::json!({
334        "type": "resolved_dispatch",
335        "iteration": iteration,
336        "call_id": call_id,
337        "span_id": span_id,
338        "timestamp": timestamp,
339        "provider": opts.provider,
340        "model": opts.model,
341        "wire_format": wire_format_for(&opts.provider, &opts.model),
342        "thinking": thinking_json(&opts.thinking),
343        "tool_format": effective_tool_format,
344        // The resolved stop list actually sent to the provider, so a
345        // missing/dropped text tool-call terminator (harn#4743) is observable
346        // per call rather than inferred from the transcript.
347        "stop": opts.stop,
348        "base_url_host": base_url_host(&opts.provider),
349        "provenance": provenance.to_json(),
350        "outcome": outcome.to_json(),
351        "outcome_kind": outcome.label(),
352    })
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    #[test]
360    fn wire_format_native_for_anthropic_claude() {
361        // A claude-* model on the anthropic provider must resolve native.
362        assert_eq!(
363            wire_format_for("anthropic", "claude-sonnet-4-6"),
364            "anthropic_native"
365        );
366    }
367
368    #[test]
369    fn wire_format_compat_for_openai_style() {
370        assert_eq!(wire_format_for("openai", "gpt-4o"), "openai_compat");
371    }
372
373    #[test]
374    fn wire_format_preserves_native_non_openai_dialects() {
375        assert_eq!(wire_format_for("gemini", "gemini-2.5-pro"), "gemini");
376        assert_eq!(wire_format_for("ollama", "llama3.2"), "ollama");
377    }
378
379    #[test]
380    fn outcome_empty_completion_terminal_from_billed_no_content() {
381        // The transport error must name the actual native wire style, not a
382        // generic OpenAI-compatible path.
383        let msg = "anthropic-native model anthropic:claude-sonnet-4-6 reported \
384                   completion_tokens=8 but delivered no content, reasoning, or tool calls";
385        assert!(matches!(
386            DispatchOutcome::from_error_message(msg),
387            DispatchOutcome::EmptyCompletionTerminal {
388                completion_tokens: 0
389            }
390        ));
391    }
392
393    #[test]
394    fn transient_recovered_is_not_served_empty() {
395        // A recovered flake (>0 empty retries but the call ultimately served)
396        // must not be flagged as the dead-lane signal.
397        let recovered = DispatchOutcome::EmptyCompletionTransientRecovered {
398            completion_tokens: 487,
399            content_len: 1666,
400            empty_retries: 3,
401        };
402        assert_eq!(recovered.label(), "empty_completion_transient_recovered");
403        assert!(!matches!(
404            recovered,
405            DispatchOutcome::EmptyCompletionTerminal { .. }
406        ));
407    }
408
409    #[test]
410    fn outcome_usage_limit_from_quota() {
411        assert_eq!(
412            DispatchOutcome::from_error_message("provider returned 429 rate limit exceeded"),
413            DispatchOutcome::UsageLimit
414        );
415    }
416
417    #[test]
418    fn outcome_provider_error_class() {
419        match DispatchOutcome::from_error_message("anthropic API error: overloaded") {
420            DispatchOutcome::ProviderError { class } => assert_eq!(class, "api_error"),
421            other => panic!("expected provider_error, got {other:?}"),
422        }
423    }
424
425    #[test]
426    fn provenance_inherited_marker_is_stable() {
427        assert_eq!(
428            DispatchProvenance::INHERITED_FROM_PRIMARY,
429            "inherited_from_primary"
430        );
431        let prov = DispatchProvenance {
432            provider: Some(DispatchProvenance::INHERITED_FROM_PRIMARY.to_string()),
433            ..Default::default()
434        };
435        let json = prov.to_json();
436        assert_eq!(json["provider"], "inherited_from_primary");
437        // Unset fields serialize as the explicit "unknown" sentinel so a
438        // consumer never sees a missing key.
439        assert_eq!(json["model"], "unknown");
440    }
441}