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}