Skip to main content

agent_engine/runtime/trace/
google.rs

1//! Task 10B: trace wiring for the Google Gemini Code Assist transport
2//! (broker-proxied `v1internal:streamGenerateContent`) and the explicit
3//! cloud-invoke routes (AWS Bedrock, Azure OpenAI, Google Vertex — all
4//! broker-mediated). Mirrors the Anthropic/OpenAI seams:
5//!
6//! - **Exact bytes only.** The Gemini transport serializes its envelope once
7//!   via `ProxyRequest::post_json_exact`; on the **local** broker those very
8//!   bytes go on the wire and are the digest preimage. On a **remote**
9//!   broker the daemon re-serializes out of process, so records carry
10//!   `wire: None` and `TransportKind::CloudProxy` — never a digest of bytes
11//!   this process did not send. Cloud-invoke bodies are always serialized
12//!   behind the broker boundary (provider-specific signing/shapes), so those
13//!   records carry `wire: None` unconditionally.
14//! - **One record per actual broker attempt.** The Gemini 429 retry loop
15//!   emits one record per `proxy_stream` call; a cancellation observed
16//!   during backoff sleep (no send in flight) emits no extra record. A
17//!   tool-bearing cloud request fails §5.5 preflight before any broker or
18//!   network work and emits **no** attempt record.
19//! - **Honest translation reporting.** The Gemini adapter's known losses —
20//!   dropped thinking/unknown blocks, dropped internal-only tools, merged
21//!   text segments, synthesized function-response names — are reported as
22//!   metadata-only entries (positional IDs, never content). Cloud-invoke
23//!   routes flatten structured content to text and report the downgrade.
24//! - **Correctness firewall.** Everything degrades to no-ops when tracing
25//!   is disabled or identity is unrepresentable; provider-controlled error
26//!   text never enters a record.
27
28use super::emit::{RequestTracer, TraceContext};
29use super::types::{
30    EndpointMeta, StopReason, TranslationAction, TranslationElement, TranslationLoss,
31    TransportKind, UsageMeta, UsageProvenance,
32};
33use crate::runtime::google_gemini::translate::GeminiUsage;
34use agent_core::auth::CloudProviderId;
35use agent_core::prompt::QualifiedModelId;
36use serde_json::Value;
37
38/// Tool names the Gemini adapter drops as internal-only (never sent on the
39/// Code Assist wire). Shared with `runtime::google_gemini::runtime`'s wire
40/// filter so the trace report can never drift from the actual translation.
41pub const GEMINI_INTERNAL_TOOLS: [&str; 3] = ["respond", "send_channel", "watcher_exit"];
42
43/// Normalize a Gemini `finishReason` into the trace enum. Unknown values
44/// collapse to `Other` — the raw string is never stored.
45pub fn stop_reason_from_gemini(raw: &str) -> StopReason {
46    match raw {
47        "STOP" => StopReason::EndTurn,
48        "MAX_TOKENS" => StopReason::MaxTokens,
49        "TOOL_CALL" | "FUNCTION_CALL" => StopReason::ToolUse,
50        "SAFETY" | "PROHIBITED_CONTENT" | "BLOCKLIST" | "SPII" | "RECITATION" => {
51            StopReason::ContentFilter
52        }
53        _ => StopReason::Other,
54    }
55}
56
57/// Provider-reported usage from a decoded Gemini stream chunk. Counts the
58/// upstream did not report stay `None` — never fabricated zeros.
59pub fn usage_from_gemini(usage: &GeminiUsage) -> UsageMeta {
60    UsageMeta {
61        provenance: UsageProvenance::ProviderReported,
62        input_tokens: usage.prompt_tokens,
63        output_tokens: usage.candidates_tokens,
64        cache_read_tokens: usage.cached_tokens,
65        cache_write_tokens: None,
66    }
67}
68
69/// Metadata-only report of the rewrites the Gemini adapter performs on the
70/// normalized (Anthropic-shaped) request. Element IDs are tool stable IDs or
71/// positional paths (`messages[i]`, `messages[i].blocks[j]`, `tools[k]`) —
72/// never content.
73///
74/// Reported honestly, mirroring `runtime::google_gemini::runtime`'s
75/// `tools_to_gemini` / `messages_to_gemini_turns` exactly:
76/// - internal-only tools are dropped from the wire tool list (reported by
77///   their stable name — the shared [`GEMINI_INTERNAL_TOOLS`] rule);
78/// - tools with a missing or empty `name` are dropped from the wire tool
79///   list (reported by position only — absent/empty content is no ID);
80/// - messages with roles other than user/assistant are dropped entirely;
81/// - messages whose content is an empty string, or neither string nor array,
82///   produce no turn at all — dropped entirely;
83/// - `thinking`/unknown blocks are dropped (not representable);
84/// - adjacent text blocks the translator actually concatenates into one turn
85///   are reported as one `Merged` entry per run (ID = the run's first
86///   contributing block). Runs are broken only by the translator's buffer
87///   flushes — `tool_use` (assistant) / `tool_result` (user); dropped blocks
88///   do *not* break a run because the text buffer survives them;
89/// - `tool_result` blocks lose their `tool_use_id` — the wire
90///   `functionResponse` name is synthesized from the id→name map.
91pub fn gemini_translation_losses(
92    messages: &[crate::SharedMessage],
93    tools_schema: &[Value],
94) -> Vec<TranslationLoss> {
95    let mut losses = Vec::new();
96    for (k, tool) in tools_schema.iter().enumerate() {
97        match tool.get("name").and_then(Value::as_str) {
98            Some(name) if !name.is_empty() => {
99                if GEMINI_INTERNAL_TOOLS.contains(&name) {
100                    losses.push(TranslationLoss {
101                        action: TranslationAction::Dropped,
102                        element: TranslationElement::Tool,
103                        element_id: super::types::TraceId::new(name).ok(),
104                    });
105                }
106            }
107            // The wire filter (`tools_to_gemini`) rejects tools with a
108            // missing or empty name — report structurally, by position.
109            _ => losses.push(TranslationLoss {
110                action: TranslationAction::Dropped,
111                element: TranslationElement::Tool,
112                element_id: super::types::TraceId::new(format!("tools[{k}]")).ok(),
113            }),
114        }
115    }
116    for (i, message) in messages.iter().enumerate() {
117        let role = message
118            .get("role")
119            .and_then(Value::as_str)
120            .unwrap_or("user");
121        if role != "user" && role != "assistant" {
122            losses.push(TranslationLoss {
123                action: TranslationAction::Dropped,
124                element: TranslationElement::Other,
125                element_id: super::types::TraceId::new(format!("messages[{i}]")).ok(),
126            });
127            continue;
128        }
129        let blocks = match message.get("content") {
130            Some(Value::String(s)) if !s.is_empty() => continue, // kept verbatim
131            Some(Value::Array(blocks)) => blocks,
132            // Empty string, or neither string nor array (including absent):
133            // the translator emits no turn — the message is dropped whole.
134            _ => {
135                losses.push(TranslationLoss {
136                    action: TranslationAction::Dropped,
137                    element: TranslationElement::Other,
138                    element_id: super::types::TraceId::new(format!("messages[{i}]")).ok(),
139                });
140                continue;
141            }
142        };
143        // Mirror the translator's text buffer: a "run" is the set of text
144        // blocks concatenated into one wire turn. Only the flushing block
145        // type for this role breaks a run; dropped blocks do not.
146        let flush_type = if role == "assistant" {
147            "tool_use"
148        } else {
149            "tool_result"
150        };
151        let mut run_len = 0usize; // text blocks that actually contributed
152        let mut run_start = 0usize;
153        let flush_run = |run_len: &mut usize, run_start: usize, losses: &mut Vec<_>| {
154            if *run_len > 1 {
155                losses.push(TranslationLoss {
156                    action: TranslationAction::Merged,
157                    element: TranslationElement::MessageBlock,
158                    element_id: super::types::TraceId::new(format!(
159                        "messages[{i}].blocks[{run_start}]"
160                    ))
161                    .ok(),
162                });
163            }
164            *run_len = 0;
165        };
166        for (j, block) in blocks.iter().enumerate() {
167            let btype = block.get("type").and_then(Value::as_str).unwrap_or("");
168            if btype == "text" {
169                // Only blocks that append text feed the buffer; blocks with
170                // an absent/non-string text field contribute nothing and so
171                // cannot participate in a merge.
172                if block
173                    .get("text")
174                    .and_then(Value::as_str)
175                    .is_some_and(|t| !t.is_empty())
176                {
177                    if run_len == 0 {
178                        run_start = j;
179                    }
180                    run_len += 1;
181                }
182                continue;
183            }
184            if btype == flush_type {
185                flush_run(&mut run_len, run_start, &mut losses);
186                if btype == "tool_result" {
187                    // The wire functionResponse name is synthesized from the
188                    // tool_use id→name map; the vendor id itself is not
189                    // representable on the Gemini wire.
190                    losses.push(TranslationLoss {
191                        action: TranslationAction::Synthesized,
192                        element: TranslationElement::MessageBlock,
193                        element_id: super::types::TraceId::new(format!(
194                            "messages[{i}].blocks[{j}]"
195                        ))
196                        .ok(),
197                    });
198                }
199                continue;
200            }
201            // Not representable for this role — dropped, but the text buffer
202            // survives, so the current run continues.
203            losses.push(TranslationLoss {
204                action: TranslationAction::Dropped,
205                element: TranslationElement::MessageBlock,
206                element_id: super::types::TraceId::new(format!("messages[{i}].blocks[{j}]")).ok(),
207            });
208        }
209        flush_run(&mut run_len, run_start, &mut losses);
210    }
211    losses
212}
213
214/// Begin tracing one Gemini Code Assist request. Delegates to the shared
215/// OpenAI-compatible builder (same normalized message walker, no cache
216/// section, wire claimed only from `exact_sent_bytes`). Returns `None` when
217/// tracing is disabled or identity is unrepresentable.
218#[allow(clippy::too_many_arguments)]
219pub async fn begin_gemini_tracer(
220    trace: &TraceContext,
221    provider: &str,
222    model: &str,
223    transport: TransportKind,
224    url: &str,
225    exact_sent_bytes: Option<&[u8]>,
226    messages: &[crate::SharedMessage],
227    system_prompt: Option<&str>,
228    tools_schema: &[Value],
229) -> Option<RequestTracer> {
230    super::openai::begin_openai_tracer(
231        trace,
232        provider,
233        model,
234        transport,
235        url,
236        exact_sent_bytes,
237        messages,
238        system_prompt,
239        tools_schema,
240        gemini_translation_losses(messages, tools_schema),
241    )
242    .await
243}
244
245/// Static, safe endpoint identity for a broker-mediated cloud invocation.
246/// The real host is broker-owned and region/deployment-specific; recording
247/// a provider-qualified name under the RFC-reserved `.invalid` TLD keeps the
248/// record honest — it can never be mistaken for an observed endpoint.
249fn cloud_endpoint(provider: CloudProviderId) -> Option<EndpointMeta> {
250    EndpointMeta::new(
251        format!("{}.cloud-broker.invalid", provider.as_str()),
252        "/invoke",
253    )
254    .ok()
255}
256
257/// Cloud-invoke translation report: the broker route is text-only, so any
258/// message whose content is not already a plain string is flattened to text
259/// (a structural downgrade), reported per message index.
260pub fn cloud_translation_losses(messages: &[crate::SharedMessage]) -> Vec<TranslationLoss> {
261    messages
262        .iter()
263        .enumerate()
264        .filter(|(_, m)| !m.get("content").map(Value::is_string).unwrap_or(false))
265        .map(|(i, _)| TranslationLoss {
266            action: TranslationAction::Downgraded,
267            element: TranslationElement::MessageBlock,
268            element_id: super::types::TraceId::new(format!("messages[{i}]")).ok(),
269        })
270        .collect()
271}
272
273/// Begin tracing one explicit cloud invocation (AWS Bedrock, Azure OpenAI,
274/// Google Vertex). Always `TransportKind::CloudProxy` with `wire: None`:
275/// the provider body is serialized and signed behind the broker boundary,
276/// so this process never holds the exact bytes sent. Returns `None` when
277/// tracing is disabled or identity is unrepresentable.
278pub async fn begin_cloud_invoke_tracer(
279    trace: &TraceContext,
280    provider: CloudProviderId,
281    cloud_model: &str,
282    messages: &[crate::SharedMessage],
283    system_prompt: Option<&str>,
284) -> Option<RequestTracer> {
285    if !trace.enabled() {
286        return None;
287    }
288    let endpoint = cloud_endpoint(provider)?;
289    let model = QualifiedModelId::parse(cloud_model).ok()?;
290    let key = trace.digest_key().await;
291    let mut structure = super::anthropic::anthropic_request_structure(
292        key.as_deref(),
293        &[],
294        messages,
295        system_prompt,
296        &[],
297        None,
298        false,
299        false,
300        cloud_translation_losses(messages),
301        None,
302    );
303    // No exact bytes exist in this process — never claim a wire digest.
304    structure.wire = None;
305    // Prompt-cache markers are not sent on the cloud-invoke route.
306    structure.cache = Default::default();
307    RequestTracer::begin(
308        trace,
309        None,
310        model,
311        TransportKind::CloudProxy,
312        endpoint,
313        structure,
314    )
315}
316
317/// Provider-reported usage from a broker `CloudEvent::Usage`. The broker
318/// event carries exactly input/output totals — nothing else is claimed.
319pub fn usage_from_cloud_event(input_tokens: u64, output_tokens: u64) -> UsageMeta {
320    UsageMeta {
321        provenance: UsageProvenance::ProviderReported,
322        input_tokens: Some(input_tokens),
323        output_tokens: Some(output_tokens),
324        cache_read_tokens: None,
325        cache_write_tokens: None,
326    }
327}