pi-taskflow 0.0.4 → 0.0.6
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.
- package/DESIGN.md +15 -1
- package/README.md +273 -54
- package/examples/conditional-research.json +56 -0
- package/examples/guarded-refactor.json +50 -0
- package/extensions/agents.ts +8 -1
- package/extensions/index.ts +30 -15
- package/extensions/interpolate.ts +231 -0
- package/extensions/render.ts +14 -3
- package/extensions/runner.ts +61 -78
- package/extensions/runtime.ts +369 -46
- package/extensions/schema.ts +85 -2
- package/extensions/store.ts +29 -3
- package/extensions/usage.ts +42 -0
- package/package.json +2 -2
- package/skills/taskflow/SKILL.md +79 -0
- package/skills/taskflow/configuration.md +275 -0
package/extensions/schema.ts
CHANGED
|
@@ -12,10 +12,11 @@ import { Type, type Static } from "typebox";
|
|
|
12
12
|
// Phase types
|
|
13
13
|
// ---------------------------------------------------------------------------
|
|
14
14
|
|
|
15
|
-
export const PHASE_TYPES = ["agent", "parallel", "map", "gate", "reduce"] as const;
|
|
15
|
+
export const PHASE_TYPES = ["agent", "parallel", "map", "gate", "reduce", "approval", "flow"] as const;
|
|
16
16
|
export type PhaseType = (typeof PHASE_TYPES)[number];
|
|
17
17
|
|
|
18
18
|
export const OUTPUT_FORMATS = ["text", "json"] as const;
|
|
19
|
+
export const JOIN_MODES = ["all", "any"] as const;
|
|
19
20
|
|
|
20
21
|
const ParallelTaskSchema = Type.Object(
|
|
21
22
|
{
|
|
@@ -25,6 +26,29 @@ const ParallelTaskSchema = Type.Object(
|
|
|
25
26
|
{ additionalProperties: false },
|
|
26
27
|
);
|
|
27
28
|
|
|
29
|
+
/** Declarative retry policy for a phase's subagent call(s). */
|
|
30
|
+
const RetrySchema = Type.Object(
|
|
31
|
+
{
|
|
32
|
+
max: Type.Number({ description: "Max retry attempts after the first try (>= 0)" }),
|
|
33
|
+
backoffMs: Type.Optional(Type.Number({ description: "Base delay between attempts, in ms", default: 0 })),
|
|
34
|
+
factor: Type.Optional(
|
|
35
|
+
Type.Number({ description: "Backoff multiplier per attempt (1 = fixed, 2 = exponential)", default: 1 }),
|
|
36
|
+
),
|
|
37
|
+
},
|
|
38
|
+
{ additionalProperties: false },
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
/** Run-wide cost / token ceiling. Exceeding it halts the run (remaining phases skipped). */
|
|
42
|
+
const BudgetSchema = Type.Object(
|
|
43
|
+
{
|
|
44
|
+
maxUSD: Type.Optional(Type.Number({ description: "Halt the run once accumulated cost exceeds this many USD" })),
|
|
45
|
+
maxTokens: Type.Optional(
|
|
46
|
+
Type.Number({ description: "Halt the run once accumulated input+output tokens exceed this" }),
|
|
47
|
+
),
|
|
48
|
+
},
|
|
49
|
+
{ additionalProperties: false },
|
|
50
|
+
);
|
|
51
|
+
|
|
28
52
|
const PhaseSchema = Type.Object(
|
|
29
53
|
{
|
|
30
54
|
id: Type.String({ description: "Unique phase identifier (referenced via {steps.<id>.output})" }),
|
|
@@ -46,7 +70,28 @@ const PhaseSchema = Type.Object(
|
|
|
46
70
|
Type.Array(Type.String(), { description: "[reduce] Phase ids whose outputs are aggregated" }),
|
|
47
71
|
),
|
|
48
72
|
|
|
73
|
+
// sub-workflow (flow)
|
|
74
|
+
use: Type.Optional(Type.String({ description: "[flow] Name of a saved taskflow to run as this phase" })),
|
|
75
|
+
with: Type.Optional(
|
|
76
|
+
Type.Record(Type.String(), Type.Unknown(), {
|
|
77
|
+
description: "[flow] Args passed to the sub-flow (string values support interpolation)",
|
|
78
|
+
}),
|
|
79
|
+
),
|
|
80
|
+
|
|
49
81
|
dependsOn: Type.Optional(Type.Array(Type.String(), { description: "Phase ids this phase depends on" })),
|
|
82
|
+
join: Type.Optional(
|
|
83
|
+
StringEnum(JOIN_MODES, {
|
|
84
|
+
description: "Dependency join: 'all' (default) waits for every dep; 'any' runs as soon as one dep completes",
|
|
85
|
+
default: "all",
|
|
86
|
+
}),
|
|
87
|
+
),
|
|
88
|
+
when: Type.Optional(
|
|
89
|
+
Type.String({
|
|
90
|
+
description:
|
|
91
|
+
"Conditional guard: skip this phase unless the expression is truthy. Supports {refs} and == != < > <= >= && || ! ()",
|
|
92
|
+
}),
|
|
93
|
+
),
|
|
94
|
+
retry: Type.Optional(RetrySchema),
|
|
50
95
|
output: Type.Optional(StringEnum(OUTPUT_FORMATS, { description: "Parse output as text or json", default: "text" })),
|
|
51
96
|
model: Type.Optional(Type.String({ description: "Model override for this phase" })),
|
|
52
97
|
thinking: Type.Optional(Type.String({ description: "Thinking level override for this phase" })),
|
|
@@ -77,6 +122,7 @@ export const TaskflowSchema = Type.Object(
|
|
|
77
122
|
version: Type.Optional(Type.Number({ default: 1 })),
|
|
78
123
|
args: Type.Optional(Type.Record(Type.String(), ArgSpecSchema, { description: "Declared invocation arguments" })),
|
|
79
124
|
concurrency: Type.Optional(Type.Number({ description: "Default max concurrent subagents", default: 8 })),
|
|
125
|
+
budget: Type.Optional(BudgetSchema),
|
|
80
126
|
agentScope: Type.Optional(
|
|
81
127
|
StringEnum(["user", "project", "both"] as const, { description: "Agent discovery scope", default: "user" }),
|
|
82
128
|
),
|
|
@@ -89,6 +135,9 @@ export type ParallelTask = Static<typeof ParallelTaskSchema>;
|
|
|
89
135
|
export type Phase = Static<typeof PhaseSchema>;
|
|
90
136
|
export type Taskflow = Static<typeof TaskflowSchema>;
|
|
91
137
|
export type ArgSpec = Static<typeof ArgSpecSchema>;
|
|
138
|
+
export type RetryPolicy = Static<typeof RetrySchema>;
|
|
139
|
+
export type Budget = Static<typeof BudgetSchema>;
|
|
140
|
+
export type JoinMode = (typeof JOIN_MODES)[number];
|
|
92
141
|
|
|
93
142
|
// ---------------------------------------------------------------------------
|
|
94
143
|
// Shorthand (non-DAG) specs — subagent-style ergonomics
|
|
@@ -227,6 +276,25 @@ export function validateTaskflow(def: unknown): ValidationResult {
|
|
|
227
276
|
if (!p.from || p.from.length === 0) errors.push(`Phase '${p.id}' (reduce) requires 'from'`);
|
|
228
277
|
if (!p.task) errors.push(`Phase '${p.id}' (reduce) requires 'task'`);
|
|
229
278
|
}
|
|
279
|
+
if (type === "flow") {
|
|
280
|
+
if (!p.use) errors.push(`Phase '${p.id}' (flow) requires 'use' (a saved flow name)`);
|
|
281
|
+
}
|
|
282
|
+
if (p.retry) {
|
|
283
|
+
if (typeof p.retry.max !== "number" || p.retry.max < 0) {
|
|
284
|
+
errors.push(`Phase '${p.id}': retry.max must be a number >= 0`);
|
|
285
|
+
} else if (p.retry.max > 20) {
|
|
286
|
+
errors.push(`Phase '${p.id}': retry.max must be <= 20`);
|
|
287
|
+
}
|
|
288
|
+
if (p.retry.backoffMs !== undefined && (p.retry.backoffMs < 0 || p.retry.backoffMs > 60000)) {
|
|
289
|
+
errors.push(`Phase '${p.id}': retry.backoffMs must be between 0 and 60000`);
|
|
290
|
+
}
|
|
291
|
+
if (p.retry.factor !== undefined && (p.retry.factor < 1 || p.retry.factor > 10)) {
|
|
292
|
+
errors.push(`Phase '${p.id}': retry.factor must be between 1 and 10`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
if (p.join && !JOIN_MODES.includes(p.join as JoinMode)) {
|
|
296
|
+
errors.push(`Phase '${p.id}': unknown join mode '${p.join}'`);
|
|
297
|
+
}
|
|
230
298
|
}
|
|
231
299
|
|
|
232
300
|
// dependsOn / from references must exist
|
|
@@ -247,7 +315,7 @@ export function validateTaskflow(def: unknown): ValidationResult {
|
|
|
247
315
|
}
|
|
248
316
|
|
|
249
317
|
// Exactly handle final-phase resolution lazily (0 finals => last phase is final)
|
|
250
|
-
const finals = (flow.phases as Phase[]).filter((p) => p
|
|
318
|
+
const finals = (flow.phases as Phase[]).filter((p) => p?.final);
|
|
251
319
|
if (finals.length > 1) errors.push(`Only one phase may be marked 'final' (found ${finals.length})`);
|
|
252
320
|
|
|
253
321
|
return { ok: errors.length === 0, errors };
|
|
@@ -341,3 +409,18 @@ export function topoLayers(phases: Phase[]): Phase[][] {
|
|
|
341
409
|
export function finalPhase(phases: Phase[]): Phase {
|
|
342
410
|
return phases.find((p) => p.final) ?? phases[phases.length - 1];
|
|
343
411
|
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Apply a flow's declared arg defaults over the provided values, then pass
|
|
415
|
+
* through any extra provided keys. Shared by the tool entrypoint (index) and the
|
|
416
|
+
* sub-flow (`flow`) phase (runtime).
|
|
417
|
+
*/
|
|
418
|
+
export function resolveArgs(def: Taskflow, provided?: Record<string, unknown>): Record<string, unknown> {
|
|
419
|
+
const args: Record<string, unknown> = {};
|
|
420
|
+
for (const [key, spec] of Object.entries(def.args ?? {})) {
|
|
421
|
+
if (provided && key in provided) args[key] = provided[key];
|
|
422
|
+
else if (spec.default !== undefined) args[key] = spec.default;
|
|
423
|
+
}
|
|
424
|
+
if (provided) for (const [k, v] of Object.entries(provided)) if (!(k in args)) args[k] = v;
|
|
425
|
+
return args;
|
|
426
|
+
}
|
package/extensions/store.ts
CHANGED
|
@@ -11,7 +11,7 @@ import * as fs from "node:fs";
|
|
|
11
11
|
import * as path from "node:path";
|
|
12
12
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
13
13
|
import type { Taskflow } from "./schema.ts";
|
|
14
|
-
import type { UsageStats } from "./
|
|
14
|
+
import type { UsageStats } from "./usage.ts";
|
|
15
15
|
|
|
16
16
|
export interface SavedFlow {
|
|
17
17
|
name: string;
|
|
@@ -39,6 +39,12 @@ export interface PhaseState {
|
|
|
39
39
|
liveText?: string;
|
|
40
40
|
/** Gate verdict (gate phases only). */
|
|
41
41
|
gate?: { verdict: "pass" | "block"; reason?: string };
|
|
42
|
+
/** Total subagent attempts incl. retries (when > calls, a retry happened). */
|
|
43
|
+
attempts?: number;
|
|
44
|
+
/** True when a map/parallel fan-out was cut short by the budget cap. */
|
|
45
|
+
budgetTruncated?: boolean;
|
|
46
|
+
/** Human-in-the-loop outcome (approval phases only). */
|
|
47
|
+
approval?: { decision: "approve" | "reject" | "edit"; note?: string; auto?: boolean };
|
|
42
48
|
}
|
|
43
49
|
|
|
44
50
|
export interface RunState {
|
|
@@ -118,7 +124,7 @@ export function saveFlow(
|
|
|
118
124
|
fs.mkdirSync(dir, { recursive: true });
|
|
119
125
|
const safe = def.name.replace(/[^\w.-]+/g, "_");
|
|
120
126
|
const filePath = path.join(dir, `${safe}.json`);
|
|
121
|
-
|
|
127
|
+
writeFileAtomic(filePath, `${JSON.stringify(def, null, 2)}\n`);
|
|
122
128
|
return { filePath };
|
|
123
129
|
}
|
|
124
130
|
|
|
@@ -138,7 +144,7 @@ export function saveRun(state: RunState): void {
|
|
|
138
144
|
const dir = runsDir(state.cwd);
|
|
139
145
|
fs.mkdirSync(dir, { recursive: true });
|
|
140
146
|
state.updatedAt = Date.now();
|
|
141
|
-
|
|
147
|
+
writeFileAtomic(path.join(dir, `${state.runId}.json`), JSON.stringify(state, null, 2));
|
|
142
148
|
}
|
|
143
149
|
|
|
144
150
|
export function loadRun(cwd: string, runId: string): RunState | null {
|
|
@@ -174,3 +180,23 @@ export function listRuns(cwd: string, limit = 20): RunState[] {
|
|
|
174
180
|
export function hashInput(...parts: string[]): string {
|
|
175
181
|
return crypto.createHash("sha256").update(parts.join("\u0000")).digest("hex").slice(0, 16);
|
|
176
182
|
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Write a file atomically: write to a unique temp file in the same directory,
|
|
186
|
+
* then rename over the target (rename is atomic on the same filesystem). Prevents
|
|
187
|
+
* a crash or concurrent write from leaving a half-written, corrupt JSON file.
|
|
188
|
+
*/
|
|
189
|
+
function writeFileAtomic(filePath: string, data: string): void {
|
|
190
|
+
const tmp = `${filePath}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`;
|
|
191
|
+
try {
|
|
192
|
+
fs.writeFileSync(tmp, data, "utf-8");
|
|
193
|
+
fs.renameSync(tmp, filePath);
|
|
194
|
+
} catch (e) {
|
|
195
|
+
try {
|
|
196
|
+
if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
|
|
197
|
+
} catch {
|
|
198
|
+
/* ignore cleanup failure */
|
|
199
|
+
}
|
|
200
|
+
throw e;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Usage accounting — token/cost stats shared across the runner (producer),
|
|
3
|
+
* runtime (aggregation), store (persistence), and render (display).
|
|
4
|
+
*
|
|
5
|
+
* Kept in its own leaf module so persistence and TUI don't have to depend on
|
|
6
|
+
* the process-spawning layer (`runner.ts`) just for these types/helpers.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface UsageStats {
|
|
10
|
+
input: number;
|
|
11
|
+
output: number;
|
|
12
|
+
cacheRead: number;
|
|
13
|
+
cacheWrite: number;
|
|
14
|
+
cost: number;
|
|
15
|
+
contextTokens: number;
|
|
16
|
+
turns: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function emptyUsage(): UsageStats {
|
|
20
|
+
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Sum numeric usage fields across runs. `contextTokens` is intentionally excluded (it is a point-in-time gauge, not additive). */
|
|
24
|
+
export function aggregateUsage(usages: UsageStats[]): UsageStats {
|
|
25
|
+
const total = emptyUsage();
|
|
26
|
+
for (const u of usages) {
|
|
27
|
+
total.input += u.input;
|
|
28
|
+
total.output += u.output;
|
|
29
|
+
total.cacheRead += u.cacheRead;
|
|
30
|
+
total.cacheWrite += u.cacheWrite;
|
|
31
|
+
total.cost += u.cost;
|
|
32
|
+
total.turns += u.turns;
|
|
33
|
+
}
|
|
34
|
+
return total;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function formatTokens(count: number): string {
|
|
38
|
+
if (count < 1000) return count.toString();
|
|
39
|
+
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
|
|
40
|
+
if (count < 1000000) return `${Math.round(count / 1000)}k`;
|
|
41
|
+
return `${(count / 1000000).toFixed(1)}M`;
|
|
42
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-taskflow",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.6",
|
|
4
4
|
"description": "Lightweight workflow orchestration for the Pi coding agent — declarative multi-phase taskflows with dynamic fan-out, isolated subagent context, resumable runs, and saveable commands.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
],
|
|
37
37
|
"scripts": {
|
|
38
38
|
"typecheck": "tsc --noEmit",
|
|
39
|
-
"test": "node --experimental-strip-types --test test/interpolate.test.ts test/schema.test.ts test/runtime.test.ts test/runner.test.ts test/store.test.ts test/agents.test.ts test/render.test.ts test/desugar.test.ts",
|
|
39
|
+
"test": "node --experimental-strip-types --test test/interpolate.test.ts test/condition.test.ts test/schema.test.ts test/usage.test.ts test/runtime.test.ts test/features.test.ts test/runner.test.ts test/store.test.ts test/agents.test.ts test/render.test.ts test/desugar.test.ts",
|
|
40
40
|
"test:e2e": "PI_TASKFLOW_PI_BIN=pi node --experimental-strip-types test/e2e.mts"
|
|
41
41
|
},
|
|
42
42
|
"pi": {
|
package/skills/taskflow/SKILL.md
CHANGED
|
@@ -86,6 +86,71 @@ Call the `taskflow` tool. To run a brand-new flow you write inline, pass
|
|
|
86
86
|
| `map` | fan out over `over` (an array) — one subagent per item, `{item}` bound |
|
|
87
87
|
| `gate` | quality/review step that can **halt the flow** (see below) |
|
|
88
88
|
| `reduce` | aggregate `from[]` phases into one output |
|
|
89
|
+
| `approval` | **human-in-the-loop** pause: ask a person to approve / reject / edit before continuing |
|
|
90
|
+
| `flow` | run a **saved sub-flow** (by `use`) as a single phase — composition/reuse |
|
|
91
|
+
|
|
92
|
+
### Control-flow fields (any phase)
|
|
93
|
+
|
|
94
|
+
| field | meaning |
|
|
95
|
+
|-------|---------|
|
|
96
|
+
| `when` | conditional guard — skip the phase unless the expression is truthy. Supports `{refs}`, `== != < > <= >=`, `&& \|\| !`, parentheses, quoted strings/numbers. Parse errors fail **open** (phase runs). |
|
|
97
|
+
| `join` | dependency join: `"all"` (default — wait for every dep) or `"any"` (OR-join — run as soon as one dep completes). |
|
|
98
|
+
| `retry` | `{ "max": N, "backoffMs": ms, "factor": k }` — retry a failing subagent up to N times; delay is `backoffMs * factor^attempt` (`factor:1`=fixed, `2`=exponential). |
|
|
99
|
+
|
|
100
|
+
### Conditional routing (when + gate/branches)
|
|
101
|
+
|
|
102
|
+
Pair `when` with an upstream phase that emits a decision to build real if/else
|
|
103
|
+
routing. Use `join: "any"` on the merge phase so it runs whichever branch fired:
|
|
104
|
+
|
|
105
|
+
```jsonc
|
|
106
|
+
{ "id": "triage", "type": "agent", "agent": "analyst", "output": "json",
|
|
107
|
+
"task": "Classify the task. Output ONLY {\"route\":\"deep\"} or {\"route\":\"quick\"}." },
|
|
108
|
+
{ "id": "deep", "when": "{steps.triage.json.route} == deep", "dependsOn": ["triage"], "agent": "analyst", "task": "..." },
|
|
109
|
+
{ "id": "quick", "when": "{steps.triage.json.route} == quick", "dependsOn": ["triage"], "agent": "executor_fast", "task": "..." },
|
|
110
|
+
{ "id": "report", "type": "reduce", "from": ["deep","quick"], "join": "any",
|
|
111
|
+
"dependsOn": ["deep","quick"], "agent": "writer", "task": "...", "final": true }
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
> `when` should reference **upstream** (`dependsOn`) phases — a ref to a phase
|
|
115
|
+
> that hasn't completed resolves empty and the guard is treated as false.
|
|
116
|
+
|
|
117
|
+
### Approval phases (human-in-the-loop)
|
|
118
|
+
|
|
119
|
+
An `approval` phase pauses the run and asks the operator to **Approve / Reject /
|
|
120
|
+
Edit**. Distinct from `gate` (which is an *agent* reviewing): this is a *human*
|
|
121
|
+
deciding. The (interpolated) `task` is the prompt shown.
|
|
122
|
+
|
|
123
|
+
- **Approve** → continue; the phase output is `(approve)`.
|
|
124
|
+
- **Reject** → halt the flow (same mechanism as a blocking gate).
|
|
125
|
+
- **Edit** → the typed note becomes this phase's `output`, so you can inject
|
|
126
|
+
guidance mid-run: reference it downstream with `{steps.<id>.output}`.
|
|
127
|
+
- **Non-interactive** runs (headless/CI/print mode) **auto-approve** and record it.
|
|
128
|
+
|
|
129
|
+
```jsonc
|
|
130
|
+
{ "id": "checkpoint", "type": "approval", "dependsOn": ["plan"],
|
|
131
|
+
"task": "Review the plan above before the expensive fan-out. Approve, reject, or add guidance." }
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Sub-flows (composition)
|
|
135
|
+
|
|
136
|
+
A `flow` phase runs another **saved** taskflow by name and bubbles up its final
|
|
137
|
+
output. Pass args via `with` (string values interpolate). Recursion is detected
|
|
138
|
+
and rejected.
|
|
139
|
+
|
|
140
|
+
```jsonc
|
|
141
|
+
{ "id": "research", "type": "flow", "use": "deep-research",
|
|
142
|
+
"with": { "topic": "{item}" }, "dependsOn": ["plan"] }
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### Budget (cost / token caps)
|
|
146
|
+
|
|
147
|
+
Add a run-wide ceiling at the top level. When accumulated cost/tokens exceed it,
|
|
148
|
+
remaining phases are skipped (and an in-flight `map`/`parallel` stops spawning
|
|
149
|
+
new items); the run ends as `blocked`.
|
|
150
|
+
|
|
151
|
+
```jsonc
|
|
152
|
+
{ "name": "...", "budget": { "maxUSD": 1.50, "maxTokens": 2000000 }, "phases": [ ... ] }
|
|
153
|
+
```
|
|
89
154
|
|
|
90
155
|
### Gate phases (quality control)
|
|
91
156
|
|
|
@@ -123,6 +188,20 @@ Review the audit results below. If any endpoint is missing auth, end with
|
|
|
123
188
|
3. Reference upstream results explicitly with `{steps.ID...}` and set `dependsOn`.
|
|
124
189
|
4. Mark the result-bearing phase with `"final": true` (else the last phase wins).
|
|
125
190
|
|
|
191
|
+
## Configuration
|
|
192
|
+
|
|
193
|
+
For the full set of knobs — per-phase `model`/`thinking`/`tools`/`cwd`, the
|
|
194
|
+
two-level concurrency model, model/thinking/tools resolution precedence,
|
|
195
|
+
`agentScope` & agent discovery, `settings.json` overrides, environment
|
|
196
|
+
variables, and storage paths — read `configuration.md` (next to this file).
|
|
197
|
+
|
|
198
|
+
Quick reference:
|
|
199
|
+
|
|
200
|
+
- **Flow:** `name`, `description`, `concurrency` (default 8), `budget` (`maxUSD`/`maxTokens`), `agentScope` (user|project|both), `args`.
|
|
201
|
+
- **Phase:** `model`, `thinking`, `tools` (whitelist), `cwd`, `output:"json"`, `concurrency` (map/parallel fan-out), `when`, `join` (all|any), `retry`, `use`/`with` (flow), `final`.
|
|
202
|
+
- **Precedence (model/thinking/tools):** phase value → `settings.subagents.agentOverrides[agent]` → agent frontmatter → global/default.
|
|
203
|
+
- **Concurrency:** same-layer phases use `flow.concurrency`; a `map`/`parallel` phase uses `phase.concurrency ?? flow.concurrency ?? 8`.
|
|
204
|
+
|
|
126
205
|
## Actions
|
|
127
206
|
|
|
128
207
|
- `action: "run"` — run inline `define` or a saved `name` (with optional `args`).
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
# Taskflow Configuration Reference
|
|
2
|
+
|
|
3
|
+
Every knob you can set on a taskflow, where it lives, and how the values are
|
|
4
|
+
resolved. Read this when you need fine control over models, concurrency, agent
|
|
5
|
+
discovery, working directories, tool restrictions, or storage.
|
|
6
|
+
|
|
7
|
+
Configuration lives in **five layers**, from most local to most global:
|
|
8
|
+
|
|
9
|
+
| Layer | Where | Sets |
|
|
10
|
+
|-------|-------|------|
|
|
11
|
+
| Phase | a phase object in the DSL | per-step model/thinking/tools/cwd/output/concurrency |
|
|
12
|
+
| Flow | the top-level DSL object | name, args, default concurrency, agent scope |
|
|
13
|
+
| Agent | `~/.pi/agent/agents/*.md`, `.pi/agents/*.md` frontmatter | per-agent default model/thinking/tools + system prompt |
|
|
14
|
+
| Settings | `~/.pi/agent/settings.json` | `subagents.agentOverrides`, global thinking |
|
|
15
|
+
| Environment | shell env | `PI_TASKFLOW_PI_BIN` |
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## 1. Flow-level options
|
|
20
|
+
|
|
21
|
+
Top-level keys of the taskflow definition object.
|
|
22
|
+
|
|
23
|
+
```jsonc
|
|
24
|
+
{
|
|
25
|
+
"name": "audit-endpoints", // required — also becomes /tf:<name> when saved
|
|
26
|
+
"description": "Audit API auth", // shown in /tf list and the command palette
|
|
27
|
+
"concurrency": 8, // default max concurrent subagents (default: 8)
|
|
28
|
+
"agentScope": "user", // user | project | both (default: user)
|
|
29
|
+
"args": { /* see §3 */ },
|
|
30
|
+
"phases": [ /* see §2 */ ] // required, at least one phase
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
| Key | Type | Default | Notes |
|
|
35
|
+
|-----|------|---------|-------|
|
|
36
|
+
| `name` | string | — | **Required.** Saved as `/tf:<name>`. |
|
|
37
|
+
| `description` | string | — | Surfaced in `/tf list` and the slash-command. |
|
|
38
|
+
| `concurrency` | number | `8` | Default fan-out / same-layer parallelism cap. See §4. |
|
|
39
|
+
| `agentScope` | `user`\|`project`\|`both` | `user` | Which agent dirs to load. See §6. |
|
|
40
|
+
| `args` | record | `{}` | Declared invocation arguments. See §3. |
|
|
41
|
+
| `phases` | array | — | **Required.** The phase DAG. See §2. |
|
|
42
|
+
| `version` | number | `1` | ⚠️ Declared in schema but **not yet used** by the runtime. |
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## 2. Phase-level options
|
|
47
|
+
|
|
48
|
+
Keys of each object in `phases[]`. Some only apply to specific `type`s.
|
|
49
|
+
|
|
50
|
+
```jsonc
|
|
51
|
+
{
|
|
52
|
+
"id": "audit", // required, unique — referenced via {steps.audit.output}
|
|
53
|
+
"type": "map", // agent | parallel | map | gate | reduce (default: agent)
|
|
54
|
+
"agent": "analyst", // agent name to run this phase
|
|
55
|
+
"task": "Audit {item.route}…",
|
|
56
|
+
"dependsOn": ["discover"],// DAG edges
|
|
57
|
+
"over": "{steps.discover.json}", // [map] array to fan out over
|
|
58
|
+
"as": "item", // [map] loop var name (default: item)
|
|
59
|
+
"branches": [ /* … */ ], // [parallel] static task list
|
|
60
|
+
"from": ["audit"], // [reduce] phase ids to aggregate
|
|
61
|
+
"output": "json", // text | json (default: text)
|
|
62
|
+
"model": "claude-sonnet-4-5", // per-phase model override
|
|
63
|
+
"thinking": "high", // per-phase thinking override
|
|
64
|
+
"tools": ["read","bash"], // restrict tools for this phase's subagent
|
|
65
|
+
"cwd": "packages/api", // working directory for this phase's subagent
|
|
66
|
+
"concurrency": 4, // [map/parallel] fan-out cap for THIS phase
|
|
67
|
+
"final": true // mark this phase's output as the workflow result
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
| Key | Applies to | Default | Notes |
|
|
72
|
+
|-----|-----------|---------|-------|
|
|
73
|
+
| `id` | all | — | **Required, unique.** Used in `{steps.<id>…}`. |
|
|
74
|
+
| `type` | all | `agent` | One of the 5 phase types. |
|
|
75
|
+
| `agent` | all | first available | Agent name; resolved from the scoped pool. |
|
|
76
|
+
| `task` | agent, gate, map, reduce | — | Prompt; supports interpolation. Required for these types. |
|
|
77
|
+
| `over` | map | — | **Required for map.** Must resolve to an array. |
|
|
78
|
+
| `as` | map | `item` | Loop variable bound per item. |
|
|
79
|
+
| `branches` | parallel | — | **Required for parallel.** `[{task, agent?}]`. |
|
|
80
|
+
| `from` | reduce | — | **Required for reduce.** Phase ids whose outputs are aggregated. |
|
|
81
|
+
| `dependsOn` | all | `[]` | DAG edges. `from` also implies a dependency. |
|
|
82
|
+
| `output` | all | `text` | `json` parses output so `{steps.id.json}` / map `over` work. |
|
|
83
|
+
| `model` | all | agent/global | Per-phase model override. See §5. |
|
|
84
|
+
| `thinking` | all | agent/global | Per-phase thinking level. See §5. |
|
|
85
|
+
| `tools` | all | agent default | Whitelist of tools for the subagent. See §5. |
|
|
86
|
+
| `cwd` | all | flow cwd | Run this phase's subagent in a different directory. |
|
|
87
|
+
| `concurrency` | map, parallel | flow concurrency | Fan-out cap for this phase only. See §4. |
|
|
88
|
+
| `final` | all | last phase | Exactly one phase may be `final`; its output is returned. |
|
|
89
|
+
| `optional` | all | `false` | ⚠️ Declared in schema but **not yet enforced** — a failed phase still skips downstream. |
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## 3. Declaring & passing arguments
|
|
94
|
+
|
|
95
|
+
Declare arguments on the flow, then reference them with `{args.X}`.
|
|
96
|
+
|
|
97
|
+
```jsonc
|
|
98
|
+
"args": {
|
|
99
|
+
"dir": { "default": "src", "description": "Directory to scan" },
|
|
100
|
+
"depth": { "default": 2 },
|
|
101
|
+
"token": { "required": true, "description": "API token" }
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
| Field | Notes |
|
|
106
|
+
|-------|-------|
|
|
107
|
+
| `default` | Used when the caller omits the arg. |
|
|
108
|
+
| `description` | Documentation only. |
|
|
109
|
+
| `required` | ⚠️ Declared but **not enforced** at runtime — treat as documentation for now. |
|
|
110
|
+
|
|
111
|
+
**Resolution:** for each declared arg, the provided value wins, else its
|
|
112
|
+
`default`. Any extra provided keys are also passed through (so undeclared args
|
|
113
|
+
still reach `{args.X}`).
|
|
114
|
+
|
|
115
|
+
**Passing args:**
|
|
116
|
+
|
|
117
|
+
```
|
|
118
|
+
/tf run audit-endpoints {"dir":"packages/api"} # JSON
|
|
119
|
+
/tf run audit-endpoints dir=packages/api depth=3 # key=value pairs
|
|
120
|
+
/tf run audit-endpoints packages/api # single positional → first declared arg
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Via the tool: `{ "action": "run", "name": "audit-endpoints", "args": { "dir": "packages/api" } }`.
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## 4. Concurrency model
|
|
128
|
+
|
|
129
|
+
There are **two independent concurrency limits**:
|
|
130
|
+
|
|
131
|
+
1. **Same-layer parallelism** — phases with no dependency between them sit in the
|
|
132
|
+
same topological layer and run concurrently, bounded by **`flow.concurrency`**
|
|
133
|
+
(default `8`).
|
|
134
|
+
2. **Fan-out within a `map`/`parallel` phase** — bounded by
|
|
135
|
+
**`phase.concurrency ?? flow.concurrency ?? 8`**.
|
|
136
|
+
|
|
137
|
+
```jsonc
|
|
138
|
+
{
|
|
139
|
+
"concurrency": 6, // ≤6 sibling phases run at once
|
|
140
|
+
"phases": [
|
|
141
|
+
{ "id": "scan", "type": "map", "over": "{steps.list.json}",
|
|
142
|
+
"concurrency": 3, // …but this map only fans out 3 at a time
|
|
143
|
+
"task": "…", "dependsOn": ["list"] }
|
|
144
|
+
]
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Set a low `phase.concurrency` to protect rate-limited models or heavy bash work;
|
|
149
|
+
keep `flow.concurrency` higher to let independent phases overlap.
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
## 5. Model, thinking & tools resolution
|
|
154
|
+
|
|
155
|
+
For any phase, the effective value is resolved in this **precedence order**
|
|
156
|
+
(first defined wins):
|
|
157
|
+
|
|
158
|
+
| Setting | Precedence (high → low) |
|
|
159
|
+
|---------|-------------------------|
|
|
160
|
+
| **model** | `phase.model` → `settings.agentOverrides[agent].model` → agent frontmatter `model` → pi default |
|
|
161
|
+
| **thinking** | `phase.thinking` → `settings.agentOverrides[agent].thinking` → agent frontmatter `thinking` → `settings` global thinking → pi default |
|
|
162
|
+
| **tools** | `phase.tools` → `settings.agentOverrides[agent].tools` → agent frontmatter `tools` → all tools |
|
|
163
|
+
|
|
164
|
+
Notes:
|
|
165
|
+
- `tools` is a **whitelist** passed as `--tools a,b,c`. Omit it to allow all.
|
|
166
|
+
- Each phase runs as an isolated process:
|
|
167
|
+
`pi --mode json -p --no-session [--model …] [--thinking …] [--tools …] [--append-system-prompt <agent>] "Task: …"`.
|
|
168
|
+
- The agent's markdown body becomes the subagent's appended system prompt.
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
## 6. Agent discovery & scope
|
|
173
|
+
|
|
174
|
+
`flow.agentScope` controls which agent directories are loaded:
|
|
175
|
+
|
|
176
|
+
| Scope | Loads from |
|
|
177
|
+
|-------|-----------|
|
|
178
|
+
| `user` (default) | `~/.pi/agent/agents/*.md` |
|
|
179
|
+
| `project` | nearest `.pi/agents/*.md` found walking up from cwd |
|
|
180
|
+
| `both` | user **then** project (project overrides on name collision) |
|
|
181
|
+
|
|
182
|
+
- Agents are `.md` files with frontmatter `name` + `description` (required), plus
|
|
183
|
+
optional `model`, `thinking`, `tools`. The body is the system prompt.
|
|
184
|
+
- Reference agents in phases by their `name`. An unknown name fails that phase
|
|
185
|
+
with the list of available agents.
|
|
186
|
+
- If a phase omits `agent`, the **first discovered agent** is used.
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## 7. settings.json
|
|
191
|
+
|
|
192
|
+
Taskflow shares the subagent settings file at `~/.pi/agent/settings.json`:
|
|
193
|
+
|
|
194
|
+
```jsonc
|
|
195
|
+
{
|
|
196
|
+
"subagents": {
|
|
197
|
+
"globalThinking": "medium", // fallback thinking for all subagents
|
|
198
|
+
"agentOverrides": {
|
|
199
|
+
"analyst": { "model": "claude-sonnet-4-5", "thinking": "high" },
|
|
200
|
+
"scout": { "tools": ["read", "bash", "grep"] }
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
"defaultThinkingLevel": "low" // used if subagents.globalThinking is absent
|
|
204
|
+
}
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
- `subagents.agentOverrides` — per-agent overrides applied at discovery; they beat
|
|
208
|
+
agent frontmatter but lose to a phase-level value (see §5).
|
|
209
|
+
- `subagents.globalThinking` (or top-level `defaultThinkingLevel`) — global
|
|
210
|
+
thinking fallback.
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
## 8. Environment variables
|
|
215
|
+
|
|
216
|
+
| Variable | Effect |
|
|
217
|
+
|----------|--------|
|
|
218
|
+
| `PI_TASKFLOW_PI_BIN` | Override the `pi` binary used to spawn subagents. Used by tests and unusual launch setups (e.g. `PI_TASKFLOW_PI_BIN=pi`). Normally auto-detected. |
|
|
219
|
+
|
|
220
|
+
---
|
|
221
|
+
|
|
222
|
+
## 9. Storage & file locations
|
|
223
|
+
|
|
224
|
+
| What | Path | Commit? |
|
|
225
|
+
|------|------|---------|
|
|
226
|
+
| User-scoped flow | `~/.pi/agent/taskflows/<name>.json` | personal |
|
|
227
|
+
| Project-scoped flow | `<nearest .pi>/taskflows/<name>.json` | ✅ commit to share |
|
|
228
|
+
| Run state (resume) | `<project .pi>/taskflows/runs/<runId>.json` | ❌ gitignore |
|
|
229
|
+
|
|
230
|
+
- `action: "save"` takes `scope: "project"` (default) or `"user"`.
|
|
231
|
+
- Saved flows auto-register as `/tf:<name>` (immediately for the current session,
|
|
232
|
+
and on future `session_start`).
|
|
233
|
+
- Project flows override user flows on a name collision.
|
|
234
|
+
- Add `.pi/taskflows/runs/` to `.gitignore`.
|
|
235
|
+
|
|
236
|
+
---
|
|
237
|
+
|
|
238
|
+
## 10. Quick recipes
|
|
239
|
+
|
|
240
|
+
**Pin a strong model only for the review gate:**
|
|
241
|
+
```jsonc
|
|
242
|
+
{ "id": "review", "type": "gate", "agent": "reviewer",
|
|
243
|
+
"model": "claude-opus-4", "thinking": "high",
|
|
244
|
+
"task": "…\nVERDICT:", "dependsOn": ["audit"] }
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
**Sandbox a phase to read-only in a subdirectory:**
|
|
248
|
+
```jsonc
|
|
249
|
+
{ "id": "scan", "type": "agent", "agent": "scout",
|
|
250
|
+
"cwd": "packages/api", "tools": ["read", "grep", "ls"],
|
|
251
|
+
"task": "List route files. Output ONLY a JSON array.", "output": "json" }
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
**Throttle a rate-limited fan-out:**
|
|
255
|
+
```jsonc
|
|
256
|
+
{ "id": "summarize", "type": "map", "over": "{steps.scan.json}",
|
|
257
|
+
"concurrency": 2, "agent": "writer",
|
|
258
|
+
"task": "Summarize {item.file}.", "dependsOn": ["scan"] }
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
**Project-only agents:**
|
|
262
|
+
```jsonc
|
|
263
|
+
{ "name": "ci-audit", "agentScope": "project", "phases": [ /* … */ ] }
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
---
|
|
267
|
+
|
|
268
|
+
## Caveats (declared but not yet enforced)
|
|
269
|
+
|
|
270
|
+
These keys validate but the runtime does **not** act on them yet — don't rely on
|
|
271
|
+
them for behavior:
|
|
272
|
+
|
|
273
|
+
- `phase.optional` — a failed phase still marks downstream phases as skipped.
|
|
274
|
+
- `arg.required` — missing required args are not rejected.
|
|
275
|
+
- `flow.version` — informational only.
|