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}