@quintinshaw/pi-dynamic-workflows 1.1.0 → 1.2.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.
package/README.md CHANGED
@@ -86,8 +86,11 @@ return { inventory, summary }
86
86
  | `label` | string | Human-readable label for progress display |
87
87
  | `phase` | string | Override the current phase for this agent |
88
88
  | `schema` | object | JSON Schema for structured output |
89
+ | `model` | string | Run this agent on a specific model — `provider/modelId` or a bare `modelId` |
89
90
  | `timeoutMs` | number | Override the default 5-minute agent timeout |
90
91
 
92
+ Models can also be set per phase via `meta.phases[].model`. Precedence is `opts.model` > phase model > session default; an unknown model logs a warning and falls back to the default.
93
+
91
94
  ### Structured output
92
95
 
93
96
  Pass a JSON Schema via `opts.schema` and the subagent returns a validated object:
@@ -117,6 +120,7 @@ Scripts run inside a Node `vm` sandbox. Intentionally unavailable: `Date.now()`,
117
120
  - **Core runtime** — `agent` / `parallel` / `pipeline` / `phase` / `log` / `budget` in a sandboxed script
118
121
  - **Structured output** — JSON-Schema-validated subagent results
119
122
  - **Real token & cost accounting** — read from each subagent's SDK session (input / output / total / cost), with a character estimate only as fallback when a provider reports no usage; `budget` gates on the real total
123
+ - **Real per-agent / per-phase model routing** — `opts.model` and `meta.phases[].model` actually select the model (resolved against your authed model registry), with graceful fallback
120
124
  - **Safety limits** — 1000-agent cap (`maxAgents`), per-agent timeout (`agentTimeoutMs`), recoverable-vs-fatal error classification
121
125
  - **Live progress + token/cost display**, `Esc` to abort
122
126
  - **Log persistence** to `.pi/workflows/runs/`
@@ -125,7 +129,6 @@ Scripts run inside a Node `vm` sandbox. Intentionally unavailable: `Date.now()`,
125
129
 
126
130
  Tracked toward closer parity with Claude Code dynamic workflows:
127
131
 
128
- - **Real per-agent / per-phase model routing** (`opts.model`, `meta.phases[].model`)
129
132
  - **Command surface** — `/workflows` (list / status / stop) and reachable background runs
130
133
  - **Resume** — journaled results, replay the unchanged prefix, run the rest live
131
134
  - **Worktree isolation** for parallel edits, and **bundled `/deep-research`**
package/dist/agent.d.ts CHANGED
@@ -30,6 +30,14 @@ export interface AgentRunOptions<TSchemaDef extends TSchema | undefined = undefi
30
30
  * usage is never lost. `total === 0` means the provider reported no usage.
31
31
  */
32
32
  onUsage?: (usage: AgentUsage) => void;
33
+ /**
34
+ * Model spec for this subagent: either `provider/modelId` (unambiguous) or a
35
+ * bare `modelId`. When it can't be resolved, the session default is used and
36
+ * a warning is logged. When omitted, the session default applies.
37
+ */
38
+ model?: string;
39
+ /** Called with the resolved model id once known (for display/telemetry). */
40
+ onModelResolved?: (modelId: string) => void;
33
41
  }
34
42
  export type AgentRunResult<TSchemaDef extends TSchema | undefined> = TSchemaDef extends TSchema ? Static<TSchemaDef> : string;
35
43
  export declare class WorkflowAgent {
@@ -37,7 +45,16 @@ export declare class WorkflowAgent {
37
45
  private readonly baseTools;
38
46
  private readonly sessionOptions;
39
47
  private readonly instructions?;
48
+ /** Lazily built once; shares the SDK's agentDir/auth so resolved models are authed. */
49
+ private registry?;
40
50
  constructor(options?: WorkflowAgentOptions);
51
+ private getRegistry;
52
+ /**
53
+ * Resolve a model spec to a Model. Accepts `provider/modelId` (unambiguous)
54
+ * or a bare `modelId` (prefers auth-configured models, then any known model).
55
+ * Returns undefined when nothing matches.
56
+ */
57
+ private resolveModel;
41
58
  run<TSchemaDef extends TSchema | undefined = undefined>(prompt: string, options?: AgentRunOptions<TSchemaDef>): Promise<AgentRunResult<TSchemaDef>>;
42
59
  private buildPrompt;
43
60
  private lastAssistantText;
package/dist/agent.js CHANGED
@@ -1,22 +1,60 @@
1
- import { createAgentSession, createCodingTools, getAgentDir, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
1
+ import { join } from "node:path";
2
+ import { AuthStorage, createAgentSession, createCodingTools, getAgentDir, ModelRegistry, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
2
3
  import { createStructuredOutputTool } from "./structured-output.js";
3
4
  export class WorkflowAgent {
4
5
  cwd;
5
6
  baseTools;
6
7
  sessionOptions;
7
8
  instructions;
9
+ /** Lazily built once; shares the SDK's agentDir/auth so resolved models are authed. */
10
+ registry;
8
11
  constructor(options = {}) {
9
12
  this.cwd = options.cwd ?? process.cwd();
10
13
  this.baseTools = options.tools ?? createCodingTools(this.cwd);
11
14
  this.sessionOptions = options.session ?? {};
12
15
  this.instructions = options.instructions;
13
16
  }
17
+ getRegistry() {
18
+ if (!this.registry) {
19
+ const dir = getAgentDir();
20
+ // Same agentDir/auth files createAgentSession uses by default, so a model
21
+ // resolved here carries valid credentials.
22
+ const auth = AuthStorage.create(join(dir, "auth.json"));
23
+ this.registry = ModelRegistry.create(auth, join(dir, "models.json"));
24
+ }
25
+ return this.registry;
26
+ }
27
+ /**
28
+ * Resolve a model spec to a Model. Accepts `provider/modelId` (unambiguous)
29
+ * or a bare `modelId` (prefers auth-configured models, then any known model).
30
+ * Returns undefined when nothing matches.
31
+ */
32
+ resolveModel(spec) {
33
+ const registry = this.getRegistry();
34
+ const slash = spec.indexOf("/");
35
+ if (slash > 0) {
36
+ return registry.find(spec.slice(0, slash), spec.slice(slash + 1));
37
+ }
38
+ return registry.getAvailable().find((m) => m.id === spec) ?? registry.getAll().find((m) => m.id === spec);
39
+ }
14
40
  async run(prompt, options = {}) {
15
41
  const capture = { called: false, value: undefined };
16
42
  const customTools = [...this.baseTools, ...(options.tools ?? [])];
17
43
  if (options.schema) {
18
44
  customTools.push(createStructuredOutputTool({ schema: options.schema, capture }));
19
45
  }
46
+ // Resolve a requested model spec to a Model object. A given-but-unresolved
47
+ // spec falls back to the session default (with a warning) rather than failing.
48
+ let resolvedModel;
49
+ if (options.model) {
50
+ resolvedModel = this.resolveModel(options.model);
51
+ if (resolvedModel) {
52
+ options.onModelResolved?.(`${resolvedModel.provider}/${resolvedModel.id}`);
53
+ }
54
+ else {
55
+ console.warn(`[workflow] model "${options.model}" not found; using session default`);
56
+ }
57
+ }
20
58
  const agentDir = getAgentDir();
21
59
  const { session } = await createAgentSession({
22
60
  cwd: this.cwd,
@@ -29,6 +67,8 @@ export class WorkflowAgent {
29
67
  settingsManager: SettingsManager.create(this.cwd, agentDir),
30
68
  customTools,
31
69
  ...this.sessionOptions,
70
+ // Per-call model wins over any sessionOptions.model.
71
+ ...(resolvedModel ? { model: resolvedModel } : {}),
32
72
  });
33
73
  let removeAbortListener;
34
74
  try {
@@ -31,6 +31,7 @@ export interface WorkflowRunOptions extends WorkflowAgentOptions {
31
31
  label: string;
32
32
  phase?: string;
33
33
  prompt: string;
34
+ model?: string;
34
35
  }) => void;
35
36
  onAgentEnd?: (event: {
36
37
  label: string;
package/dist/workflow.js CHANGED
@@ -4,10 +4,13 @@ import { WorkflowAgent } from "./agent.js";
4
4
  import { DEFAULT_AGENT_TIMEOUT_MS, MAX_AGENTS_PER_RUN, MAX_CONCURRENCY } from "./config.js";
5
5
  import { WorkflowError, WorkflowErrorCode, wrapError } from "./errors.js";
6
6
  import { createWorkflowLogger } from "./logger.js";
7
+ import { parseModelRoutingFromMeta, resolveModelForPhase } from "./model-routing.js";
7
8
  const DETERMINISM_BLOCKLIST = /\bDate\s*\.\s*now\b|\bMath\s*\.\s*random\b|\bnew\s+Date\s*\(\s*\)/;
8
9
  export async function runWorkflow(script, options = {}) {
9
10
  const started = Date.now();
10
11
  const { meta, body } = parseWorkflowScript(script);
12
+ // Per-phase model routing from meta.phases[].model (empty when none declared).
13
+ const routingConfig = parseModelRoutingFromMeta(meta.phases);
11
14
  const maxAgents = options.maxAgents ?? MAX_AGENTS_PER_RUN;
12
15
  const agentTimeoutMs = options.agentTimeoutMs ?? DEFAULT_AGENT_TIMEOUT_MS;
13
16
  const runId = options.runId ?? `run-${started.toString(36)}`;
@@ -62,11 +65,13 @@ export async function runWorkflow(script, options = {}) {
62
65
  }
63
66
  const assignedPhase = agentOptions.phase ?? state.currentPhase;
64
67
  const requestedLabel = agentOptions.label?.trim();
68
+ // Precedence: explicit agentOptions.model > phase model (meta.phases[].model).
69
+ const modelSpec = agentOptions.model ?? resolveModelForPhase(assignedPhase, routingConfig);
65
70
  return limiter(async () => {
66
71
  state.agentCount++;
67
72
  const label = requestedLabel || defaultAgentLabel(assignedPhase, state.agentCount);
68
73
  const timeout = agentOptions.timeoutMs ?? agentTimeoutMs;
69
- options.onAgentStart?.({ label, phase: assignedPhase, prompt });
74
+ options.onAgentStart?.({ label, phase: assignedPhase, prompt, model: modelSpec });
70
75
  // Captured from the subagent's real session usage; falls back to an
71
76
  // estimate when the provider reports no usage (total === 0).
72
77
  let usage;
@@ -89,6 +94,7 @@ export async function runWorkflow(script, options = {}) {
89
94
  schema: agentOptions.schema,
90
95
  signal: options.signal,
91
96
  instructions: buildAgentInstructions(assignedPhase, agentOptions),
97
+ model: modelSpec,
92
98
  onUsage: (u) => {
93
99
  usage = u;
94
100
  },
@@ -350,8 +356,7 @@ function buildAgentInstructions(phase, options) {
350
356
  lines.push(`Act as workflow subagent type: ${options.agentType}`);
351
357
  if (options.isolation)
352
358
  lines.push(`Requested isolation: ${options.isolation}`);
353
- if (options.model)
354
- lines.push(`Requested model: ${options.model}`);
359
+ // Note: options.model is applied for real via the session, not injected as prose.
355
360
  return lines.length ? lines.join("\n") : undefined;
356
361
  }
357
362
  function estimateTokens(value) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quintinshaw/pi-dynamic-workflows",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Claude-Code-style dynamic workflow orchestration for Pi.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/agent.ts CHANGED
@@ -1,9 +1,12 @@
1
- import type { AssistantMessage, TextContent } from "@earendil-works/pi-ai";
1
+ import { join } from "node:path";
2
+ import type { AssistantMessage, Model, TextContent } from "@earendil-works/pi-ai";
2
3
  import {
4
+ AuthStorage,
3
5
  type CreateAgentSessionOptions,
4
6
  createAgentSession,
5
7
  createCodingTools,
6
8
  getAgentDir,
9
+ ModelRegistry,
7
10
  SessionManager,
8
11
  SettingsManager,
9
12
  type ToolDefinition,
@@ -43,6 +46,14 @@ export interface AgentRunOptions<TSchemaDef extends TSchema | undefined = undefi
43
46
  * usage is never lost. `total === 0` means the provider reported no usage.
44
47
  */
45
48
  onUsage?: (usage: AgentUsage) => void;
49
+ /**
50
+ * Model spec for this subagent: either `provider/modelId` (unambiguous) or a
51
+ * bare `modelId`. When it can't be resolved, the session default is used and
52
+ * a warning is logged. When omitted, the session default applies.
53
+ */
54
+ model?: string;
55
+ /** Called with the resolved model id once known (for display/telemetry). */
56
+ onModelResolved?: (modelId: string) => void;
46
57
  }
47
58
 
48
59
  export type AgentRunResult<TSchemaDef extends TSchema | undefined> = TSchemaDef extends TSchema
@@ -54,6 +65,8 @@ export class WorkflowAgent {
54
65
  private readonly baseTools: ToolDefinition[];
55
66
  private readonly sessionOptions: Partial<CreateAgentSessionOptions>;
56
67
  private readonly instructions?: string;
68
+ /** Lazily built once; shares the SDK's agentDir/auth so resolved models are authed. */
69
+ private registry?: ModelRegistry;
57
70
 
58
71
  constructor(options: WorkflowAgentOptions = {}) {
59
72
  this.cwd = options.cwd ?? process.cwd();
@@ -62,6 +75,31 @@ export class WorkflowAgent {
62
75
  this.instructions = options.instructions;
63
76
  }
64
77
 
78
+ private getRegistry(): ModelRegistry {
79
+ if (!this.registry) {
80
+ const dir = getAgentDir();
81
+ // Same agentDir/auth files createAgentSession uses by default, so a model
82
+ // resolved here carries valid credentials.
83
+ const auth = AuthStorage.create(join(dir, "auth.json"));
84
+ this.registry = ModelRegistry.create(auth, join(dir, "models.json"));
85
+ }
86
+ return this.registry;
87
+ }
88
+
89
+ /**
90
+ * Resolve a model spec to a Model. Accepts `provider/modelId` (unambiguous)
91
+ * or a bare `modelId` (prefers auth-configured models, then any known model).
92
+ * Returns undefined when nothing matches.
93
+ */
94
+ private resolveModel(spec: string): Model<any> | undefined {
95
+ const registry = this.getRegistry();
96
+ const slash = spec.indexOf("/");
97
+ if (slash > 0) {
98
+ return registry.find(spec.slice(0, slash), spec.slice(slash + 1));
99
+ }
100
+ return registry.getAvailable().find((m) => m.id === spec) ?? registry.getAll().find((m) => m.id === spec);
101
+ }
102
+
65
103
  async run<TSchemaDef extends TSchema | undefined = undefined>(
66
104
  prompt: string,
67
105
  options: AgentRunOptions<TSchemaDef> = {},
@@ -73,6 +111,18 @@ export class WorkflowAgent {
73
111
  customTools.push(createStructuredOutputTool({ schema: options.schema, capture }) as unknown as ToolDefinition);
74
112
  }
75
113
 
114
+ // Resolve a requested model spec to a Model object. A given-but-unresolved
115
+ // spec falls back to the session default (with a warning) rather than failing.
116
+ let resolvedModel: Model<any> | undefined;
117
+ if (options.model) {
118
+ resolvedModel = this.resolveModel(options.model);
119
+ if (resolvedModel) {
120
+ options.onModelResolved?.(`${resolvedModel.provider}/${resolvedModel.id}`);
121
+ } else {
122
+ console.warn(`[workflow] model "${options.model}" not found; using session default`);
123
+ }
124
+ }
125
+
76
126
  const agentDir = getAgentDir();
77
127
  const { session } = await createAgentSession({
78
128
  cwd: this.cwd,
@@ -85,6 +135,8 @@ export class WorkflowAgent {
85
135
  settingsManager: SettingsManager.create(this.cwd, agentDir),
86
136
  customTools,
87
137
  ...this.sessionOptions,
138
+ // Per-call model wins over any sessionOptions.model.
139
+ ...(resolvedModel ? { model: resolvedModel } : {}),
88
140
  });
89
141
 
90
142
  let removeAbortListener: (() => void) | undefined;
package/src/workflow.ts CHANGED
@@ -7,6 +7,7 @@ import { WorkflowAgent, type WorkflowAgentOptions } from "./agent.js";
7
7
  import { DEFAULT_AGENT_TIMEOUT_MS, MAX_AGENTS_PER_RUN, MAX_CONCURRENCY } from "./config.js";
8
8
  import { WorkflowError, WorkflowErrorCode, wrapError } from "./errors.js";
9
9
  import { createWorkflowLogger } from "./logger.js";
10
+ import { parseModelRoutingFromMeta, resolveModelForPhase } from "./model-routing.js";
10
11
 
11
12
  export interface WorkflowMetaPhase {
12
13
  title: string;
@@ -37,7 +38,7 @@ export interface WorkflowRunOptions extends WorkflowAgentOptions {
37
38
  runId?: string;
38
39
  onLog?: (message: string) => void;
39
40
  onPhase?: (title: string) => void;
40
- onAgentStart?: (event: { label: string; phase?: string; prompt: string }) => void;
41
+ onAgentStart?: (event: { label: string; phase?: string; prompt: string; model?: string }) => void;
41
42
  onAgentEnd?: (event: { label: string; phase?: string; result: unknown; tokens?: number }) => void;
42
43
  onTokenUsage?: (usage: { input: number; output: number; total: number; cost: number }) => void;
43
44
  }
@@ -93,6 +94,8 @@ export async function runWorkflow<T = unknown>(
93
94
  ): Promise<WorkflowRunResult<T>> {
94
95
  const started = Date.now();
95
96
  const { meta, body } = parseWorkflowScript(script);
97
+ // Per-phase model routing from meta.phases[].model (empty when none declared).
98
+ const routingConfig = parseModelRoutingFromMeta(meta.phases);
96
99
  const maxAgents = options.maxAgents ?? MAX_AGENTS_PER_RUN;
97
100
  const agentTimeoutMs = options.agentTimeoutMs ?? DEFAULT_AGENT_TIMEOUT_MS;
98
101
  const runId = options.runId ?? `run-${started.toString(36)}`;
@@ -164,13 +167,15 @@ export async function runWorkflow<T = unknown>(
164
167
 
165
168
  const assignedPhase = agentOptions.phase ?? state.currentPhase;
166
169
  const requestedLabel = agentOptions.label?.trim();
170
+ // Precedence: explicit agentOptions.model > phase model (meta.phases[].model).
171
+ const modelSpec = agentOptions.model ?? resolveModelForPhase(assignedPhase, routingConfig);
167
172
 
168
173
  return limiter(async () => {
169
174
  state.agentCount++;
170
175
  const label = requestedLabel || defaultAgentLabel(assignedPhase, state.agentCount);
171
176
  const timeout = agentOptions.timeoutMs ?? agentTimeoutMs;
172
177
 
173
- options.onAgentStart?.({ label, phase: assignedPhase, prompt });
178
+ options.onAgentStart?.({ label, phase: assignedPhase, prompt, model: modelSpec });
174
179
 
175
180
  // Captured from the subagent's real session usage; falls back to an
176
181
  // estimate when the provider reports no usage (total === 0).
@@ -197,6 +202,7 @@ export async function runWorkflow<T = unknown>(
197
202
  schema: agentOptions.schema,
198
203
  signal: options.signal,
199
204
  instructions: buildAgentInstructions(assignedPhase, agentOptions),
205
+ model: modelSpec,
200
206
  onUsage: (u: AgentUsage) => {
201
207
  usage = u;
202
208
  },
@@ -480,7 +486,7 @@ function buildAgentInstructions(phase: string | undefined, options: AgentOptions
480
486
  if (phase) lines.push(`Workflow phase: ${phase}`);
481
487
  if (options.agentType) lines.push(`Act as workflow subagent type: ${options.agentType}`);
482
488
  if (options.isolation) lines.push(`Requested isolation: ${options.isolation}`);
483
- if (options.model) lines.push(`Requested model: ${options.model}`);
489
+ // Note: options.model is applied for real via the session, not injected as prose.
484
490
  return lines.length ? lines.join("\n") : undefined;
485
491
  }
486
492