@osolmaz/pi-workflows 0.1.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 (113) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +182 -0
  3. package/dist/extension/executor.d.ts +58 -0
  4. package/dist/extension/executor.js +201 -0
  5. package/dist/extension/executor.js.map +1 -0
  6. package/dist/extension/index.d.ts +17 -0
  7. package/dist/extension/index.js +504 -0
  8. package/dist/extension/index.js.map +1 -0
  9. package/dist/extension/widget.d.ts +21 -0
  10. package/dist/extension/widget.js +142 -0
  11. package/dist/extension/widget.js.map +1 -0
  12. package/dist/render/ansi.d.ts +16 -0
  13. package/dist/render/ansi.js +42 -0
  14. package/dist/render/ansi.js.map +1 -0
  15. package/dist/render/canvas.d.ts +40 -0
  16. package/dist/render/canvas.js +177 -0
  17. package/dist/render/canvas.js.map +1 -0
  18. package/dist/render/format.d.ts +3 -0
  19. package/dist/render/format.js +17 -0
  20. package/dist/render/format.js.map +1 -0
  21. package/dist/render/graph-render.d.ts +22 -0
  22. package/dist/render/graph-render.js +520 -0
  23. package/dist/render/graph-render.js.map +1 -0
  24. package/dist/render/graph.d.ts +46 -0
  25. package/dist/render/graph.js +272 -0
  26. package/dist/render/graph.js.map +1 -0
  27. package/dist/viewer/cli.d.ts +10 -0
  28. package/dist/viewer/cli.js +132 -0
  29. package/dist/viewer/cli.js.map +1 -0
  30. package/dist/viewer/render.d.ts +19 -0
  31. package/dist/viewer/render.js +162 -0
  32. package/dist/viewer/render.js.map +1 -0
  33. package/dist/viewer/tui.d.ts +11 -0
  34. package/dist/viewer/tui.js +140 -0
  35. package/dist/viewer/tui.js.map +1 -0
  36. package/dist/viewer/watch.d.ts +9 -0
  37. package/dist/viewer/watch.js +46 -0
  38. package/dist/viewer/watch.js.map +1 -0
  39. package/dist/workflows/decision.d.ts +25 -0
  40. package/dist/workflows/decision.js +96 -0
  41. package/dist/workflows/decision.js.map +1 -0
  42. package/dist/workflows/definition.d.ts +9 -0
  43. package/dist/workflows/definition.js +61 -0
  44. package/dist/workflows/definition.js.map +1 -0
  45. package/dist/workflows/engine.d.ts +65 -0
  46. package/dist/workflows/engine.js +574 -0
  47. package/dist/workflows/engine.js.map +1 -0
  48. package/dist/workflows/errors.d.ts +9 -0
  49. package/dist/workflows/errors.js +24 -0
  50. package/dist/workflows/errors.js.map +1 -0
  51. package/dist/workflows/graph.d.ts +17 -0
  52. package/dist/workflows/graph.js +127 -0
  53. package/dist/workflows/graph.js.map +1 -0
  54. package/dist/workflows/index.d.ts +11 -0
  55. package/dist/workflows/index.js +11 -0
  56. package/dist/workflows/index.js.map +1 -0
  57. package/dist/workflows/json.d.ts +14 -0
  58. package/dist/workflows/json.js +134 -0
  59. package/dist/workflows/json.js.map +1 -0
  60. package/dist/workflows/loader.d.ts +28 -0
  61. package/dist/workflows/loader.js +94 -0
  62. package/dist/workflows/loader.js.map +1 -0
  63. package/dist/workflows/schema.d.ts +7 -0
  64. package/dist/workflows/schema.js +176 -0
  65. package/dist/workflows/schema.js.map +1 -0
  66. package/dist/workflows/shell.d.ts +9 -0
  67. package/dist/workflows/shell.js +177 -0
  68. package/dist/workflows/shell.js.map +1 -0
  69. package/dist/workflows/store.d.ts +35 -0
  70. package/dist/workflows/store.js +181 -0
  71. package/dist/workflows/store.js.map +1 -0
  72. package/dist/workflows/text.d.ts +10 -0
  73. package/dist/workflows/text.js +32 -0
  74. package/dist/workflows/text.js.map +1 -0
  75. package/dist/workflows/types.d.ts +280 -0
  76. package/dist/workflows/types.js +2 -0
  77. package/dist/workflows/types.js.map +1 -0
  78. package/docs/development.md +130 -0
  79. package/docs/run-bundles.md +114 -0
  80. package/docs/workflows.md +311 -0
  81. package/examples/workflows/autoimplement.workflow.ts +92 -0
  82. package/examples/workflows/autoresearch.workflow.ts +139 -0
  83. package/examples/workflows/branch.workflow.ts +63 -0
  84. package/examples/workflows/echo.workflow.ts +23 -0
  85. package/examples/workflows/elegant-solution.workflow.ts +95 -0
  86. package/examples/workflows/shell.workflow.ts +31 -0
  87. package/examples/workflows/two-turn.workflow.ts +64 -0
  88. package/package.json +80 -0
  89. package/src/extension/executor.ts +251 -0
  90. package/src/extension/index.ts +627 -0
  91. package/src/extension/widget.ts +183 -0
  92. package/src/render/ansi.ts +47 -0
  93. package/src/render/canvas.ts +196 -0
  94. package/src/render/format.ts +19 -0
  95. package/src/render/graph-render.ts +738 -0
  96. package/src/render/graph.ts +341 -0
  97. package/src/viewer/cli.ts +150 -0
  98. package/src/viewer/render.ts +236 -0
  99. package/src/viewer/tui.ts +159 -0
  100. package/src/viewer/watch.ts +55 -0
  101. package/src/workflows/decision.ts +127 -0
  102. package/src/workflows/definition.ts +104 -0
  103. package/src/workflows/engine.ts +793 -0
  104. package/src/workflows/errors.ts +27 -0
  105. package/src/workflows/graph.ts +161 -0
  106. package/src/workflows/index.ts +76 -0
  107. package/src/workflows/json.ts +155 -0
  108. package/src/workflows/loader.ts +123 -0
  109. package/src/workflows/schema.ts +218 -0
  110. package/src/workflows/shell.ts +199 -0
  111. package/src/workflows/store.ts +234 -0
  112. package/src/workflows/text.ts +34 -0
  113. package/src/workflows/types.ts +318 -0
@@ -0,0 +1,311 @@
1
+ # Workflow authoring reference
2
+
3
+ This document is the authoring reference for pi-workflows definitions. It
4
+ covers the file format, every node type, edge routing, the step contract the
5
+ model sees, and how runs behave at runtime. For the on-disk run format, see
6
+ [run-bundles.md](run-bundles.md).
7
+
8
+ ## Workflow files
9
+
10
+ A workflow is a TypeScript module whose default export is `defineWorkflow(...)`.
11
+ Files are discovered by suffix (`.workflow.ts`, `.workflow.js`, `.workflow.mts`,
12
+ `.workflow.mjs`) from two directories, in precedence order:
13
+
14
+ 1. `.pi/workflows/` in the project (highest precedence on name collisions)
15
+ 2. `~/.pi/agent/workflows/` globally
16
+
17
+ The workflow's command name is the file stem, so `.pi/workflows/triage.workflow.ts`
18
+ runs as `/workflow triage`. A direct path also works: `/workflow ./somewhere/x.workflow.ts`.
19
+ Files are loaded with [jiti](https://github.com/unjs/jiti), so plain TypeScript
20
+ works without a build step, and `import ... from "@osolmaz/pi-workflows"` resolves to
21
+ the engine that loaded the file.
22
+
23
+ ```typescript
24
+ import { agent, compute, defineWorkflow } from "@osolmaz/pi-workflows";
25
+
26
+ export default defineWorkflow({
27
+ name: "example",
28
+ title: ({ input }) => `example: ${(input as { task?: string }).task}`,
29
+ presentationPrompt: "Present the final answer clearly and concisely.",
30
+ startAt: "ask",
31
+ maxSteps: 50,
32
+ nodes: {
33
+ ask: agent({
34
+ prompt: ({ input }) => `Answer: ${(input as { task?: string }).task}`,
35
+ expectedOutput: `{ "answer": "text" }`,
36
+ }),
37
+ finish: compute({ run: ({ outputs }) => outputs.ask }),
38
+ },
39
+ edges: [{ from: "ask", to: "finish" }],
40
+ });
41
+ ```
42
+
43
+ Top-level fields:
44
+
45
+ | Field | Type | Notes |
46
+ | -------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
47
+ | `name` | `string` | Required. Used in run ids and the step contract. `cancel`, `list`, `pause`, and `resume` are reserved for `/workflow` subcommands. |
48
+ | `title` | `string` or function | Optional run title, resolved once at start from `{ input, workflowName }`. Async resolution is bounded (30s) and cancellable. |
49
+ | `presentationPrompt` | `string` or function | Optional instructions for a normal assistant response after the run. A function receives `{ state, finalOutput, signal }` and may return a prompt or `undefined`. See [Result presentation](#result-presentation). |
50
+ | `startAt` | `string` | Required. Id of the first node. |
51
+ | `nodes` | `Record<string, node>` | Required, non-empty. Node ids must match `[A-Za-z_][A-Za-z0-9_-]*`. |
52
+ | `edges` | `WorkflowEdge[]` | Required. See routing below. |
53
+ | `maxSteps` | `number` | Optional loop bound, default 100. The run fails when exceeded. |
54
+
55
+ `defineWorkflow` validates the shape eagerly (node ids, edge shapes, function
56
+ fields) and validates the graph (unknown targets, duplicate outgoing edges,
57
+ unreachable nodes) when a run starts.
58
+
59
+ ## Node context
60
+
61
+ Every node callback receives the same context object:
62
+
63
+ ```typescript
64
+ type WorkflowNodeContext = {
65
+ input: unknown; // the run input
66
+ outputs: Record<string, unknown>; // accepted output per finished node id
67
+ results: Record<string, WorkflowNodeResult>; // full result records, including failures
68
+ state: WorkflowRunState; // the live run state (read-only by convention)
69
+ signal: AbortSignal; // aborted on node timeout or run cancellation
70
+ };
71
+ ```
72
+
73
+ `outputs` only contains nodes that finished with outcome `ok`. When a node runs
74
+ more than once (a loop), the latest result wins. A failed retry removes the
75
+ node's earlier output from `outputs`.
76
+
77
+ Long-running compute, action, and checkpoint callbacks should observe
78
+ `context.signal` (pass it to `fetch`/`spawn`, or check `signal.aborted` between
79
+ steps). When the node times out or the run is cancelled, the engine stops
80
+ waiting immediately, but only cooperative callbacks stop doing work.
81
+
82
+ ## Node types
83
+
84
+ ### agent
85
+
86
+ Sends a prompt into the current pi conversation and waits for the model to
87
+ submit output through the `workflow` tool.
88
+
89
+ ```typescript
90
+ agent({
91
+ prompt: ({ outputs }) => `Review this: ${JSON.stringify(outputs.implement)}`,
92
+ expectedOutput: `{ "verdict": "clean" | "issues_found" }`,
93
+ validate: (output) => output, // optional; throw to reject the submission
94
+ timeoutMs: 30 * 60_000, // optional; default 15 minutes
95
+ statusDetail: "reviewing", // optional; shown in widget and viewer
96
+ });
97
+ ```
98
+
99
+ The engine appends a step contract to the prompt (see below). When the model
100
+ calls the tool, the output passes through normalization (a JSON string is
101
+ parsed tolerantly) and then `validate`. If `validate` throws, the tool call
102
+ returns an error and the model can retry within the same step. If the agent
103
+ ends its turn without submitting, the extension nudges it, twice by default,
104
+ then fails the step.
105
+
106
+ ### compute
107
+
108
+ Runs a TypeScript function inline. Use it for pure data shaping.
109
+
110
+ ```typescript
111
+ compute({ run: ({ outputs }) => ({ merged: { ...outputs } }) });
112
+ ```
113
+
114
+ ### action
115
+
116
+ Performs a side effect. Two forms exist. The function form runs arbitrary
117
+ TypeScript:
118
+
119
+ ```typescript
120
+ action({ run: async ({ input }) => await deployPreview(input) });
121
+ ```
122
+
123
+ The shell form (`shell` is a synonym that requires `exec`) runs a command owned
124
+ by the runtime, so the workflow author decides exactly what executes, with a
125
+ timeout and captured output:
126
+
127
+ ```typescript
128
+ shell({
129
+ exec: ({ input }) => ({
130
+ command: "git",
131
+ args: ["status", "--porcelain"],
132
+ cwd: "/path/to/repo",
133
+ timeoutMs: 10_000,
134
+ allowNonZeroExit: false,
135
+ }),
136
+ parse: (result) => ({ dirty: result.stdout.trim().length > 0 }),
137
+ });
138
+ ```
139
+
140
+ Without `parse`, the node output is the full `ShellActionResult` (`stdout`,
141
+ `stderr`, `exitCode`, `signal`, `durationMs`). A non-zero exit fails the node
142
+ unless `allowNonZeroExit` is set. Captured stdout and stderr are each capped
143
+ (default 1,000,000 characters, configurable with `maxOutputChars`) so verbose
144
+ commands cannot exhaust memory. Both action forms record a receipt (command,
145
+ exit code, duration) in the step record for auditability, including when the
146
+ command fails.
147
+
148
+ ### checkpoint
149
+
150
+ Ends the run in a `waiting` state for human review. Runs after a checkpoint do
151
+ not resume automatically; the checkpoint output is the run's final output.
152
+ Because nothing resumes past a checkpoint, graph validation rejects outgoing
153
+ edges from checkpoint nodes.
154
+
155
+ ```typescript
156
+ checkpoint({
157
+ summary: "human decides how to proceed",
158
+ run: ({ outputs }) => outputs.reconcile, // optional; default output is { summary }
159
+ });
160
+ ```
161
+
162
+ ### decision
163
+
164
+ `decision` is sugar over `agent` for constrained choices. It builds the prompt
165
+ suffix listing the choices, sets `expectedOutput`, and validates that the
166
+ submitted object carries one of the allowed values in the decision field
167
+ (default `route`).
168
+
169
+ ```typescript
170
+ const choices = ["y", "n"] as const;
171
+
172
+ decision({
173
+ choices,
174
+ question: ({ outputs }) => `Same as proposed? ${JSON.stringify(outputs.propose)}`,
175
+ });
176
+ ```
177
+
178
+ Pair it with `decisionEdge`, which builds the matching `switch` edge and makes
179
+ a missing case a compile-time error:
180
+
181
+ ```typescript
182
+ decisionEdge({ from: "compare", choices, cases: { y: "implement", n: "reconcile" } });
183
+ ```
184
+
185
+ ## Edges and routing
186
+
187
+ Each node has at most one outgoing edge. A plain edge is unconditional:
188
+
189
+ ```typescript
190
+ { from: "a", to: "b" }
191
+ ```
192
+
193
+ A `switch` edge routes on a JSON path evaluated against the node's result:
194
+
195
+ ```typescript
196
+ { from: "review", switch: { on: "$.route", cases: { clean: "done", issues_found: "fix" } } }
197
+ ```
198
+
199
+ Path roots:
200
+
201
+ - `$.field` and `$output.field` read from the node's accepted output.
202
+ - `$result.field` reads from the result record. `$result.outcome` is the main
203
+ use, with values `ok`, `failed`, `timed_out`, or `cancelled`, which lets a
204
+ workflow route failures to a recovery node instead of failing the run.
205
+
206
+ A missing case for the resolved value fails the run with a routing error. A
207
+ node with no outgoing edge (or no matching failure route) ends the run:
208
+ `completed` on success, `failed`/`timed_out`/`cancelled` otherwise.
209
+
210
+ ## The step contract
211
+
212
+ Every `agent` prompt ends with a step contract block naming the workflow, the
213
+ step id, the attempt id, and the expected output shape:
214
+
215
+ ```
216
+ ---
217
+ Workflow step contract (workflow: autoimplement, step: review, attempt: 6f9d…)
218
+
219
+ Complete this step by calling the `workflow` tool exactly once with:
220
+ {"step": "review", "attempt": "6f9d…", "output": <your result>}
221
+ Expected output: { "route": "clean" | "issues_found", "reason": "short justification" }
222
+ The step is complete only after the workflow tool accepts the output.
223
+ If the tool reports a validation error, correct the output and call it again.
224
+ ```
225
+
226
+ The `workflow` tool takes `{ step, attempt, output }`. Submissions are
227
+ rejected (with a reason the model sees) when no step is pending, the step id
228
+ is wrong, the attempt id belongs to an earlier attempt of the same node (loops
229
+ revisit node ids, so each attempt gets a fresh id), or `validate` throws.
230
+ Acceptance resolves the step and the engine advances; the next agent prompt
231
+ arrives as a new user message in the same conversation.
232
+
233
+ ## Result presentation
234
+
235
+ Workflow nodes produce structured JSON for routing and persistence. When a
236
+ person should see a normal prose response after the run, add
237
+ `presentationPrompt` at the top level:
238
+
239
+ ```typescript
240
+ export default defineWorkflow({
241
+ name: "report",
242
+ presentationPrompt: ({ state, finalOutput }) =>
243
+ state.status === "waiting"
244
+ ? `Explain this recommendation and ask the user to decide: ${JSON.stringify(finalOutput)}`
245
+ : "Summarize the completed result and any remaining limitations.",
246
+ // ...startAt, nodes, and edges
247
+ });
248
+ ```
249
+
250
+ After the final run state has been persisted, the Pi extension sends the
251
+ presentation instructions and bounded final result to the model as a hidden
252
+ follow-up message. The next visible message is a normal assistant response.
253
+ Returning `undefined`, returning an empty string, or omitting
254
+ `presentationPrompt` produces no follow-up. Cancelled runs are never
255
+ presented. Async prompt builders have 30 seconds to finish and receive an
256
+ `AbortSignal` that fires on timeout, session shutdown, or when a new workflow
257
+ or normal user turn starts; stale presentations are discarded. Once a presentation message has
258
+ been queued, another workflow cannot start until that assistant response
259
+ settles, so results cannot interleave.
260
+
261
+ Presentation is outside the workflow graph: it cannot route to another node,
262
+ change the run status, or alter the run bundle. If prompt generation or message
263
+ delivery fails, the extension reports a warning and leaves the finished run
264
+ unchanged. Opting in adds one hidden custom message and one assistant response
265
+ to the normal Pi session; it adds no other persistent data and uses no Pi
266
+ internals.
267
+
268
+ ## Runtime behavior
269
+
270
+ Runs execute one node at a time. Every transition is persisted to the run
271
+ bundle before the engine moves on, which is what makes the live viewer
272
+ possible. Defaults worth knowing:
273
+
274
+ - Node timeout is 15 minutes unless the node sets `timeoutMs`. A timed-out
275
+ node has outcome `timed_out` and can be routed with `$result.outcome`.
276
+ - `maxSteps` (workflow-level, default 100) bounds loops built from cycles in
277
+ the graph.
278
+ - `/workflow pause` requests a pause: the current step finishes normally,
279
+ then the run holds at the step boundary (`paused: true` in the run state,
280
+ `run_paused` in the trace) until `/workflow resume` or `/workflow cancel`.
281
+ Pausing never interrupts a node mid-flight.
282
+ - Interrupting a turn (escape) auto-pauses the run: the pending agent step is
283
+ held without nudges and the engine pauses at the next boundary. Node
284
+ timeouts keep ticking while held, so a long-abandoned step still times out.
285
+ `/workflow resume` re-delivers the pending step prompt.
286
+ - `/workflow cancel` aborts the current node and marks the run `cancelled`.
287
+ When no run is live but the widget still shows a parked or finished run,
288
+ the same command clears the widget.
289
+ - One workflow runs per session at a time.
290
+ - Agent nudges: if the model ends its turn without submitting the pending
291
+ step, it gets a reminder, twice by default, then the step fails.
292
+
293
+ ## Using the engine outside pi
294
+
295
+ The engine is pi-agnostic. `WorkflowEngine` takes any `AgentStepExecutor`, so
296
+ tests (and other hosts) can script agent steps:
297
+
298
+ ```typescript
299
+ import { WorkflowEngine, type AgentStepExecutor } from "@osolmaz/pi-workflows";
300
+
301
+ const executor: AgentStepExecutor = {
302
+ async runAgentStep(request) {
303
+ const accepted = await request.accept({ answer: "42" });
304
+ if (!accepted.ok) throw new Error(accepted.error);
305
+ return { output: accepted.value };
306
+ },
307
+ };
308
+
309
+ const engine = new WorkflowEngine({ executor, outputRoot: "/tmp/runs" });
310
+ const { state } = await engine.run(workflow, { task: "..." });
311
+ ```
@@ -0,0 +1,92 @@
1
+ import { agent, compute, decision, decisionEdge, defineWorkflow } from "@osolmaz/pi-workflows";
2
+
3
+ type AutoimplementInput = {
4
+ task?: string;
5
+ };
6
+
7
+ const reviewChoices = ["clean", "issues_found"] as const;
8
+
9
+ /**
10
+ * Implement, verify, then loop a self-review until it comes back clean. The
11
+ * decision edge routes `issues_found` back to the fix step, and the engine's
12
+ * maxSteps guard bounds the loop.
13
+ */
14
+ export default defineWorkflow({
15
+ name: "autoimplement",
16
+ title: ({ input }) => {
17
+ const task = (input as AutoimplementInput).task;
18
+ return task ? `autoimplement: ${task.slice(0, 60)}` : undefined;
19
+ },
20
+ presentationPrompt:
21
+ "Summarize what was implemented, what verification passed, and any remaining limitation. Be concise and direct.",
22
+ maxSteps: 20,
23
+ startAt: "implement",
24
+ nodes: {
25
+ implement: agent({
26
+ timeoutMs: 60 * 60_000,
27
+ statusDetail: "implementing",
28
+ prompt: ({ input }) => {
29
+ const task =
30
+ (input as AutoimplementInput).task ?? "the plan discussed so far in this conversation";
31
+ return [
32
+ `Implement ${task} end-to-end.`,
33
+ "Aim for the most elegant, long-term production-ready solution without gold-plating.",
34
+ ].join("\n");
35
+ },
36
+ expectedOutput: `{ "summary": "what was implemented", "files": ["changed file", "changed file"] }`,
37
+ }),
38
+ verify: agent({
39
+ timeoutMs: 30 * 60_000,
40
+ statusDetail: "verifying",
41
+ prompt: () =>
42
+ [
43
+ "Verify the implementation.",
44
+ "Run the test suite plus any relevant builds, linters, or local smoke tests.",
45
+ "Do not run destructive commands.",
46
+ ].join("\n"),
47
+ expectedOutput: `{ "passed": true | false, "details": "what was run and what happened" }`,
48
+ }),
49
+ review: decision({
50
+ choices: reviewChoices,
51
+ question: ({ outputs }) =>
52
+ [
53
+ "Critically review your implementation as a strict reviewer.",
54
+ "Look for correctness bugs, missed requirements, and failing checks.",
55
+ "Pick `issues_found` if anything must be fixed, otherwise `clean`.",
56
+ "",
57
+ `Verification: ${JSON.stringify(outputs.verify)}`,
58
+ ].join("\n"),
59
+ }),
60
+ fix: agent({
61
+ timeoutMs: 30 * 60_000,
62
+ statusDetail: "fixing",
63
+ prompt: ({ outputs }) =>
64
+ [
65
+ "Fix the issues you found in review, then stop.",
66
+ "",
67
+ `Review: ${JSON.stringify(outputs.review)}`,
68
+ ].join("\n"),
69
+ expectedOutput: `{ "fixed": "what was changed" }`,
70
+ }),
71
+ finalize: compute({
72
+ run: ({ outputs }) => ({
73
+ implementation: outputs.implement,
74
+ verification: outputs.verify,
75
+ review: outputs.review,
76
+ }),
77
+ }),
78
+ },
79
+ edges: [
80
+ { from: "implement", to: "verify" },
81
+ { from: "verify", to: "review" },
82
+ decisionEdge({
83
+ from: "review",
84
+ choices: reviewChoices,
85
+ cases: {
86
+ clean: "finalize",
87
+ issues_found: "fix",
88
+ },
89
+ }),
90
+ { from: "fix", to: "verify" },
91
+ ],
92
+ });
@@ -0,0 +1,139 @@
1
+ import { agent, compute, decision, decisionEdge, defineWorkflow } from "@osolmaz/pi-workflows";
2
+
3
+ type AutoresearchInput = {
4
+ /** What to search for, e.g. "a feature separating group A from group B". */
5
+ goal?: string;
6
+ /** Loop directory for the three artifacts; created if missing. */
7
+ dir?: string;
8
+ };
9
+
10
+ const assessChoices = ["continue", "plateau", "dead_end"] as const;
11
+
12
+ /**
13
+ * An iterative feature-search loop in the style of karpathy/autoresearch.
14
+ * The discipline lives in three artifacts kept in one directory: a harness
15
+ * that never changes during the search, a single feature file that changes
16
+ * every experiment, and a journal that records every run whether it worked
17
+ * or not. Each loop iteration is one generation of candidates; the assess
18
+ * decision keeps looping until a kept result plateaus or a diverse
19
+ * generation all fails.
20
+ */
21
+ export default defineWorkflow({
22
+ name: "autoresearch",
23
+ title: ({ input }) => {
24
+ const goal = (input as AutoresearchInput).goal;
25
+ return goal ? `autoresearch: ${goal.slice(0, 60)}` : undefined;
26
+ },
27
+ presentationPrompt:
28
+ "Report the research conclusion in plain language: the winner and its numbers, or the strongest negative result, plus the journal path.",
29
+ maxSteps: 40,
30
+ startAt: "setup",
31
+ nodes: {
32
+ setup: agent({
33
+ timeoutMs: 20 * 60_000,
34
+ statusDetail: "setting up harness",
35
+ prompt: ({ input }) => {
36
+ const { goal, dir } = input as AutoresearchInput;
37
+ return [
38
+ `Set up an autoresearch loop for: ${goal ?? "the research goal discussed so far in this conversation"}.`,
39
+ `Create the loop directory (${dir ?? "pick a sensible project-local directory"}) with three artifacts:`,
40
+ "",
41
+ "1. A harness that is FROZEN after the first run. It loads the dataset",
42
+ " once, calls the candidate feature on every item, and prints the",
43
+ " metrics that decide keep-or-discard (for a two-group separation:",
44
+ " AUC, edge margin, and leave-one-out accuracy with a refit",
45
+ " threshold). Held-out items are printed but never used for selection.",
46
+ "2. A feature file with one function of a fixed signature. This is the",
47
+ " ONLY file that changes between experiments; state the input",
48
+ " contract in its docstring.",
49
+ "3. A program.md recording the goal, the input contract, what is in",
50
+ " and out of bounds, the keep criterion, and the rule that every run",
51
+ " is journaled. Start an empty journal file next to it.",
52
+ "",
53
+ "Run the harness once on a trivial baseline feature to prove the",
54
+ "plumbing works, then journal that baseline as experiment 0.",
55
+ ].join("\n");
56
+ },
57
+ expectedOutput: `{ "dir": "loop directory", "keepCriterion": "what counts as a keeper", "baseline": "baseline numbers" }`,
58
+ }),
59
+ experiment: agent({
60
+ timeoutMs: 30 * 60_000,
61
+ statusDetail: "running experiments",
62
+ prompt: ({ outputs }) =>
63
+ [
64
+ "Run the next generation of experiments in the loop directory.",
65
+ "",
66
+ "One experiment is one edit to the feature file followed by one",
67
+ "harness run. Batch cheap candidates: when testing many ideas in one",
68
+ "generation, evaluate them all in one sweep process. The harness",
69
+ "stays frozen; if a bug forces a change, rerun every kept result and",
70
+ "journal the change.",
71
+ "",
72
+ "Decide the next edit from what the numbers said, not the original",
73
+ "plan. Chase the failure case closest to the boundary. Vary the",
74
+ "current winner before trusting it: a result that survives only one",
75
+ "parameterization is a tuned cliff, a plateau across neighboring",
76
+ "parameters is a finding. Never select on held-out items.",
77
+ "",
78
+ "Journal every candidate — negatives with the same care as",
79
+ "positives — as: idea in one line, the numbers, keep or discard,",
80
+ "and what to try next.",
81
+ "",
82
+ `Setup: ${JSON.stringify(outputs.setup)}`,
83
+ ].join("\n"),
84
+ expectedOutput: `{ "generation": "what was tried", "best": "best numbers so far", "kept": true | false, "next": "most informative next experiment, if any" }`,
85
+ }),
86
+ assess: decision({
87
+ choices: assessChoices,
88
+ statusDetail: "assessing progress",
89
+ question: ({ outputs }) =>
90
+ [
91
+ "Assess the search after this generation.",
92
+ "",
93
+ "- `continue`: there is an informative next experiment to run.",
94
+ "- `plateau`: a kept result is stable across neighboring parameter",
95
+ " choices and further edits only trade margin sideways.",
96
+ "- `dead_end`: a generation of diverse candidates all failed, so the",
97
+ " signal is not where the program said it would be.",
98
+ "",
99
+ `Latest generation: ${JSON.stringify(outputs.experiment)}`,
100
+ ].join("\n"),
101
+ }),
102
+ conclude: agent({
103
+ timeoutMs: 15 * 60_000,
104
+ statusDetail: "writing conclusions",
105
+ prompt: ({ outputs }) =>
106
+ [
107
+ "Finish the journal with a conclusions section: the winning feature",
108
+ "and its numbers (or the strongest negative result), the plateau",
109
+ "evidence, and the negative results that stop future re-litigation.",
110
+ "Only after the journal is complete, promote the winner out of the",
111
+ "loop directory into the real analysis code, if there is one.",
112
+ "",
113
+ `Assessment: ${JSON.stringify(outputs.assess)}`,
114
+ `Latest generation: ${JSON.stringify(outputs.experiment)}`,
115
+ ].join("\n"),
116
+ expectedOutput: `{ "outcome": "plateau" | "dead_end", "winner": "winning feature and numbers, or null", "journal": "path to the finished journal" }`,
117
+ }),
118
+ finalize: compute({
119
+ run: ({ outputs }) => ({
120
+ setup: outputs.setup,
121
+ conclusions: outputs.conclude,
122
+ }),
123
+ }),
124
+ },
125
+ edges: [
126
+ { from: "setup", to: "experiment" },
127
+ { from: "experiment", to: "assess" },
128
+ decisionEdge({
129
+ from: "assess",
130
+ choices: assessChoices,
131
+ cases: {
132
+ continue: "experiment",
133
+ plateau: "conclude",
134
+ dead_end: "conclude",
135
+ },
136
+ }),
137
+ { from: "conclude", to: "finalize" },
138
+ ],
139
+ });
@@ -0,0 +1,63 @@
1
+ import { agent, checkpoint, decision, decisionEdge, defineWorkflow } from "@osolmaz/pi-workflows";
2
+
3
+ type BranchInput = {
4
+ task?: string;
5
+ };
6
+
7
+ const classifyChoices = ["continue", "checkpoint"] as const;
8
+
9
+ /**
10
+ * decision() + decisionEdge() constrained-choice classification, then a
11
+ * deterministic branch. Analogous to the acpx branch example.
12
+ */
13
+ export default defineWorkflow({
14
+ name: "branch",
15
+ presentationPrompt: ({ state }) =>
16
+ state.status === "waiting"
17
+ ? "Explain briefly why the task needs clarification, then ask the user one concrete clarification question."
18
+ : "Tell the user in one concise sentence how the workflow recommends proceeding.",
19
+ startAt: "classify",
20
+ nodes: {
21
+ classify: decision({
22
+ choices: classifyChoices,
23
+ question: ({ input }) => {
24
+ const task =
25
+ (input as BranchInput).task ??
26
+ "Investigate a flaky test and decide whether the request is clear enough to continue.";
27
+ return [
28
+ "Read the task below.",
29
+ "Pick `continue` if it is concrete and scoped.",
30
+ "Pick `checkpoint` if it is ambiguous or needs clarification.",
31
+ "",
32
+ `Task: ${task}`,
33
+ ].join("\n");
34
+ },
35
+ }),
36
+ continue_lane: agent({
37
+ prompt: ({ outputs }) =>
38
+ [
39
+ "We are on the continue path.",
40
+ `Decision: ${JSON.stringify(outputs.classify)}`,
41
+ "Summarize in one sentence how you would proceed.",
42
+ ].join("\n"),
43
+ expectedOutput: `{ "summary": "short explanation" }`,
44
+ }),
45
+ checkpoint_lane: checkpoint({
46
+ summary: "needs clarification",
47
+ run: ({ outputs }) => ({
48
+ route: "checkpoint",
49
+ summary: (outputs.classify as { reason?: string }).reason ?? "Needs clarification.",
50
+ }),
51
+ }),
52
+ },
53
+ edges: [
54
+ decisionEdge({
55
+ from: "classify",
56
+ choices: classifyChoices,
57
+ cases: {
58
+ continue: "continue_lane",
59
+ checkpoint: "checkpoint_lane",
60
+ },
61
+ }),
62
+ ],
63
+ });
@@ -0,0 +1,23 @@
1
+ import { agent, defineWorkflow } from "@osolmaz/pi-workflows";
2
+
3
+ type EchoInput = {
4
+ task?: string;
5
+ };
6
+
7
+ /** Smallest possible workflow: one agent step that submits a JSON reply. */
8
+ export default defineWorkflow({
9
+ name: "echo",
10
+ presentationPrompt:
11
+ "Give the user the concise reply from the workflow result, with no extra commentary.",
12
+ startAt: "reply",
13
+ nodes: {
14
+ reply: agent({
15
+ prompt: ({ input }) => {
16
+ const request = (input as EchoInput).task ?? "Summarize this repository in one sentence.";
17
+ return `Answer the following request concisely.\n\nRequest: ${request}`;
18
+ },
19
+ expectedOutput: `{ "reply": "your concise answer" }`,
20
+ }),
21
+ },
22
+ edges: [],
23
+ });