Skip to main content

kiss_workflow/
script.rs

1//! A parsed, ready-to-run workflow script.
2
3use crate::ast::{Ast, Expr, ExprId, FnBody, Span, Stmt};
4use crate::diagnostic::Diagnostic;
5use crate::lexer::{Interner, Lexer, Symbol};
6use crate::parser;
7use serde_json::{Map, Value};
8use std::sync::Arc;
9
10/// A name the script does not declare itself.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub(crate) enum Global {
13    Agent,
14    Parallel,
15    Pipeline,
16    Phase,
17    Log,
18    Args,
19    Cwd,
20    Math,
21    Json,
22    ObjectNamespace,
23    ArrayNamespace,
24    /// Present only so that `Date.now()` and `new Date()` can explain the
25    /// determinism rule rather than failing as an unknown name.
26    DateNamespace,
27    NumberCast,
28    StringCast,
29    BooleanCast,
30    ParseInt,
31    ParseFloat,
32    IsNaN,
33}
34
35fn global_for(name: &str) -> Option<Global> {
36    Some(match name {
37        "agent" => Global::Agent,
38        "parallel" => Global::Parallel,
39        "pipeline" => Global::Pipeline,
40        "phase" => Global::Phase,
41        "log" => Global::Log,
42        "args" => Global::Args,
43        "cwd" => Global::Cwd,
44        "Math" => Global::Math,
45        "JSON" => Global::Json,
46        "Object" => Global::ObjectNamespace,
47        "Array" => Global::ArrayNamespace,
48        "Date" => Global::DateNamespace,
49        "Number" => Global::NumberCast,
50        "String" => Global::StringCast,
51        "Boolean" => Global::BooleanCast,
52        "parseInt" => Global::ParseInt,
53        "parseFloat" => Global::ParseFloat,
54        "isNaN" => Global::IsNaN,
55        _ => return None,
56    })
57}
58
59/// The `export const meta = { ... }` block at the top of a script.
60#[derive(Debug, Clone, PartialEq, Eq, Default)]
61pub struct Meta {
62    pub name: String,
63    pub description: String,
64    /// Phase titles declared in `meta`, in order.
65    pub phases: Vec<String>,
66}
67
68/// A parsed workflow script.
69///
70/// Parsing is separate from running so that a script can be shown to the user
71/// for approval, and so that a bad script produces a diagnostic the model can
72/// act on before any agent starts.
73pub struct Script {
74    pub(crate) ast: Ast,
75    pub(crate) body: Span,
76    pub(crate) interner: Interner,
77    /// Global meaning for each interned symbol, indexed by symbol. Resolving a
78    /// free name is one bounds-checked index rather than a string comparison.
79    pub(crate) globals: Vec<Option<Global>>,
80    meta: Meta,
81    source: Arc<str>,
82    phase_titles: Vec<String>,
83    estimated_agents: Option<u32>,
84}
85
86impl std::fmt::Debug for Script {
87    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88        f.debug_struct("Script")
89            .field("meta", &self.meta)
90            .field("statements", &self.body.len)
91            .finish()
92    }
93}
94
95impl Script {
96    /// Parse `source`, or return the first error with the position that caused
97    /// it.
98    pub fn parse(source: &str) -> Result<Script, Diagnostic> {
99        let mut interner = Interner::default();
100        let tokens = Lexer::tokenize(source, &mut interner)?;
101        let parsed = parser::parse(&tokens, &mut interner)?;
102
103        let meta = match parsed.meta {
104            Some(expr) => read_meta(&parsed.ast, &interner, expr)?,
105            None => {
106                return Err(
107                    Diagnostic::new(1, 1, "this script has no `meta` block").with_help(
108                        "start the script with \
109                     `export const meta = { name: '...', description: '...' }`",
110                    ),
111                );
112            }
113        };
114
115        let globals = (0..interner.len())
116            .map(|symbol| global_for(interner.resolve(symbol as Symbol)))
117            .collect();
118
119        let mut script = Script {
120            ast: parsed.ast,
121            body: parsed.body,
122            interner,
123            globals,
124            meta,
125            source: Arc::from(source),
126            phase_titles: Vec::new(),
127            estimated_agents: None,
128        };
129        script.phase_titles = script.collect_phase_titles();
130        script.estimated_agents = script.count_agents();
131        Ok(script)
132    }
133
134    pub fn meta(&self) -> &Meta {
135        &self.meta
136    }
137
138    pub fn source(&self) -> &str {
139        &self.source
140    }
141
142    /// Every phase title the run will show, in the order they first appear.
143    ///
144    /// Titles declared in `meta` come first, then any `phase()` title the
145    /// script uses that `meta` did not declare.
146    pub fn declared_phases(&self) -> &[String] {
147        &self.phase_titles
148    }
149
150    /// How many agents the run will start, when that is knowable before it
151    /// runs.
152    ///
153    /// `None` means the count depends on data the script has not fetched yet,
154    /// such as a list of files an earlier agent returns. The approval prompt
155    /// says "unbounded" rather than guessing, because a number shown there has
156    /// to be one the user can rely on.
157    pub fn estimated_agents(&self) -> Option<u32> {
158        self.estimated_agents
159    }
160
161    fn symbol_is(&self, symbol: Symbol, global: Global) -> bool {
162        self.globals
163            .get(symbol as usize)
164            .copied()
165            .flatten()
166            .is_some_and(|found| found == global)
167    }
168
169    fn collect_phase_titles(&self) -> Vec<String> {
170        let mut titles = self.meta.phases.clone();
171        for expr in &self.ast.exprs {
172            let Expr::Call { callee, args } = expr else {
173                continue;
174            };
175            let Expr::Name(symbol) = self.ast.expr(*callee) else {
176                continue;
177            };
178            if !self.symbol_is(*symbol, Global::Phase) {
179                continue;
180            }
181            let Some(first) = self.ast.exprs_in(*args).first() else {
182                continue;
183            };
184            if let Expr::Text(title) = self.ast.expr(*first)
185                && !titles.iter().any(|known| known == title.as_ref())
186            {
187                titles.push(title.to_string());
188            }
189        }
190        titles
191    }
192
193    /// Count `agent()` call sites that are certain to run exactly once.
194    fn count_agents(&self) -> Option<u32> {
195        let mut count = 0u32;
196        self.walk_statements(self.body, &mut count)?;
197        Some(count)
198    }
199
200    fn walk_statements(&self, span: Span, count: &mut u32) -> Option<()> {
201        for id in self.ast.stmts_in(span) {
202            match self.ast.stmt(*id) {
203                Stmt::Declare { value, .. } => self.walk_expr(*value, count)?,
204                Stmt::Assign { target, value } => {
205                    self.walk_expr(*target, count)?;
206                    self.walk_expr(*value, count)?;
207                }
208                Stmt::Expr(value) => self.walk_expr(*value, count)?,
209                Stmt::If {
210                    test,
211                    consequent,
212                    alternate,
213                } => {
214                    self.walk_expr(*test, count)?;
215                    // Both branches are counted, so a conditional fan-out is
216                    // reported at its largest rather than its smallest.
217                    self.walk_statements(*consequent, count)?;
218                    self.walk_statements(*alternate, count)?;
219                }
220                // A loop runs an unknown number of times. If it can start an
221                // agent at all, the total is not knowable in advance.
222                Stmt::ForOf { iterable, body, .. } => {
223                    self.walk_expr(*iterable, count)?;
224                    if self.contains_agent_call(*body) {
225                        return None;
226                    }
227                }
228                Stmt::While { test, body } => {
229                    self.walk_expr(*test, count)?;
230                    if self.contains_agent_call(*body) {
231                        return None;
232                    }
233                }
234                Stmt::Return(Some(value)) => self.walk_expr(*value, count)?,
235                Stmt::Return(None) | Stmt::Break | Stmt::Continue => {}
236            }
237        }
238        Some(())
239    }
240
241    fn walk_expr(&self, id: ExprId, count: &mut u32) -> Option<()> {
242        match self.ast.expr(id) {
243            Expr::Null | Expr::Bool(_) | Expr::Number(_) | Expr::Text(_) | Expr::Name(_) => {}
244            Expr::Template(span) => {
245                for part in self.ast.parts_in(*span) {
246                    if let crate::ast::TemplatePart::Expr(expr) = part {
247                        self.walk_expr(*expr, count)?;
248                    }
249                }
250            }
251            Expr::Array(span) => {
252                for item in self.ast.exprs_in(*span) {
253                    self.walk_expr(*item, count)?;
254                }
255            }
256            Expr::Object(span) => {
257                for field in self.ast.fields_in(*span) {
258                    self.walk_expr(field.value, count)?;
259                }
260            }
261            Expr::Member { object, .. } => self.walk_expr(*object, count)?,
262            Expr::Index { object, index } => {
263                self.walk_expr(*object, count)?;
264                self.walk_expr(*index, count)?;
265            }
266            Expr::Call { callee, args } => {
267                if let Expr::Name(symbol) = self.ast.expr(*callee) {
268                    if self.symbol_is(*symbol, Global::Agent) {
269                        *count += 1;
270                    }
271                    // `parallel([() => agent(...), ...])` over a literal array
272                    // calls each function exactly once, so the count is known.
273                    if self.symbol_is(*symbol, Global::Parallel)
274                        && let Some(first) = self.ast.exprs_in(*args).first()
275                        && let Expr::Array(items) = self.ast.expr(*first)
276                    {
277                        for item in self.ast.exprs_in(*items) {
278                            match self.ast.expr(*item) {
279                                Expr::Arrow(function) => {
280                                    let def = self.ast.function(*function)?;
281                                    match &def.body {
282                                        FnBody::Expr(value) => self.walk_expr(*value, count)?,
283                                        FnBody::Block(block) => {
284                                            self.walk_statements(*block, count)?
285                                        }
286                                    }
287                                }
288                                _ => self.walk_expr(*item, count)?,
289                            }
290                        }
291                        return Some(());
292                    }
293                }
294                self.walk_expr(*callee, count)?;
295                for arg in self.ast.exprs_in(*args) {
296                    self.walk_expr(*arg, count)?;
297                }
298            }
299            // A function value may be called any number of times, or none.
300            Expr::Arrow(function) => {
301                let def = self.ast.function(*function)?;
302                let reachable = match &def.body {
303                    FnBody::Expr(value) => self.expr_contains_agent_call(*value),
304                    FnBody::Block(block) => self.contains_agent_call(*block),
305                };
306                if reachable {
307                    return None;
308                }
309            }
310            Expr::Await(value)
311            | Expr::Unary { operand: value, .. }
312            | Expr::New { callee: value } => self.walk_expr(*value, count)?,
313            Expr::Binary { left, right, .. } | Expr::Logical { left, right, .. } => {
314                self.walk_expr(*left, count)?;
315                self.walk_expr(*right, count)?;
316            }
317            Expr::Conditional {
318                test,
319                consequent,
320                alternate,
321            } => {
322                self.walk_expr(*test, count)?;
323                self.walk_expr(*consequent, count)?;
324                self.walk_expr(*alternate, count)?;
325            }
326        }
327        Some(())
328    }
329
330    fn contains_agent_call(&self, span: Span) -> bool {
331        self.ast
332            .stmts_in(span)
333            .iter()
334            .any(|id| match self.ast.stmt(*id) {
335                Stmt::Declare { value, .. } | Stmt::Expr(value) | Stmt::Return(Some(value)) => {
336                    self.expr_contains_agent_call(*value)
337                }
338                Stmt::Assign { target, value } => {
339                    self.expr_contains_agent_call(*target) || self.expr_contains_agent_call(*value)
340                }
341                Stmt::If {
342                    test,
343                    consequent,
344                    alternate,
345                } => {
346                    self.expr_contains_agent_call(*test)
347                        || self.contains_agent_call(*consequent)
348                        || self.contains_agent_call(*alternate)
349                }
350                Stmt::ForOf { iterable, body, .. } => {
351                    self.expr_contains_agent_call(*iterable) || self.contains_agent_call(*body)
352                }
353                Stmt::While { test, body } => {
354                    self.expr_contains_agent_call(*test) || self.contains_agent_call(*body)
355                }
356                Stmt::Return(None) | Stmt::Break | Stmt::Continue => false,
357            })
358    }
359
360    fn expr_contains_agent_call(&self, id: ExprId) -> bool {
361        let mut found = false;
362        let mut pending = vec![id];
363        // An explicit stack rather than recursion: this runs on script data and
364        // the release profile aborts on a stack overflow.
365        while let Some(current) = pending.pop() {
366            match self.ast.expr(current) {
367                Expr::Call { callee, args } => {
368                    if let Expr::Name(symbol) = self.ast.expr(*callee)
369                        && self.symbol_is(*symbol, Global::Agent)
370                    {
371                        found = true;
372                        break;
373                    }
374                    pending.push(*callee);
375                    pending.extend_from_slice(self.ast.exprs_in(*args));
376                }
377                Expr::Template(span) => {
378                    for part in self.ast.parts_in(*span) {
379                        if let crate::ast::TemplatePart::Expr(expr) = part {
380                            pending.push(*expr);
381                        }
382                    }
383                }
384                Expr::Array(span) => pending.extend_from_slice(self.ast.exprs_in(*span)),
385                Expr::Object(span) => {
386                    pending.extend(self.ast.fields_in(*span).iter().map(|field| field.value));
387                }
388                Expr::Member { object, .. } => pending.push(*object),
389                Expr::Index { object, index } => {
390                    pending.push(*object);
391                    pending.push(*index);
392                }
393                Expr::Arrow(function) => {
394                    if let Some(def) = self.ast.function(*function) {
395                        match &def.body {
396                            FnBody::Expr(value) => pending.push(*value),
397                            FnBody::Block(block) => {
398                                if self.contains_agent_call(*block) {
399                                    found = true;
400                                    break;
401                                }
402                            }
403                        }
404                    }
405                }
406                Expr::Await(value)
407                | Expr::Unary { operand: value, .. }
408                | Expr::New { callee: value } => pending.push(*value),
409                Expr::Binary { left, right, .. } | Expr::Logical { left, right, .. } => {
410                    pending.push(*left);
411                    pending.push(*right);
412                }
413                Expr::Conditional {
414                    test,
415                    consequent,
416                    alternate,
417                } => {
418                    pending.push(*test);
419                    pending.push(*consequent);
420                    pending.push(*alternate);
421                }
422                Expr::Null | Expr::Bool(_) | Expr::Number(_) | Expr::Text(_) | Expr::Name(_) => {}
423            }
424        }
425        found
426    }
427}
428
429/// Read the `meta` object literal, which must contain only literal values.
430fn read_meta(ast: &Ast, interner: &Interner, expr: ExprId) -> Result<Meta, Diagnostic> {
431    let pos = ast.expr_position(expr);
432    let value = literal_value(ast, interner, expr).ok_or_else(|| {
433        Diagnostic::new(
434            pos.line,
435            pos.column,
436            "`meta` may contain only literal values",
437        )
438        .with_help("remove any variable, function call, or expression from the `meta` block")
439    })?;
440    let Value::Object(fields) = value else {
441        return Err(Diagnostic::new(
442            pos.line,
443            pos.column,
444            "`meta` must be an object literal",
445        ));
446    };
447
448    let name = fields
449        .get("name")
450        .and_then(Value::as_str)
451        .map(str::trim)
452        .filter(|name| !name.is_empty())
453        .ok_or_else(|| {
454            Diagnostic::new(
455                pos.line,
456                pos.column,
457                "`meta` needs a non-empty `name` string",
458            )
459            .with_help("the name becomes the slash command when the workflow is saved")
460        })?
461        .to_string();
462    let description = fields
463        .get("description")
464        .and_then(Value::as_str)
465        .map(str::trim)
466        .filter(|text| !text.is_empty())
467        .ok_or_else(|| {
468            Diagnostic::new(
469                pos.line,
470                pos.column,
471                "`meta` needs a non-empty `description` string",
472            )
473            .with_help("one sentence saying what the workflow does")
474        })?
475        .to_string();
476
477    let mut phases = Vec::new();
478    if let Some(Value::Array(items)) = fields.get("phases") {
479        for item in items {
480            let title = match item {
481                Value::String(title) => title.clone(),
482                Value::Object(entry) => entry
483                    .get("title")
484                    .and_then(Value::as_str)
485                    .unwrap_or_default()
486                    .to_string(),
487                _ => String::new(),
488            };
489            if !title.is_empty() {
490                phases.push(title);
491            }
492        }
493    }
494
495    Ok(Meta {
496        name,
497        description,
498        phases,
499    })
500}
501
502/// Convert a literal-only expression into JSON, or `None` when it is not one.
503fn literal_value(ast: &Ast, interner: &Interner, expr: ExprId) -> Option<Value> {
504    Some(match ast.expr(expr) {
505        Expr::Null => Value::Null,
506        Expr::Bool(value) => Value::Bool(*value),
507        Expr::Number(value) => {
508            serde_json::Number::from_f64(*value).map_or(Value::Null, Value::Number)
509        }
510        Expr::Text(text) => Value::String(text.to_string()),
511        Expr::Array(span) => Value::Array(
512            ast.exprs_in(*span)
513                .iter()
514                .map(|item| literal_value(ast, interner, *item))
515                .collect::<Option<Vec<_>>>()?,
516        ),
517        Expr::Object(span) => {
518            let mut map = Map::new();
519            for field in ast.fields_in(*span) {
520                map.insert(
521                    interner.resolve(field.name).to_string(),
522                    literal_value(ast, interner, field.value)?,
523                );
524            }
525            Value::Object(map)
526        }
527        _ => return None,
528    })
529}
530
531#[cfg(test)]
532mod tests {
533    use super::*;
534
535    const AUDIT: &str = r#"
536export const meta = {
537  name: 'audit-routes',
538  description: 'Audit every tool file for missing path checks',
539  phases: [{ title: 'Discover' }, { title: 'Audit' }],
540}
541
542phase('Discover')
543const found = await agent('List every .rs file under crates/kiss-coding/src/tools.', {
544  schema: {
545    type: 'object',
546    required: ['files'],
547    properties: { files: { type: 'array', items: { type: 'string' } } },
548  },
549})
550
551phase('Audit')
552const audits = await pipeline(found.files, file =>
553  agent(`Audit ${file} for missing path checks.`, { label: file }),
554)
555
556return audits.filter(Boolean)
557"#;
558
559    #[test]
560    fn the_reference_script_parses() {
561        let script = Script::parse(AUDIT).expect("audit script parses");
562        assert_eq!(script.meta().name, "audit-routes");
563        assert_eq!(
564            script.meta().description,
565            "Audit every tool file for missing path checks"
566        );
567        assert_eq!(script.declared_phases(), ["Discover", "Audit"]);
568    }
569
570    #[test]
571    fn a_data_dependent_fan_out_reports_an_unknown_agent_count() {
572        let script = Script::parse(AUDIT).expect("audit script parses");
573        // One agent is certain. The pipeline over a list fetched at run time is
574        // not, so the total is deliberately not guessed.
575        assert_eq!(script.estimated_agents(), None);
576    }
577
578    #[test]
579    fn a_fixed_fan_out_reports_an_exact_agent_count() {
580        let script = Script::parse(
581            r#"
582export const meta = { name: 'three', description: 'Three fixed agents' }
583const results = await parallel([
584  () => agent('one'),
585  () => agent('two'),
586  () => agent('three'),
587])
588return results
589"#,
590        )
591        .expect("script parses");
592        assert_eq!(script.estimated_agents(), Some(3));
593    }
594
595    #[test]
596    fn a_sequential_script_reports_its_exact_count() {
597        let script = Script::parse(
598            r#"
599export const meta = { name: 'two', description: 'Two agents in order' }
600const first = await agent('one')
601const second = await agent(`two after ${first}`)
602return [first, second]
603"#,
604        )
605        .expect("script parses");
606        assert_eq!(script.estimated_agents(), Some(2));
607    }
608
609    #[test]
610    fn a_loop_that_starts_agents_is_unbounded() {
611        let script = Script::parse(
612            r#"
613export const meta = { name: 'loop', description: 'One agent per item' }
614const out = []
615for (const item of args) {
616  out.push(await agent(`check ${item}`))
617}
618return out
619"#,
620        )
621        .expect("script parses");
622        assert_eq!(script.estimated_agents(), None);
623    }
624
625    #[test]
626    fn phase_titles_not_declared_in_meta_are_still_listed() {
627        let script = Script::parse(
628            r#"
629export const meta = { name: 'p', description: 'Phases', phases: [{ title: 'First' }] }
630phase('First')
631const a = await agent('a')
632phase('Second')
633const b = await agent('b')
634return [a, b]
635"#,
636        )
637        .expect("script parses");
638        assert_eq!(script.declared_phases(), ["First", "Second"]);
639    }
640
641    #[test]
642    fn a_missing_meta_block_explains_what_to_write() {
643        let error = Script::parse("const a = await agent('x')\nreturn a\n").unwrap_err();
644        assert!(error.message.contains("no `meta` block"));
645        assert!(
646            error
647                .help
648                .is_some_and(|help| help.contains("export const meta"))
649        );
650    }
651
652    #[test]
653    fn meta_must_hold_only_literals() {
654        let error = Script::parse(
655            "export const meta = { name: 'x', description: makeDescription() }\nreturn 1\n",
656        )
657        .unwrap_err();
658        assert!(error.message.contains("only literal values"));
659    }
660
661    #[test]
662    fn meta_requires_a_name_and_a_description() {
663        let error = Script::parse("export const meta = { name: 'x' }\nreturn 1\n").unwrap_err();
664        assert!(error.message.contains("`description`"));
665
666        let error =
667            Script::parse("export const meta = { description: 'x' }\nreturn 1\n").unwrap_err();
668        assert!(error.message.contains("`name`"));
669    }
670
671    #[test]
672    fn unsupported_syntax_names_a_supported_alternative() {
673        let error = Script::parse(
674            "export const meta = { name: 'x', description: 'y' }\nfor (let i = 0; i < 3; i++) {}\n",
675        )
676        .unwrap_err();
677        assert_eq!(error.line, 2);
678        assert!(error.message.contains("for (const item of list)"));
679        assert!(
680            error
681                .help
682                .is_some_and(|help| help.contains("pipeline(list, item => agent(...))"))
683        );
684
685        let error = Script::parse(
686            "export const meta = { name: 'x', description: 'y' }\nimport fs from 'fs'\n",
687        )
688        .unwrap_err();
689        assert!(error.message.contains("`import` is not supported"));
690        assert!(
691            error
692                .help
693                .is_some_and(|help| help.contains("loads no modules"))
694        );
695    }
696
697    #[test]
698    fn deep_nesting_is_rejected_rather_than_overflowing_the_stack() {
699        let mut source =
700            String::from("export const meta = { name: 'x', description: 'y' }\nconst a = ");
701        source.push_str(&"(".repeat(200));
702        source.push('1');
703        source.push_str(&")".repeat(200));
704        let error = Script::parse(&source).unwrap_err();
705        assert!(error.message.contains("nests too deeply"));
706    }
707}
708
709#[cfg(test)]
710mod benchmarks {
711    use super::*;
712
713    /// A script of about 200 lines, the shape a model writes for a large task.
714    fn representative_script() -> String {
715        let mut source = String::from(
716            "export const meta = {\n\
717             \x20 name: 'benchmark',\n\
718             \x20 description: 'A representative workflow',\n\
719             \x20 phases: [{ title: 'Discover' }, { title: 'Audit' }, { title: 'Report' }],\n\
720             }\n\n",
721        );
722        for round in 0..18 {
723            source.push_str(&format!(
724                "phase('Audit {round}')\n\
725                 const found{round} = await agent(`list the files in group {round}`, {{\n\
726                 \x20 schema: {{ type: 'object', required: ['files'] }},\n\
727                 }})\n\
728                 const audits{round} = await pipeline(\n\
729                 \x20 found{round}.files,\n\
730                 \x20 file => agent(`audit ${{file}} in round {round}`, {{ label: file }}),\n\
731                 )\n\
732                 if (audits{round}.length > 0) {{\n\
733                 \x20 log(`round {round} produced ${{audits{round}.length}} findings`)\n\
734                 }}\n\n"
735            ));
736        }
737        source.push_str("return 'done'\n");
738        source
739    }
740
741    #[test]
742    #[ignore = "release-mode performance benchmark"]
743    fn benchmark_performance_workflow_parse() {
744        let source = representative_script();
745        let lines = source.lines().count();
746        assert!(
747            (190..=230).contains(&lines),
748            "expected about 200 lines: {lines}"
749        );
750        kiss_bench::measure(
751            "workflow_script_parse",
752            21,
753            200,
754            "parse_200_line_script",
755            || Script::parse(&source).expect("the benchmark script parses"),
756        );
757    }
758}