saya_agent/protocol/contracts.rs
1use async_trait::async_trait;
2use saya_types::{ClaimId, ClaimStatus};
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
6pub struct AgentRequest {
7 pub prompt: String,
8 pub profile_names: Vec<String>,
9 pub model: String,
10 /// Optional extra system context appended to the base SAYA system prompt (e.g. available database connections).
11 #[serde(default, skip_serializing_if = "Option::is_none")]
12 pub system_prompt: Option<String>,
13 #[serde(default)]
14 pub history: Vec<ChatMessage>,
15 /// Untrusted, labelled database context rendered into the user turn — never the
16 /// system message. `#[serde(default)]` keeps old serialized requests deserializable;
17 /// `skip_serializing_if` keeps an empty vector off the wire.
18 #[serde(default, skip_serializing_if = "Vec::is_empty")]
19 pub context_blocks: Vec<ContextBlock>,
20}
21
22/// A labelled, untrusted chunk of database context (a contract, a schema note, a
23/// comment) that reaches the model as quoted data inside the user turn, never as
24/// policy in the system message. Nothing populates this in Phase 2a; Phase 2b wires
25/// recall in.
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
27pub struct ContextBlock {
28 /// Short machine-ish label for the block's source, e.g. "database-contracts".
29 pub label: String,
30 /// The block's content. Untrusted.
31 pub body: String,
32 /// True when the source had more to give than the caller's budget allowed.
33 pub truncated: bool,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
37pub struct ChatMessage {
38 pub role: String,
39 pub content: String,
40 #[serde(default, skip_serializing_if = "Vec::is_empty")]
41 pub tool_calls: Vec<ToolCall>,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub tool_call_id: Option<String>,
44}
45
46impl ChatMessage {
47 pub fn text(role: &str, content: impl Into<String>) -> Self {
48 Self {
49 role: role.into(),
50 content: content.into(),
51 tool_calls: Vec::new(),
52 tool_call_id: None,
53 }
54 }
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
58pub struct ToolCall {
59 pub id: String,
60 pub name: String,
61 pub arguments: serde_json::Value,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
65pub struct ToolMetadata {
66 pub name: String,
67 pub status: String,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct ChatRequest {
72 pub model: String,
73 pub messages: Vec<ChatMessage>,
74 pub tools: Vec<ToolDefinition>,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct ChatResponse {
79 pub message: ChatMessage,
80}
81
82/// The three distinguishable states of a turn's recall, as
83/// [`AgentEvent::KnowledgeSupplied`] carries them (spec P1b §3). `Off` (recall
84/// disabled by config), `Skipped` (the privacy gate closed — SAYA was not
85/// allowed to look), and `Ran` (recall ran against the store) are three facts a
86/// user reads differently; collapsing them into a single "no event" would hide
87/// the distinction between "SAYA was not allowed to look" and "SAYA looked and
88/// had nothing". `Ran { store_unavailable: true }` records a store failure that
89/// degraded recall to an empty result — the turn still completes (recall is
90/// fail-soft, spec §3).
91#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
92#[serde(rename_all = "snake_case")]
93#[non_exhaustive]
94pub enum KnowledgeOutcome {
95 /// Recall is off by config; SAYA did not look.
96 Off,
97 /// The privacy gate closed; SAYA was not allowed to look. No store query.
98 Skipped,
99 /// Recall ran against the store. `store_unavailable` is true when a store
100 /// failure degraded recall to an empty result.
101 Ran { store_unavailable: bool },
102}
103
104/// One claim as **supplied** to a turn's context block, in the DTO shape that
105/// crosses the crate boundary into [`AgentEvent::KnowledgeSupplied`]. Carries
106/// the claim id (so a later phase can name exactly which saved claims shaped
107/// an answer), its kind, the short rendered value the prompt block shows, a
108/// column when the claim is column-scoped, and its persisted status — so a
109/// `Candidate` reads as `candidate`, distinct from `confirmed` (spec P1b §4.5).
110///
111/// No raw payload, evidence, or SQL. `value` is the same short rendered form
112/// the prompt block already shows (a column name, an alias), not the stored
113/// payload — and it is named **supplied**, never *used*: a confirmed claim
114/// being supplied does not mean the generated SQL honoured it (we have
115/// measured that it frequently does not).
116#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
117pub struct SuppliedClaimDto {
118 pub claim_id: ClaimId,
119 /// The claim kind token (`table_alias`, `default_time_column`, …).
120 pub kind: String,
121 /// The short rendered value the prompt block shows, not the stored payload.
122 pub value: String,
123 /// A column name when the claim is column-scoped; `None` for table-level
124 /// claims. `skip_serializing_if` keeps it off the wire when absent.
125 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub column: Option<String>,
127 pub status: ClaimStatus,
128}
129
130/// One object's claims, as supplied to the turn, in the DTO shape that crosses
131/// the crate boundary into [`AgentEvent::KnowledgeSupplied`]. `profile` is the
132/// human-facing profile **name**, never the opaque [`saya_types::ProfileIdentity`]
133/// — the identity has no field here, by construction (spec P1b §3). `schema_state`
134/// is the contract's aggregated state token (`current` / `needs_review` /
135/// `live_schema_unavailable`); `stale` never appears (a contract aggregating to
136/// `Stale` is dropped by the model-path policy before supply).
137#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
138pub struct SuppliedContractDto {
139 /// The human-facing profile name. Never the opaque identity.
140 pub profile: String,
141 /// The object's qualified name (`catalog.schema.object`).
142 pub object: String,
143 /// The aggregated schema state token; `stale` never appears here.
144 pub schema_state: String,
145 pub claims: Vec<SuppliedClaimDto>,
146}
147
148/// One candidate claim **proposed** (persisted) this turn, in the DTO shape that
149/// crosses the crate boundary into [`AgentEvent::KnowledgeProposed`] (spec P2d).
150/// Mirrors [`SuppliedClaimDto`]'s vocabulary — same `claim_id` / `kind` / `value`
151/// / `column` / `status` — and adds `profile` and `object`, because a proposal is
152/// a single flat claim, not a claim nested under a contract stanza. `profile` is
153/// the human-facing profile **name**, never the opaque
154/// [`saya_types::ProfileIdentity`] (no identity field, by construction).
155///
156/// `value` is the same short rendered form a later recall would show (a column
157/// name, an alias), reusing the recall render path's `claim_value` so a proposal
158/// can never name a value recall would not — not the stored payload. `status` is
159/// the status the claim *landed with*: `contract_propose` stores only a
160/// `Candidate`, so a `KnowledgeProposed` event never reads as established (a
161/// candidate is inert until a human confirms it). No raw SQL, evidence, or cells.
162#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
163pub struct ProposedClaimDto {
164 pub claim_id: ClaimId,
165 /// The human-facing profile name. Never the opaque identity.
166 pub profile: String,
167 /// The object's qualified name (`catalog.schema.object`).
168 pub object: String,
169 /// The claim kind token (`table_alias`, `default_time_column`, …).
170 pub kind: String,
171 /// The short rendered value, not the stored payload.
172 pub value: String,
173 /// A column name when the claim is column-scoped; `None` for table-level
174 /// claims. `skip_serializing_if` keeps it off the wire when absent.
175 #[serde(default, skip_serializing_if = "Option::is_none")]
176 pub column: Option<String>,
177 /// The status the claim landed with — `Candidate` for a proposal.
178 pub status: ClaimStatus,
179}
180
181/// One confirmed claim the turn's SQL contradicted, in the DTO shape that
182/// crosses the crate boundary into [`AgentEvent::KnowledgeOverridden`] (spec
183/// A1). Mirrors nothing about a claim being *used* — the finding says the claim
184/// was contradicted and names the time-named columns the SQL **referenced**
185/// instead, which is all the extractor can prove from names. `claimed_value`
186/// is the value the claim specifies (the claimed time column), carried so a
187/// render can say "where you specified Y". No opaque identity, no raw SQL.
188#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
189pub struct OverrideFindingDto {
190 pub claim_id: ClaimId,
191 /// The claim kind token. Only `default_time_column` is ever produced.
192 pub kind: String,
193 /// The value the claim specifies — for `default_time_column`, the claimed
194 /// time column. "Where you specified Y" in the render.
195 pub claimed_value: String,
196 /// Time-named columns the SQL referenced instead, as written, sorted for
197 /// determinism. Observed references, not an asserted "used" column.
198 pub observed_columns: Vec<String>,
199}
200
201// `arguments` carries a `serde_json::Value`, which is not `Eq`, so this enum is
202// `PartialEq` only.
203#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
204#[serde(tag = "type", rename_all = "snake_case")]
205#[non_exhaustive]
206pub enum AgentEvent {
207 AssistantText {
208 text: String,
209 },
210 /// A tool was requested. `arguments` is the raw call payload (e.g. the SQL),
211 /// surfaced so the user can see exactly what will run before approving it.
212 ToolRequested {
213 name: String,
214 #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
215 arguments: serde_json::Value,
216 },
217 ToolCompleted {
218 name: String,
219 summary: String,
220 },
221 ToolDenied {
222 name: String,
223 reason: String,
224 },
225 /// What recall **supplied** to this turn's context block, emitted once per
226 /// turn *before* any provider request (so a reader can see what shaped the
227 /// SQL before it runs, not after — spec P1b §1/§2). The payload says
228 /// **supplied**, never *used*: a confirmed claim being supplied does not
229 /// mean the generated SQL honoured it. Carries at most what recall supplied
230 /// (already capped: ≤5 objects, ≤12 claims/object); no raw SQL or evidence.
231 KnowledgeSupplied {
232 outcome: KnowledgeOutcome,
233 contracts: Vec<SuppliedContractDto>,
234 /// Claims the byte or count bounds dropped (not the schema policy). A
235 /// non-zero count is the event's way of saying "the list above is a
236 /// subset, not the whole"; zero means the supply path kept everything.
237 dropped_by_bounds: usize,
238 },
239 /// A candidate claim was **proposed** — persisted — this turn (spec P2d).
240 /// Emitted once per persisted proposal, at the moment the store accepts it
241 /// (the `Stored` arm), so a refused, duplicate, or validation-failed proposal
242 /// emits nothing: the event names what was *written*, never what was merely
243 /// *asked for*. Carries the persisted claim's id, profile name, object, kind,
244 /// rendered value, and the `Candidate` status it landed with — never the
245 /// opaque identity, raw SQL, or evidence. Bounded by the tool's per-turn
246 /// proposal cap (≤8); the event stream inherits that bound, so no unbounded
247 /// field is needed.
248 KnowledgeProposed {
249 claim: ProposedClaimDto,
250 },
251 /// Post-turn extraction has started. The answer is already streamed and on
252 /// screen at this point, but the turn is not over: extraction is a second
253 /// provider call that the loop awaits, so an adapter stays busy until it
254 /// resolves. Emitted so that wait can be labelled — an unexplained spinner
255 /// after a finished answer reads as a hang, which is what forces the
256 /// extraction budget to be tighter than the work needs.
257 ///
258 /// Carries nothing. It is a progress signal, not content: an adapter with
259 /// no progress surface (the headless renderer) is right to ignore it.
260 KnowledgeLearningStarted,
261 /// A confirmed claim the turn's SQL **contradicted** — spec A1. Emitted at
262 /// most once per turn, after the loop, carrying every finding the detector
263 /// raised across the turn's statements. Silent when there is nothing to say
264 /// (the detector fails closed on unparseable SQL, partial column lists, joins,
265 /// and ambiguous objects); no event is emitted for an empty finding set.
266 ///
267 /// The finding says the claim was contradicted and names the time-named
268 /// columns the SQL **referenced** — observed references, not "the time column
269 /// SAYA used": from names alone the role of a column (predicate vs projection)
270 /// is unknowable, so the finding stops at "these were referenced where the
271 /// claim named a different column." No opaque identity, no raw SQL.
272 KnowledgeOverridden {
273 findings: Vec<OverrideFindingDto>,
274 },
275 /// Post-turn extraction was **skipped after the turn already succeeded** —
276 /// the turn's answer is unaffected, but no memory was recorded for it. Emitted
277 /// at most once per turn, after the loop, only when extraction was *expected*
278 /// to run (the gate admitted it) and then failed unexpectedly: it timed out
279 /// or the provider/parse/ingest step errored. A gate that *declines* emits
280 /// nothing — declining is the common case on ordinary turns and a line every
281 /// turn would be noise; only an unexpected failure surfaces. Carries the
282 /// reason so a render can distinguish "timed out" from "failed" without
283 /// re-deriving it. No raw response, no payload (spec packet-54 decision 1/2).
284 KnowledgeLearningSkipped {
285 reason: LearningSkipReason,
286 },
287 Complete,
288}
289
290/// Why post-turn extraction was skipped after the gate admitted it
291/// (`AgentEvent::KnowledgeLearningSkipped`, spec packet-54 decision 1). Two
292/// unexpected outcomes — a timeout and an error — each surface; a gate decline
293/// is silent and has no variant here. `#[non_exhaustive]` so a future cause
294/// (e.g. a bounded-cancel) can be added without breaking serialization.
295#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
296#[serde(rename_all = "snake_case")]
297#[non_exhaustive]
298pub enum LearningSkipReason {
299 /// Extraction exceeded the post-turn timeout. The turn's answer is already
300 /// in hand; learning is bounded so a long hang never gates the prompt.
301 TimedOut,
302 /// The provider, parse, or ingest step errored. Distinct from a timeout so a
303 /// render can name the right thing without re-deriving the outcome.
304 Failed,
305}
306
307impl AgentEvent {
308 pub fn assistant_text(text: impl Into<String>) -> Self {
309 Self::AssistantText { text: text.into() }
310 }
311
312 pub fn tool_requested(name: impl Into<String>, arguments: serde_json::Value) -> Self {
313 Self::ToolRequested {
314 name: name.into(),
315 arguments,
316 }
317 }
318
319 /// Builds the per-turn `KnowledgeSupplied` event from recall's outcome, the
320 /// supplied contracts, and the count the bounds dropped.
321 pub fn knowledge_supplied(
322 outcome: KnowledgeOutcome,
323 contracts: Vec<SuppliedContractDto>,
324 dropped_by_bounds: usize,
325 ) -> Self {
326 Self::KnowledgeSupplied {
327 outcome,
328 contracts,
329 dropped_by_bounds,
330 }
331 }
332
333 /// Builds the per-proposal `KnowledgeProposed` event for one persisted
334 /// candidate claim. The caller is the propose tool, at the `Stored` arm.
335 pub fn knowledge_proposed(claim: ProposedClaimDto) -> Self {
336 Self::KnowledgeProposed { claim }
337 }
338
339 /// Builds the per-turn `KnowledgeOverridden` event carrying every finding
340 /// the detector raised across the turn's statements. The caller is the
341 /// runtime, after the loop drains the override log; an empty `findings`
342 /// means the caller emits nothing (spec A1: "if it returns nothing, say
343 /// nothing").
344 pub fn knowledge_overridden(findings: Vec<OverrideFindingDto>) -> Self {
345 Self::KnowledgeOverridden { findings }
346 }
347
348 /// Builds the per-turn `KnowledgeLearningSkipped` event the runtime emits
349 /// when the gate admitted extraction but it then timed out or errored (spec
350 /// packet-54). The caller is the runtime, after the loop; a gate decline
351 /// never calls this — declining is silent, and only an unexpected failure
352 /// surfaces.
353 pub fn knowledge_learning_skipped(reason: LearningSkipReason) -> Self {
354 Self::KnowledgeLearningSkipped { reason }
355 }
356
357 pub fn complete() -> Self {
358 Self::Complete
359 }
360}
361
362#[async_trait]
363pub trait ApprovalDecider: Send + Sync {
364 /// Decides whether a tool call may run. `arguments` is the raw call payload
365 /// so implementations can show the user what they are approving.
366 async fn approve(&self, tool: &ToolDefinition, arguments: &serde_json::Value) -> bool;
367}
368
369pub struct AllowReadOnlyApproval;
370
371#[async_trait]
372impl ApprovalDecider for AllowReadOnlyApproval {
373 async fn approve(&self, _: &ToolDefinition, _: &serde_json::Value) -> bool {
374 true
375 }
376}
377
378#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
379#[non_exhaustive]
380pub enum ProviderError {
381 #[error("provider request failed: {0}")]
382 Request(String),
383 #[error("provider returned an invalid response")]
384 InvalidResponse,
385 #[error("provider is not configured: {0}")]
386 Configuration(String),
387 #[error("provider stream was cancelled")]
388 Cancelled,
389}
390
391impl ProviderError {
392 pub fn configuration(message: impl Into<String>) -> Self {
393 Self::Configuration(message.into())
394 }
395}
396
397#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
398pub enum ToolError {
399 #[error("data sharing is disabled for this cloud provider")]
400 DataSharingDisabled,
401 #[error("invalid query arguments")]
402 InvalidQueryArguments,
403 #[error("unsupported read-only tool")]
404 UnsupportedTool,
405 #[error("invalid tool arguments: expected an object")]
406 ArgumentsNotObject,
407 #[error("invalid tool arguments: unsupported property")]
408 UnsupportedProperty,
409 #[error("invalid tool arguments: connection must be a string")]
410 ConnectionNotString,
411 #[error("invalid tool arguments: sql must be a string")]
412 SqlNotString,
413 #[error("no database profile is selected")]
414 NoConnectionSelected,
415 #[error("unknown connection \"{target}\"; available connections: {available}")]
416 UnknownConnection { target: String, available: String },
417 #[error("read-only query failed")]
418 QueryFailed,
419 #[error("read-only query failed: {0}")]
420 QueryFailedDetail(String),
421 #[error("read-only query timed out")]
422 QueryTimedOut,
423 #[error("query result unavailable")]
424 QueryResultUnavailable,
425 #[error("schema discovery failed: {0}")]
426 SchemaDiscoveryFailed(String),
427 #[error("{0}")]
428 Chart(String),
429}
430
431/// What local state a tool may touch — contracts, the schema cache, anything
432/// persisted on the user's machine. Declared per tool so "may this tool write
433/// local state?" is a property the loop reads rather than something inferred
434/// from a tool's name. Phase 3a introduces the type; Phase 3c adds the first
435/// tool that declares `WriteCandidate`.
436#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
437#[serde(rename_all = "snake_case")]
438#[non_exhaustive]
439pub enum LocalStateEffect {
440 /// Touches no local state.
441 #[default]
442 None,
443 /// Reads local state (contracts, cache) and writes nothing.
444 Read,
445 /// May persist a *candidate* claim. Never a confirmed one — confirmation is a
446 /// human action and has no tool.
447 WriteCandidate,
448}
449
450#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
451pub struct ToolEffect {
452 pub database_data: bool,
453 pub external_side_effect: bool,
454 pub requires_approval: bool,
455 /// What local state this tool may touch. `#[serde(default)]` keeps the
456 /// pre-3a serialized form (no key) deserializing to `None`.
457 #[serde(default)]
458 pub local_state: LocalStateEffect,
459}
460
461#[derive(Debug, Clone, Serialize, Deserialize)]
462pub struct ToolDefinition {
463 pub name: String,
464 pub description: String,
465 pub read_only: bool,
466 pub parameters: serde_json::Value,
467 pub effect: ToolEffect,
468}
469
470#[async_trait]
471pub trait ToolExecutor: Send + Sync {
472 async fn execute(
473 &self,
474 name: &str,
475 arguments: serde_json::Value,
476 ) -> Result<serde_json::Value, ToolError>;
477}
478
479#[cfg(test)]
480mod tests {
481 use super::LocalStateEffect;
482
483 /// The back-compat guarantee: a `ToolEffect` serialized before this slice
484 /// (no `local_state` key) deserializes to the default `None`.
485 #[test]
486 fn tool_effect_without_local_state_key_defaults_to_none() {
487 let json = r#"{
488 "database_data": false,
489 "external_side_effect": false,
490 "requires_approval": false
491 }"#;
492 let effect: super::ToolEffect = serde_json::from_str(json).expect("old form deserializes");
493 assert_eq!(effect.local_state, LocalStateEffect::None);
494 }
495
496 /// Each variant round-trips through snake_case.
497 #[test]
498 fn local_state_effect_round_trips_through_snake_case() {
499 for (variant, expected) in [
500 (LocalStateEffect::None, "none"),
501 (LocalStateEffect::Read, "read"),
502 (LocalStateEffect::WriteCandidate, "write_candidate"),
503 ] {
504 let text = serde_json::to_string(&variant).expect("serializes");
505 assert_eq!(text, format!("\"{expected}\""), "{variant:?}");
506 let back: LocalStateEffect = serde_json::from_str(&text).expect("deserializes back");
507 assert_eq!(back, variant, "{variant:?}");
508 }
509 }
510
511 /// `KnowledgeOverridden` serializes under its `knowledge_overridden` type tag
512 /// (spec A1) and carries the finding's fields, with no opaque identity — the
513 /// DTO has no such field, by construction.
514 #[test]
515 fn knowledge_overridden_serializes_with_type_tag_and_findings() {
516 use super::{AgentEvent, OverrideFindingDto};
517 use saya_types::ClaimId;
518 let event = AgentEvent::knowledge_overridden(vec![OverrideFindingDto {
519 claim_id: ClaimId::parse("c-rental-time").unwrap(),
520 kind: "default_time_column".into(),
521 claimed_value: "return_date".into(),
522 observed_columns: vec!["rental_date".into()],
523 }]);
524 let json = serde_json::to_string(&event).expect("serializes");
525 assert!(json.contains(r#""type":"knowledge_overridden""#), "{json}");
526 assert!(
527 json.contains("return_date"),
528 "carries the claimed value: {json}"
529 );
530 assert!(
531 json.contains("rental_date"),
532 "carries the observed column: {json}"
533 );
534 // No opaque identity field exists on the DTO; a fabricated one must not
535 // appear in the serialized event.
536 let fake_identity =
537 "sha256:9f2a8c7b1e4d0a6f3c5b8e2d7a9f1c4b6e8a0d2f4c6b8e0a2d4f6c8b0e2d4f6";
538 assert!(!json.contains(fake_identity), "identity leaked: {json}");
539 }
540
541 /// `KnowledgeLearningSkipped` serializes under its `knowledge_learning_skipped`
542 /// type tag and carries the reason; both reasons round-trip (spec packet-54
543 /// decision 1 — `#[non_exhaustive]` enum with the same derive set as siblings).
544 #[test]
545 fn knowledge_learning_skipped_serializes_with_type_tag_and_reason() {
546 use super::{AgentEvent, LearningSkipReason};
547 for (reason, token) in [
548 (LearningSkipReason::TimedOut, "timed_out"),
549 (LearningSkipReason::Failed, "failed"),
550 ] {
551 let event = AgentEvent::knowledge_learning_skipped(reason);
552 let json = serde_json::to_string(&event).expect("serializes");
553 assert!(
554 json.contains(r#""type":"knowledge_learning_skipped""#),
555 "type tag for {reason:?}: {json}"
556 );
557 assert!(
558 json.contains(&format!(r#""reason":"{token}""#)),
559 "reason token for {reason:?}: {json}"
560 );
561 let back: AgentEvent = serde_json::from_str(&json).expect("deserializes back");
562 assert_eq!(back, event, "round-trips for {reason:?}");
563 }
564 }
565}