Skip to main content

kiss_coding/workflows/
mod.rs

1//! Dynamic workflows: many child agents orchestrated by a script the model
2//! writes.
3//!
4//! A workflow is built on the same child sessions `spawn_agent` uses, so it is
5//! available only when subagents are on. The difference is where the plan
6//! lives: with `spawn_agent` the model decides what to start next on every
7//! turn, and every result lands in its context. With a workflow the plan is a
8//! script, and only the final answer comes back.
9
10mod prompt;
11mod runner;
12mod store;
13mod tool;
14
15pub use prompt::{authoring_prompt, workflow_trigger};
16pub use store::{SaveLocation, SavedWorkflow, discover, save};
17
18// Re-exported so the terminal renders workflow state without depending on
19// `kiss-workflow` directly.
20pub use kiss_workflow::{
21    AgentId, AgentOutcome, AgentSnapshot, AgentStatus, PhaseSnapshot, RunSnapshot, RunStatus,
22    Script,
23};
24
25use crate::session_runner::{AgentSession, WorkflowTurnStatus};
26use kiss_agent::DynTool;
27use kiss_workflow::{Limits, Workflow};
28use serde_json::Value;
29use std::sync::atomic::{AtomicU64, Ordering};
30use std::sync::{Arc, Mutex, Weak};
31use std::time::Duration;
32use tokio_util::sync::CancellationToken;
33
34/// Identifies one run within a session.
35pub type RunId = u64;
36
37/// How many finished runs to keep, so `/workflows` can still show and save a
38/// run the user is only now getting back to.
39const MAX_RETAINED_RUNS: usize = 20;
40
41/// One run, with the script that produced it.
42pub struct RunRecord {
43    pub id: RunId,
44    pub name: String,
45    pub description: String,
46    /// The script text, kept so the user can read it and save it afterward.
47    pub source: Arc<str>,
48    workflow: Arc<Workflow>,
49}
50
51impl RunRecord {
52    pub fn snapshot(&self) -> Arc<RunSnapshot> {
53        self.workflow.snapshot()
54    }
55
56    pub fn subscribe(&self) -> tokio::sync::watch::Receiver<u64> {
57        self.workflow.subscribe()
58    }
59
60    pub fn pause(&self) {
61        self.workflow.pause();
62    }
63
64    pub fn resume(&self) {
65        self.workflow.resume();
66    }
67
68    pub fn is_paused(&self) -> bool {
69        self.workflow.is_paused()
70    }
71
72    pub fn stop(&self) {
73        self.workflow.stop();
74    }
75
76    pub fn stop_agent(&self, agent: kiss_workflow::AgentId) {
77        self.workflow.stop_agent(agent);
78    }
79
80    pub fn restart_agent(&self, agent: kiss_workflow::AgentId) {
81        self.workflow.restart_agent(agent);
82    }
83}
84
85/// The text a finished run reports: a string as is, anything else as JSON.
86pub fn report_text(value: Value) -> String {
87    match value {
88        Value::String(text) => text,
89        Value::Null => "The workflow returned nothing.".to_string(),
90        other => serde_json::to_string_pretty(&other).unwrap_or_else(|_| other.to_string()),
91    }
92}
93
94/// A run as listed in `/workflows`.
95#[derive(Debug, Clone)]
96pub struct RunSummary {
97    pub id: RunId,
98    pub name: String,
99    pub description: String,
100    pub status: RunStatus,
101    pub total_agents: usize,
102    pub finished_agents: usize,
103    pub tokens: u64,
104    pub elapsed: Duration,
105}
106
107/// What the user decided when asked to approve a run.
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum ApprovalDecision {
110    Approve,
111    Cancel,
112}
113
114/// What the user is shown before a run starts.
115#[derive(Debug, Clone)]
116pub struct WorkflowPlan {
117    pub name: String,
118    pub description: String,
119    pub phases: Vec<String>,
120    /// `None` when the agent count depends on data the run has not fetched yet.
121    pub estimated_agents: Option<u32>,
122    pub source: Arc<str>,
123}
124
125impl WorkflowPlan {
126    pub fn from_script(script: &Script) -> WorkflowPlan {
127        WorkflowPlan {
128            name: script.meta().name.clone(),
129            description: script.meta().description.clone(),
130            phases: script.declared_phases().to_vec(),
131            estimated_agents: script.estimated_agents(),
132            source: Arc::from(script.source()),
133        }
134    }
135
136    /// The agent count as shown to the user.
137    ///
138    /// A count is only stated when the script fixes it. Otherwise this says so,
139    /// because a number in an approval prompt has to be one the user can rely
140    /// on.
141    pub fn agent_estimate(&self) -> String {
142        match self.estimated_agents {
143            Some(1) => "1 agent".into(),
144            Some(count) => format!("{count} agents"),
145            None => "an unbounded number of agents".into(),
146        }
147    }
148}
149
150/// Asks the user to approve a run. Interactive mode installs one. Other modes
151/// leave it unset and runs start without asking, since nothing can answer.
152pub type WorkflowApprover = Arc<
153    dyn Fn(WorkflowPlan) -> futures::future::BoxFuture<'static, ApprovalDecision> + Send + Sync,
154>;
155
156/// The workflow runs belonging to one session.
157pub struct WorkflowRuntime {
158    parent: Weak<AgentSession>,
159    runs: Mutex<Vec<Arc<RunRecord>>>,
160    next_id: AtomicU64,
161}
162
163impl WorkflowRuntime {
164    pub(crate) fn new(parent: Weak<AgentSession>) -> Arc<WorkflowRuntime> {
165        Arc::new(WorkflowRuntime {
166            parent,
167            runs: Mutex::new(Vec::new()),
168            next_id: AtomicU64::new(1),
169        })
170    }
171
172    /// The tool the model calls to run a workflow it has written.
173    pub(crate) fn tool(self: &Arc<Self>) -> DynTool {
174        Arc::new(tool::RunWorkflowTool::new(self.clone()))
175    }
176
177    pub(crate) fn emit_outcome(
178        &self,
179        run: Option<RunId>,
180        name: String,
181        status: WorkflowTurnStatus,
182    ) {
183        if let Some(parent) = self.parent.upgrade() {
184            parent.emit_workflow_outcome(run, name, status);
185        }
186    }
187
188    /// Ask the user to approve a run.
189    ///
190    /// This is a cost gate rather than a permission gate: one run can start
191    /// hundreds of child agents, and the user should see that before it is
192    /// spent. Modes with no way to answer, such as `-p`, start the run.
193    pub async fn approve(&self, plan: WorkflowPlan) -> ApprovalDecision {
194        let Some(parent) = self.parent.upgrade() else {
195            return ApprovalDecision::Cancel;
196        };
197        if !parent.settings().workflows.confirm {
198            return ApprovalDecision::Approve;
199        }
200        match parent.workflow_approver() {
201            Some(approver) => approver(plan).await,
202            None => ApprovalDecision::Approve,
203        }
204    }
205
206    /// Prepare a run and add it to the list, without starting it.
207    ///
208    /// Registering before running is what lets the progress view find the run
209    /// as soon as it is approved, rather than after its first agent answers.
210    pub fn prepare(
211        &self,
212        plan: &WorkflowPlan,
213        script: Script,
214        args: Value,
215    ) -> anyhow::Result<Arc<RunRecord>> {
216        let parent = self
217            .parent
218            .upgrade()
219            .ok_or_else(|| anyhow::anyhow!("the parent session has closed"))?;
220        let limits = Limits::default();
221        let runner = Arc::new(runner::SessionAgentRunner::new(
222            self.parent.clone(),
223            limits.max_concurrency,
224        ));
225        let cwd = parent
226            .manager
227            .lock()
228            .map(|manager| manager.cwd().display().to_string())
229            .unwrap_or_default();
230
231        let id = self.next_id.fetch_add(1, Ordering::SeqCst);
232        let name = plan.name.clone();
233        let description = plan.description.clone();
234        let source: Arc<str> = Arc::from(script.source());
235        let workflow = Arc::new(Workflow::new(script, args, cwd, runner, limits));
236        let record = Arc::new(RunRecord {
237            id,
238            name,
239            description,
240            source,
241            workflow,
242        });
243
244        // Forward the workflow's watch channel into the normal session event
245        // stream. The terminal then sleeps until state changes instead of
246        // polling every frame.
247        let mut updates = record.subscribe();
248        let parent_for_updates = self.parent.clone();
249        let record_for_updates = record.clone();
250        tokio::spawn(async move {
251            while updates.changed().await.is_ok() {
252                let version = *updates.borrow_and_update();
253                let Some(parent) = parent_for_updates.upgrade() else {
254                    break;
255                };
256                parent.emit_workflow(id, version);
257                if record_for_updates.snapshot().status.is_finished() {
258                    break;
259                }
260            }
261        });
262
263        let mut runs = self
264            .runs
265            .lock()
266            .map_err(|_| anyhow::anyhow!("the workflow list is unavailable"))?;
267        runs.push(record.clone());
268        // Keep the newest runs, but never drop one that is still working.
269        while runs.len() > MAX_RETAINED_RUNS {
270            let Some(position) = runs
271                .iter()
272                .position(|run| run.snapshot().status.is_finished())
273            else {
274                break;
275            };
276            runs.remove(position);
277        }
278        Ok(record)
279    }
280
281    /// Run a prepared workflow to completion.
282    /// Run a prepared workflow until it ends. Cancelling `stop` stops it,
283    /// so an interrupted turn or command leaves no child agents working.
284    pub async fn run(
285        &self,
286        record: &Arc<RunRecord>,
287        stop: CancellationToken,
288    ) -> Result<Value, String> {
289        let stopping = record.clone();
290        let guard = tokio::spawn(async move {
291            stop.cancelled().await;
292            stopping.stop();
293        });
294        let outcome = record
295            .workflow
296            .run()
297            .await
298            .map_err(|error| error.to_string());
299        guard.abort();
300        outcome
301    }
302
303    pub fn get(&self, id: RunId) -> Option<Arc<RunRecord>> {
304        self.runs
305            .lock()
306            .ok()?
307            .iter()
308            .find(|run| run.id == id)
309            .cloned()
310    }
311
312    /// Every run in this session, oldest first.
313    pub fn summaries(&self) -> Vec<RunSummary> {
314        let Ok(runs) = self.runs.lock() else {
315            return Vec::new();
316        };
317        runs.iter()
318            .map(|run| {
319                let snapshot = run.snapshot();
320                RunSummary {
321                    id: run.id,
322                    name: run.name.clone(),
323                    description: run.description.clone(),
324                    status: snapshot.status,
325                    total_agents: snapshot.total_agents(),
326                    finished_agents: snapshot.finished_agents(),
327                    tokens: snapshot.tokens,
328                    elapsed: snapshot.elapsed,
329                }
330            })
331            .collect()
332    }
333
334    /// The run still working, if any.
335    pub fn active(&self) -> Option<Arc<RunRecord>> {
336        let runs = self.runs.lock().ok()?;
337        runs.iter()
338            .rev()
339            .find(|run| !run.snapshot().status.is_finished())
340            .cloned()
341    }
342
343    /// The most recent run, working or finished.
344    pub fn latest(&self) -> Option<Arc<RunRecord>> {
345        self.runs.lock().ok()?.last().cloned()
346    }
347
348    pub fn is_empty(&self) -> bool {
349        self.runs.lock().map(|runs| runs.is_empty()).unwrap_or(true)
350    }
351
352    /// Stop every run still working. Called when the session closes or when the
353    /// setting is turned off.
354    pub(crate) fn stop_all(&self) {
355        let Ok(runs) = self.runs.lock() else {
356            return;
357        };
358        for run in runs.iter() {
359            if !run.snapshot().status.is_finished() {
360                run.stop();
361            }
362        }
363    }
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369
370    #[test]
371    fn an_agent_estimate_never_guesses_a_number_it_does_not_know() {
372        let plan = |estimated_agents| WorkflowPlan {
373            name: "x".into(),
374            description: "y".into(),
375            phases: Vec::new(),
376            estimated_agents,
377            source: Arc::from(""),
378        };
379        assert_eq!(plan(Some(1)).agent_estimate(), "1 agent");
380        assert_eq!(plan(Some(12)).agent_estimate(), "12 agents");
381        assert_eq!(plan(None).agent_estimate(), "an unbounded number of agents");
382    }
383}