Skip to main content

kiss_coding/workflows/
prompt.rs

1//! The reference the model works from when it writes a workflow script.
2//!
3//! This text is sent only on a turn where workflow mode is armed, because it is
4//! long and most turns do not need it. It has to be accurate: it is the only
5//! description of the language the model gets, and every inaccuracy costs a
6//! round trip through a parse error. The test at the bottom parses every
7//! example here with the real parser, so this file cannot drift away from the
8//! implementation.
9
10use crate::settings::WorkflowSize;
11
12/// Find the complete word or phrase that asks for a dynamic workflow.
13///
14/// This function is shared by interactive, print, and JSON prompt entry
15/// points, so the same user text has the same meaning in every mode.
16pub fn workflow_trigger(text: &str) -> Option<&'static str> {
17    let lowered = text.to_lowercase();
18    let words = lowered
19        .split(|character: char| !(character.is_alphanumeric() || character == '_'))
20        .filter(|word| !word.is_empty())
21        .collect::<Vec<_>>();
22    for (phrase, phrase_words) in [
23        ("use a workflow", &["use", "a", "workflow"][..]),
24        ("run a workflow", &["run", "a", "workflow"][..]),
25        ("as a workflow", &["as", "a", "workflow"][..]),
26        ("dynamic workflow", &["dynamic", "workflow"][..]),
27    ] {
28        if words
29            .windows(phrase_words.len())
30            .any(|window| window == phrase_words)
31        {
32            return Some(phrase);
33        }
34    }
35    words.contains(&"ultracode").then_some("ultracode")
36}
37
38/// Two complete scripts, kept separate so the test can parse each one.
39pub(crate) const FAN_OUT_EXAMPLE: &str = r#"export const meta = {
40  name: 'audit-tools',
41  description: 'Audit every tool file for missing path checks',
42  phases: [{ title: 'Discover' }, { title: 'Audit' }, { title: 'Report' }],
43}
44
45phase('Discover')
46const found = await agent('List every .rs file under crates/kiss-coding/src/tools. Return the paths.', {
47  schema: {
48    type: 'object',
49    required: ['files'],
50    properties: { files: { type: 'array', items: { type: 'string' } } },
51  },
52})
53log(`auditing ${found.files.length} files`)
54
55phase('Audit')
56const findings = await pipeline(
57  found.files,
58  file => agent(`Audit ${file} for paths used without validation. Report each one.`, { label: file }),
59  finding => agent(`Try to refute this finding. Reply "confirmed" or "refuted" and why.\n\n${finding}`),
60)
61
62phase('Report')
63const kept = findings.filter(Boolean)
64return await agent(`Merge these audits into one ranked list, most severe first.\n\n${kept.join('\n\n')}`)
65"#;
66
67/// A fixed fan-out, where the agent count is known before the run starts.
68pub(crate) const PERSPECTIVES_EXAMPLE: &str = r#"export const meta = {
69  name: 'three-angles',
70  description: 'Draft a plan from three angles, then choose between them',
71  phases: [{ title: 'Draft' }, { title: 'Choose' }],
72}
73
74phase('Draft')
75const drafts = await parallel([
76  () => agent(`Plan this work for correctness above all.\n\n${args}`, { label: 'correctness' }),
77  () => agent(`Plan this work for the smallest change.\n\n${args}`, { label: 'smallest' }),
78  () => agent(`Plan this work for long-term maintenance.\n\n${args}`, { label: 'maintenance' }),
79])
80
81phase('Choose')
82const usable = drafts.filter(Boolean)
83if (usable.length === 0) {
84  return 'every draft failed'
85}
86return await agent(`Weigh these plans against each other and recommend one.\n\n${usable.join('\n\n---\n\n')}`)
87"#;
88
89/// Build the instructions, including the size advice the user chose.
90pub fn authoring_prompt(size: WorkflowSize, max_agents: u32, max_fanout: usize) -> String {
91    let size_advice = match size.target_agents() {
92        Some(target) => format!(
93            "Aim for fewer than {target} agents unless the task clearly needs more. \
94             This is guidance, not a limit."
95        ),
96        None => {
97            "Size the workflow to the task because no agent-count guideline is set.".to_string()
98        }
99    };
100
101    format!(
102        "# Writing a dynamic workflow\n\
103         \n\
104         The user asked for this task to run as a dynamic workflow. Call the `run_workflow` tool \
105         with a script that orchestrates child agents, instead of working through the task turn by \
106         turn yourself.\n\
107         \n\
108         Write the script once and let it run. Its intermediate results stay in script variables \
109         rather than in your context, which is what lets one run coordinate far more agents than a \
110         conversation can. {size_advice}\n\
111         \n\
112         ## The language\n\
113         \n\
114         A script is a small, fixed subset of JavaScript. It is not JavaScript: anything outside \
115         this list is an error.\n\
116         \n\
117         Statements: `const`, `let`, assignment, `if` / `else`, `for (const item of list)`, \
118         `while`, `break`, `continue`, `return`, and an expression on its own. Top-level `await` \
119         is allowed.\n\
120         \n\
121         Expressions: numbers, strings, back-quoted template strings with `${{...}}`, `true`, \
122         `false`, `null`, array and object literals, `.field`, `[index]`, calls, arrow functions, \
123         `await`, `!`, unary `-`, `+ - * / %`, `=== !==`, `< <= > >=`, `&& || ??`, and `a ? b : c`.\n\
124         \n\
125         Not available, with what to use instead:\n\
126         - counted `for` loops, `++`, `--`: use `for (const item of list)` or `pipeline(...)`\n\
127         - `==`, `!=`: use `===` and `!==`\n\
128         - `function`, `class`, `var`: use `const` and arrow functions\n\
129         - `try` / `catch` / `throw`: an agent that fails returns null, so test for null instead\n\
130         - `import`, `require`: a script loads no modules and touches no files\n\
131         - `typeof`, `instanceof`: use `Array.isArray(value)` or `value === null`\n\
132         - `...` spread, `?.`, destructuring, computed object keys\n\
133         - `Date.now()`, `Math.random()`, `new Date()`: a script must be repeatable so a stopped \
134         run can resume. Pass a timestamp or a seed in through `args`.\n\
135         \n\
136         ## What a script can call\n\
137         \n\
138         - `agent(prompt, options?)` starts one child agent and waits for its answer.\n\
139         - `parallel(tasks)` runs an array of zero-argument functions at once and returns their \
140         results in input order.\n\
141         - `pipeline(items, ...stages)` sends every item through each stage in turn, with items \
142         processed at the same time and results in input order.\n\
143         - `phase(title)` names the group the agents after it belong to, for the progress view.\n\
144         - `log(message)` shows one line above the phases in the progress view.\n\
145         - `args` is the input the workflow was invoked with. `cwd` is the working directory.\n\
146         - `JSON.stringify`, `JSON.parse`, `Object.keys`, `Object.values`, `Object.entries`, \
147         `Array.isArray`, `Math.min`, `Math.max`, `Math.floor`, `Math.ceil`, `Math.abs`, \
148         `Math.round`, `Number`, `String`, `Boolean`, `parseInt`, `parseFloat`, `isNaN`.\n\
149         - On arrays: `length`, `map`, `filter`, `find`, `some`, `every`, `slice`, `join`, `push`, \
150         `includes`, `indexOf`, `concat`, `flat`, `reverse`, `sort`.\n\
151         - On strings: `length`, `split`, `trim`, `toLowerCase`, `toUpperCase`, `includes`, \
152         `indexOf`, `startsWith`, `endsWith`, `slice`, `replace`, `replaceAll`, `padStart`, \
153         `padEnd`.\n\
154         \n\
155         `agent()` options, all optional: `label` for the progress view, `phase` to override the \
156         current phase, `model` such as `sonnet` or `haiku`, `effort` such as `low` or `high`, \
157         `schema` for a JSON Schema the answer must match, `timeoutMs`, and `retries`.\n\
158         \n\
159         ## Two rules that decide whether a script works\n\
160         \n\
161         First, `agent()` returns **null** when that agent is stopped or fails. `parallel` and \
162         `pipeline` keep those nulls in place so the results line up with the input. Always drop \
163         them before using the results, with `.filter(Boolean)`, and never read a field off an \
164         agent result without knowing it is not null.\n\
165         \n\
166         Second, `agent()` returns **text** unless you pass a `schema`. Pass a schema whenever the \
167         script needs to read fields or iterate a list out of the answer. Without one you get a \
168         string and `.files` on it is an error.\n\
169         \n\
170         ## Limits\n\
171         \n\
172         Up to {max_agents} agents in one run, up to {max_fanout} items in a single `parallel` or \
173         `pipeline` call, and up to 16 agents running at once. A script that exceeds one of these \
174         fails rather than quietly doing less.\n\
175         \n\
176         ## Write a good workflow, not just a big one\n\
177         \n\
178         The value of a workflow is the pattern, not the agent count. Prefer shapes that produce a \
179         more trustworthy answer than one pass would: have independent agents check or try to \
180         refute each other's findings, draft from several angles and weigh them, or repeat a \
181         check-and-fix round until it stops making progress. Give each agent one bounded task and \
182         enough context to do it without guessing.\n\
183         \n\
184         Agents share one working directory. Two agents told to edit the same file will conflict, \
185         so give parallel writers separate files, or fan out for reading and keep the writing in \
186         one place.\n\
187         \n\
188         ## Example: fan out over files, then verify\n\
189         \n\
190         {FAN_OUT_EXAMPLE}\n\
191         ## Example: several angles, then choose\n\
192         \n\
193         {PERSPECTIVES_EXAMPLE}\n\
194         Give `run_workflow` a short kebab-case `name`, a one-sentence `description`, and the \
195         `script`. If the script does not parse, the error names the line and a supported \
196         alternative: fix it and call the tool again.\n"
197    )
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    #[test]
205    fn both_examples_parse_with_the_real_parser() {
206        // The instructions are the only description of the language the model
207        // receives. If an example here stopped parsing, every workflow would
208        // start with a wasted round trip.
209        for (name, source) in [
210            ("fan out", FAN_OUT_EXAMPLE),
211            ("perspectives", PERSPECTIVES_EXAMPLE),
212        ] {
213            let script = kiss_workflow::Script::parse(source)
214                .unwrap_or_else(|error| panic!("the {name} example must parse: {error}"));
215            assert!(!script.meta().name.is_empty());
216            assert!(!script.meta().description.is_empty());
217        }
218    }
219
220    #[test]
221    fn the_fan_out_example_declares_the_phases_it_uses() {
222        let script = kiss_workflow::Script::parse(FAN_OUT_EXAMPLE).unwrap();
223        assert_eq!(script.declared_phases(), ["Discover", "Audit", "Report"]);
224        // Its fan-out depends on a list fetched at run time, so the agent count
225        // is deliberately not predicted.
226        assert_eq!(script.estimated_agents(), None);
227    }
228
229    #[test]
230    fn the_perspectives_example_has_a_known_agent_count() {
231        let script = kiss_workflow::Script::parse(PERSPECTIVES_EXAMPLE).unwrap();
232        // Three drafts plus the one that chooses between them.
233        assert_eq!(script.estimated_agents(), Some(4));
234    }
235
236    #[test]
237    fn the_size_guideline_reaches_the_instructions() {
238        let small = authoring_prompt(WorkflowSize::Small, 1000, 4096);
239        assert!(small.contains("fewer than 5 agents"));
240
241        let unrestricted = authoring_prompt(WorkflowSize::Unrestricted, 1000, 4096);
242        assert!(unrestricted.contains("no agent-count guideline"));
243    }
244
245    #[test]
246    fn the_instructions_state_the_two_rules_that_break_scripts() {
247        let text = authoring_prompt(WorkflowSize::Medium, 1000, 4096);
248        assert!(text.contains("filter(Boolean)"));
249        assert!(text.contains("schema"));
250        assert!(text.contains("Date.now()"));
251        // The template placeholder must survive formatting rather than being
252        // eaten as a format argument.
253        assert!(text.contains("${...}"));
254    }
255}