Skip to main content

kiss_workflow/
runner.rs

1//! The boundary between a workflow script and real agents.
2//!
3//! This crate never starts an agent itself. It hands a request to an
4//! [`AgentRunner`] the host supplies. Tests supply a fake runner, so the whole
5//! language is exercised without a model.
6
7use serde_json::Value as Json;
8use std::collections::HashMap;
9use tokio_util::sync::CancellationToken;
10
11/// Identifies one `agent()` call site by its position in the run.
12///
13/// Because the interpreter is deterministic, the same script with the same
14/// arguments issues the same sequence of calls, so the position is a stable key
15/// for [`Journal`] without any bookkeeping in the script itself.
16pub type AgentId = u32;
17
18/// One child agent to start.
19#[derive(Debug, Clone, PartialEq)]
20pub struct AgentRequest {
21    /// Position of this `agent()` call in the run, counting from zero.
22    pub index: AgentId,
23    pub prompt: String,
24    /// Display name for the progress view. Falls back to the phase and index.
25    pub label: Option<String>,
26    pub phase: String,
27    /// A model pattern for the host to resolve, such as `sonnet` or
28    /// `anthropic/claude-sonnet-5`.
29    pub model: Option<String>,
30    /// A thinking level name, such as `low` or `high`.
31    pub effort: Option<String>,
32    /// A JSON Schema the answer must satisfy. When set, the call returns parsed
33    /// data instead of text.
34    pub schema: Option<Json>,
35    pub timeout_ms: Option<u64>,
36}
37
38/// How one child agent ended.
39#[derive(Debug, Clone, PartialEq)]
40pub enum AgentOutcome {
41    /// The agent answered. The value is text unless the request carried a
42    /// schema, in which case it is the parsed structured answer.
43    Done(Json),
44    /// The user stopped this agent, or the whole run.
45    Stopped,
46    /// The agent failed after any retries the host applied.
47    Failed(String),
48}
49
50impl AgentOutcome {
51    /// Only a completed outcome is worth remembering for a resumed run: a
52    /// failure must be retried rather than replayed.
53    pub(crate) fn is_journalable(&self) -> bool {
54        matches!(self, AgentOutcome::Done(_))
55    }
56}
57
58/// Starts one child agent and waits for its answer.
59#[async_trait::async_trait]
60pub trait AgentRunner: Send + Sync + 'static {
61    async fn run_agent(&self, request: AgentRequest, cancel: CancellationToken) -> AgentOutcome;
62
63    /// Tokens this agent used, for the progress view. Hosts that do not track
64    /// usage may leave this at zero.
65    fn tokens_used(&self, _index: AgentId) -> u64 {
66        0
67    }
68}
69
70/// Bounds on one run.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub struct Limits {
73    /// Agents running at once.
74    pub max_concurrency: usize,
75    /// Agents started over the whole run, which bounds the cost of a runaway
76    /// script.
77    pub max_agents: u32,
78    /// Items one `parallel()` or `pipeline()` call may accept. A longer list is
79    /// an error rather than a silent truncation, because dropping part of the
80    /// work without saying so is worse than refusing it.
81    pub max_fanout: usize,
82    /// Interpreter steps, which stops a loop that never terminates.
83    pub max_steps: u64,
84}
85
86impl Default for Limits {
87    fn default() -> Self {
88        Limits {
89            max_concurrency: default_concurrency(),
90            max_agents: 1000,
91            max_fanout: 4096,
92            max_steps: 50_000_000,
93        }
94    }
95}
96
97/// Sixteen at once, or fewer on a machine or container with fewer cores.
98fn default_concurrency() -> usize {
99    let cores = std::thread::available_parallelism()
100        .map(|count| count.get())
101        .unwrap_or(4);
102    cores.clamp(2, 16)
103}
104
105impl Limits {
106    /// Clamp caller-supplied limits into a range the runtime can honor.
107    pub fn sanitized(mut self) -> Limits {
108        self.max_concurrency = self.max_concurrency.clamp(1, 16);
109        self.max_agents = self.max_agents.clamp(1, 1000);
110        self.max_fanout = self.max_fanout.clamp(1, 4096);
111        self.max_steps = self.max_steps.max(1000);
112        self
113    }
114}
115
116/// Results kept from an earlier run of the same script, so that a stopped run
117/// resumes instead of starting over.
118///
119/// An entry is reused only while the prompt at that position still matches. At
120/// the first position whose prompt differs, because the script was edited or an
121/// earlier agent answered differently, that agent and every agent after it run
122/// again.
123#[derive(Debug, Clone, Default)]
124pub struct Journal {
125    entries: HashMap<AgentId, (String, AgentOutcome)>,
126}
127
128impl Journal {
129    pub fn is_empty(&self) -> bool {
130        self.entries.is_empty()
131    }
132
133    pub fn len(&self) -> usize {
134        self.entries.len()
135    }
136
137    /// Remember one completed answer for a later deterministic replay.
138    ///
139    /// Hosts normally obtain a journal from [`crate::Workflow::journal`]. This
140    /// constructor is also useful for imported runs and for tests that need a
141    /// large completed run without starting real child sessions.
142    pub fn record_completed(&mut self, index: AgentId, prompt: &str, value: Json) {
143        self.entries
144            .insert(index, (prompt.to_string(), AgentOutcome::Done(value)));
145    }
146
147    pub(crate) fn record(&mut self, index: AgentId, prompt: &str, outcome: &AgentOutcome) {
148        if outcome.is_journalable() {
149            self.entries
150                .insert(index, (prompt.to_string(), outcome.clone()));
151        }
152    }
153
154    /// The remembered outcome for this position, when its prompt still matches.
155    pub(crate) fn take_matching(&self, index: AgentId, prompt: &str) -> Option<AgentOutcome> {
156        let (recorded, outcome) = self.entries.get(&index)?;
157        (recorded == prompt).then(|| outcome.clone())
158    }
159
160    /// Forget this position and every later one.
161    ///
162    /// Called when a prompt stops matching: everything after a changed input is
163    /// no longer trustworthy, even if it completed.
164    pub(crate) fn invalidate_from(&mut self, index: AgentId) {
165        self.entries.retain(|recorded, _| *recorded < index);
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    #[test]
174    fn only_completed_agents_are_remembered() {
175        let mut journal = Journal::default();
176        journal.record(0, "a", &AgentOutcome::Done(Json::String("ok".into())));
177        journal.record(1, "b", &AgentOutcome::Failed("boom".into()));
178        journal.record(2, "c", &AgentOutcome::Stopped);
179        assert_eq!(journal.len(), 1);
180        assert!(journal.take_matching(1, "b").is_none());
181    }
182
183    #[test]
184    fn a_host_can_seed_a_completed_result() {
185        let mut journal = Journal::default();
186        journal.record_completed(4, "audit a.rs", Json::String("ok".into()));
187        assert_eq!(
188            journal.take_matching(4, "audit a.rs"),
189            Some(AgentOutcome::Done(Json::String("ok".into())))
190        );
191    }
192
193    #[test]
194    fn a_changed_prompt_does_not_match_its_remembered_result() {
195        let mut journal = Journal::default();
196        journal.record(
197            0,
198            "audit a.rs",
199            &AgentOutcome::Done(Json::String("ok".into())),
200        );
201        assert!(journal.take_matching(0, "audit a.rs").is_some());
202        assert!(journal.take_matching(0, "audit b.rs").is_none());
203    }
204
205    #[test]
206    fn invalidating_a_position_drops_everything_after_it() {
207        let mut journal = Journal::default();
208        for index in 0..5 {
209            journal.record(index, "p", &AgentOutcome::Done(Json::Null));
210        }
211        journal.invalidate_from(2);
212        assert_eq!(journal.len(), 2);
213        assert!(journal.take_matching(1, "p").is_some());
214        assert!(journal.take_matching(2, "p").is_none());
215    }
216
217    #[test]
218    fn limits_are_clamped_into_a_range_the_runtime_can_honor() {
219        let limits = Limits {
220            max_concurrency: 500,
221            max_agents: 100_000,
222            max_fanout: usize::MAX,
223            max_steps: 1,
224        }
225        .sanitized();
226        assert_eq!(limits.max_concurrency, 16);
227        assert_eq!(limits.max_agents, 1000);
228        assert_eq!(limits.max_fanout, 4096);
229        assert_eq!(limits.max_steps, 1000);
230    }
231
232    #[test]
233    fn the_default_concurrency_stays_within_bounds() {
234        let limits = Limits::default();
235        assert!((2..=16).contains(&limits.max_concurrency));
236    }
237}