@fyeeme/pi-dynamic-workflows 2.0.1 → 2.0.2

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/README.md CHANGED
@@ -180,6 +180,9 @@ defineWorkflow({
180
180
  // results: { candidates, winner, judges: [{winner, reason}] }
181
181
 
182
182
  // Classify input, then run the matching route's sub-steps.
183
+ // On pi 0.99+ hosts with a classifier model, the category comes from
184
+ // ModelRuntime.classify() (no classification subprocess); without one, an
185
+ // agent answers the classification. The step result records which path ran.
183
186
  defineWorkflow({
184
187
  name: "route",
185
188
  steps: [
@@ -195,7 +198,7 @@ defineWorkflow({
195
198
  },
196
199
  ],
197
200
  });
198
- // results: { category, matched, route: StepResult[], routeStatus }
201
+ // results: { category, matched, route: StepResult[], routeStatus, path }
199
202
  ```
200
203
 
201
204
  Judge/classifier JSON is parsed leniently (LLMs return `"true"`/`"0"` as strings); route nesting is depth-capped to catch cycles.
@@ -314,7 +317,7 @@ const result = await runWorkflow({ workflow: wf, cwd: tempDir, now: 1000, dispat
314
317
  | `loop_until` | `prompt(ctx,i)`, `until(ctx,i)`, `maxIterations?` | array of per-iteration outputs |
315
318
  | `adversarial` | `produce`, `rubric[]`, `judges?`, `minPass?` | `{ candidate, passed, passCount, judges }` |
316
319
  | `tournament` | `candidates`, `judges`, `produce` | `{ candidates, winner, judges }` |
317
- | `classify_route` | `classifier`, `routes: Record<cat, Step[]>`, `fallback?` | `{ category, matched, route, routeStatus }` |
320
+ | `classify_route` | `classifier`, `routes: Record<cat, Step[]>`, `fallback?` | `{ category, matched, route, routeStatus, path }` |
318
321
  | `sub_workflow` | `workflow: WorkflowDefinition`, `input?`, `inheritBudget?` | `{ steps, status, workflowName, error }` |
319
322
  | `loop_until_dry` | `agent(item, i)`, `keyOf?`, `merge?`, `maxRounds?`, `dryThreshold?` | array of discovered items |
320
323
 
package/README.zh-CN.md CHANGED
@@ -185,7 +185,7 @@ defineWorkflow({
185
185
  },
186
186
  ],
187
187
  });
188
- // results: { category, matched, route: StepResult[], routeStatus }
188
+ // results: { category, matched, route: StepResult[], routeStatus, path }
189
189
  ```
190
190
 
191
191
  评判/分类的 JSON 采用宽松解析(LLM 常把 `"true"`/`"0"` 当字符串返回);路由嵌套有深度上限以防循环。
package/index.ts CHANGED
@@ -20,6 +20,7 @@ import { StringEnum } from "@earendil-works/pi-ai";
20
20
  import { Type } from "typebox";
21
21
  import piSubagents from "@fyeeme/pi-subagents";
22
22
  import { defineWorkflow, runWorkflow } from "./src/index.ts";
23
+ import { createClassifyHook } from "./src/classifier-hook.ts";
23
24
  import type { Budget, StepContext, StepDefinition, WorkflowDefinition } from "./src/types.ts";
24
25
  import { WorkflowError } from "./src/errors.ts";
25
26
  import { discoverWorkflowLibrary, loadLibraryWorkflow } from "./src/library.ts";
@@ -80,7 +81,7 @@ const StepSchema = Type.Union([
80
81
  Type.Object({
81
82
  id: Type.String(),
82
83
  type: Type.Literal("classify_route"),
83
- prompt: Type.String({ description: "Classifier prompt; agent should reply {category: \"...\"}" }),
84
+ prompt: Type.String({ description: "Classification prompt; routing runs on a classifier model when the host has one (pi 0.99), otherwise an agent replies with JSON {category: \"...\"}" }),
84
85
  routes: Type.Record(
85
86
  Type.String(),
86
87
  Type.Array(Type.Object({ id: Type.String(), prompt: Type.String(), model: Type.Optional(Type.String()) })),
@@ -413,6 +414,9 @@ export default function (pi: ExtensionAPI): void {
413
414
  cwd: params.cwd ?? ctx.cwd,
414
415
  now: params.now ?? Date.now(),
415
416
  signal,
417
+ // pi 0.99 classifier routing: classify_route prefers ModelRuntime.classify()
418
+ // when a classifier model is available; otherwise the agent path runs.
419
+ classify: createClassifyHook(ctx.modelRegistry),
416
420
  });
417
421
 
418
422
  const lines = [
package/package.json CHANGED
@@ -1,67 +1,68 @@
1
1
  {
2
- "name": "@fyeeme/pi-dynamic-workflows",
3
- "version": "2.0.1",
4
- "description": "Deterministic TypeScript workflow orchestration for pi. Fuses the pi-dynamic-workflows design (declarative graph, 10 step primitives, heuristic planner, outcome collectors) with Claude Code's workflow engine coordination mechanisms (deterministic sandbox, cache-key resume, per-agent abort map, dynamic budget, runaway caps).",
5
- "type": "module",
6
- "license": "MIT",
7
- "author": "fyeeme",
8
- "engines": {
9
- "node": ">=18"
10
- },
11
- "keywords": [
12
- "pi-package",
13
- "pi",
14
- "dynamic-workflow",
15
- "workflow",
16
- "orchestration",
17
- "fan-out",
18
- "pipeline",
19
- "multi-agent"
20
- ],
21
- "files": [
22
- "*.ts",
23
- "src/**/*.ts",
24
- "sessions/**/*.ts",
25
- "workflows/**/*.ts",
26
- "skills/**/*.md",
27
- "prompts/**/*.md",
28
- "README.md",
29
- "README.zh-CN.md",
30
- "LICENSE"
31
- ],
32
- "pi": {
33
- "extensions": [
34
- "./index.ts"
35
- ],
36
- "skills": [
37
- "skills/workflow-author"
38
- ],
39
- "prompts": [
40
- "prompts"
41
- ]
42
- },
43
- "scripts": {
44
- "test": "vitest --run",
45
- "typecheck": "tsc"
46
- },
47
- "dependencies": {
48
- "@fyeeme/pi-subagents": "2.1.1"
49
- },
50
- "peerDependencies": {
51
- "@earendil-works/pi-ai": ">=0.84.4",
52
- "@earendil-works/pi-coding-agent": ">=0.84.4",
53
- "@earendil-works/pi-tui": ">=0.84.4",
54
- "jiti": ">=2.0.0",
55
- "typebox": ">=1.0.0",
56
- "typescript": ">=5.0.0"
57
- },
58
- "devDependencies": {
59
- "@earendil-works/pi-ai": "0.84.4",
60
- "@earendil-works/pi-coding-agent": "0.84.4",
61
- "@earendil-works/pi-tui": "0.84.4",
62
- "@types/node": "22.19.19",
63
- "jiti": "2.7.0",
64
- "typebox": "1.1.38",
65
- "typescript": "5.9.3"
66
- }
2
+ "name": "@fyeeme/pi-dynamic-workflows",
3
+ "version": "2.0.2",
4
+ "description": "Deterministic TypeScript workflow orchestration for pi. Fuses the pi-dynamic-workflows design (declarative graph, 10 step primitives, heuristic planner, outcome collectors) with Claude Code's workflow engine coordination mechanisms (deterministic sandbox, cache-key resume, per-agent abort map, dynamic budget, runaway caps).",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "fyeeme",
8
+ "engines": {
9
+ "node": ">=18"
10
+ },
11
+ "keywords": [
12
+ "pi-package",
13
+ "pi",
14
+ "dynamic-workflow",
15
+ "workflow",
16
+ "orchestration",
17
+ "fan-out",
18
+ "pipeline",
19
+ "multi-agent"
20
+ ],
21
+ "files": [
22
+ "*.ts",
23
+ "src/**/*.ts",
24
+ "sessions/**/*.ts",
25
+ "workflows/**/*.ts",
26
+ "skills/**/*.md",
27
+ "prompts/**/*.md",
28
+ "README.md",
29
+ "README.zh-CN.md",
30
+ "LICENSE"
31
+ ],
32
+ "pi": {
33
+ "extensions": [
34
+ "./index.ts"
35
+ ],
36
+ "skills": [
37
+ "skills/workflow-author"
38
+ ],
39
+ "prompts": [
40
+ "prompts"
41
+ ]
42
+ },
43
+ "scripts": {
44
+ "test": "vitest --run",
45
+ "typecheck": "tsc"
46
+ },
47
+ "dependencies": {
48
+ "@fyeeme/pi-subagents": "2.1.1"
49
+ },
50
+ "peerDependencies": {
51
+ "@earendil-works/pi-ai": ">=0.99.0",
52
+ "@earendil-works/pi-coding-agent": ">=0.99.0",
53
+ "@earendil-works/pi-tui": ">=0.99.0",
54
+ "jiti": ">=2.0.0",
55
+ "typebox": ">=1.0.0",
56
+ "typescript": ">=5.0.0"
57
+ },
58
+ "devDependencies": {
59
+ "@earendil-works/pi-ai": "0.99.2",
60
+ "@earendil-works/pi-coding-agent": "0.99.2",
61
+ "@earendil-works/pi-agent-core": "0.99.2",
62
+ "@earendil-works/pi-tui": "0.99.2",
63
+ "@types/node": "22.19.19",
64
+ "jiti": "2.7.0",
65
+ "typebox": "1.1.38",
66
+ "typescript": "5.9.3"
67
+ }
67
68
  }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * classify_route classifier hook — pi 0.99 `ModelRuntime.classify()` path.
3
+ *
4
+ * Model discovery (design D3): a step-declared `model` is honored only when it
5
+ * resolves to a classifier-type model with working credentials; otherwise the
6
+ * first available classifier on the host. None → `undefined` (the runner falls
7
+ * back to the agent path). The choice question's criteria are the route names;
8
+ * the answer is returned verbatim so undeclared categories resolve through
9
+ * fallback sub-steps exactly like the agent path (Routing equivalence).
10
+ */
11
+ import type { ClassifierApi, ClassifierContext, ClassifierModel, ClassifierResult } from "@earendil-works/pi-ai";
12
+ import type { ClassifyHook } from "./types.ts";
13
+
14
+ /** The registry surface the hook needs — satisfied by the extension context's
15
+ * ModelRegistry (pi 0.99+); tests mock this narrow slice. */
16
+ export interface ClassifierRegistrySlice {
17
+ getAvailableOfType(type: "classifier"): Promise<readonly ClassifierModel<ClassifierApi>[]>;
18
+ classify(model: ClassifierModel<ClassifierApi>, context: ClassifierContext): Promise<ClassifierResult>;
19
+ }
20
+
21
+ export function createClassifyHook(registry: ClassifierRegistrySlice): ClassifyHook {
22
+ return async (prompt, routeNames, model) => {
23
+ const available = await registry.getAvailableOfType("classifier");
24
+ if (available.length === 0) return undefined;
25
+ const chosen = (model ? available.find((m) => m.id === model) : undefined) ?? available[0]!;
26
+
27
+ const result = await registry.classify(chosen, {
28
+ state: { prompt, routes: [...routeNames] },
29
+ questions: {
30
+ route: {
31
+ type: "choice",
32
+ instructions: prompt,
33
+ criteria: Object.fromEntries(routeNames.map((name) => [name, name])),
34
+ },
35
+ },
36
+ });
37
+ if (result.stopReason !== "stop") return undefined;
38
+ const answer = result.answers.route;
39
+ if (answer?.type !== "choice") return undefined;
40
+
41
+ const u = result.usage;
42
+ return {
43
+ category: answer.choice,
44
+ usage: u
45
+ ? {
46
+ input: u.input ?? 0,
47
+ output: u.output ?? 0,
48
+ cacheRead: u.cacheRead ?? 0,
49
+ cacheWrite: u.cacheWrite ?? 0,
50
+ totalTokens: u.totalTokens ?? (u.input ?? 0) + (u.output ?? 0),
51
+ cost: u.cost?.total ?? 0,
52
+ }
53
+ : undefined,
54
+ };
55
+ };
56
+ }
@@ -19,7 +19,7 @@ import { BudgetPool } from "../budget/index.ts";
19
19
  import { Journal, type RunManifest } from "../cache/index.ts";
20
20
  import type { AgentLifecycleListeners } from "../lifecycle.ts";
21
21
  import { generateRunId } from "../state/index.ts";
22
- import type { Budget, RunResult, WorkflowDefinition } from "../types.ts";
22
+ import type { Budget, ClassifyHook, RunResult, WorkflowDefinition } from "../types.ts";
23
23
  import {
24
24
  createSpawnRegistry,
25
25
  spawnAgent,
@@ -73,6 +73,11 @@ export interface RunWorkflowOptions {
73
73
  /** A6: policy gate invoked before the first dispatch. Return { allow: false,
74
74
  * reason } to deny the run with a policy-gate error. */
75
75
  readonly policyGate?: (workflow: WorkflowDefinition) => { readonly allow: boolean; readonly reason?: string } | Promise<{ readonly allow: boolean; readonly reason?: string }>;
76
+ /** pi 0.99 classifier hook for classify_route steps: when provided and a
77
+ * classifier model is available, route categories come from
78
+ * ModelRuntime.classify() instead of a spawned classification agent;
79
+ * unavailability and failures fall back to the agent path. */
80
+ readonly classify?: ClassifyHook;
76
81
  }
77
82
 
78
83
  export async function runWorkflow(opts: RunWorkflowOptions): Promise<RunResult> {
@@ -136,6 +141,7 @@ export async function runWorkflow(opts: RunWorkflowOptions): Promise<RunResult>
136
141
  maxPromptBytes: opts.maxPromptBytes ?? DEFAULT_MAX_PROMPT_BYTES,
137
142
  degradedStepIds: new Set(),
138
143
  allowChildRecursion: opts.allowChildRecursion ?? false,
144
+ classify: opts.classify,
139
145
  dispatched: 0,
140
146
  };
141
147
 
@@ -35,6 +35,7 @@ import type {
35
35
  AgentCallSpec,
36
36
  AgentOpts,
37
37
  AgentStep,
38
+ ClassifyHook,
38
39
  ClassifyRouteStep,
39
40
  CodeStep,
40
41
  LoopUntilDryStep,
@@ -81,6 +82,9 @@ export interface StepExecContext {
81
82
  budgetPolicy: "throw" | "null";
82
83
  /** A6: max resolved-prompt byte size; oversize throws a size-limit error. */
83
84
  maxPromptBytes: number;
85
+ /** pi 0.99 classifier hook for classify_route (undefined → agent path only).
86
+ * See ClassifyHook / spec workflow-classifier-routing. */
87
+ readonly classify?: ClassifyHook;
84
88
  /** A3: step ids that degraded to null under the "null" policy this run. */
85
89
  degradedStepIds: Set<string>;
86
90
  /** Recursion opt-in propagated to every spawned workflow sub-agent: when
@@ -555,28 +559,63 @@ async function execTournament(step: TournamentStep, ctx: StepContext, exec: Step
555
559
  async function execClassifyRoute(step: ClassifyRouteStep, ctx: StepContext, exec: StepExecContext): Promise<StepResult> {
556
560
  const start = Date.now();
557
561
  const classifyPrompt = await resolvePrompt(step.classifier, ctx);
558
- const classify = await dispatchAgentCall(`${step.id}#classify`, classifyPrompt, step.classifier, exec);
559
- if (!classify.ok) {
560
- return stepResult(
561
- step.id,
562
- "classify_route",
563
- classify.aborted ? "skipped" : "failed",
564
- undefined,
565
- withDuration(classify.stats, start),
566
- undefined,
567
- classify.aborted ? undefined : "dispatch-error",
568
- );
562
+
563
+ // pi 0.99 classifier path first (spec: workflow-classifier-routing): when a
564
+ // classifier hook is available and answers, the category comes from its
565
+ // ranked choice and no classification subprocess is spawned. Unavailable or
566
+ // failing hook → the original agent path, unchanged (fallback contract).
567
+ let classifyPath: "classifier" | "agent" = "agent";
568
+ let category = "";
569
+ let classifyStats: StepStats = zeroStats;
570
+ if (exec.classify) {
571
+ try {
572
+ const hookResult = await exec.classify(classifyPrompt, Object.keys(step.routes), step.classifier.model);
573
+ if (hookResult) {
574
+ classifyPath = "classifier";
575
+ category = parseCategoryStr(hookResult.category);
576
+ const u = hookResult.usage;
577
+ classifyStats = {
578
+ tokens: u ? u.totalTokens : 0,
579
+ cost: u ? u.cost : 0,
580
+ durationMs: 0,
581
+ agents: 0,
582
+ failures: 0,
583
+ usage: u
584
+ ? { input: u.input, output: u.output, cacheRead: u.cacheRead, cacheWrite: u.cacheWrite }
585
+ : undefined,
586
+ };
587
+ }
588
+ } catch {
589
+ // classifier call failed → agent fallback (spec: Fallback to the agent path)
590
+ }
569
591
  }
570
592
 
571
- // A3: a degraded classifier (budget exhausted under the "null" policy)
572
- // returns value null — the step degrades to a null result per the README
573
- // contract; do not fabricate a route run from an empty category.
574
- if (classify.value === null) {
575
- return stepResult(step.id, "classify_route", "done", null, withDuration(classify.stats, start));
593
+ if (classifyPath === "agent") {
594
+ const classify = await dispatchAgentCall(`${step.id}#classify`, classifyPrompt, step.classifier, exec);
595
+ if (!classify.ok) {
596
+ return stepResult(
597
+ step.id,
598
+ "classify_route",
599
+ classify.aborted ? "skipped" : "failed",
600
+ undefined,
601
+ withDuration(classify.stats, start),
602
+ undefined,
603
+ classify.aborted ? undefined : "dispatch-error",
604
+ );
605
+ }
606
+
607
+ // A3: a degraded classifier (budget exhausted under the "null" policy)
608
+ // returns value null — the step degrades to a null result per the README
609
+ // contract; do not fabricate a route run from an empty category.
610
+ if (classify.value === null) {
611
+ return stepResult(step.id, "classify_route", "done", null, withDuration(classify.stats, start));
612
+ }
613
+
614
+ const parsed = parseFirstJson(classify.value ?? "") as { category?: unknown } | undefined;
615
+ category = parseCategoryStr(parsed?.category);
616
+ classifyStats = classify.stats;
576
617
  }
577
618
 
578
- const parsed = parseFirstJson(classify.value ?? "") as { category?: unknown } | undefined;
579
- const category = parseCategoryStr(parsed?.category);
580
619
  const routeSteps = step.routes[category] ?? step.fallback ?? [];
581
620
 
582
621
  exec.depth++;
@@ -592,8 +631,8 @@ async function execClassifyRoute(step: ClassifyRouteStep, ctx: StepContext, exec
592
631
  step.id,
593
632
  "classify_route",
594
633
  status,
595
- { category, matched: category in step.routes, route: sub.steps, routeStatus: sub.status },
596
- withDuration(addStats(classify.stats, aggregateStats(sub.steps.map((s) => s.stats), 0)), start),
634
+ { category, matched: category in step.routes, route: sub.steps, routeStatus: sub.status, path: classifyPath },
635
+ withDuration(addStats(classifyStats, aggregateStats(sub.steps.map((s) => s.stats), 0)), start),
597
636
  undefined,
598
637
  sub.errorCategory,
599
638
  );
package/src/types.ts CHANGED
@@ -49,6 +49,36 @@ export interface Budget {
49
49
  // Step statistics + result
50
50
  // ---------------------------------------------------------------------------
51
51
 
52
+ // ---------------------------------------------------------------------------
53
+ // classify_route classifier hook (pi 0.99 ModelRuntime.classify)
54
+ // ---------------------------------------------------------------------------
55
+
56
+ /** Result of a successful classifier-path classification for classify_route.
57
+ * `category` is the classifier's chosen route name (may be undeclared — the
58
+ * runner resolves it through fallback sub-steps exactly like the agent path). */
59
+ export interface ClassifyHookResult {
60
+ readonly category: string;
61
+ /** Classifier token usage for truthful step stats; omit when unknown. */
62
+ readonly usage?: {
63
+ readonly input: number;
64
+ readonly output: number;
65
+ readonly cacheRead: number;
66
+ readonly cacheWrite: number;
67
+ readonly totalTokens: number;
68
+ readonly cost: number;
69
+ };
70
+ }
71
+
72
+ /** Optional classifier hook injected by the extension entry (spec:
73
+ * workflow-classifier-routing). Returns undefined when no classifier model is
74
+ * available; a throw also falls back to the agent path. Route sub-steps still
75
+ * run as agent dispatches either way. */
76
+ export type ClassifyHook = (
77
+ prompt: string,
78
+ routeNames: readonly string[],
79
+ model?: string,
80
+ ) => Promise<ClassifyHookResult | undefined>;
81
+
52
82
  export interface StepStats {
53
83
  readonly tokens: number;
54
84
  readonly cost: number;