@fyeeme/pi-dynamic-workflows 0.1.1

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.
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Outcome collectors — extract a structured value from an agent's text output.
3
+ *
4
+ * The new runner's agent results are plain strings (finalText); a collector
5
+ * turns that text into a typed value (a URL list, a file-path list, a JSON
6
+ * object). This is the lean successor to the old prototype's Artifact/handle
7
+ * outcome system: no handle abstraction, no RunState coupling — collectors are
8
+ * pure functions of text, applied by whoever consumes a StepResult.
9
+ *
10
+ * Not wired into step types (no speculative executor change): call
11
+ * `collect(spec, text)` on a step's results when you want a structured outcome.
12
+ */
13
+
14
+ /** Declarative description of what to extract from an agent's text. */
15
+ export type OutputSpec =
16
+ | { readonly kind: "url"; readonly pattern?: RegExp }
17
+ | { readonly kind: "file_path" }
18
+ | { readonly kind: "json" };
19
+
20
+ /** A pure extractor: text → value (or undefined if nothing matched). */
21
+ export type Collector<T = unknown> = (text: string) => T | undefined;
22
+
23
+ const DEFAULT_URL_PATTERN = /https?:\/\/[^\s)"'<>]+/g;
24
+ // Heuristic: any whitespace-delimited token containing a slash (a path); URLs filtered after.
25
+ const DEFAULT_PATH_PATTERN = /[^\s"'<>]*\/[^\s"'<>]+/g;
26
+
27
+ /** Collect every URL in `text` (default http/https pattern). */
28
+ export const urlCollector: Collector<string[]> = (text) => {
29
+ const out = [...text.matchAll(DEFAULT_URL_PATTERN)].map((m) => m[0]);
30
+ return out.length > 0 ? out : undefined;
31
+ };
32
+
33
+ /** Collect heuristic file paths from `text`. */
34
+ export const filePathCollector: Collector<string[]> = (text) => {
35
+ const out = [...text.matchAll(DEFAULT_PATH_PATTERN)].map((m) => m[0]).filter((p) => !/^https?:\/\//.test(p));
36
+ return out.length > 0 ? out : undefined;
37
+ };
38
+
39
+ /** Extract the first balanced JSON object or array from `text` (string/escape aware). Shared with the runner's composite judges. */
40
+ export function parseFirstJson(text: string): unknown {
41
+ return extractJson(text);
42
+ }
43
+
44
+ /** Collect the first JSON value via the shared extractor. */
45
+ export const jsonCollector: Collector<unknown> = (text) => extractJson(text);
46
+
47
+ /**
48
+ * Apply a spec to `text`, returning the extracted value (or undefined).
49
+ * Generic so callers narrow: `collect<string[]>>({ kind: "url" }, text)`.
50
+ */
51
+ export function collect<T = unknown>(spec: OutputSpec, text: string): T | undefined {
52
+ switch (spec.kind) {
53
+ case "url":
54
+ return urlList(text, spec.pattern) as T | undefined;
55
+ case "file_path":
56
+ return filePathCollector(text) as T | undefined;
57
+ case "json":
58
+ return jsonCollector(text) as T | undefined;
59
+ }
60
+ }
61
+
62
+ function urlList(text: string, pattern?: RegExp): string[] | undefined {
63
+ const re = pattern ?? DEFAULT_URL_PATTERN;
64
+ const out = [...text.matchAll(re)].map((m) => m[0]);
65
+ return out.length > 0 ? out : undefined;
66
+ }
67
+
68
+ function extractJson(text: string): unknown {
69
+ // Scan for the earliest balanced {...} or [...]. If the first candidate
70
+ // fails JSON.parse (e.g. a "[8/10]" rating prefix before the real object),
71
+ // advance past it and try the next opener instead of giving up — LLM judges
72
+ // commonly prefix their JSON with prose or a rating.
73
+ let searchFrom = 0;
74
+ while (searchFrom < text.length) {
75
+ const objStart = text.indexOf("{", searchFrom);
76
+ const arrStart = text.indexOf("[", searchFrom);
77
+ let start: number;
78
+ let openCh: string;
79
+ let closeCh: string;
80
+ if (objStart === -1 && arrStart === -1) return undefined;
81
+ else if (objStart === -1) { start = arrStart; openCh = "["; closeCh = "]"; }
82
+ else if (arrStart === -1) { start = objStart; openCh = "{"; closeCh = "}"; }
83
+ else if (objStart < arrStart) { start = objStart; openCh = "{"; closeCh = "}"; }
84
+ else { start = arrStart; openCh = "["; closeCh = "]"; }
85
+
86
+ let depth = 0;
87
+ let inStr = false;
88
+ let escape = false;
89
+ let end = -1;
90
+ for (let i = start; i < text.length; i++) {
91
+ const ch = text[i];
92
+ if (inStr) {
93
+ if (escape) escape = false;
94
+ else if (ch === "\\") escape = true;
95
+ else if (ch === '"') inStr = false;
96
+ continue;
97
+ }
98
+ if (ch === '"') inStr = true;
99
+ else if (ch === openCh) depth++;
100
+ else if (ch === closeCh) {
101
+ depth--;
102
+ if (depth === 0) { end = i; break; }
103
+ }
104
+ }
105
+ if (end === -1) return undefined; // unbalanced — no complete value from here
106
+ try {
107
+ return JSON.parse(text.slice(start, end + 1));
108
+ } catch {
109
+ searchFrom = end + 1; // first candidate didn't parse; try the next opener
110
+ }
111
+ }
112
+ return undefined;
113
+ }
package/src/planner.ts ADDED
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Heuristic planner — a keyword sketch that turns a natural-language goal into
3
+ * a WorkflowDefinition by picking a step type from cues in the text.
4
+ *
5
+ * @deprecated This is an EXPERIMENTAL heuristic scaffold, not a real planner.
6
+ * Freeform NL → full workflow (with real fan-out items, route branches,
7
+ * rubrics) needs an LLM and is non-deterministic, which this deterministic
8
+ * engine deliberately avoids. The keyword match only decides the step SHAPE;
9
+ * structural fields the keywords cannot infer (route tables, fan-out item
10
+ * lists) are left as defaults the caller refines. Think of the output as a
11
+ * scaffold to edit, not a finished plan. May be removed or replaced in a
12
+ * future version once a proper LLM-based planner is available.
13
+ */
14
+ import type { StepDefinition, WorkflowDefinition } from "./types.ts";
15
+
16
+ export interface HeuristicPlanOptions {
17
+ /** Candidates for the tournament heuristic. Default 3. */
18
+ readonly tournamentCandidates?: number;
19
+ /** Judges for the tournament/adversarial heuristics. Default 3. */
20
+ readonly judges?: number;
21
+ }
22
+
23
+ /**
24
+ * Map a goal string to a single-step workflow by keyword:
25
+ * - compare/versus/best-of → tournament (N candidates, M judges)
26
+ * - review/judge/critique → adversarial (produce + judges, default rubric)
27
+ * - classify/categorize/route → classify_route (empty routes — caller fills)
28
+ * - otherwise → single agent
29
+ *
30
+ * @deprecated See module-level deprecation notice. This is an experimental
31
+ * heuristic that produces a scaffold to edit, not a finished workflow.
32
+ */
33
+ export function heuristicallyPlan(goal: string, opts: HeuristicPlanOptions = {}): WorkflowDefinition {
34
+ const g = goal.toLowerCase();
35
+ const judges = opts.judges ?? 3;
36
+
37
+ let primary: StepDefinition;
38
+ if (/\b(compare|versus|vs\.?|best of|competing)\b/.test(g)) {
39
+ primary = {
40
+ id: "tournament",
41
+ type: "tournament",
42
+ candidates: opts.tournamentCandidates ?? 3,
43
+ judges,
44
+ produce: { prompt: goal },
45
+ };
46
+ } else if (/\b(review|judge|critique|adversarial|evaluate)\b/.test(g)) {
47
+ primary = {
48
+ id: "adversarial",
49
+ type: "adversarial",
50
+ produce: { prompt: goal },
51
+ rubric: ["correctness", "clarity", "completeness"],
52
+ judges,
53
+ };
54
+ } else if (/\b(classify|categor|route|dispatch)\b/.test(g)) {
55
+ primary = {
56
+ id: "classify",
57
+ type: "classify_route",
58
+ classifier: { prompt: goal },
59
+ routes: {}, // keywords can't infer the route table — caller fills it in
60
+ };
61
+ } else {
62
+ primary = { id: "agent", type: "agent", prompt: goal };
63
+ }
64
+
65
+ return { name: "heuristic", steps: [primary] };
66
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Runner — the engine that wires the five CC-fusion modules into an executable
3
+ * workflow (Task 7).
4
+ *
5
+ * A workflow is a flat list of typed steps (types.ts discriminated union).
6
+ * runWorkflow prepares the run (deterministic runId, journal load for resume,
7
+ * budget pool, spawn registry) and delegates the step walk to runStepSequence.
8
+ * Per agent call the sequence consults the cache/journal (resume without
9
+ * re-dispatch), enforces the budget/caps, and routes abort/skip through the
10
+ * per-call registry. classify_route reuses runStepSequence for its sub-steps.
11
+ *
12
+ * `dispatch` is injectable (default = real spawnAgent) so the run is fully
13
+ * exercisable in tests with a fake dispatch — no `pi` binary, no provider API.
14
+ */
15
+ import * as fs from "node:fs";
16
+ import * as path from "node:path";
17
+ import { BudgetPool } from "../budget/index.ts";
18
+ import { Journal, type RunManifest } from "../cache/index.ts";
19
+ import type { AgentLifecycleListeners } from "../lifecycle.ts";
20
+ import { generateRunId } from "../state/index.ts";
21
+ import type { Budget, RunResult, WorkflowDefinition } from "../types.ts";
22
+ import {
23
+ createSpawnRegistry,
24
+ spawnAgent,
25
+ type AgentSpawnRegistry,
26
+ } from "../agent/dispatch.ts";
27
+ import { runStepSequence, aggregateStats, DEFAULT_MAX_PROMPT_BYTES, type AgentDispatch, type StepExecContext } from "./stage-executor.ts";
28
+
29
+ export { type AgentDispatch } from "./stage-executor.ts";
30
+
31
+ /** Reject workflow names containing path traversal segments — journalDir is
32
+ * derived from the name and must stay within the workflow directory tree. */
33
+ function sanitizeWorkflowName(name: string): string {
34
+ // The name becomes a path segment under <cwd>/.pi/workflows/<name>, so it must
35
+ // be a single segment (no separators) and not "." / ".." — otherwise it escapes
36
+ // the per-workflow dir (e.g. name ".." writes the journal into <cwd>/.pi).
37
+ if (name === "." || name === ".." || /[\\/]/.test(name) || /[<>:"|?*\x00-\x1f]/.test(name)) {
38
+ throw new Error(`workflow name contains invalid characters: ${JSON.stringify(name)}`);
39
+ }
40
+ return name;
41
+ }
42
+
43
+ export interface RunWorkflowOptions {
44
+ readonly workflow: WorkflowDefinition;
45
+ /** Initial input exposed as ctx.input to the first step. */
46
+ readonly input?: unknown;
47
+ /** Base directory for the journal (journalDir defaults under here). */
48
+ readonly cwd: string;
49
+ /** Deterministic run inception time (ms) — passed to generateRunId + BudgetPool. Required. */
50
+ readonly now: number;
51
+ /** Sequence disambiguator for same-timestamp runs. Default 0. */
52
+ readonly sequence?: number;
53
+ /** Overrides workflow.budget if set. */
54
+ readonly budget?: Budget;
55
+ /** Run-wide abort signal; aborts every in-flight agent. */
56
+ readonly signal?: AbortSignal;
57
+ readonly listeners?: AgentLifecycleListeners;
58
+ /** Injectable agent dispatch (default = real spawnAgent). */
59
+ readonly dispatch?: AgentDispatch;
60
+ /** Reuse an existing registry (e.g. to drive skip/retry from outside). */
61
+ readonly registry?: AgentSpawnRegistry;
62
+ /** Recursion opt-in for workflow sub-agents: when true, spawned agents may
63
+ * register the subagent/fan-out tools (bounded by PI_SUBAGENT_MAX_SPAWN_DEPTH
64
+ * if set). Default false — children run WITHOUT those tools, so nested
65
+ * fan-out requires explicit opt-in. */
66
+ readonly allowChildRecursion?: boolean;
67
+ /** Journal directory. Default <cwd>/.pi/workflows/<workflow.name> (per-workflow → cross-run cache). */
68
+ readonly journalDir?: string;
69
+ /** A6: max resolved-prompt byte size; oversize throws a size-limit error.
70
+ * Default DEFAULT_MAX_PROMPT_BYTES (256 KB). */
71
+ readonly maxPromptBytes?: number;
72
+ /** A6: policy gate invoked before the first dispatch. Return { allow: false,
73
+ * reason } to deny the run with a policy-gate error. */
74
+ readonly policyGate?: (workflow: WorkflowDefinition) => { readonly allow: boolean; readonly reason?: string } | Promise<{ readonly allow: boolean; readonly reason?: string }>;
75
+ }
76
+
77
+ export async function runWorkflow(opts: RunWorkflowOptions): Promise<RunResult> {
78
+ const { workflow, cwd, now } = opts;
79
+ const registry = opts.registry ?? createSpawnRegistry();
80
+ const dispatch = opts.dispatch ?? spawnAgent;
81
+ const budget = opts.budget ?? workflow.budget ?? {};
82
+ const journalDir =
83
+ opts.journalDir ??
84
+ path.join(cwd, ".pi", "workflows", sanitizeWorkflowName(workflow.name));
85
+
86
+ await fs.promises.mkdir(journalDir, { recursive: true });
87
+ const journal = new Journal({ dir: journalDir });
88
+ await journal.load();
89
+
90
+ // Staged resume: load last run's manifest (best-effort; crash-resilient).
91
+ // Real cache-hit accounting is OBSERVED during the run via the counting
92
+ // listeners below — not predicted from the manifest, which would replay the
93
+ // prior journal against itself and always read 100% (review M4).
94
+ const prevManifest = await journal.loadManifest();
95
+ let cacheHits = 0;
96
+ let dispatchStarts = 0;
97
+ const baseListeners = opts.listeners;
98
+ const listeners: AgentLifecycleListeners = {
99
+ onAgentStart: (id) => {
100
+ dispatchStarts++;
101
+ baseListeners?.onAgentStart?.(id);
102
+ },
103
+ onAgentEnd: baseListeners?.onAgentEnd,
104
+ onAgentSkip: baseListeners?.onAgentSkip,
105
+ onAgentRetry: baseListeners?.onAgentRetry,
106
+ onAgentCacheHit: (id) => {
107
+ cacheHits++;
108
+ baseListeners?.onAgentCacheHit?.(id);
109
+ },
110
+ onLog: baseListeners?.onLog,
111
+ onUpdate: baseListeners?.onUpdate,
112
+ };
113
+
114
+ // maxDurationMs is wall-clock: the pool's originMs must be the same clock
115
+ // the guard points use (Date.now()), NOT the caller-supplied deterministic
116
+ // `now` — mixing epochs (e.g. runWorkflow({ now: 1 }) + maxDurationMs) would
117
+ // make the duration budget read as already-exhausted at the first guard.
118
+ // `now` still drives runId / journal timestamps / exec.now (deterministic).
119
+ const pool = new BudgetPool(budget, Date.now());
120
+
121
+ const runId = generateRunId({ timestamp: now, sequence: opts.sequence ?? 0 });
122
+
123
+ const exec: StepExecContext = {
124
+ workflowName: workflow.name,
125
+ dispatch,
126
+ registry,
127
+ journal,
128
+ pool,
129
+ signal: opts.signal,
130
+ listeners,
131
+ now,
132
+ spawned: 0,
133
+ depth: 0,
134
+ budgetPolicy: "throw",
135
+ maxPromptBytes: opts.maxPromptBytes ?? DEFAULT_MAX_PROMPT_BYTES,
136
+ degradedStepIds: new Set(),
137
+ allowChildRecursion: opts.allowChildRecursion ?? false,
138
+ dispatched: 0,
139
+ };
140
+
141
+ // A6: policy gate runs before any agent is dispatched. A denial aborts the
142
+ // run with a terminal policy-gate error (not retryable).
143
+ if (opts.policyGate) {
144
+ const decision = await opts.policyGate(workflow);
145
+ if (!decision.allow) {
146
+ return {
147
+ runId,
148
+ status: "failed",
149
+ steps: [],
150
+ stats: { tokens: 0, cost: 0, durationMs: 0, agents: 0, failures: 0 },
151
+ journalFile: journal.file,
152
+ error: decision.reason ?? "denied by policy gate",
153
+ errorCategory: "policy-gate",
154
+ };
155
+ }
156
+ }
157
+
158
+ const outcome = await runStepSequence(workflow.steps, opts.input, exec);
159
+
160
+ const writeErr = journal.writeError;
161
+ const journalWarning: string | undefined = writeErr
162
+ ? `journal write error (entries may be missing from disk; resume could re-dispatch): ${writeErr instanceof Error ? writeErr.message : String(writeErr)}`
163
+ : undefined;
164
+
165
+ // Write staged-resume manifest when the run completes (even partially).
166
+ // Only `runId` is consumed downstream (resume.previousRunId); the key list
167
+ // is dead weight now that cache-hit accounting is observed live.
168
+ if (!writeErr) {
169
+ const manifest: RunManifest = { runId, at: now };
170
+ await journal.writeManifest(manifest).catch(() => { /* best-effort */ });
171
+ }
172
+
173
+ return {
174
+ runId,
175
+ status: outcome.status,
176
+ steps: outcome.steps,
177
+ stats: aggregateStats(outcome.steps.map((s) => s.stats), 0),
178
+ journalFile: journal.file,
179
+ error: outcome.error ?? journalWarning,
180
+ errorCategory: outcome.errorCategory,
181
+ degradedSteps: exec.degradedStepIds.size > 0 ? [...exec.degradedStepIds] : undefined,
182
+ resume: {
183
+ cachedHits: cacheHits,
184
+ cachedTotal: cacheHits + exec.dispatched,
185
+ ...(prevManifest ? { previousRunId: prevManifest.runId } : {}),
186
+ },
187
+ };
188
+ }