Skip to main content

formal_ai/
event_log.rs

1//! Append-only event log for the universal solver.
2//!
3//! Every step the solver takes is recorded as a content-addressed event
4//! before the user-facing answer is built. The answer is then a projection
5//! of the log — see `VISION.md` and `GOALS.md` for the rationale.
6//!
7//! The log is intentionally small: it lives in-process, holds plain Rust
8//! records, and uses the same FNV-1a 64-bit hash that `engine::stable_id`
9//! uses so identifiers stay stable across surfaces.
10
11use crate::engine::{stable_id, ThinkingStep};
12use crate::link_store::{LinkStore, LinkStoreError};
13use crate::memory::MemoryEvent;
14
15/// A single event in the append-only log.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct Event {
18    pub id: String,
19    pub kind: &'static str,
20    pub payload: String,
21}
22
23/// In-process append-only event log.
24#[derive(Debug, Default, Clone)]
25pub struct EventLog {
26    events: Vec<Event>,
27}
28
29impl EventLog {
30    #[must_use]
31    pub const fn new() -> Self {
32        Self { events: Vec::new() }
33    }
34
35    /// Append a new event with content-addressed id.
36    ///
37    /// The id is derived from the kind, the payload, and the current log
38    /// length so that repeated events produce stable, distinct ids.
39    pub fn append(&mut self, kind: &'static str, payload: impl Into<String>) -> String {
40        let payload = payload.into();
41        let seed = format!("{kind}:{}:{payload}", self.events.len());
42        let id = stable_id(kind, &seed);
43        self.events.push(Event {
44            id: id.clone(),
45            kind,
46            payload,
47        });
48        id
49    }
50
51    #[must_use]
52    pub fn events(&self) -> &[Event] {
53        &self.events
54    }
55
56    /// Returns the first event of the given kind, if any.
57    #[must_use]
58    pub fn first_of(&self, kind: &str) -> Option<&Event> {
59        self.events.iter().find(|event| event.kind == kind)
60    }
61
62    /// Returns the most recent event of the given kind, if any.
63    #[must_use]
64    pub fn last_of(&self, kind: &str) -> Option<&Event> {
65        self.events.iter().rev().find(|event| event.kind == kind)
66    }
67
68    /// Project the log to a list of `<kind>:<id>` links for the user-facing
69    /// evidence array. Each link points back to a distinct event.
70    #[must_use]
71    pub fn evidence_links(&self) -> Vec<String> {
72        self.events
73            .iter()
74            .map(|event| format!("{}:{}", event.kind, event.id))
75            .collect()
76    }
77
78    /// Build a Links Notation `steps` block listing every event in order.
79    /// Used by trace serialization in [`crate::solver`].
80    #[must_use]
81    pub fn steps_block(&self) -> String {
82        use std::fmt::Write as _;
83        let mut buffer = String::from("steps:");
84        for (index, event) in self.events.iter().enumerate() {
85            let _ = write!(
86                buffer,
87                "\n  step_{index} {} {}",
88                event.kind,
89                sanitize_payload(&event.payload)
90            );
91        }
92        buffer
93    }
94
95    /// Project raw solver events into user-readable, ordered thinking steps.
96    ///
97    /// This is the *curated* projection introduced for issue #488. Instead of a
98    /// noisy 1:1 dump of every internal event, it keeps only the meaningful
99    /// reasoning milestones, cleans each `detail` down to concrete content,
100    /// folds the calculator's reduction trace into one composite `compute` step
101    /// with detailed children, de-duplicates consecutive repeats, and labels the
102    /// universal-algorithm phases `high` so the minimum granularity surfaces only
103    /// the high-level direction.
104    ///
105    /// The raw, lossless trace stays available to maintainers through
106    /// [`EventLog::steps_block`] and [`EventLog::evidence_links`].
107    #[must_use]
108    pub fn thinking_steps(&self) -> Vec<ThinkingStep> {
109        self.thinking_steps_for_answer("")
110    }
111
112    /// Like [`EventLog::thinking_steps`], but substitutes the actual composed
113    /// answer for the opaque `response:<route>` payload of the closing
114    /// `deformalize` step so the final reasoning step is concrete (issue #488).
115    #[must_use]
116    pub fn thinking_steps_for_answer(&self, final_answer: &str) -> Vec<ThinkingStep> {
117        let answer = collapse_thinking_whitespace(final_answer);
118
119        // Phase 1 — curate raw events into a clean plan, folding the
120        // calculator's reduction trace into one composite parent + children.
121        let mut plan: Vec<PlannedThinkingStep> = Vec::new();
122        let mut calculation = CalculationCluster::default();
123        for event in &self.events {
124            if event.kind == "calculation" || event.kind.starts_with("calculation:") {
125                calculation.absorb(event.kind, &event.payload);
126                continue;
127            }
128            if calculation.has_data() {
129                calculation.drain_into(&mut plan);
130            }
131            if let Some(step) = curate_thinking_event(event.kind, &event.payload, &answer) {
132                plan.push(step);
133            }
134        }
135        if calculation.has_data() {
136            calculation.drain_into(&mut plan);
137        }
138
139        // Phase 2 — de-duplicate consecutive repeats, assign order and parent
140        // ids (so composite steps render as recursively nested thinking).
141        let mut steps: Vec<ThinkingStep> = Vec::new();
142        let mut order: u32 = 0;
143        let mut previous_key: Option<String> = None;
144        let mut current_parent: Option<String> = None;
145        for planned in plan {
146            let key = format!("{}\u{1f}{}", planned.step, planned.detail);
147            let is_child = planned.role == StepRole::Child;
148            if !is_child && previous_key.as_deref() == Some(key.as_str()) {
149                continue;
150            }
151            previous_key = Some(key);
152            let mut step = ThinkingStep::new(
153                order,
154                planned.step,
155                planned.detail,
156                planned.level,
157                planned.source,
158            );
159            match planned.role {
160                StepRole::Parent => current_parent = Some(step.id.clone()),
161                StepRole::Child => {
162                    if let Some(parent) = current_parent.clone() {
163                        step = step.with_parent(parent);
164                    }
165                }
166                StepRole::Normal => current_parent = None,
167            }
168            steps.push(step);
169            order += 1;
170        }
171        steps
172    }
173
174    /// Replay every in-process event into a durable link store projection.
175    ///
176    /// This is additive: the original event log remains in-process, while
177    /// the target store receives memory records that can be exported as
178    /// `.lino` or reduced to doublets by the active backend.
179    pub fn append_to_link_store<S: LinkStore>(
180        &self,
181        store: &mut S,
182    ) -> Result<usize, LinkStoreError> {
183        for event in &self.events {
184            store.append_memory_event(MemoryEvent {
185                id: event.id.clone(),
186                kind: Some(event.kind.to_owned()),
187                content: Some(event.payload.clone()),
188                evidence: vec![format!("{}:{}", event.kind, event.id)],
189                ..MemoryEvent::default()
190            })?;
191        }
192        Ok(self.events.len())
193    }
194}
195
196/// Role of a planned step in a recursively-composite (fractal) thinking tree.
197#[derive(Clone, Copy, PartialEq, Eq)]
198enum StepRole {
199    /// A leaf step at the top level.
200    Normal,
201    /// A composite step that owns the following [`StepRole::Child`] steps.
202    Parent,
203    /// A sub-step nested under the most recent [`StepRole::Parent`].
204    Child,
205}
206
207/// A curated thinking step before ordering, de-duplication and id assignment.
208struct PlannedThinkingStep {
209    step: &'static str,
210    detail: String,
211    level: &'static str,
212    source: &'static str,
213    role: StepRole,
214}
215
216impl PlannedThinkingStep {
217    fn normal(
218        step: &'static str,
219        detail: impl Into<String>,
220        level: &'static str,
221        source: &'static str,
222    ) -> Self {
223        Self {
224            step,
225            detail: detail.into(),
226            level,
227            source,
228            role: StepRole::Normal,
229        }
230    }
231
232    fn parent(
233        step: &'static str,
234        detail: impl Into<String>,
235        level: &'static str,
236        source: &'static str,
237    ) -> Self {
238        Self {
239            role: StepRole::Parent,
240            ..Self::normal(step, detail, level, source)
241        }
242    }
243
244    fn child(
245        step: &'static str,
246        detail: impl Into<String>,
247        level: &'static str,
248        source: &'static str,
249    ) -> Self {
250        Self {
251            role: StepRole::Child,
252            ..Self::normal(step, detail, level, source)
253        }
254    }
255}
256
257/// Accumulates the calculator's `calculation:*` trace so it can be rendered as a
258/// single composite `compute` step (parent) with detailed children (issue #488,
259/// requirement R11 — recursively composite / fractal steps).
260#[derive(Default)]
261struct CalculationCluster {
262    request: Option<String>,
263    engine: Option<String>,
264    expression: Option<String>,
265    steps: Option<String>,
266    result: Option<String>,
267}
268
269impl CalculationCluster {
270    fn absorb(&mut self, kind: &str, payload: &str) {
271        let value = payload.trim().to_owned();
272        match kind {
273            "calculation" => self.result = Some(value),
274            "calculation:request" => self.request = Some(value),
275            "calculation:engine" => self.engine = Some(value),
276            "calculation:lino" => self.expression = Some(value),
277            "calculation:steps" => self.steps = Some(value),
278            // `calculation:rate_basis` and any other detail kinds stay in the raw
279            // evidence trace but are not surfaced in the curated view.
280            _ => {}
281        }
282    }
283
284    const fn has_data(&self) -> bool {
285        self.result.is_some()
286            || self.request.is_some()
287            || self.engine.is_some()
288            || self.expression.is_some()
289            || self.steps.is_some()
290    }
291
292    fn drain_into(&mut self, plan: &mut Vec<PlannedThinkingStep>) {
293        let parent_detail = self
294            .result
295            .clone()
296            .or_else(|| self.request.clone())
297            .unwrap_or_default();
298        plan.push(PlannedThinkingStep::parent(
299            "compute",
300            parent_detail,
301            "high",
302            "calculation",
303        ));
304        if let Some(engine) = self.engine.take() {
305            plan.push(PlannedThinkingStep::child(
306                "compute_engine",
307                engine,
308                "detailed",
309                "calculation:engine",
310            ));
311        }
312        if let Some(expression) = self.expression.take() {
313            plan.push(PlannedThinkingStep::child(
314                "compute_expression",
315                expression,
316                "detailed",
317                "calculation:lino",
318            ));
319        }
320        if let Some(steps) = self.steps.take() {
321            plan.push(PlannedThinkingStep::child(
322                "compute_steps",
323                steps,
324                "detailed",
325                "calculation:steps",
326            ));
327        }
328        *self = Self::default();
329    }
330}
331
332/// Collapse any run of whitespace (including newlines) into single spaces so a
333/// multi-line answer renders as one concrete `deformalize` detail.
334fn collapse_thinking_whitespace(value: &str) -> String {
335    value.split_whitespace().collect::<Vec<_>>().join(" ")
336}
337
338/// Curate a single raw solver event into a clean, concrete thinking step, or
339/// `None` when the event is internal bookkeeping that should not surface to the
340/// user.
341///
342/// This is an **allowlist**: only events that carry meaningful reasoning are
343/// kept, and each is reduced to concrete content (a search term, a route, a
344/// looked-up entity, the composed answer). Unknown kinds are dropped so the
345/// default view stays concrete — the lossless trace remains in the evidence
346/// links for maintainers.
347fn curate_thinking_event(
348    kind: &'static str,
349    payload: &str,
350    final_answer: &str,
351) -> Option<PlannedThinkingStep> {
352    let detail = payload.trim();
353    let keep = |step: &'static str, detail: &str, level: &'static str| {
354        Some(PlannedThinkingStep::normal(
355            step,
356            detail.to_owned(),
357            level,
358            kind,
359        ))
360    };
361    match kind {
362        // ---- Universal-algorithm phases (high level / minimum granularity) ----
363        "impulse" => keep("impulse", detail, "high"),
364        "language" | "language_from" => keep("detect_language", detail, "high"),
365        "language_to" => keep("resolve_response_language", detail, "high"),
366        "intent_formalization:route" => keep("formalize", detail, "high"),
367        "intent" | "legacy_intent" => keep("dispatch_handler", detail, "high"),
368        "program_plan" | "program_parameters" => keep("program_plan", detail, "high"),
369        "response" => {
370            let value = if final_answer.is_empty() {
371                detail
372            } else {
373                final_answer
374            };
375            keep("deformalize", value, "high")
376        }
377
378        // ---- Concrete detailed reasoning ----
379        "concept_lookup:request" | "procedural_how_to:request" => {
380            keep("scan_memory", detail, "detailed")
381        }
382        "concept_lookup:hit" | "fact_query:relation" | "fact_query:subject" | "fact_lookup:hit" => {
383            keep("lookup_fact", detail, "detailed")
384        }
385        "web_search:request" | "http_fetch:request" | "url_navigate:request" => {
386            keep("http_chat", detail, "detailed")
387        }
388        "tool_call" | "tool_result" => keep("invoke_tool", detail, "detailed"),
389        "coreference" | "program_coreference" => keep("coreference_binding", "", "detailed"),
390        "modifier_detection" | "program_modifiers" => keep("modifier_detection", "", "detailed"),
391        "rule_construction" => keep("rule_construction", "", "detailed"),
392        // The validation payload is an internal acceptance reason
393        // (`accepted_without_extra_constraints`); surface a clean verification
394        // step without the meta-language token.
395        "validation" => keep("rule_verification", "", "detailed"),
396        _ if kind.starts_with("tool_") => keep("invoke_tool", detail, "detailed"),
397        _ if kind.starts_with("agent_mode") || kind == "action_log" => {
398            keep("agent_plan", detail, "detailed")
399        }
400
401        // ---- Everything else: internal bookkeeping, dropped from the view ----
402        _ => None,
403    }
404}
405
406/// Build the evidence links array for a symbolic answer.
407///
408/// Translates each log event into a typed link, appending `response_link` at
409/// the end when it is not already present.
410#[must_use]
411pub fn build_evidence_links(prompt: &str, log: &EventLog, response_link: &str) -> Vec<String> {
412    let mut links: Vec<String> = Vec::new();
413    links.push(format!("prompt:{}", stable_id("prompt", prompt)));
414    for event in log.events() {
415        let evidence = match event.kind {
416            "trace:execution_failure" => format!("trace:execution_failure:{}", event.id),
417            "language" => format!("language:{}", event.payload),
418            "language_from" => format!("language_from:{}", event.payload),
419            "language_to" => format!("language_to:{}", event.payload),
420            "definition_merge:language" => format!("definition_merge:language:{}", event.payload),
421            "meaning" => format!("meaning:{}", event.payload),
422            "translation_gap" => format!("translation_gap:{}", event.payload),
423            "wikidata" => format!("wikidata:{}", event.payload),
424            "formalization" => format!("formalization:{}", event.id),
425            "formalization:subject_q" => {
426                format!("formalization:subject_q:{}", event.payload)
427            }
428            "formalization:predicate_p" => {
429                format!("formalization:predicate_p:{}", event.payload)
430            }
431            "formalization:object_q" => {
432                format!("formalization:object_q:{}", event.payload)
433            }
434            "formalization:item_q" => format!("formalization:item_q:{}", event.payload),
435            "formalization:property_p" => {
436                format!("formalization:property_p:{}", event.payload)
437            }
438            "formalization:fallback" => {
439                format!("formalization:fallback:{}", event.payload)
440            }
441            "formalization:raw" => format!("formalization:raw:{}", event.payload),
442            "formalization_unresolved" => {
443                format!("formalization_unresolved:{}", event.payload)
444            }
445            // Issue #661 (R384): each accepted interpretation's posterior weight,
446            // surfaced verbatim (`formalization:<i> weight=<p> <summary>`) so a
447            // consumer can read every formalized statement as a weighted claim and
448            // verify the weights sum to 1 across candidates. Trace-only.
449            "statement_weight" => format!("statement_weight:{}", event.payload),
450            // Issue #661 (R384): a detected clash between a new formalized
451            // requirement and a retained one. The id keys back to the event whose
452            // payload names both statements, the shared subject, and their weights.
453            "requirement_contradiction" => {
454                format!("requirement_contradiction:{}", event.id)
455            }
456            "intent_formalization" => format!("intent_formalization:{}", event.id),
457            "intent_formalization_cache" => {
458                format!(
459                    "intent_formalization_cache:{}",
460                    event.payload.replace(' ', ":")
461                )
462            }
463            "intent_formalization:kind" => {
464                format!("intent_formalization:kind:{}", event.payload)
465            }
466            "intent_formalization:route" => {
467                format!("intent_formalization:route:{}", event.payload)
468            }
469            "intent_formalization:relevant" => {
470                format!("intent_formalization:relevant:{}", event.payload)
471            }
472            // Structured fact_query trace events (Issue #127): preserve the
473            // payload verbatim so memory consumers can render the parsed
474            // relation, subject, and cache decision (rather than a hash id).
475            "fact_query:request" => format!("fact_query:request:{}", event.id),
476            "fact_query:relation" => format!("fact_query:relation:{}", event.payload),
477            "fact_query:subject" => format!("fact_query:subject:{}", event.payload),
478            "fact_query:cache:hit" => format!("fact_query:cache:hit:{}", event.payload),
479            "fact_query:cache:miss" => String::from("fact_query:cache:miss"),
480            "fact_query:cache:bypass" => String::from("fact_query:cache:bypass"),
481            "fact_query:force_fresh" => String::from("fact_query:force_fresh"),
482            "fact_query:subject_qid" => format!("fact_query:subject_qid:{}", event.payload),
483            "fact_query:value_qid" => format!("fact_query:value_qid:{}", event.payload),
484            // Structured web_search trace events (Issue #133): record the
485            // request, the providers considered (DuckDuckGo first, plus
486            // CORS-readable knowledge bases), per-provider ranks, and the
487            // combined-ranking strategy so memory consumers can reconstruct
488            // the multi-engine reasoning offline.
489            "web_search:request" => format!("web_search:request:{}", event.payload),
490            "web_search:query_kind" => format!("web_search:query_kind:{}", event.payload),
491            "web_search:provider" => format!("web_search:provider:{}", event.payload),
492            "web_search:language" => format!("web_search:language:{}", event.payload),
493            "web_search:combined" => format!("web_search:combined:{}", event.payload),
494            "web_search:rank" => format!("web_search:rank:{}", event.payload),
495            "web_search:fused" => format!("web_search:fused:{}", event.payload),
496            "web_search:disabled" => format!("web_search:disabled:{}", event.payload),
497            "document_originality_check:request" => {
498                format!("document_originality_check:request:{}", event.payload)
499            }
500            "document_originality_check:attachment" => {
501                format!("document_originality_check:attachment:{}", event.payload)
502            }
503            "document_originality_check:text_sample" => {
504                format!("document_originality_check:text_sample:{}", event.payload)
505            }
506            // Relative-meta-logic statement probability trace (issue #535):
507            // preserve the assumed-true prior, the trusted-source tier weights,
508            // and the ignored (unoriginal) tier verbatim so consumers can
509            // reconstruct how each statement's probability would move.
510            "relative_meta_logic:assumed_prior" => {
511                format!("relative_meta_logic:assumed_prior:{}", event.payload)
512            }
513            "relative_meta_logic:trusted_source_tier" => {
514                format!("relative_meta_logic:trusted_source_tier:{}", event.payload)
515            }
516            "relative_meta_logic:ignored_source_tier" => {
517                format!("relative_meta_logic:ignored_source_tier:{}", event.payload)
518            }
519            // Per-statement web-search grounding plan (issue #535): each
520            // extracted statement, its grounding query, and its assessment.
521            "statement_verification:statement_count" => {
522                format!("statement_verification:statement_count:{}", event.payload)
523            }
524            "statement_verification:statement" => {
525                format!("statement_verification:statement:{}", event.payload)
526            }
527            "statement_verification:query" => {
528                format!("statement_verification:query:{}", event.payload)
529            }
530            "statement_verification:assessment" => {
531                format!("statement_verification:assessment:{}", event.payload)
532            }
533            "market_price_claim:claim_count" => {
534                format!("market_price_claim:claim_count:{}", event.payload)
535            }
536            "market_price_claim:claim" => {
537                format!("market_price_claim:claim:{}", event.payload)
538            }
539            "market_price_claim:asset" => {
540                format!("market_price_claim:asset:{}", event.payload)
541            }
542            "market_price_claim:period" => {
543                format!("market_price_claim:period:{}", event.payload)
544            }
545            "market_price_claim:claimed_price" => {
546                format!("market_price_claim:claimed_price:{}", event.payload)
547            }
548            "market_price_claim:source" => {
549                format!("market_price_claim:source:{}", event.payload)
550            }
551            "market_price_claim:range" => {
552                format!("market_price_claim:range:{}", event.payload)
553            }
554            "market_price_claim:assessment" => {
555                format!("market_price_claim:assessment:{}", event.payload)
556            }
557            "read_local_file:request" => format!("read_local_file:request:{}", event.payload),
558            "http_fetch:request" => format!("http_fetch:request:{}", event.payload),
559            "docs_method:request" => format!("docs_method:request:{}", event.id),
560            "docs_method:project" => format!("docs_method:project:{}", event.payload),
561            "docs_method:method" => format!("docs_method:method:{}", event.payload),
562            "docs_method:source_kind" => {
563                format!("docs_method:source_kind:{}", event.payload)
564            }
565            "docs_method:source" | "source" => format!("source:{}", event.payload),
566            "project:promoted" => format!("project:promoted:{}", event.payload),
567            "project_lookup:promotion" => {
568                format!("project_lookup:promotion:{}", event.payload)
569            }
570            "project_lookup:repository:github" => {
571                format!("project_lookup:repository:github:{}", event.payload)
572            }
573            "project_lookup:repository:gitlab" => {
574                format!("project_lookup:repository:gitlab:{}", event.payload)
575            }
576            "project_lookup:repository:bitbucket" => {
577                format!("project_lookup:repository:bitbucket:{}", event.payload)
578            }
579            "url_navigate:request" => format!("url_navigate:request:{}", event.payload),
580            "url_preview:iframe" => format!("url_preview:iframe:{}", event.payload),
581            "tool_call" => format!("tool_call:{}", event.payload),
582            "tool_parameter" => format!("tool_parameter:{}", event.payload.replace(' ', ":")),
583            "tool_result" => format!("tool_result:{}", event.payload.replace(' ', ":")),
584            "tool_permission" => {
585                format!("tool_permission:{}", event.payload.replace(' ', ":"))
586            }
587            "text_operation" => format!("text_operation:{}", event.payload),
588            "text_rule" => format!("text_rule:{}", event.payload),
589            "text_rule_chain" => format!("text_rule_chain:{}", event.payload),
590            "text_result" => format!("text_result:{}", event.id),
591            "text_substitution_rules" => format!("text_substitution_rules:{}", event.id),
592            "text_substitution_trace" => format!("text_substitution_trace:{}", event.id),
593            "text_substitution_graph" => format!("text_substitution_graph:{}", event.id),
594            "procedural_how_to:request" => {
595                format!("procedural_how_to:request:{}", event.payload)
596            }
597            "procedural_how_to:action" => {
598                format!("procedural_how_to:action:{}", event.payload)
599            }
600            "procedural_how_to:object" => {
601                format!("procedural_how_to:object:{}", event.payload)
602            }
603            "procedural_how_to:stage" => {
604                format!("procedural_how_to:stage:{}", event.payload)
605            }
606            "procedural_how_to:wikihow_candidate" => {
607                format!("procedural_how_to:wikihow_candidate:{}", event.payload)
608            }
609            "procedural_how_to:source_gate" => {
610                format!("procedural_how_to:source_gate:{}", event.payload)
611            }
612            "spelling_correction" => {
613                format!("spelling_correction:{}", event.payload.replace(' ', ""))
614            }
615            "concept_lookup:request" => format!("concept_lookup:request:{}", event.payload),
616            "concept_lookup:context" => format!("concept_lookup:context:{}", event.payload),
617            "concept_lookup:response-language" => {
618                format!("concept_lookup:response-language:{}", event.payload)
619            }
620            "concept_lookup:hit" => format!("concept_lookup:hit:{}", event.payload),
621            "concept_lookup:miss" => format!("concept_lookup:miss:{}", event.payload),
622            "concept_lookup:context-match" => {
623                format!("concept_lookup:context-match:{}", event.payload)
624            }
625            "concept_lookup:context-mismatch" => {
626                format!("concept_lookup:context-mismatch:{}", event.payload)
627            }
628            "followup:subject" => format!("followup:subject:{}", event.payload),
629            "mechanism_query:request" => {
630                format!("mechanism_query:request:{}", event.payload)
631            }
632            "mechanism_query:stage" => format!("mechanism_query:stage:{}", event.payload),
633            "mechanism_query:source_gate" => {
634                format!("mechanism_query:source_gate:{}", event.payload)
635            }
636            "search:local" => format!("search:local:{}", event.id),
637            "search:external" if event.payload == "skipped:offline" => {
638                String::from("policy:offline")
639            }
640            "search:external" => format!("search:external:{}", event.id),
641            "source:http" => format!("source:http:{}", event.payload.replace(' ', ":")),
642            "source_refresh" => format!("source_refresh:{}", event.payload),
643            "skill_compile:package" => format!("skill_compile:package:{}", event.payload),
644            "compiled_skill:package" => format!("compiled_skill:package:{}", event.id),
645            "compiled_skill:replay" => format!("compiled_skill:replay:{}", event.payload),
646            "conflict:source_disagreement" => {
647                format!("conflict:source_disagreement:{}", event.id)
648            }
649            "cache_hit" => format!("cache_hit:{}", event.payload),
650            "network_fetch" => format!("network_fetch:{}", event.id),
651            "calculation:engine" => format!("calculation:engine:{}", event.payload),
652            "calculation:lino" => format!("calculation:lino:{}", event.payload),
653            "intent" => format!("intent:{}", event.payload),
654            "program_parameter:language" => {
655                format!("program_parameter:language:{}", event.payload)
656            }
657            "program_parameter:task" => format!("program_parameter:task:{}", event.payload),
658            "program_parameters" => {
659                format!(
660                    "program_parameters:{}",
661                    event.payload.replace([' ', ','], ":")
662                )
663            }
664            "legacy_intent" => format!("legacy_intent:{}", event.payload),
665            "response" => event.payload.clone(),
666            "agent_mode:opted_in" => format!("agent_mode:opted_in:{}", event.id),
667            "agent_mode:active" => format!("agent_mode:active:{}", event.id),
668            "policy:chat_bounded_autonomy" => String::from("policy:chat_bounded_autonomy"),
669            "policy:add_only_history" => String::from("policy:add_only_history"),
670            "policy:destructive_action_requires_confirmation" => {
671                String::from("policy:destructive_action_requires_confirmation")
672            }
673            "policy:agent_time_budget" => format!("policy:agent_time_budget:{}", event.id),
674            "policy:cache_flush_requires_confirmation" => {
675                String::from("policy:cache_flush_requires_confirmation")
676            }
677            "policy:offline" => String::from("policy:offline"),
678            "policy:inappropriate_content" => String::from("policy:inappropriate_content"),
679            "policy:agent_mode_required_for_tools" => {
680                format!("policy:agent_mode_required_for_tools:{}", event.payload)
681            }
682            "policy:package_permission_required" => {
683                format!("policy:package_permission_required:{}", event.payload)
684            }
685            "policy:temperature_selection" => {
686                format!("policy:temperature_selection:{}", event.id)
687            }
688            "policy:guessed_under_ambiguity" => String::from("policy:guessed_under_ambiguity"),
689            "policy:clarify_under_ambiguity" => String::from("policy:clarify_under_ambiguity"),
690            "probability:evidence" => format!("probability:evidence:{}", event.id),
691            "probability:model" => format!("probability:model:{}", event.payload),
692            "probability:ranking" => format!("probability:ranking:{}", event.id),
693            "error" => format!("error:{}", event.id),
694            "filter:user" => format!("filter:user:{}", event.payload),
695            "diagnostic_mode" => format!("diagnostic_mode:{}", event.payload),
696            "execution_status" => format!("execution_status:{}", event.id),
697            "execution_environment" => format!("execution_environment:{}", event.id),
698            _ => format!("{}:{}", event.kind, event.id),
699        };
700        links.push(evidence);
701    }
702    if !links.iter().any(|link| link == response_link) {
703        links.push(response_link.to_owned());
704    }
705    links
706}
707
708fn sanitize_payload(value: &str) -> String {
709    value
710        .replace('\r', "\\r")
711        .replace('\n', "\\n")
712        .replace('\t', "\\t")
713}