Skip to main content

formal_ai/
thinking.rs

1//! Human-readable "thinking" steps (issue #488).
2//!
3//! This module owns the [`ThinkingStep`] model and the deterministic
4//! `(step, detail) -> concrete English sentence` naturalizer that every native
5//! surface shares: the core `EventLog` projection, the CLI `--thinking` output,
6//! the OpenAI/Anthropic API responses, and the Telegram bot. The browser mirrors
7//! the same two stages in JavaScript — the curated projection in
8//! `src/web/formal_ai_worker.js` and the naturalizer (`naturalizeThinkingStep`,
9//! which additionally localizes into the user's language) in `src/web/app.js`.
10//! Keeping it in its own module (rather than inside `engine.rs`) keeps each file
11//! focused and within the repository's per-file line budget while making
12//! "thinking" a first-class concern of the architecture rather than an engine
13//! implementation detail.
14
15use serde::{Deserialize, Serialize};
16
17use crate::engine::stable_id;
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct ThinkingStep {
21    pub id: String,
22    pub order: u32,
23    pub step: String,
24    pub detail: String,
25    /// Concrete, human-readable description of this step (issue #488).
26    ///
27    /// This is the "meta-language description" layer: a single English sentence
28    /// that surfaces the actual content of the step (the prompt, the computed
29    /// result, the looked-up entity, the chosen route, the composed answer)
30    /// rather than a generic category label. UI surfaces translate it into the
31    /// target user language; non-UI surfaces (CLI, API, Telegram) show it as-is.
32    #[serde(default, skip_serializing_if = "String::is_empty")]
33    pub summary: String,
34    pub level: String,
35    pub source_event: String,
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub parent_id: Option<String>,
38}
39
40impl ThinkingStep {
41    #[must_use]
42    pub fn new(
43        order: u32,
44        step: impl Into<String>,
45        detail: impl Into<String>,
46        level: impl Into<String>,
47        source_event: impl Into<String>,
48    ) -> Self {
49        let step = step.into();
50        let detail = detail.into();
51        let level = level.into();
52        let source_event = source_event.into();
53        let summary = naturalize_thinking_step(&step, &detail);
54        let seed = format!("{order}:{step}:{detail}:{level}:{source_event}");
55        Self {
56            id: stable_id("thinking_step", &seed),
57            order,
58            step,
59            detail,
60            summary,
61            level,
62            source_event,
63            parent_id: None,
64        }
65    }
66
67    /// Attach a parent step id so callers can express recursively composite
68    /// (fractal) thinking, where finer-granularity sub-steps roll up into a
69    /// single high-level step (issue #488).
70    #[must_use]
71    pub fn with_parent(mut self, parent_id: impl Into<String>) -> Self {
72        self.parent_id = Some(parent_id.into());
73        self
74    }
75}
76
77/// Map a language slug to its English name for concrete thinking summaries.
78#[must_use]
79pub fn thinking_language_label(code: &str) -> String {
80    let normalized = code.trim().to_ascii_lowercase();
81    let primary = normalized
82        .split(['-', '_'])
83        .next()
84        .unwrap_or(normalized.as_str());
85    match primary {
86        "en" => "English".to_owned(),
87        "ru" => "Russian".to_owned(),
88        "hi" => "Hindi".to_owned(),
89        "zh" => "Chinese".to_owned(),
90        "" | "unknown" => "an unrecognized language".to_owned(),
91        other => other.to_owned(),
92    }
93}
94
95/// Turn a meta-language identifier (`write_program`, `route:greeting`,
96/// `concept_lookup:hit`) into a lowercase human phrase (`write program`,
97/// `greeting`, `concept lookup hit`).
98#[must_use]
99pub fn humanize_meta_identifier(value: &str) -> String {
100    let mut spaced = String::with_capacity(value.len());
101    let mut previous_lower = false;
102    for character in value.chars() {
103        if character.is_ascii_uppercase() && previous_lower {
104            spaced.push(' ');
105        }
106        if matches!(character, '_' | ':' | '.' | '-' | '/') {
107            spaced.push(' ');
108        } else {
109            spaced.push(character);
110        }
111        previous_lower = character.is_ascii_lowercase() || character.is_ascii_digit();
112    }
113    let collapsed = spaced.split_whitespace().collect::<Vec<_>>().join(" ");
114    collapsed.trim().to_ascii_lowercase()
115}
116
117/// Build a short, first-person narrative headline that says, in plain language,
118/// what the assistant understood and decided (issue #676, R8).
119///
120/// This is the deliberately *human* layer that sits above the concrete step
121/// sentences. The reporter reached Formal AI through an agentic CLI (`OpenCode`)
122/// that renders the API `reasoning` field verbatim, and that field read as a
123/// robotic list of category steps identical across unrelated prompts. The
124/// headline restores a human summary while the detailed steps below stay intact
125/// as the recursive "robotic detail" layer (high-level thought → its sub-steps).
126///
127/// The narrative is derived purely from the steps already present — the chosen
128/// route from `dispatch_handler` (falling back to `formalize`) — so it stays
129/// deterministic and needs no extra engine state. Returns `None` when the trace
130/// has no recognizable route, leaving the robotic steps to speak for themselves.
131///
132/// The English sentences are `src` template literals, exactly like the
133/// [`naturalize_thinking_step`] sentences below; the browser mirrors the same
134/// per-intent framing through its localized "plain" thinking variants.
135#[must_use]
136pub fn thinking_narrative(steps: &[ThinkingStep]) -> Option<String> {
137    let route_detail = steps
138        .iter()
139        .find(|step| strip_agent_substep_prefix(&step.step) == "dispatch_handler")
140        .or_else(|| {
141            steps
142                .iter()
143                .find(|step| strip_agent_substep_prefix(&step.step) == "formalize")
144        })
145        .map(|step| step.detail.trim().to_ascii_lowercase())?;
146    if route_detail.is_empty() {
147        return None;
148    }
149    let narrative = match route_detail.as_str() {
150        "greeting" => "You said hello, so I greeted you back.",
151        "wellbeing" => "You asked how I'm doing, so I told you and offered to help.",
152        "assistant_free_time" => {
153            "You asked what I get up to, so I answered in a friendly way and offered to help."
154        }
155        "farewell" => "You said goodbye, so I wished you well in return.",
156        "gratitude" | "thanks" | "courtesy_response" | "courtesy" => {
157            "You thanked me, so I acknowledged it warmly."
158        }
159        "identity" | "assistant_name" | "recall_name" | "naming" | "assistant_naming" => {
160            "You asked about my name or who I am, so I answered from what I remember of our chat."
161        }
162        "calculation" | "arithmetic" => {
163            "This was a calculation, so I worked it out step by step and checked the result."
164        }
165        "fact_lookup" | "concept_lookup" | "concept_lookup_in_context" => {
166            "You asked for a fact, so I looked it up and reported what I found."
167        }
168        "translation" => "You asked for a translation, so I converted the text and returned it.",
169        "web_search" | "http_fetch" | "url_navigate" => {
170            "You pointed me at the web, so I fetched what you needed and summarized it."
171        }
172        "write_program"
173        | "software_project_plan"
174        | "software_project_implementation"
175        | "algorithm" => "You asked for code, so I planned it and wrote the program.",
176        "test_status" => "You asked about the tests, so I checked their status and reported it.",
177        "self_healing" | "self_heal" => {
178            "You asked me to fix myself, so I diagnosed the failure and repaired it."
179        }
180        "meta_explanation" => "You asked how I work, so I walked through my reasoning.",
181        "learn_from_source" => {
182            "You gave me something to learn from, so I read it and updated what I know."
183        }
184        "clarification" => "The request could mean more than one thing, so I asked you to clarify.",
185        "unknown" | "fallback" => {
186            "I wasn't sure how to handle this one yet, so I explained what I can do."
187        }
188        other => {
189            // Any other resolved route still gets a human headline rather than a
190            // bare category label: describe it as the task it was read as.
191            let task = humanize_meta_identifier(other);
192            return Some(format!(
193                "I read this as {} {task} request, worked out the answer, and replied.",
194                indefinite_article(&task)
195            ));
196        }
197    };
198    Some(narrative.to_owned())
199}
200
201/// Render a full reasoning trace as the plain-text form expected by protocol
202/// surfaces that expose a single thinking/reasoning string.
203///
204/// The output leads with a human [`thinking_narrative`] headline (issue #676,
205/// R8) when the route is recognizable, followed by the concrete per-step
206/// sentences as the recursive "robotic detail" beneath it.
207#[must_use]
208pub fn render_thinking_steps(steps: &[ThinkingStep]) -> String {
209    let mut lines = Vec::with_capacity(steps.len() + 1);
210    if let Some(narrative) = thinking_narrative(steps) {
211        lines.push(narrative);
212    }
213    for step in steps {
214        let sentence = if step.summary.is_empty() {
215            naturalize_thinking_step(&step.step, &step.detail)
216        } else {
217            step.summary.clone()
218        };
219        if step.parent_id.is_some() {
220            lines.push(format!("  ↳ {sentence}"));
221        } else {
222            lines.push(sentence);
223        }
224    }
225    lines.join("\n")
226}
227
228/// Pick the English indefinite article (`a`/`an`) for the following phrase based
229/// on its first letter, so naturalized steps read grammatically ("an arithmetic
230/// task", "a greeting task").
231fn indefinite_article(phrase: &str) -> &'static str {
232    match phrase.trim_start().chars().next() {
233        Some(first) if matches!(first.to_ascii_lowercase(), 'a' | 'e' | 'i' | 'o' | 'u') => "an",
234        _ => "a",
235    }
236}
237
238/// Strip an `agent_<n>_` sub-agent prefix from a step kind, mirroring the
239/// browser worker's nested-agent naming (`agent_0_impulse` -> `impulse`).
240fn strip_agent_substep_prefix(step: &str) -> &str {
241    if let Some(rest) = step.strip_prefix("agent_") {
242        if let Some(index) = rest.find('_') {
243            if index > 0 && rest[..index].bytes().all(|b| b.is_ascii_digit()) {
244                return &rest[index + 1..];
245            }
246        }
247    }
248    step
249}
250
251fn truncate_thinking_detail(value: &str) -> String {
252    let trimmed = value.trim();
253    // Issue #1963 (problem 2, "Thinking steps are not fully written, some parts
254    // are omitted."): the previous 120-char cap clipped the concrete detail of a
255    // step mid-sentence (e.g. a pasted prompt or a composed answer), so the
256    // visible reasoning read as truncated rather than complete. 600 chars keeps
257    // the detail bounded (the panel still scrolls and the fade still applies)
258    // while letting realistic single-step content render in full. This mirrors
259    // the JS `thinkingDetailText` helper; keep both constants in sync.
260    let limit = 600;
261    if trimmed.chars().count() <= limit {
262        return trimmed.to_owned();
263    }
264    let truncated: String = trimmed.chars().take(limit - 1).collect();
265    format!("{}…", truncated.trim_end())
266}
267
268/// Translate a single `(step, detail)` pair into one concrete English sentence.
269///
270/// This is the deterministic "meta-language description" stage from issue #488.
271/// It is the single source of truth shared by the core projection, the CLI, the
272/// OpenAI/Anthropic API surfaces, and (mirrored) the browser worker, so every
273/// surface renders the *same* concrete thinking rather than a generic label.
274#[must_use]
275pub fn naturalize_thinking_step(step: &str, detail: &str) -> String {
276    let canonical = strip_agent_substep_prefix(step);
277    let trimmed = truncate_thinking_detail(detail);
278    let has_detail = !trimmed.is_empty();
279    match canonical {
280        "impulse" => {
281            if has_detail {
282                format!("Read the request: \"{trimmed}\".")
283            } else {
284                "Read the incoming request.".to_owned()
285            }
286        }
287        "detect_language" => {
288            format!(
289                "Detect the request language: {}.",
290                thinking_language_label(detail)
291            )
292        }
293        "resolve_response_language" => {
294            format!("Plan to answer in {}.", thinking_language_label(detail))
295        }
296        "formalize" => {
297            if has_detail {
298                let task = humanize_meta_identifier(&trimmed);
299                format!(
300                    "Formalize the request as {} {task} task.",
301                    indefinite_article(&task)
302                )
303            } else {
304                "Formalize the request into a symbolic tuple.".to_owned()
305            }
306        }
307        "formalize_resolved" => {
308            if has_detail {
309                format!(
310                    "Resolve the request to {}.",
311                    humanize_meta_identifier(&trimmed)
312                )
313            } else {
314                "Resolve the request to a concrete entity.".to_owned()
315            }
316        }
317        "clarify_formalization" => {
318            if has_detail {
319                format!("Ask for clarification between {trimmed}.")
320            } else {
321                "Ask for clarification because the request was ambiguous.".to_owned()
322            }
323        }
324        "dispatch_handler" => {
325            if has_detail {
326                format!(
327                    "Route to the {} handler.",
328                    humanize_meta_identifier(&trimmed)
329                )
330            } else {
331                "Route the request to a handler.".to_owned()
332            }
333        }
334        "route_attempt" => {
335            if has_detail {
336                format!("Try the {} approach.", humanize_meta_identifier(&trimmed))
337            } else {
338                "Try the next candidate approach.".to_owned()
339            }
340        }
341        "match_rule" => {
342            if has_detail {
343                format!("Match the {} rule.", humanize_meta_identifier(&trimmed))
344            } else {
345                "Match a known rule.".to_owned()
346            }
347        }
348        "compute" => {
349            if has_detail {
350                format!("Compute {trimmed}.")
351            } else {
352                "Compute the result.".to_owned()
353            }
354        }
355        "compute_engine" => {
356            if has_detail {
357                format!("Evaluate with the {}.", humanize_meta_identifier(&trimmed))
358            } else {
359                "Evaluate with the calculator.".to_owned()
360            }
361        }
362        "compute_expression" => format!("Reduce the expression {trimmed}."),
363        "compute_steps" => format!("Apply {trimmed} reduction step(s)."),
364        "lookup_fact" => {
365            if has_detail {
366                format!("Look up {}.", humanize_meta_identifier(&trimmed))
367            } else {
368                "Look up the relevant fact.".to_owned()
369            }
370        }
371        "invoke_tool" => {
372            if has_detail {
373                format!("Use the {} capability.", humanize_meta_identifier(&trimmed))
374            } else {
375                "Use an available capability.".to_owned()
376            }
377        }
378        "rule_verification" => {
379            if has_detail {
380                format!(
381                    "Verify the result against the {} rule.",
382                    humanize_meta_identifier(&trimmed)
383                )
384            } else {
385                "Verify the result against the rules.".to_owned()
386            }
387        }
388        "policy_refusal" => {
389            if has_detail {
390                format!(
391                    "Decline the request under the {} policy.",
392                    humanize_meta_identifier(&trimmed)
393                )
394            } else {
395                "Decline the request under the safety policy.".to_owned()
396            }
397        }
398        "rule_construction" => "Build a local behavior rule.".to_owned(),
399        "coreference_binding" => "Resolve what the follow-up refers to.".to_owned(),
400        "modifier_detection" => "Detect modifiers in the request.".to_owned(),
401        "program_plan" => {
402            if has_detail {
403                format!("Plan the program: {}.", humanize_meta_identifier(&trimmed))
404            } else {
405                "Plan the requested program.".to_owned()
406            }
407        }
408        "scan_memory" => {
409            if has_detail {
410                format!("Search memory for {trimmed}.")
411            } else {
412                "Search memory for relevant facts.".to_owned()
413            }
414        }
415        "user_context" => {
416            if has_detail {
417                format!("Apply available context: {trimmed}.")
418            } else {
419                "Apply the available context.".to_owned()
420            }
421        }
422        "deformalize" => {
423            if has_detail {
424                format!("Compose the answer: \"{trimmed}\".")
425            } else {
426                "Compose the answer in natural language.".to_owned()
427            }
428        }
429        "http_chat" => "Exchange a request with the configured endpoint.".to_owned(),
430        "agent_plan" => {
431            if has_detail {
432                format!("Add an agent task: {}.", humanize_meta_identifier(&trimmed))
433            } else {
434                "Extend the agent plan.".to_owned()
435            }
436        }
437        "memory" => "Update the local memory bundle.".to_owned(),
438        "extract_term" => "Extract the search term.".to_owned(),
439        "group_by_conversation" => "Group matching memories by conversation.".to_owned(),
440        "fallback" => "Fall back to the general unknown-request strategy.".to_owned(),
441        other => {
442            let readable = humanize_meta_identifier(other);
443            let label = if readable.is_empty() {
444                "step".to_owned()
445            } else {
446                readable
447            };
448            if has_detail {
449                format!("{label}: {trimmed}.")
450            } else {
451                format!("{label}.")
452            }
453        }
454    }
455}