formal-ai 0.284.0

Formal symbolic AI implementation with OpenAI-compatible APIs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
//! Human-readable "thinking" steps (issue #488).
//!
//! This module owns the [`ThinkingStep`] model and the deterministic
//! `(step, detail) -> concrete English sentence` naturalizer that every native
//! surface shares: the core `EventLog` projection, the CLI `--thinking` output,
//! the OpenAI/Anthropic API responses, and the Telegram bot. The browser mirrors
//! the same two stages in JavaScript — the curated projection in
//! `src/web/formal_ai_worker.js` and the naturalizer (`naturalizeThinkingStep`,
//! which additionally localizes into the user's language) in `src/web/app.js`.
//! Keeping it in its own module (rather than inside `engine.rs`) keeps each file
//! focused and within the repository's per-file line budget while making
//! "thinking" a first-class concern of the architecture rather than an engine
//! implementation detail.

use serde::{Deserialize, Serialize};

use crate::engine::stable_id;

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ThinkingStep {
    pub id: String,
    pub order: u32,
    pub step: String,
    pub detail: String,
    /// Concrete, human-readable description of this step (issue #488).
    ///
    /// This is the "meta-language description" layer: a single English sentence
    /// that surfaces the actual content of the step (the prompt, the computed
    /// result, the looked-up entity, the chosen route, the composed answer)
    /// rather than a generic category label. UI surfaces translate it into the
    /// target user language; non-UI surfaces (CLI, API, Telegram) show it as-is.
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub summary: String,
    pub level: String,
    pub source_event: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub parent_id: Option<String>,
}

impl ThinkingStep {
    #[must_use]
    pub fn new(
        order: u32,
        step: impl Into<String>,
        detail: impl Into<String>,
        level: impl Into<String>,
        source_event: impl Into<String>,
    ) -> Self {
        let step = step.into();
        let detail = detail.into();
        let level = level.into();
        let source_event = source_event.into();
        let summary = naturalize_thinking_step(&step, &detail);
        let seed = format!("{order}:{step}:{detail}:{level}:{source_event}");
        Self {
            id: stable_id("thinking_step", &seed),
            order,
            step,
            detail,
            summary,
            level,
            source_event,
            parent_id: None,
        }
    }

    /// Attach a parent step id so callers can express recursively composite
    /// (fractal) thinking, where finer-granularity sub-steps roll up into a
    /// single high-level step (issue #488).
    #[must_use]
    pub fn with_parent(mut self, parent_id: impl Into<String>) -> Self {
        self.parent_id = Some(parent_id.into());
        self
    }
}

/// Map a language slug to its English name for concrete thinking summaries.
#[must_use]
pub fn thinking_language_label(code: &str) -> String {
    let normalized = code.trim().to_ascii_lowercase();
    let primary = normalized
        .split(['-', '_'])
        .next()
        .unwrap_or(normalized.as_str());
    match primary {
        "en" => "English".to_owned(),
        "ru" => "Russian".to_owned(),
        "hi" => "Hindi".to_owned(),
        "zh" => "Chinese".to_owned(),
        "" | "unknown" => "an unrecognized language".to_owned(),
        other => other.to_owned(),
    }
}

/// Turn a meta-language identifier (`write_program`, `route:greeting`,
/// `concept_lookup:hit`) into a lowercase human phrase (`write program`,
/// `greeting`, `concept lookup hit`).
#[must_use]
pub fn humanize_meta_identifier(value: &str) -> String {
    let mut spaced = String::with_capacity(value.len());
    let mut previous_lower = false;
    for character in value.chars() {
        if character.is_ascii_uppercase() && previous_lower {
            spaced.push(' ');
        }
        if matches!(character, '_' | ':' | '.' | '-' | '/') {
            spaced.push(' ');
        } else {
            spaced.push(character);
        }
        previous_lower = character.is_ascii_lowercase() || character.is_ascii_digit();
    }
    let collapsed = spaced.split_whitespace().collect::<Vec<_>>().join(" ");
    collapsed.trim().to_ascii_lowercase()
}

/// Build a short, first-person narrative headline that says, in plain language,
/// what the assistant understood and decided (issue #676, R8).
///
/// This is the deliberately *human* layer that sits above the concrete step
/// sentences. The reporter reached Formal AI through an agentic CLI (`OpenCode`)
/// that renders the API `reasoning` field verbatim, and that field read as a
/// robotic list of category steps identical across unrelated prompts. The
/// headline restores a human summary while the detailed steps below stay intact
/// as the recursive "robotic detail" layer (high-level thought → its sub-steps).
///
/// The narrative is derived purely from the steps already present — the chosen
/// route from `dispatch_handler` (falling back to `formalize`) — so it stays
/// deterministic and needs no extra engine state. Returns `None` when the trace
/// has no recognizable route, leaving the robotic steps to speak for themselves.
///
/// The English sentences are `src` template literals, exactly like the
/// [`naturalize_thinking_step`] sentences below; the browser mirrors the same
/// per-intent framing through its localized "plain" thinking variants.
#[must_use]
pub fn thinking_narrative(steps: &[ThinkingStep]) -> Option<String> {
    let route_detail = steps
        .iter()
        .find(|step| strip_agent_substep_prefix(&step.step) == "dispatch_handler")
        .or_else(|| {
            steps
                .iter()
                .find(|step| strip_agent_substep_prefix(&step.step) == "formalize")
        })
        .map(|step| step.detail.trim().to_ascii_lowercase())?;
    if route_detail.is_empty() {
        return None;
    }
    let narrative = match route_detail.as_str() {
        "greeting" => "You said hello, so I greeted you back.",
        "wellbeing" => "You asked how I'm doing, so I told you and offered to help.",
        "assistant_free_time" => {
            "You asked what I get up to, so I answered in a friendly way and offered to help."
        }
        "farewell" => "You said goodbye, so I wished you well in return.",
        "gratitude" | "thanks" | "courtesy_response" | "courtesy" => {
            "You thanked me, so I acknowledged it warmly."
        }
        "identity" | "assistant_name" | "recall_name" | "naming" | "assistant_naming" => {
            "You asked about my name or who I am, so I answered from what I remember of our chat."
        }
        "calculation" | "arithmetic" => {
            "This was a calculation, so I worked it out step by step and checked the result."
        }
        "fact_lookup" | "concept_lookup" | "concept_lookup_in_context" => {
            "You asked for a fact, so I looked it up and reported what I found."
        }
        "translation" => "You asked for a translation, so I converted the text and returned it.",
        "web_search" | "http_fetch" | "url_navigate" => {
            "You pointed me at the web, so I fetched what you needed and summarized it."
        }
        "write_program"
        | "software_project_plan"
        | "software_project_implementation"
        | "algorithm" => "You asked for code, so I planned it and wrote the program.",
        "test_status" => "You asked about the tests, so I checked their status and reported it.",
        "self_healing" | "self_heal" => {
            "You asked me to fix myself, so I diagnosed the failure and repaired it."
        }
        "meta_explanation" => "You asked how I work, so I walked through my reasoning.",
        "learn_from_source" => {
            "You gave me something to learn from, so I read it and updated what I know."
        }
        "clarification" => "The request could mean more than one thing, so I asked you to clarify.",
        "unknown" | "fallback" => {
            "I wasn't sure how to handle this one yet, so I explained what I can do."
        }
        other => {
            // Any other resolved route still gets a human headline rather than a
            // bare category label: describe it as the task it was read as.
            let task = humanize_meta_identifier(other);
            return Some(format!(
                "I read this as {} {task} request, worked out the answer, and replied.",
                indefinite_article(&task)
            ));
        }
    };
    Some(narrative.to_owned())
}

/// Render a full reasoning trace as the plain-text form expected by protocol
/// surfaces that expose a single thinking/reasoning string.
///
/// The output leads with a human [`thinking_narrative`] headline (issue #676,
/// R8) when the route is recognizable, followed by the concrete per-step
/// sentences as the recursive "robotic detail" beneath it.
#[must_use]
pub fn render_thinking_steps(steps: &[ThinkingStep]) -> String {
    let mut lines = Vec::with_capacity(steps.len() + 1);
    if let Some(narrative) = thinking_narrative(steps) {
        lines.push(narrative);
    }
    for step in steps {
        let sentence = if step.summary.is_empty() {
            naturalize_thinking_step(&step.step, &step.detail)
        } else {
            step.summary.clone()
        };
        if step.parent_id.is_some() {
            lines.push(format!("  ↳ {sentence}"));
        } else {
            lines.push(sentence);
        }
    }
    lines.join("\n")
}

/// Pick the English indefinite article (`a`/`an`) for the following phrase based
/// on its first letter, so naturalized steps read grammatically ("an arithmetic
/// task", "a greeting task").
fn indefinite_article(phrase: &str) -> &'static str {
    match phrase.trim_start().chars().next() {
        Some(first) if matches!(first.to_ascii_lowercase(), 'a' | 'e' | 'i' | 'o' | 'u') => "an",
        _ => "a",
    }
}

/// Strip an `agent_<n>_` sub-agent prefix from a step kind, mirroring the
/// browser worker's nested-agent naming (`agent_0_impulse` -> `impulse`).
fn strip_agent_substep_prefix(step: &str) -> &str {
    if let Some(rest) = step.strip_prefix("agent_") {
        if let Some(index) = rest.find('_') {
            if index > 0 && rest[..index].bytes().all(|b| b.is_ascii_digit()) {
                return &rest[index + 1..];
            }
        }
    }
    step
}

fn truncate_thinking_detail(value: &str) -> String {
    let trimmed = value.trim();
    // Issue #1963 (problem 2, "Thinking steps are not fully written, some parts
    // are omitted."): the previous 120-char cap clipped the concrete detail of a
    // step mid-sentence (e.g. a pasted prompt or a composed answer), so the
    // visible reasoning read as truncated rather than complete. 600 chars keeps
    // the detail bounded (the panel still scrolls and the fade still applies)
    // while letting realistic single-step content render in full. This mirrors
    // the JS `thinkingDetailText` helper; keep both constants in sync.
    let limit = 600;
    if trimmed.chars().count() <= limit {
        return trimmed.to_owned();
    }
    let truncated: String = trimmed.chars().take(limit - 1).collect();
    format!("{}…", truncated.trim_end())
}

/// Translate a single `(step, detail)` pair into one concrete English sentence.
///
/// This is the deterministic "meta-language description" stage from issue #488.
/// It is the single source of truth shared by the core projection, the CLI, the
/// OpenAI/Anthropic API surfaces, and (mirrored) the browser worker, so every
/// surface renders the *same* concrete thinking rather than a generic label.
#[must_use]
pub fn naturalize_thinking_step(step: &str, detail: &str) -> String {
    let canonical = strip_agent_substep_prefix(step);
    let trimmed = truncate_thinking_detail(detail);
    let has_detail = !trimmed.is_empty();
    match canonical {
        "impulse" => {
            if has_detail {
                format!("Read the request: \"{trimmed}\".")
            } else {
                "Read the incoming request.".to_owned()
            }
        }
        "detect_language" => {
            format!(
                "Detect the request language: {}.",
                thinking_language_label(detail)
            )
        }
        "resolve_response_language" => {
            format!("Plan to answer in {}.", thinking_language_label(detail))
        }
        "formalize" => {
            if has_detail {
                let task = humanize_meta_identifier(&trimmed);
                format!(
                    "Formalize the request as {} {task} task.",
                    indefinite_article(&task)
                )
            } else {
                "Formalize the request into a symbolic tuple.".to_owned()
            }
        }
        "formalize_resolved" => {
            if has_detail {
                format!(
                    "Resolve the request to {}.",
                    humanize_meta_identifier(&trimmed)
                )
            } else {
                "Resolve the request to a concrete entity.".to_owned()
            }
        }
        "clarify_formalization" => {
            if has_detail {
                format!("Ask for clarification between {trimmed}.")
            } else {
                "Ask for clarification because the request was ambiguous.".to_owned()
            }
        }
        "dispatch_handler" => {
            if has_detail {
                format!(
                    "Route to the {} handler.",
                    humanize_meta_identifier(&trimmed)
                )
            } else {
                "Route the request to a handler.".to_owned()
            }
        }
        "route_attempt" => {
            if has_detail {
                format!("Try the {} approach.", humanize_meta_identifier(&trimmed))
            } else {
                "Try the next candidate approach.".to_owned()
            }
        }
        "match_rule" => {
            if has_detail {
                format!("Match the {} rule.", humanize_meta_identifier(&trimmed))
            } else {
                "Match a known rule.".to_owned()
            }
        }
        "compute" => {
            if has_detail {
                format!("Compute {trimmed}.")
            } else {
                "Compute the result.".to_owned()
            }
        }
        "compute_engine" => {
            if has_detail {
                format!("Evaluate with the {}.", humanize_meta_identifier(&trimmed))
            } else {
                "Evaluate with the calculator.".to_owned()
            }
        }
        "compute_expression" => format!("Reduce the expression {trimmed}."),
        "compute_steps" => format!("Apply {trimmed} reduction step(s)."),
        "lookup_fact" => {
            if has_detail {
                format!("Look up {}.", humanize_meta_identifier(&trimmed))
            } else {
                "Look up the relevant fact.".to_owned()
            }
        }
        "invoke_tool" => {
            if has_detail {
                format!("Use the {} capability.", humanize_meta_identifier(&trimmed))
            } else {
                "Use an available capability.".to_owned()
            }
        }
        "rule_verification" => {
            if has_detail {
                format!(
                    "Verify the result against the {} rule.",
                    humanize_meta_identifier(&trimmed)
                )
            } else {
                "Verify the result against the rules.".to_owned()
            }
        }
        "policy_refusal" => {
            if has_detail {
                format!(
                    "Decline the request under the {} policy.",
                    humanize_meta_identifier(&trimmed)
                )
            } else {
                "Decline the request under the safety policy.".to_owned()
            }
        }
        "rule_construction" => "Build a local behavior rule.".to_owned(),
        "coreference_binding" => "Resolve what the follow-up refers to.".to_owned(),
        "modifier_detection" => "Detect modifiers in the request.".to_owned(),
        "program_plan" => {
            if has_detail {
                format!("Plan the program: {}.", humanize_meta_identifier(&trimmed))
            } else {
                "Plan the requested program.".to_owned()
            }
        }
        "scan_memory" => {
            if has_detail {
                format!("Search memory for {trimmed}.")
            } else {
                "Search memory for relevant facts.".to_owned()
            }
        }
        "user_context" => {
            if has_detail {
                format!("Apply available context: {trimmed}.")
            } else {
                "Apply the available context.".to_owned()
            }
        }
        "deformalize" => {
            if has_detail {
                format!("Compose the answer: \"{trimmed}\".")
            } else {
                "Compose the answer in natural language.".to_owned()
            }
        }
        "http_chat" => "Exchange a request with the configured endpoint.".to_owned(),
        "agent_plan" => {
            if has_detail {
                format!("Add an agent task: {}.", humanize_meta_identifier(&trimmed))
            } else {
                "Extend the agent plan.".to_owned()
            }
        }
        "memory" => "Update the local memory bundle.".to_owned(),
        "extract_term" => "Extract the search term.".to_owned(),
        "group_by_conversation" => "Group matching memories by conversation.".to_owned(),
        "fallback" => "Fall back to the general unknown-request strategy.".to_owned(),
        other => {
            let readable = humanize_meta_identifier(other);
            let label = if readable.is_empty() {
                "step".to_owned()
            } else {
                readable
            };
            if has_detail {
                format!("{label}: {trimmed}.")
            } else {
                format!("{label}.")
            }
        }
    }
}