Skip to main content

agent_engine/runtime/trace/
extension.rs

1//! Task 10C: trace wiring for extension-hosted providers (completing the
2//! Task 10 transport coverage). Mirrors the seams in `trace::anthropic` /
3//! `trace::openai` / `trace::google` with extension-specific honesty rules:
4//!
5//! - **Trace only actual IPC.** A record begins only after every routing gate
6//!   has passed — provider existence, per-provider trust, trust-state
7//!   readability, and the pre-IPC cancellation check — and immediately before
8//!   the `provider_stream`/`provider_complete` call crosses the extension
9//!   process boundary. Blocked, untrusted, unavailable, or
10//!   cancelled-before-start requests emit **no** request-attempt record.
11//! - **Wire is always `None`.** JSON-RPC framing and serialization for the
12//!   sidecar transport are owned by the extension process transport, not this
13//!   routing layer — there is no buffer of exact bytes here, so no wire
14//!   digest is ever claimed (same rule as the remote-broker paths).
15//! - **Attempt rule (documented invariant):** one outer extension turn is one
16//!   transport attempt — one record per `provider_stream` call or per outer
17//!   `provider_complete`/`complete_provider_with_tools` turn. The tool-loop
18//!   helper may invoke `provider.complete` several times inside one turn;
19//!   those interior tool-use iterations are *not* separate request attempts
20//!   and are deliberately not counted as retries (they are successful calls,
21//!   not failures — the `attempt`/`retries` invariant would be false for
22//!   them). The record therefore never claims an interior call count.
23//! - **Reserved static endpoint identity.** The endpoint host is the
24//!   RFC 2606-style reserved name `extension.invalid` with static paths
25//!   `/provider/stream` / `/provider/complete`; the exact qualified
26//!   `plugin:provider/model` identity travels in the validated model field.
27//!   Plugin file paths, spawn commands, params, messages, tools, errors and
28//!   credentials never enter a record.
29//! - **Extension-controlled data is untrusted.** Error text from the
30//!   extension is replaced by the static code
31//!   [`EXTENSION_PROVIDER_ERROR_CODE`]; stop reasons pass a closed mapping;
32//!   usage numbers are accepted only as plain unsigned integers under known
33//!   keys (never copied as raw values of any other shape).
34
35use super::emit::{AttemptClock, RequestStructure, RequestTracer, TraceContext};
36use super::types::{
37    EndpointMeta, StopReason, TraceId, TranslationAction, TranslationElement, TranslationLoss,
38    TransportKind, UsageMeta, UsageProvenance,
39};
40use agent_core::prompt::QualifiedModelId;
41use agent_core::TurnOutcome;
42use serde_json::Value;
43use std::sync::atomic::{AtomicU64, Ordering};
44use std::sync::Arc;
45use std::time::Instant;
46
47/// Reserved, never-routable endpoint host for extension provider records.
48/// `.invalid` is an RFC 2606 reserved TLD — this can never collide with or
49/// leak a real network endpoint.
50pub const EXTENSION_ENDPOINT_HOST: &str = "extension.invalid";
51
52/// Static endpoint path for `provider.stream` IPC.
53pub const EXTENSION_STREAM_PATH: &str = "/provider/stream";
54
55/// Static endpoint path for `provider.complete` IPC (including the tool-loop
56/// outer turn).
57pub const EXTENSION_COMPLETE_PATH: &str = "/provider/complete";
58
59/// The only failure code an extension-controlled error may produce in a
60/// trace record — extension error text is untrusted and never copied.
61pub const EXTENSION_PROVIDER_ERROR_CODE: &str = "extension_provider_error";
62
63/// Normalize an extension-reported stop reason through a closed mapping.
64/// Unknown values collapse to `Other`; the raw string is never stored.
65pub fn stop_reason_from_extension(raw: &str) -> StopReason {
66    match raw {
67        "end_turn" => StopReason::EndTurn,
68        "max_tokens" => StopReason::MaxTokens,
69        "stop_sequence" => StopReason::StopSequence,
70        "tool_use" => StopReason::ToolUse,
71        "pause_turn" => StopReason::PauseTurn,
72        "refusal" => StopReason::Refusal,
73        "content_filter" => StopReason::ContentFilter,
74        _ => StopReason::Other,
75    }
76}
77
78/// Extract provider-reported usage from the extension's free-shape `usage`
79/// value. Only plain unsigned integers under the known keys are accepted;
80/// anything else stays `None` — numbers are never fabricated and non-numeric
81/// extension content is never copied. Returns `None` when no metric parses.
82pub fn usage_from_extension_value(usage: Option<&Value>) -> Option<UsageMeta> {
83    let usage = usage?.as_object()?;
84    let field = |k: &str| usage.get(k).and_then(Value::as_u64);
85    let meta = UsageMeta {
86        provenance: UsageProvenance::ProviderReported,
87        input_tokens: field("input_tokens"),
88        output_tokens: field("output_tokens"),
89        cache_read_tokens: field("cache_read_input_tokens"),
90        cache_write_tokens: field("cache_creation_input_tokens"),
91    };
92    if meta.input_tokens.is_none()
93        && meta.output_tokens.is_none()
94        && meta.cache_read_tokens.is_none()
95        && meta.cache_write_tokens.is_none()
96    {
97        return None;
98    }
99    Some(meta)
100}
101
102/// Capability-driven translation report for the extension route, structural
103/// only. Known losses today:
104///
105/// - Tools are exposed to a model whose capabilities do not include
106///   `tool_use` (this is exactly the condition that selects the streaming
107///   path when tools exist — streaming tool-use events are ignored by the
108///   current forwarder): each tool is `Unsupported`. Element IDs are the
109///   tool names only when they fit the bounded safe grammar; otherwise the
110///   entry stands without an ID — unvalidated names are never copied.
111/// - The non-streaming display path joins the response's text blocks into a
112///   single live text event: reported as one `Merged` message-block entry.
113pub fn extension_capability_losses(
114    tools_schema: &[Value],
115    model_tool_use: bool,
116    nonstreaming_display_merge: bool,
117) -> Vec<TranslationLoss> {
118    let mut losses = Vec::new();
119    if !tools_schema.is_empty() && !model_tool_use {
120        for tool in tools_schema {
121            losses.push(TranslationLoss {
122                action: TranslationAction::Unsupported,
123                element: TranslationElement::Tool,
124                element_id: tool
125                    .get("name")
126                    .and_then(Value::as_str)
127                    .and_then(|name| TraceId::new(name).ok()),
128            });
129        }
130    }
131    if nonstreaming_display_merge {
132        losses.push(TranslationLoss {
133            action: TranslationAction::Merged,
134            element: TranslationElement::MessageBlock,
135            element_id: None,
136        });
137    }
138    losses
139}
140
141/// Begin tracing one extension provider IPC turn. Returns `None` when
142/// tracing is disabled or the qualified identity is unrepresentable —
143/// tracing can never fail or alter the provider request.
144#[allow(clippy::too_many_arguments)]
145pub async fn begin_extension_tracer(
146    trace: &TraceContext,
147    plugin_id: &str,
148    provider_id: &str,
149    model_id: &str,
150    streaming: bool,
151    messages: &[crate::SharedMessage],
152    system_prompt: Option<&str>,
153    tools_schema: &[Value],
154    translation: Vec<TranslationLoss>,
155) -> Option<RequestTracer> {
156    if !trace.enabled() {
157        return None;
158    }
159    // The qualified identity is `plugin:provider/model`. A `/` inside the
160    // plugin or provider segment would shift the provider boundary of the
161    // QualifiedModelId — refuse rather than record a misattributed identity.
162    if plugin_id.contains('/') || provider_id.contains('/') {
163        return None;
164    }
165    let model = QualifiedModelId::parse(format!("{plugin_id}:{provider_id}/{model_id}")).ok()?;
166    let path = if streaming {
167        EXTENSION_STREAM_PATH
168    } else {
169        EXTENSION_COMPLETE_PATH
170    };
171    let endpoint = EndpointMeta::new(EXTENSION_ENDPOINT_HOST, path).ok()?;
172    let key = trace.digest_key().await;
173    let mut structure: RequestStructure = super::anthropic::anthropic_request_structure(
174        key.as_deref(),
175        &[],
176        messages,
177        system_prompt,
178        tools_schema,
179        None,
180        false,
181        false,
182        translation,
183        None,
184    );
185    // The JSON-RPC sidecar transport owns framing/serialization — this
186    // process never holds the exact bytes, so no wire digest is ever claimed.
187    structure.wire = None;
188    // cache_control annotations on the internal messages are not a wire
189    // contract with the extension; reporting boundaries would fabricate them.
190    structure.cache = Default::default();
191    // Content capture (`/trace next content`) is statically unsupported
192    // here for the same reason: no exact serialized request body exists in
193    // this process, and a re-serialization of the params would masquerade
194    // as wire content. Fail the arm explicitly (metadata warning + degraded
195    // counter) — never silently consume it with no artifact.
196    trace.capture_unsupported(
197        "extension provider: the sidecar owns request serialization; \
198         no exact request body exists in this process",
199    );
200    RequestTracer::begin(
201        trace,
202        None,
203        model,
204        TransportKind::Extension,
205        endpoint,
206        structure,
207    )
208}
209
210/// Sentinel meaning "no first model event observed yet".
211const FIRST_EVENT_UNSET: u64 = u64::MAX;
212
213/// Cheap cloneable handle the stream forwarder uses to mark the first model
214/// event (first `TextDelta`/`ThinkingDelta`/`ToolUse`/`Usage`). Set-once via
215/// compare-exchange; a `None` interior (tracing disabled) makes `mark()` a
216/// no-op with zero shared state.
217#[derive(Clone)]
218pub struct FirstEventMark(Option<(Instant, Arc<AtomicU64>)>);
219
220impl FirstEventMark {
221    pub fn mark(&self) {
222        if let Some((started, cell)) = &self.0 {
223            let _ = cell.compare_exchange(
224                FIRST_EVENT_UNSET,
225                started.elapsed().as_millis() as u64,
226                Ordering::AcqRel,
227                Ordering::Relaxed,
228            );
229        }
230    }
231}
232
233/// Per-turn trace state for one extension provider IPC invocation: the
234/// optional tracer, this turn's monotonic clock, and the shared first-event
235/// cell. Every method is a no-op when tracing is disabled; nothing here can
236/// fail the request. Exactly one `finish_*` record is emitted — the tracer
237/// is taken on finish, so later calls can never double-emit.
238pub struct ExtensionAttempt {
239    tracer: Option<RequestTracer>,
240    clock: AttemptClock,
241    started: Instant,
242    first_event: Arc<AtomicU64>,
243}
244
245impl ExtensionAttempt {
246    /// Start the turn clock. Call immediately before the IPC send.
247    pub fn new(tracer: Option<RequestTracer>) -> Self {
248        Self {
249            tracer,
250            clock: AttemptClock::start(),
251            started: Instant::now(),
252            first_event: Arc::new(AtomicU64::new(FIRST_EVENT_UNSET)),
253        }
254    }
255
256    /// Handle for the forwarder task to mark the first model event.
257    pub fn first_event_mark(&self) -> FirstEventMark {
258        if self.tracer.is_none() {
259            return FirstEventMark(None);
260        }
261        FirstEventMark(Some((self.started, self.first_event.clone())))
262    }
263
264    fn apply_first_event(&mut self) {
265        let observed = self.first_event.load(Ordering::Acquire);
266        if observed != FIRST_EVENT_UNSET {
267            self.clock.set_first_model_event_offset(observed);
268        }
269    }
270
271    /// Final record: turn completed. Stop reason/usage stay `None` when the
272    /// extension reported nothing — never fabricated.
273    pub fn finish_success(&mut self, stop_reason: Option<StopReason>, usage: Option<UsageMeta>) {
274        self.apply_first_event();
275        self.clock.mark_stream_end();
276        if let Some(t) = self.tracer.take() {
277            t.finish(
278                self.clock,
279                None,
280                None,
281                stop_reason,
282                usage,
283                TurnOutcome::Completed,
284            );
285        }
286    }
287
288    /// Final record: canceled while the IPC turn was active.
289    pub fn finish_canceled(&mut self) {
290        self.apply_first_event();
291        self.clock.mark_stream_end();
292        if let Some(t) = self.tracer.take() {
293            t.finish(self.clock, None, None, None, None, TurnOutcome::Canceled);
294        }
295    }
296
297    /// Final record: the extension turn failed. `code` must be a static
298    /// literal ([`EXTENSION_PROVIDER_ERROR_CODE`]) — never extension text.
299    pub fn finish_failed(&mut self, code: &str) {
300        self.apply_first_event();
301        self.clock.mark_stream_end();
302        if let Some(t) = self.tracer.take() {
303            let terminal = t.failed_terminal(code);
304            t.finish(self.clock, None, None, None, None, terminal);
305        }
306    }
307}