@bastani/atomic 0.5.34 → 0.6.0-0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +329 -50
  2. package/dist/commands/cli/session.d.ts +67 -0
  3. package/dist/commands/cli/session.d.ts.map +1 -0
  4. package/dist/commands/cli/workflow-status.d.ts +63 -0
  5. package/dist/commands/cli/workflow-status.d.ts.map +1 -0
  6. package/dist/sdk/commander.d.ts +74 -0
  7. package/dist/sdk/commander.d.ts.map +1 -0
  8. package/dist/sdk/components/workflow-picker-panel.d.ts +14 -17
  9. package/dist/sdk/components/workflow-picker-panel.d.ts.map +1 -1
  10. package/dist/sdk/define-workflow.d.ts +18 -9
  11. package/dist/sdk/define-workflow.d.ts.map +1 -1
  12. package/dist/sdk/index.d.ts +4 -3
  13. package/dist/sdk/index.d.ts.map +1 -1
  14. package/dist/sdk/management-commands.d.ts +42 -0
  15. package/dist/sdk/management-commands.d.ts.map +1 -0
  16. package/dist/sdk/registry.d.ts +27 -0
  17. package/dist/sdk/registry.d.ts.map +1 -0
  18. package/dist/sdk/runtime/attached-footer.d.ts +1 -1
  19. package/dist/sdk/runtime/executor-env.d.ts +20 -0
  20. package/dist/sdk/runtime/executor-env.d.ts.map +1 -0
  21. package/dist/sdk/runtime/executor.d.ts +61 -10
  22. package/dist/sdk/runtime/executor.d.ts.map +1 -1
  23. package/dist/sdk/types.d.ts +147 -4
  24. package/dist/sdk/types.d.ts.map +1 -1
  25. package/dist/sdk/worker-shared.d.ts +42 -0
  26. package/dist/sdk/worker-shared.d.ts.map +1 -0
  27. package/dist/sdk/workflow-cli.d.ts +103 -0
  28. package/dist/sdk/workflow-cli.d.ts.map +1 -0
  29. package/dist/sdk/workflows/builtin-registry.d.ts +113 -0
  30. package/dist/sdk/workflows/builtin-registry.d.ts.map +1 -0
  31. package/dist/sdk/workflows/index.d.ts +5 -5
  32. package/dist/sdk/workflows/index.d.ts.map +1 -1
  33. package/package.json +12 -8
  34. package/src/cli.ts +85 -144
  35. package/src/commands/cli/chat/index.ts +10 -0
  36. package/src/commands/cli/workflow-command.test.ts +279 -938
  37. package/src/commands/cli/workflow-inputs.test.ts +41 -11
  38. package/src/commands/cli/workflow-inputs.ts +47 -12
  39. package/src/commands/cli/workflow-list.test.ts +234 -0
  40. package/src/commands/cli/workflow-list.ts +0 -0
  41. package/src/commands/cli/workflow.ts +11 -798
  42. package/src/scripts/constants.ts +2 -1
  43. package/src/sdk/commander.ts +161 -0
  44. package/src/sdk/components/workflow-picker-panel.tsx +78 -258
  45. package/src/sdk/define-workflow.test.ts +104 -11
  46. package/src/sdk/define-workflow.ts +47 -11
  47. package/src/sdk/errors.test.ts +16 -0
  48. package/src/sdk/index.ts +8 -8
  49. package/src/sdk/management-commands.ts +151 -0
  50. package/src/sdk/registry.ts +132 -0
  51. package/src/sdk/runtime/attached-footer.ts +1 -1
  52. package/src/sdk/runtime/executor-env.ts +45 -0
  53. package/src/sdk/runtime/executor.test.ts +37 -0
  54. package/src/sdk/runtime/executor.ts +147 -68
  55. package/src/sdk/types.ts +169 -4
  56. package/src/sdk/worker-shared.test.ts +163 -0
  57. package/src/sdk/worker-shared.ts +155 -0
  58. package/src/sdk/workflow-cli.ts +409 -0
  59. package/src/sdk/workflows/builtin/deep-research-codebase/claude/index.ts +1 -1
  60. package/src/sdk/workflows/builtin/deep-research-codebase/copilot/index.ts +1 -1
  61. package/src/sdk/workflows/builtin/deep-research-codebase/opencode/index.ts +1 -1
  62. package/src/sdk/workflows/builtin/open-claude-design/claude/index.ts +1 -1
  63. package/src/sdk/workflows/builtin/open-claude-design/copilot/index.ts +1 -1
  64. package/src/sdk/workflows/builtin/open-claude-design/opencode/index.ts +1 -1
  65. package/src/sdk/workflows/builtin/ralph/claude/index.ts +1 -1
  66. package/src/sdk/workflows/builtin/ralph/copilot/index.ts +1 -1
  67. package/src/sdk/workflows/builtin/ralph/opencode/index.ts +1 -1
  68. package/src/sdk/workflows/builtin-registry.ts +23 -0
  69. package/src/sdk/workflows/index.ts +10 -20
  70. package/src/services/system/auth.test.ts +63 -1
  71. package/.agents/skills/workflow-creator/SKILL.md +0 -334
  72. package/.agents/skills/workflow-creator/references/agent-sessions.md +0 -888
  73. package/.agents/skills/workflow-creator/references/computation-and-validation.md +0 -201
  74. package/.agents/skills/workflow-creator/references/control-flow.md +0 -470
  75. package/.agents/skills/workflow-creator/references/discovery-and-verification.md +0 -232
  76. package/.agents/skills/workflow-creator/references/failure-modes.md +0 -903
  77. package/.agents/skills/workflow-creator/references/getting-started.md +0 -275
  78. package/.agents/skills/workflow-creator/references/running-workflows.md +0 -235
  79. package/.agents/skills/workflow-creator/references/session-config.md +0 -384
  80. package/.agents/skills/workflow-creator/references/state-and-data-flow.md +0 -357
  81. package/.agents/skills/workflow-creator/references/user-input.md +0 -234
  82. package/.agents/skills/workflow-creator/references/workflow-inputs.md +0 -272
  83. package/dist/sdk/runtime/discovery.d.ts +0 -132
  84. package/dist/sdk/runtime/discovery.d.ts.map +0 -1
  85. package/dist/sdk/runtime/executor-entry.d.ts +0 -11
  86. package/dist/sdk/runtime/executor-entry.d.ts.map +0 -1
  87. package/dist/sdk/runtime/loader.d.ts +0 -70
  88. package/dist/sdk/runtime/loader.d.ts.map +0 -1
  89. package/dist/version.d.ts +0 -2
  90. package/dist/version.d.ts.map +0 -1
  91. package/src/commands/cli/workflow.test.ts +0 -317
  92. package/src/sdk/runtime/discovery.ts +0 -368
  93. package/src/sdk/runtime/executor-entry.ts +0 -18
  94. package/src/sdk/runtime/loader.ts +0 -267
@@ -1,201 +0,0 @@
1
- # Computation and Validation
2
-
3
- Deterministic computation — validation, data transforms, file I/O, API calls — is written as plain TypeScript inside `.run()` or session callbacks. No LLM session is needed. This is the programmatic equivalent of a `.tool()` node.
4
-
5
- ## Inline computation
6
-
7
- Any TypeScript code inside a session callback that doesn't call an SDK prompt function is deterministic computation. Prefer handle-based lookups (`s.getMessages(plannerHandle)`) over string names when a handle is in scope — it preserves type information and survives stage renames:
8
-
9
- ```ts
10
- const plannerHandle = await ctx.stage({ name: "planner" }, {}, {}, async (s) => { /* ... */ });
11
-
12
- await ctx.stage({ name: "validate-and-fix", description: "Validate, then fix if needed" }, {}, {}, async (s) => {
13
- // Step 1: Deterministic — parse prior session's output (handle-based lookup)
14
- const messages = await s.getMessages(plannerHandle);
15
- const planText = extractText(messages);
16
- const plan = JSON.parse(planText);
17
-
18
- // Step 2: Deterministic — validate the plan
19
- const isValid = plan.tasks?.length > 0 && plan.tasks.every((t: { id: string; description: string }) => t.id && t.description);
20
-
21
- if (!isValid) {
22
- // Step 3: Agent session — ask the agent to fix the plan
23
- await s.session.query("The plan is invalid. Please create a valid plan with tasks.");
24
- } else {
25
- // Step 4: Agent session — execute the valid plan
26
- await s.session.query(`Execute this plan:\n${JSON.stringify(plan.tasks)}`);
27
- }
28
-
29
- s.save(s.sessionId);
30
- });
31
- ```
32
-
33
- ## Parsing SDK responses
34
-
35
- Each SDK returns responses in different formats. Use helpers to extract text:
36
-
37
- ### Claude
38
-
39
- `s.session.query()` returns `SessionMessage[]` — the native SDK transcript messages from this turn. Use `extractAssistantText()` to extract the plain text:
40
-
41
- ```ts
42
- import { extractAssistantText } from "@bastani/atomic/workflows";
43
-
44
- const result = await s.session.query("...");
45
- const text = extractAssistantText(result, 0); // Extract text from SessionMessage[]
46
- ```
47
-
48
- ### Copilot
49
-
50
- `s.session.getMessages()` returns `SessionEvent[]`. Concatenate every
51
- top-level assistant turn's non-empty content — picking only `.at(-1)` is a
52
- silent-failure trap (see `failure-modes.md` §F1 / §F2). Use the
53
- `getAssistantText` helper defined in `failure-modes.md` §F1:
54
-
55
- ```ts
56
- // Usage:
57
- const messages = await s.session.getMessages();
58
- const text = getAssistantText(messages);
59
- ```
60
-
61
- ### OpenCode
62
-
63
- `session.prompt()` returns `{ data: { info, parts } }`. Filter for text
64
- parts only — non-text parts produce `[object Object]` (see
65
- `failure-modes.md` §F3). Use the `extractResponseText` helper defined
66
- there:
67
-
68
- ```ts
69
- // Usage:
70
- const text = extractResponseText(result.data!.parts);
71
- ```
72
-
73
- ## Validation patterns
74
-
75
- ### JSON parsing with fallback
76
-
77
- Use a layered fallback: direct parse → **last** fenced block (not the
78
- first — prose often quotes examples earlier; see `failure-modes.md` §F8) →
79
- last balanced object. Canonical helper lives in `state-and-data-flow.md`
80
- §"Response parsers"; copy it into a sibling `helpers/parsers.ts` and
81
- import. The full three-layer implementation, including the balanced-object
82
- fallback, is in `src/sdk/workflows/builtin/ralph/helpers/prompts.ts`.
83
-
84
- ### Zod validation
85
-
86
- Import Zod directly in your workflow file for runtime validation:
87
-
88
- ```ts
89
- import { z } from "zod";
90
-
91
- const TaskSchema = z.object({
92
- id: z.string(),
93
- description: z.string(),
94
- status: z.enum(["pending", "in_progress", "completed", "error"]),
95
- blockedBy: z.array(z.string()).optional(),
96
- });
97
-
98
- // In run():
99
- const parsed = parseJsonResponse(responseText);
100
- const result = TaskSchema.array().safeParse(parsed?.tasks);
101
- if (!result.success) {
102
- console.error("Validation failed:", result.error.issues);
103
- }
104
- ```
105
-
106
- ## File I/O
107
-
108
- Read and write files directly in `run()`:
109
-
110
- ```ts
111
- import { readFile, writeFile, mkdir } from "fs/promises";
112
- import { join } from "path";
113
-
114
- // Inside a ctx.stage() callback:
115
- async (s) => {
116
- // Write to session directory
117
- const outputDir = join(s.sessionDir, "artifacts");
118
- await mkdir(outputDir, { recursive: true });
119
- await writeFile(join(outputDir, "report.json"), JSON.stringify(data));
120
-
121
- // Read from project
122
- const config = await readFile("./tsconfig.json", "utf-8");
123
- },
124
- ```
125
-
126
- ## API calls
127
-
128
- Make HTTP requests for external integrations:
129
-
130
- ```ts
131
- // Inside a ctx.stage() callback:
132
- async (s) => {
133
- const response = await fetch("https://api.example.com/data");
134
- const data = await response.json();
135
-
136
- // Use the data in a prompt
137
- await s.session.query(`Process this data:\n${JSON.stringify(data)}`);
138
- s.save(s.sessionId);
139
- },
140
- ```
141
-
142
- ## Data transforms
143
-
144
- Transform data between sessions:
145
-
146
- ```ts
147
- // Inside a ctx.stage() callback:
148
- async (s) => {
149
- const raw = await s.getMessages("planner"); // or s.getMessages(handle) if a handle is in scope (preferred)
150
-
151
- // Transform: extract only task IDs and descriptions
152
- const tasks = extractTasks(raw).map(t => ({
153
- id: t.id,
154
- description: t.description,
155
- priority: calculatePriority(t),
156
- }));
157
-
158
- // Sort by priority
159
- tasks.sort((a, b) => b.priority - a.priority);
160
-
161
- // Pass to agent
162
- await s.session.query(`Execute these tasks in order:\n${JSON.stringify(tasks)}`);
163
- s.save(s.sessionId);
164
- },
165
- ```
166
-
167
- ## Quality Gate with LLM-as-Judge
168
-
169
- Add automated quality checkpoints using evaluation rubrics. This pattern applies `evaluation` + `advanced-evaluation`:
170
-
171
- ```ts
172
- .run(async (ctx) => {
173
- const impl = await ctx.stage({ name: "implement" }, {}, {}, async (s) => {
174
- await s.session.query((s.inputs.prompt ?? ""));
175
- s.save(s.sessionId);
176
- });
177
-
178
- await ctx.stage({ name: "quality-gate" }, {}, {}, async (s) => {
179
- const implTranscript = await s.transcript(impl);
180
- const result = await s.session.query(
181
- `You are a code quality judge. Score this implementation 1-5 for:
182
- - **Correctness**: Does it solve the stated problem?
183
- - **Completeness**: Are edge cases handled?
184
- - **Style**: Does it follow project conventions?
185
-
186
- ## Implementation to judge
187
- ${implTranscript.content}
188
-
189
- Respond with JSON: { "correctness": N, "completeness": N, "style": N, "pass": boolean, "issues": [...] }`,
190
- );
191
-
192
- const scores = parseJsonResponse(extractAssistantText(result, 0));
193
-
194
- if (!scores.pass) {
195
- await s.session.query(`Fix these quality issues:\n${scores.issues.join("\n")}`);
196
- }
197
-
198
- s.save(s.sessionId);
199
- });
200
- })
201
- ```
@@ -1,470 +0,0 @@
1
- # Control Flow
2
-
3
- Control flow in workflows is plain TypeScript inside `.run()`. Use `if`/`else` for conditionals, `for`/`while` for loops, and `break`/`continue` for early termination.
4
-
5
- There are two levels where control flow can live:
6
-
7
- - **Intra-session**: multiple SDK calls within one `ctx.stage()` callback — the agent remembers context across all of them.
8
- - **Inter-session**: loops/conditionals at the `.run()` level that spawn multiple `ctx.stage()` calls — each iteration becomes its own visible graph node in the UI.
9
-
10
- Prefer inter-session control flow when you want the workflow graph to reflect what actually happened at runtime.
11
-
12
- ## Conditional branching
13
-
14
- ### Inter-session branching (recommended)
15
-
16
- Run a triage session first, then branch at the `.run()` level to spawn a purpose-built session for each outcome. Every branch appears as a distinct node in the graph:
17
-
18
- ```ts
19
- import { extractAssistantText } from "@bastani/atomic/workflows";
20
-
21
- .run(async (ctx) => {
22
- // Step 1: Classify the request
23
- const triage = await ctx.stage({ name: "triage" }, {}, {}, async (s) => {
24
- const result = await s.session.query(
25
- `Classify this as "bug", "feature", or "question": ${(s.inputs.prompt ?? "")}`,
26
- );
27
- s.save(s.sessionId);
28
- return extractAssistantText(result, 0).toLowerCase();
29
- });
30
-
31
- const classification = triage.result;
32
-
33
- // Step 2: Branch — each path spawns its own session
34
- if (classification.includes("bug")) {
35
- await ctx.stage({ name: "fix-bug" }, {}, {}, async (s) => {
36
- await s.session.query("Diagnose and fix the bug described above.");
37
- s.save(s.sessionId);
38
- });
39
- } else if (classification.includes("feature")) {
40
- await ctx.stage({ name: "implement-feature" }, {}, {}, async (s) => {
41
- await s.session.query("Design and implement the feature described above.");
42
- s.save(s.sessionId);
43
- });
44
- } else {
45
- await ctx.stage({ name: "answer-question" }, {}, {}, async (s) => {
46
- await s.session.query("Research and answer the question above.");
47
- s.save(s.sessionId);
48
- });
49
- }
50
- })
51
- ```
52
-
53
- ### Intra-session branching
54
-
55
- When the branching logic is simple and you want the agent to retain full context across both the triage and the action, do it all inside a single session callback:
56
-
57
- ```ts
58
- import { extractAssistantText } from "@bastani/atomic/workflows";
59
-
60
- .run(async (ctx) => {
61
- await ctx.stage({ name: "triage-and-act" }, {}, {}, async (s) => {
62
- const triageResult = await s.session.query(
63
- `Classify this as "bug", "feature", or "question": ${(s.inputs.prompt ?? "")}`,
64
- );
65
-
66
- const classification = extractAssistantText(triageResult, 0).toLowerCase();
67
-
68
- if (classification.includes("bug")) {
69
- await s.session.query("Diagnose and fix the bug described above.");
70
- } else if (classification.includes("feature")) {
71
- await s.session.query("Design and implement the feature described above.");
72
- } else {
73
- await s.session.query("Research and answer the question above.");
74
- }
75
-
76
- s.save(s.sessionId);
77
- });
78
- })
79
- ```
80
-
81
- ## Bounded loops
82
-
83
- ### Inter-session loops (recommended)
84
-
85
- Each iteration spawns its own session, so the graph shows exactly how many passes ran:
86
-
87
- ```ts
88
- import { extractAssistantText } from "@bastani/atomic/workflows";
89
-
90
- .run(async (ctx) => {
91
- const MAX_ITERATIONS = 5;
92
-
93
- for (let i = 1; i <= MAX_ITERATIONS; i++) {
94
- const iteration = await ctx.stage({ name: `refine-${i}` }, {}, {}, async (s) => {
95
- const result = await s.session.query(`Iteration ${i}: Improve the implementation.`);
96
- s.save(s.sessionId);
97
- return extractAssistantText(result, 0);
98
- });
99
-
100
- if (iteration.result.includes("LGTM") || iteration.result.includes("no issues")) {
101
- break;
102
- }
103
- }
104
- })
105
- ```
106
-
107
- ### Intra-session loops
108
-
109
- When the agent must remember every prior iteration's output to make progress, keep the loop inside one session:
110
-
111
- ```ts
112
- import { extractAssistantText } from "@bastani/atomic/workflows";
113
-
114
- .run(async (ctx) => {
115
- await ctx.stage({ name: "iterative-refinement" }, {}, {}, async (s) => {
116
- const MAX_ITERATIONS = 5;
117
-
118
- for (let i = 0; i < MAX_ITERATIONS; i++) {
119
- const result = await s.session.query(`Iteration ${i + 1}: Improve the implementation.`);
120
-
121
- if (extractAssistantText(result, 0).includes("LGTM") || extractAssistantText(result, 0).includes("no issues")) {
122
- break;
123
- }
124
- }
125
-
126
- s.save(s.sessionId);
127
- });
128
- })
129
- ```
130
-
131
- ## Review/fix loop pattern
132
-
133
- The inter-session pattern is the right fit here: every review and every fix becomes its own graph node, so the executed path is fully visible. This is the production-grade approach with consecutive clean-pass detection:
134
-
135
- ```ts
136
- import { extractAssistantText } from "@bastani/atomic/workflows";
137
-
138
- .run(async (ctx) => {
139
- const MAX_CYCLES = 10;
140
- const CLEAN_THRESHOLD = 2;
141
- let consecutiveClean = 0;
142
-
143
- for (let cycle = 1; cycle <= MAX_CYCLES; cycle++) {
144
- // Each review is a visible graph node
145
- const review = await ctx.stage({ name: `review-${cycle}` }, {}, {}, async (s) => {
146
- const result = await s.session.query(buildReviewPrompt((s.inputs.prompt ?? "")));
147
- s.save(s.sessionId);
148
- return extractAssistantText(result, 0);
149
- });
150
-
151
- const reviewRaw = review.result;
152
- const parsed = parseReviewResult(reviewRaw);
153
-
154
- if (!hasActionableFindings(parsed, reviewRaw)) {
155
- consecutiveClean++;
156
- if (consecutiveClean >= CLEAN_THRESHOLD) {
157
- break; // Two consecutive clean passes → done
158
- }
159
- continue; // One clean pass → verify again
160
- }
161
-
162
- consecutiveClean = 0;
163
-
164
- const fixPrompt = parsed
165
- ? buildFixSpecFromReview(parsed, (s.inputs.prompt ?? ""))
166
- : buildFixSpecFromRawReview(reviewRaw, (s.inputs.prompt ?? ""));
167
-
168
- // Each fix is also a visible graph node
169
- await ctx.stage({ name: `fix-${cycle}` }, {}, {}, async (s) => {
170
- await s.session.query(fixPrompt || "Fix any remaining issues.");
171
- s.save(s.sessionId);
172
- });
173
- }
174
- })
175
- ```
176
-
177
- ### Same pattern with Copilot
178
-
179
- Copilot lacks a built-in text extractor — define `getAssistantText` as a
180
- helper in your workflow (canonical definition in `failure-modes.md` §F1)
181
- and import it from a sibling file:
182
-
183
- ```ts
184
- import { getAssistantText } from "../helpers/parsers.ts"; // see failure-modes.md §F1
185
-
186
- .run(async (ctx) => {
187
- const MAX_CYCLES = 10;
188
- let consecutiveClean = 0;
189
-
190
- for (let cycle = 1; cycle <= MAX_CYCLES; cycle++) {
191
- const review = await ctx.stage({ name: `review-${cycle}` }, {}, {}, async (s) => {
192
- await s.session.send({
193
- prompt: buildReviewPrompt((s.inputs.prompt ?? "")),
194
- });
195
- const reviewRaw = getAssistantText(await s.session.getMessages());
196
-
197
- s.save(await s.session.getMessages());
198
- return reviewRaw;
199
- });
200
-
201
- const reviewRaw = review.result;
202
- const parsed = parseReviewResult(reviewRaw);
203
-
204
- if (!hasActionableFindings(parsed, reviewRaw)) {
205
- consecutiveClean++;
206
- if (consecutiveClean >= 2) break;
207
- continue;
208
- }
209
- consecutiveClean = 0;
210
-
211
- const fixPrompt = parsed
212
- ? buildFixSpecFromReview(parsed, (s.inputs.prompt ?? ""))
213
- : buildFixSpecFromRawReview(reviewRaw, (s.inputs.prompt ?? ""));
214
-
215
- await ctx.stage({ name: `fix-${cycle}` }, {}, {}, async (s) => {
216
- await s.session.send({
217
- prompt: fixPrompt || "Fix remaining issues.",
218
- });
219
-
220
- s.save(await s.session.getMessages());
221
- });
222
- }
223
- })
224
- ```
225
-
226
- ## Graph topology: auto-inferred from `await`/`Promise.all`
227
-
228
- The runtime automatically infers the workflow graph topology from the JavaScript control flow. No explicit dependency declarations are needed or supported — the graph always reflects the actual execution structure.
229
-
230
- ### Sequential (`await`): `a → b` edge
231
-
232
- Each sequential `await ctx.stage(...)` produces a parent-child edge from the previous stage. The graph draws a real chain:
233
-
234
- ```ts
235
- // ✅ Graph infers: orchestrator → planner → worker
236
- .run(async (ctx) => {
237
- await ctx.stage({ name: "planner" }, {}, {}, async (s) => { /* ... */ });
238
- await ctx.stage({ name: "worker" }, {}, {}, async (s) => { /* ... */ });
239
- })
240
- ```
241
-
242
- ### Parallel (`Promise.all`): both branch from same parent
243
-
244
- Sessions passed to `Promise.all([...])` branch from the same parent and run concurrently. The runtime gives each a sibling edge from the enclosing scope:
245
-
246
- ```ts
247
- // ✅ Graph infers: orchestrator → [summarize-a, summarize-b] (parallel siblings)
248
- .run(async (ctx) => {
249
- const [a, b] = await Promise.all([
250
- ctx.stage({ name: "summarize-a" }, {}, {}, async (s) => { /* ... */ }),
251
- ctx.stage({ name: "summarize-b" }, {}, {}, async (s) => { /* ... */ }),
252
- ]);
253
- })
254
- ```
255
-
256
- ### Fan-in: stage after `Promise.all` gets all parallel stages as parents
257
-
258
- A stage awaited after a `Promise.all` resolves automatically receives all parallel stages as parents — the graph draws a merge node:
259
-
260
- ```ts
261
- // ✅ Graph infers: A → [B, C] → D (fan-in merge)
262
- .run(async (ctx) => {
263
- await ctx.stage({ name: "A" }, {}, {}, async (s) => { /* ... */ });
264
-
265
- await Promise.all([
266
- ctx.stage({ name: "B" }, {}, {}, async (s) => { /* ... */ }),
267
- ctx.stage({ name: "C" }, {}, {}, async (s) => { /* ... */ }),
268
- ]);
269
-
270
- // D receives B and C as parents — rendered as a merge node.
271
- await ctx.stage({ name: "D" }, {}, {}, async (s) => { /* ... */ });
272
- })
273
- ```
274
-
275
- ### Nested sub-sessions: child of the enclosing session
276
-
277
- `s.stage()` inside a callback automatically becomes a child of the enclosing session — no declaration needed:
278
-
279
- ```ts
280
- await ctx.stage({ name: "outer" }, {}, {}, async (s) => {
281
- // inner is a child of outer in the graph automatically
282
- await s.stage({ name: "inner" }, {}, {}, async (s2) => { /* ... */ });
283
- });
284
- ```
285
-
286
- ### Pattern: iterative loop chains
287
-
288
- In iterative loops each stage is naturally the successor of the last because `await` serializes them within the loop body. The graph renders as a chain by default:
289
-
290
- ```ts
291
- // ✅ Graph infers a spine: planner-1 → worker-1 → planner-2 → worker-2 → ...
292
- .run(async (ctx) => {
293
- for (let i = 1; i <= MAX_LOOPS; i++) {
294
- await ctx.stage({ name: `planner-${i}` }, {}, {}, async (s) => { /* ... */ });
295
- await ctx.stage({ name: `worker-${i}` }, {}, {}, async (s) => { /* ... */ });
296
-
297
- if (needsReview) {
298
- await ctx.stage({ name: `reviewer-${i}` }, {}, {}, async (s) => { /* ... */ });
299
- }
300
- }
301
- })
302
- ```
303
-
304
- Each iteration's stages form a natural chain because each `await` follows the previous one. Conditional stages fit in seamlessly — the graph reflects whatever path was actually executed.
305
-
306
- ### Headless (background) stages: transparent to graph topology
307
-
308
- Headless stages (`{ headless: true }`) are **invisible in the workflow graph** — they don't consume or update the execution frontier. This means they don't affect the parent-child edges inferred for visible stages.
309
-
310
- ```ts
311
- import { extractAssistantText } from "@bastani/atomic/workflows";
312
-
313
- // ✅ Graph renders: seed → merge (headless stages are transparent)
314
- .run(async (ctx) => {
315
- const seed = await ctx.stage({ name: "seed" }, {}, {}, async (s) => {
316
- const result = await s.session.query("Describe the project.");
317
- s.save(s.sessionId);
318
- return extractAssistantText(result, 0);
319
- });
320
-
321
- // Three parallel headless stages — invisible in the graph
322
- const [a, b, c] = await Promise.all([
323
- ctx.stage({ name: "gather-a", headless: true }, {}, {}, async (s) => {
324
- const result = await s.session.query(`List 3 pros:\n\n${seed.result}`);
325
- s.save(s.sessionId);
326
- return extractAssistantText(result, 0);
327
- }),
328
- ctx.stage({ name: "gather-b", headless: true }, {}, {}, async (s) => {
329
- const result = await s.session.query(`List 3 cons:\n\n${seed.result}`);
330
- s.save(s.sessionId);
331
- return extractAssistantText(result, 0);
332
- }),
333
- ctx.stage({ name: "gather-c", headless: true }, {}, {}, async (s) => {
334
- const result = await s.session.query(`List 3 uses:\n\n${seed.result}`);
335
- s.save(s.sessionId);
336
- return extractAssistantText(result, 0);
337
- }),
338
- ]);
339
-
340
- // Visible merge stage — chains from "seed" in the graph (not from headless stages)
341
- await ctx.stage({ name: "merge" }, {}, {}, async (s) => {
342
- await s.session.query(
343
- `Combine:\n\n## Pros\n${a.result}\n\n## Cons\n${b.result}\n\n## Uses\n${c.result}`,
344
- );
345
- s.save(s.sessionId);
346
- });
347
- })
348
- ```
349
-
350
- **Key behaviors:**
351
- - Headless stages don't produce graph nodes — they are tracked by a background task counter in the statusline instead
352
- - The execution frontier is not updated when a headless stage spawns or settles, so the next visible stage chains from the last visible stage
353
- - Headless stages still participate in `Promise.all()` — the merge stage correctly awaits all three before running
354
- - Return values (`handle.result`) and transcript access (`s.transcript(handle)`) work identically
355
-
356
- **When to use headless vs. visible parallel stages:**
357
-
358
- | Concern | Use visible (`headless: false`) | Use headless (`headless: true`) |
359
- |---|---|---|
360
- | User needs to see the work | Yes — each stage gets a tmux window | No — tracked by counter only |
361
- | Debugging/monitoring | Yes — visible in graph + pane preview | No — errors tracked but no TUI |
362
- | Data-gathering/analysis | Possible but clutters the graph | Ideal — keeps graph clean |
363
- | Infrastructure discovery | Clutters graph for support work | Ideal — Ralph uses this pattern |
364
-
365
- ### Note on data flow vs. topology
366
-
367
- Graph topology (parent-child edges) is inferred from control flow. Data flow between sessions is separate: use `s.transcript(handle)` to read a prior session's saved output. The two concerns are independent — you do not need explicit dependency declarations to access another session's transcript; you just need that session's `await` to have completed before you read it.
368
-
369
- ## Multi-turn conversations
370
-
371
- Within a single session callback, each SDK call adds to the conversation context — the agent remembers every prior turn. This is inherently intra-session:
372
-
373
- ```ts
374
- .run(async (ctx) => {
375
- await ctx.stage({ name: "guided-implementation" }, {}, {}, async (s) => {
376
- // The session remembers all prior turns within the same callback
377
- await s.session.query("Step 1: Set up the project structure.");
378
- await s.session.query("Step 2: Implement the core logic.");
379
- await s.session.query("Step 3: Add error handling.");
380
- await s.session.query("Step 4: Write tests.");
381
- s.save(s.sessionId);
382
- });
383
- })
384
- ```
385
-
386
- ## Error handling and retry patterns
387
-
388
- ### Try/catch with fallback
389
-
390
- ```ts
391
- .run(async (ctx) => {
392
- await ctx.stage({ name: "implement" }, {}, {}, async (s) => {
393
- try {
394
- await s.session.query((s.inputs.prompt ?? ""));
395
- } catch (error) {
396
- // Retry with simpler prompt
397
- await s.session.query(
398
- `The previous attempt failed. Please try a simpler approach: ${(s.inputs.prompt ?? "")}`,
399
- );
400
- }
401
- s.save(s.sessionId);
402
- });
403
- })
404
- ```
405
-
406
- ### Retry with exponential backoff
407
-
408
- ```ts
409
- async function retryWithBackoff<T>(
410
- fn: () => Promise<T>,
411
- maxRetries: number = 3,
412
- baseDelay: number = 1000,
413
- ): Promise<T> {
414
- for (let attempt = 0; attempt < maxRetries; attempt++) {
415
- try {
416
- return await fn();
417
- } catch (error) {
418
- if (attempt === maxRetries - 1) throw error;
419
- await new Promise(r => setTimeout(r, baseDelay * Math.pow(2, attempt)));
420
- }
421
- }
422
- throw new Error("Unreachable");
423
- }
424
-
425
- .run(async (ctx) => {
426
- await ctx.stage({ name: "implement" }, {}, {}, async (s) => {
427
- await retryWithBackoff(() => s.session.query((s.inputs.prompt ?? "")));
428
- s.save(s.sessionId);
429
- });
430
- })
431
- ```
432
-
433
- ## Combining patterns
434
-
435
- Combine loops, conditionals, and inter-session data passing. Session callbacks return typed values via `SessionHandle<T>.result`, and `s.transcript(handle)` accepts a prior `SessionHandle` to read another session's saved output:
436
-
437
- ```ts
438
- import { extractAssistantText } from "@bastani/atomic/workflows";
439
-
440
- .run(async (ctx) => {
441
- // Step 1: Analyse — result is available as a typed handle
442
- const analysisHandle = await ctx.stage({ name: "analyze" }, {}, {}, async (s) => {
443
- const result = await s.session.query(`Analyse the task: ${(s.inputs.prompt ?? "")}`);
444
- s.save(s.sessionId);
445
- return extractAssistantText(result, 0);
446
- });
447
-
448
- const isComplex = analysisHandle.result.includes("complex");
449
- const maxIterations = isComplex ? 10 : 3;
450
-
451
- // Step 2: Iterative implementation — each pass is a graph node
452
- for (let i = 1; i <= maxIterations; i++) {
453
- const impl = await ctx.stage({ name: `implement-${i}` }, {}, {}, async (s) => {
454
- // Pass the analysis transcript into this session
455
- const analysis = await s.transcript(analysisHandle);
456
- const result = await s.session.query(
457
- i === 1
458
- ? `Implement based on:\n${analysis.content}`
459
- : "Continue improving the implementation.",
460
- );
461
- s.save(s.sessionId);
462
- return extractAssistantText(result, 0);
463
- });
464
-
465
- if (impl.result.includes("all tests pass")) {
466
- break;
467
- }
468
- }
469
- })
470
- ```