1use 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 #[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 #[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#[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#[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#[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 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#[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
228fn 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
238fn 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 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#[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}