1use std::collections::HashSet;
4
5use serde_json::Value;
6
7use crate::events::{
8 EVENT_GOAL_CREATED, EVENT_PLAN_BATCH_STARTED, EVENT_PLAN_CREATED, EVENT_TOOL_STARTED,
9};
10
11const MAX_THINKING_STEP_RUNES: usize = 280;
12
13pub fn default_thinking_step_events() -> HashSet<String> {
15 [
16 "soothe.cognition.plan.step.started",
17 "soothe.cognition.plan.step.completed",
18 "soothe.cognition.plan.step.failed",
19 "soothe.lifecycle.iteration.started",
20 "soothe.agent.loop.step.started",
21 "soothe.agent.loop.started",
22 EVENT_PLAN_BATCH_STARTED,
23 EVENT_PLAN_CREATED,
24 EVENT_GOAL_CREATED,
25 EVENT_TOOL_STARTED,
26 ]
27 .into_iter()
28 .map(str::to_string)
29 .collect()
30}
31
32pub fn extract_thinking_step(
34 allow: Option<&HashSet<String>>,
35 event_type: &str,
36 data: &serde_json::Map<String, Value>,
37) -> Option<String> {
38 let event_type = event_type.trim();
39 if event_type.is_empty() {
40 return None;
41 }
42
43 let default_allow = default_thinking_step_events();
44 let allow = allow.unwrap_or(&default_allow);
45 if !allow.contains(event_type) {
46 return None;
47 }
48
49 let line = match event_type {
50 "soothe.cognition.plan.step.started" => format_plan_step_line(data, ""),
51 "soothe.cognition.plan.step.completed" => format_plan_step_line(data, "done"),
52 "soothe.cognition.plan.step.failed" => {
53 let step_id = str_field(data, &["step_id"]);
54 let err_msg = str_field(data, &["error"]);
55 match (step_id.as_str(), err_msg.as_str()) {
56 (s, e) if !s.is_empty() && !e.is_empty() => format!("Step {s} failed: {e}"),
57 (s, _) if !s.is_empty() => format!("Step {s} failed"),
58 (_, e) if !e.is_empty() => format!("Step failed: {e}"),
59 _ => String::new(),
60 }
61 }
62 "soothe.agent.loop.step.started" => format_agent_step_line(data, ""),
63 EVENT_PLAN_BATCH_STARTED => data
64 .get("parallel_count")
65 .and_then(|v| v.as_f64())
66 .filter(|n| *n > 0.0)
67 .map(|n| format!("Running {} steps in parallel", n as i64))
68 .unwrap_or_default(),
69 EVENT_PLAN_CREATED | "soothe.agent.loop.started" => {
70 let g = str_field(data, &["goal"]);
71 if g.is_empty() {
72 String::new()
73 } else {
74 format!("Goal: {g}")
75 }
76 }
77 EVENT_GOAL_CREATED => {
78 let g = str_field(data, &["friendly_message", "description"]);
79 if g.is_empty() {
80 String::new()
81 } else {
82 format!("Goal: {g}")
83 }
84 }
85 "soothe.lifecycle.iteration.started" => {
86 let g = str_field(data, &["goal_description"]);
87 if g.is_empty() {
88 String::new()
89 } else {
90 format!("Iteration: {g}")
91 }
92 }
93 EVENT_TOOL_STARTED => {
94 let name = str_field(data, &["tool_name", "name"]);
95 if name.is_empty() {
96 String::new()
97 } else {
98 format!("Tool: {name}")
99 }
100 }
101 _ => return None,
102 };
103
104 let line = line.trim().to_string();
105 if line.is_empty() {
106 return None;
107 }
108 truncate_thinking_step(line)
109}
110
111fn truncate_thinking_step(line: String) -> Option<String> {
112 let runes: Vec<char> = line.chars().collect();
113 if runes.len() <= MAX_THINKING_STEP_RUNES {
114 Some(line)
115 } else {
116 let truncated: String = runes[..MAX_THINKING_STEP_RUNES].iter().collect();
117 Some(format!("{truncated}…"))
118 }
119}
120
121fn format_plan_step_line(data: &serde_json::Map<String, Value>, suffix: &str) -> String {
122 let step_id = str_field(data, &["step_id"]);
123 let desc = str_field(data, &["description"]);
124 match (step_id.as_str(), desc.as_str(), suffix) {
125 (s, _, suf) if !s.is_empty() && !suf.is_empty() => format!("Step {s}: {suf}"),
126 (s, d, _) if !s.is_empty() && !d.is_empty() => format!("Step {s}: {d}"),
127 (s, _, _) if !s.is_empty() => format!("Step {s}"),
128 (_, d, suf) if !d.is_empty() && !suf.is_empty() => format!("Step: {suf}"),
129 (_, d, _) if !d.is_empty() => format!("Step: {d}"),
130 (_, _, suf) if !suf.is_empty() => format!("Step: {suf}"),
131 _ => String::new(),
132 }
133}
134
135fn format_agent_step_line(data: &serde_json::Map<String, Value>, suffix: &str) -> String {
136 let step_id = str_field(data, &["step_id"]);
137 let desc = str_field(data, &["description"]);
138 match (step_id.as_str(), desc.as_str(), suffix) {
139 (s, d, _) if !s.is_empty() && !d.is_empty() => format!("Step {s}: {d}"),
140 (_, d, suf) if !d.is_empty() && !suf.is_empty() => format!("Step: {suf}"),
141 (_, d, _) if !d.is_empty() => format!("Step: {d}"),
142 (s, _, _) if !s.is_empty() => format!("Step {s}"),
143 _ => String::new(),
144 }
145}
146
147fn str_field(data: &serde_json::Map<String, Value>, keys: &[&str]) -> String {
148 for key in keys {
149 if let Some(s) = data.get(*key).and_then(|v| v.as_str()) {
150 let s = s.trim();
151 if !s.is_empty() {
152 return s.to_string();
153 }
154 }
155 }
156 String::new()
157}