Skip to main content

soothe_client/appkit/
thinking_step.rs

1//! Maps allowlisted progress events to one-line thinking steps.
2
3use 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
13/// Default thinking-step event allowlist (overridable via [`ClassifierConfig`](super::ClassifierConfig)).
14pub 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
32/// Maps an allowlisted progress event to one structured UI line.
33pub 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}