@quintinshaw/pi-dynamic-workflows 1.0.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 +6 -3
- package/dist/agent.d.ts +32 -0
- package/dist/agent.js +58 -1
- package/dist/display.d.ts +1 -0
- package/dist/display.js +4 -2
- package/dist/workflow-tool.js +4 -2
- package/dist/workflow.d.ts +3 -0
- package/dist/workflow.js +29 -11
- package/package.json +1 -1
- package/src/agent.ts +85 -1
- package/src/display.ts +5 -2
- package/src/workflow-tool.ts +6 -2
- package/src/workflow.ts +35 -13
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:
|
|
@@ -116,16 +119,16 @@ Scripts run inside a Node `vm` sandbox. Intentionally unavailable: `Date.now()`,
|
|
|
116
119
|
|
|
117
120
|
- **Core runtime** — `agent` / `parallel` / `pipeline` / `phase` / `log` / `budget` in a sandboxed script
|
|
118
121
|
- **Structured output** — JSON-Schema-validated subagent results
|
|
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
|
|
119
124
|
- **Safety limits** — 1000-agent cap (`maxAgents`), per-agent timeout (`agentTimeoutMs`), recoverable-vs-fatal error classification
|
|
120
|
-
- **Live progress + token display**, `Esc` to abort
|
|
125
|
+
- **Live progress + token/cost display**, `Esc` to abort
|
|
121
126
|
- **Log persistence** to `.pi/workflows/runs/`
|
|
122
127
|
|
|
123
128
|
## Roadmap
|
|
124
129
|
|
|
125
130
|
Tracked toward closer parity with Claude Code dynamic workflows:
|
|
126
131
|
|
|
127
|
-
- **Real per-agent / per-phase model routing** (`opts.model`, `meta.phases[].model`)
|
|
128
|
-
- **Real token accounting** via the SDK's session stats (today's display uses an estimate)
|
|
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
|
@@ -9,12 +9,35 @@ export interface WorkflowAgentOptions {
|
|
|
9
9
|
/** Extra system guidance prepended to every subagent task. */
|
|
10
10
|
instructions?: string;
|
|
11
11
|
}
|
|
12
|
+
/** Real token/cost usage for a single subagent run, read from the SDK session. */
|
|
13
|
+
export interface AgentUsage {
|
|
14
|
+
input: number;
|
|
15
|
+
output: number;
|
|
16
|
+
cacheRead: number;
|
|
17
|
+
cacheWrite: number;
|
|
18
|
+
total: number;
|
|
19
|
+
cost: number;
|
|
20
|
+
}
|
|
12
21
|
export interface AgentRunOptions<TSchemaDef extends TSchema | undefined = undefined> {
|
|
13
22
|
label?: string;
|
|
14
23
|
schema?: TSchemaDef;
|
|
15
24
|
tools?: ToolDefinition[];
|
|
16
25
|
instructions?: string;
|
|
17
26
|
signal?: AbortSignal;
|
|
27
|
+
/**
|
|
28
|
+
* Called once with this subagent's real usage, read from the session right
|
|
29
|
+
* before disposal. Fires on both the success and error paths so partial
|
|
30
|
+
* usage is never lost. `total === 0` means the provider reported no usage.
|
|
31
|
+
*/
|
|
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;
|
|
18
41
|
}
|
|
19
42
|
export type AgentRunResult<TSchemaDef extends TSchema | undefined> = TSchemaDef extends TSchema ? Static<TSchemaDef> : string;
|
|
20
43
|
export declare class WorkflowAgent {
|
|
@@ -22,7 +45,16 @@ export declare class WorkflowAgent {
|
|
|
22
45
|
private readonly baseTools;
|
|
23
46
|
private readonly sessionOptions;
|
|
24
47
|
private readonly instructions?;
|
|
48
|
+
/** Lazily built once; shares the SDK's agentDir/auth so resolved models are authed. */
|
|
49
|
+
private registry?;
|
|
25
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;
|
|
26
58
|
run<TSchemaDef extends TSchema | undefined = undefined>(prompt: string, options?: AgentRunOptions<TSchemaDef>): Promise<AgentRunResult<TSchemaDef>>;
|
|
27
59
|
private buildPrompt;
|
|
28
60
|
private lastAssistantText;
|
package/dist/agent.js
CHANGED
|
@@ -1,22 +1,60 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
@@ -52,6 +92,23 @@ export class WorkflowAgent {
|
|
|
52
92
|
}
|
|
53
93
|
finally {
|
|
54
94
|
removeAbortListener?.();
|
|
95
|
+
// Read real usage before disposing — dispose tears down the session state.
|
|
96
|
+
if (options.onUsage) {
|
|
97
|
+
try {
|
|
98
|
+
const { tokens, cost } = session.getSessionStats();
|
|
99
|
+
options.onUsage({
|
|
100
|
+
input: tokens.input,
|
|
101
|
+
output: tokens.output,
|
|
102
|
+
cacheRead: tokens.cacheRead,
|
|
103
|
+
cacheWrite: tokens.cacheWrite,
|
|
104
|
+
total: tokens.total,
|
|
105
|
+
cost,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// Usage is best-effort; never let stats failure mask the real result/error.
|
|
110
|
+
}
|
|
111
|
+
}
|
|
55
112
|
session.dispose();
|
|
56
113
|
}
|
|
57
114
|
}
|
package/dist/display.d.ts
CHANGED
package/dist/display.js
CHANGED
|
@@ -80,8 +80,10 @@ export function renderWorkflowLines(snapshot, options = {}) {
|
|
|
80
80
|
: snapshot.runningCount > 0
|
|
81
81
|
? `, ${snapshot.runningCount} running`
|
|
82
82
|
: "";
|
|
83
|
-
// Build header with token info
|
|
84
|
-
const
|
|
83
|
+
// Build header with token info (and cost when the provider reports it)
|
|
84
|
+
const usage = snapshot.tokenUsage;
|
|
85
|
+
const costInfo = usage?.cost ? ` · $${usage.cost.toFixed(4)}` : "";
|
|
86
|
+
const tokenInfo = usage ? ` · ${usage.total.toLocaleString()} tokens${costInfo}` : "";
|
|
85
87
|
const lines = [
|
|
86
88
|
`◆ Workflow: ${snapshot.name} (${snapshot.doneCount}/${snapshot.agentCount} done${state}${tokenInfo})`,
|
|
87
89
|
];
|
package/dist/workflow-tool.js
CHANGED
|
@@ -160,8 +160,10 @@ export function createWorkflowTool(options = {}) {
|
|
|
160
160
|
snapshot.durationMs = result.durationMs;
|
|
161
161
|
snapshot = recomputeWorkflowSnapshot(snapshot);
|
|
162
162
|
display.complete(snapshot);
|
|
163
|
-
// Format token usage
|
|
164
|
-
const tokenInfo = result.tokenUsage
|
|
163
|
+
// Format token usage (include cost when the provider reports it)
|
|
164
|
+
const tokenInfo = result.tokenUsage
|
|
165
|
+
? `\n\nToken usage: ${result.tokenUsage.total.toLocaleString()} tokens${result.tokenUsage.cost ? ` ($${result.tokenUsage.cost.toFixed(4)})` : ""}`
|
|
166
|
+
: "";
|
|
165
167
|
return {
|
|
166
168
|
content: [
|
|
167
169
|
{
|
package/dist/workflow.d.ts
CHANGED
|
@@ -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;
|
|
@@ -42,6 +43,7 @@ export interface WorkflowRunOptions extends WorkflowAgentOptions {
|
|
|
42
43
|
input: number;
|
|
43
44
|
output: number;
|
|
44
45
|
total: number;
|
|
46
|
+
cost: number;
|
|
45
47
|
}) => void;
|
|
46
48
|
}
|
|
47
49
|
export interface WorkflowRunResult<T = unknown> {
|
|
@@ -56,6 +58,7 @@ export interface WorkflowRunResult<T = unknown> {
|
|
|
56
58
|
input: number;
|
|
57
59
|
output: number;
|
|
58
60
|
total: number;
|
|
61
|
+
cost: number;
|
|
59
62
|
};
|
|
60
63
|
}
|
|
61
64
|
export interface AgentOptions<TSchemaDef extends TSchema | undefined = TSchema | undefined> {
|
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)}`;
|
|
@@ -23,7 +26,7 @@ export async function runWorkflow(script, options = {}) {
|
|
|
23
26
|
phases: [],
|
|
24
27
|
agentCount: 0,
|
|
25
28
|
spent: 0,
|
|
26
|
-
tokenUsage: { input: 0, output: 0, total: 0 },
|
|
29
|
+
tokenUsage: { input: 0, output: 0, total: 0, cost: 0 },
|
|
27
30
|
};
|
|
28
31
|
const agentRunner = options.agent ?? new WorkflowAgent(options);
|
|
29
32
|
const concurrency = Math.max(1, Math.min(options.concurrency ?? Math.max(1, (globalThis.navigator?.hardwareConcurrency ?? 8) - 2), MAX_CONCURRENCY));
|
|
@@ -62,11 +65,27 @@ 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 });
|
|
75
|
+
// Captured from the subagent's real session usage; falls back to an
|
|
76
|
+
// estimate when the provider reports no usage (total === 0).
|
|
77
|
+
let usage;
|
|
78
|
+
const recordTokens = (result) => {
|
|
79
|
+
const tokens = usage && usage.total > 0 ? usage.total : estimateTokens(result) + estimateTokens(prompt);
|
|
80
|
+
if (usage) {
|
|
81
|
+
state.tokenUsage.input += usage.input;
|
|
82
|
+
state.tokenUsage.output += usage.output;
|
|
83
|
+
state.tokenUsage.cost += usage.cost;
|
|
84
|
+
}
|
|
85
|
+
state.tokenUsage.total += tokens;
|
|
86
|
+
state.spent += tokens;
|
|
87
|
+
return tokens;
|
|
88
|
+
};
|
|
70
89
|
try {
|
|
71
90
|
throwIfAborted();
|
|
72
91
|
// Run agent with timeout
|
|
@@ -75,12 +94,13 @@ export async function runWorkflow(script, options = {}) {
|
|
|
75
94
|
schema: agentOptions.schema,
|
|
76
95
|
signal: options.signal,
|
|
77
96
|
instructions: buildAgentInstructions(assignedPhase, agentOptions),
|
|
97
|
+
model: modelSpec,
|
|
98
|
+
onUsage: (u) => {
|
|
99
|
+
usage = u;
|
|
100
|
+
},
|
|
78
101
|
}), timeout, `Agent "${label}" timed out after ${timeout}ms`);
|
|
79
102
|
throwIfAborted();
|
|
80
|
-
|
|
81
|
-
const tokens = estimateTokens(result) + estimateTokens(prompt);
|
|
82
|
-
state.spent += tokens;
|
|
83
|
-
state.tokenUsage.total += tokens;
|
|
103
|
+
const tokens = recordTokens(result);
|
|
84
104
|
options.onAgentEnd?.({ label, phase: assignedPhase, result, tokens });
|
|
85
105
|
return result;
|
|
86
106
|
}
|
|
@@ -89,9 +109,8 @@ export async function runWorkflow(script, options = {}) {
|
|
|
89
109
|
throw error;
|
|
90
110
|
const workflowError = wrapError(error, { agentLabel: label });
|
|
91
111
|
logger.error(`agent ${label} failed: ${workflowError.message}`);
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
options.onAgentEnd?.({ label, phase: assignedPhase, result: null, tokens: errorTokens });
|
|
112
|
+
const tokens = recordTokens(null);
|
|
113
|
+
options.onAgentEnd?.({ label, phase: assignedPhase, result: null, tokens });
|
|
95
114
|
// Return null for recoverable errors
|
|
96
115
|
if (workflowError.recoverable) {
|
|
97
116
|
return null;
|
|
@@ -337,8 +356,7 @@ function buildAgentInstructions(phase, options) {
|
|
|
337
356
|
lines.push(`Act as workflow subagent type: ${options.agentType}`);
|
|
338
357
|
if (options.isolation)
|
|
339
358
|
lines.push(`Requested isolation: ${options.isolation}`);
|
|
340
|
-
|
|
341
|
-
lines.push(`Requested model: ${options.model}`);
|
|
359
|
+
// Note: options.model is applied for real via the session, not injected as prose.
|
|
342
360
|
return lines.length ? lines.join("\n") : undefined;
|
|
343
361
|
}
|
|
344
362
|
function estimateTokens(value) {
|
package/package.json
CHANGED
package/src/agent.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import
|
|
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,
|
|
@@ -21,12 +24,36 @@ export interface WorkflowAgentOptions {
|
|
|
21
24
|
instructions?: string;
|
|
22
25
|
}
|
|
23
26
|
|
|
27
|
+
/** Real token/cost usage for a single subagent run, read from the SDK session. */
|
|
28
|
+
export interface AgentUsage {
|
|
29
|
+
input: number;
|
|
30
|
+
output: number;
|
|
31
|
+
cacheRead: number;
|
|
32
|
+
cacheWrite: number;
|
|
33
|
+
total: number;
|
|
34
|
+
cost: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
24
37
|
export interface AgentRunOptions<TSchemaDef extends TSchema | undefined = undefined> {
|
|
25
38
|
label?: string;
|
|
26
39
|
schema?: TSchemaDef;
|
|
27
40
|
tools?: ToolDefinition[];
|
|
28
41
|
instructions?: string;
|
|
29
42
|
signal?: AbortSignal;
|
|
43
|
+
/**
|
|
44
|
+
* Called once with this subagent's real usage, read from the session right
|
|
45
|
+
* before disposal. Fires on both the success and error paths so partial
|
|
46
|
+
* usage is never lost. `total === 0` means the provider reported no usage.
|
|
47
|
+
*/
|
|
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;
|
|
30
57
|
}
|
|
31
58
|
|
|
32
59
|
export type AgentRunResult<TSchemaDef extends TSchema | undefined> = TSchemaDef extends TSchema
|
|
@@ -38,6 +65,8 @@ export class WorkflowAgent {
|
|
|
38
65
|
private readonly baseTools: ToolDefinition[];
|
|
39
66
|
private readonly sessionOptions: Partial<CreateAgentSessionOptions>;
|
|
40
67
|
private readonly instructions?: string;
|
|
68
|
+
/** Lazily built once; shares the SDK's agentDir/auth so resolved models are authed. */
|
|
69
|
+
private registry?: ModelRegistry;
|
|
41
70
|
|
|
42
71
|
constructor(options: WorkflowAgentOptions = {}) {
|
|
43
72
|
this.cwd = options.cwd ?? process.cwd();
|
|
@@ -46,6 +75,31 @@ export class WorkflowAgent {
|
|
|
46
75
|
this.instructions = options.instructions;
|
|
47
76
|
}
|
|
48
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
|
+
|
|
49
103
|
async run<TSchemaDef extends TSchema | undefined = undefined>(
|
|
50
104
|
prompt: string,
|
|
51
105
|
options: AgentRunOptions<TSchemaDef> = {},
|
|
@@ -57,6 +111,18 @@ export class WorkflowAgent {
|
|
|
57
111
|
customTools.push(createStructuredOutputTool({ schema: options.schema, capture }) as unknown as ToolDefinition);
|
|
58
112
|
}
|
|
59
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
|
+
|
|
60
126
|
const agentDir = getAgentDir();
|
|
61
127
|
const { session } = await createAgentSession({
|
|
62
128
|
cwd: this.cwd,
|
|
@@ -69,6 +135,8 @@ export class WorkflowAgent {
|
|
|
69
135
|
settingsManager: SettingsManager.create(this.cwd, agentDir),
|
|
70
136
|
customTools,
|
|
71
137
|
...this.sessionOptions,
|
|
138
|
+
// Per-call model wins over any sessionOptions.model.
|
|
139
|
+
...(resolvedModel ? { model: resolvedModel } : {}),
|
|
72
140
|
});
|
|
73
141
|
|
|
74
142
|
let removeAbortListener: (() => void) | undefined;
|
|
@@ -93,6 +161,22 @@ export class WorkflowAgent {
|
|
|
93
161
|
return this.lastAssistantText(session.messages) as AgentRunResult<TSchemaDef>;
|
|
94
162
|
} finally {
|
|
95
163
|
removeAbortListener?.();
|
|
164
|
+
// Read real usage before disposing — dispose tears down the session state.
|
|
165
|
+
if (options.onUsage) {
|
|
166
|
+
try {
|
|
167
|
+
const { tokens, cost } = session.getSessionStats();
|
|
168
|
+
options.onUsage({
|
|
169
|
+
input: tokens.input,
|
|
170
|
+
output: tokens.output,
|
|
171
|
+
cacheRead: tokens.cacheRead,
|
|
172
|
+
cacheWrite: tokens.cacheWrite,
|
|
173
|
+
total: tokens.total,
|
|
174
|
+
cost,
|
|
175
|
+
});
|
|
176
|
+
} catch {
|
|
177
|
+
// Usage is best-effort; never let stats failure mask the real result/error.
|
|
178
|
+
}
|
|
179
|
+
}
|
|
96
180
|
session.dispose();
|
|
97
181
|
}
|
|
98
182
|
}
|
package/src/display.ts
CHANGED
|
@@ -32,6 +32,7 @@ export interface WorkflowSnapshot {
|
|
|
32
32
|
input: number;
|
|
33
33
|
output: number;
|
|
34
34
|
total: number;
|
|
35
|
+
cost?: number;
|
|
35
36
|
};
|
|
36
37
|
runId?: string;
|
|
37
38
|
}
|
|
@@ -143,8 +144,10 @@ export function renderWorkflowLines(snapshot: WorkflowSnapshot, options: Workflo
|
|
|
143
144
|
: snapshot.runningCount > 0
|
|
144
145
|
? `, ${snapshot.runningCount} running`
|
|
145
146
|
: "";
|
|
146
|
-
// Build header with token info
|
|
147
|
-
const
|
|
147
|
+
// Build header with token info (and cost when the provider reports it)
|
|
148
|
+
const usage = snapshot.tokenUsage;
|
|
149
|
+
const costInfo = usage?.cost ? ` · $${usage.cost.toFixed(4)}` : "";
|
|
150
|
+
const tokenInfo = usage ? ` · ${usage.total.toLocaleString()} tokens${costInfo}` : "";
|
|
148
151
|
const lines = [
|
|
149
152
|
`◆ Workflow: ${snapshot.name} (${snapshot.doneCount}/${snapshot.agentCount} done${state}${tokenInfo})`,
|
|
150
153
|
];
|
package/src/workflow-tool.ts
CHANGED
|
@@ -198,8 +198,12 @@ export function createWorkflowTool(options: WorkflowToolOptions = {}): ToolDefin
|
|
|
198
198
|
snapshot = recomputeWorkflowSnapshot(snapshot);
|
|
199
199
|
display.complete(snapshot);
|
|
200
200
|
|
|
201
|
-
// Format token usage
|
|
202
|
-
const tokenInfo = result.tokenUsage
|
|
201
|
+
// Format token usage (include cost when the provider reports it)
|
|
202
|
+
const tokenInfo = result.tokenUsage
|
|
203
|
+
? `\n\nToken usage: ${result.tokenUsage.total.toLocaleString()} tokens${
|
|
204
|
+
result.tokenUsage.cost ? ` ($${result.tokenUsage.cost.toFixed(4)})` : ""
|
|
205
|
+
}`
|
|
206
|
+
: "";
|
|
203
207
|
|
|
204
208
|
return {
|
|
205
209
|
content: [
|
package/src/workflow.ts
CHANGED
|
@@ -2,10 +2,12 @@ import vm from "node:vm";
|
|
|
2
2
|
import type { Node } from "acorn";
|
|
3
3
|
import { parse } from "acorn";
|
|
4
4
|
import type { TSchema } from "typebox";
|
|
5
|
+
import type { AgentUsage } from "./agent.js";
|
|
5
6
|
import { WorkflowAgent, type WorkflowAgentOptions } from "./agent.js";
|
|
6
7
|
import { DEFAULT_AGENT_TIMEOUT_MS, MAX_AGENTS_PER_RUN, MAX_CONCURRENCY } from "./config.js";
|
|
7
8
|
import { WorkflowError, WorkflowErrorCode, wrapError } from "./errors.js";
|
|
8
9
|
import { createWorkflowLogger } from "./logger.js";
|
|
10
|
+
import { parseModelRoutingFromMeta, resolveModelForPhase } from "./model-routing.js";
|
|
9
11
|
|
|
10
12
|
export interface WorkflowMetaPhase {
|
|
11
13
|
title: string;
|
|
@@ -36,9 +38,9 @@ export interface WorkflowRunOptions extends WorkflowAgentOptions {
|
|
|
36
38
|
runId?: string;
|
|
37
39
|
onLog?: (message: string) => void;
|
|
38
40
|
onPhase?: (title: string) => void;
|
|
39
|
-
onAgentStart?: (event: { label: string; phase?: string; prompt: string }) => void;
|
|
41
|
+
onAgentStart?: (event: { label: string; phase?: string; prompt: string; model?: string }) => void;
|
|
40
42
|
onAgentEnd?: (event: { label: string; phase?: string; result: unknown; tokens?: number }) => void;
|
|
41
|
-
onTokenUsage?: (usage: { input: number; output: number; total: number }) => void;
|
|
43
|
+
onTokenUsage?: (usage: { input: number; output: number; total: number; cost: number }) => void;
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
export interface WorkflowRunResult<T = unknown> {
|
|
@@ -53,6 +55,7 @@ export interface WorkflowRunResult<T = unknown> {
|
|
|
53
55
|
input: number;
|
|
54
56
|
output: number;
|
|
55
57
|
total: number;
|
|
58
|
+
cost: number;
|
|
56
59
|
};
|
|
57
60
|
}
|
|
58
61
|
|
|
@@ -77,6 +80,7 @@ interface RuntimeState {
|
|
|
77
80
|
input: number;
|
|
78
81
|
output: number;
|
|
79
82
|
total: number;
|
|
83
|
+
cost: number;
|
|
80
84
|
};
|
|
81
85
|
}
|
|
82
86
|
|
|
@@ -90,6 +94,8 @@ export async function runWorkflow<T = unknown>(
|
|
|
90
94
|
): Promise<WorkflowRunResult<T>> {
|
|
91
95
|
const started = Date.now();
|
|
92
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);
|
|
93
99
|
const maxAgents = options.maxAgents ?? MAX_AGENTS_PER_RUN;
|
|
94
100
|
const agentTimeoutMs = options.agentTimeoutMs ?? DEFAULT_AGENT_TIMEOUT_MS;
|
|
95
101
|
const runId = options.runId ?? `run-${started.toString(36)}`;
|
|
@@ -107,7 +113,7 @@ export async function runWorkflow<T = unknown>(
|
|
|
107
113
|
phases: [],
|
|
108
114
|
agentCount: 0,
|
|
109
115
|
spent: 0,
|
|
110
|
-
tokenUsage: { input: 0, output: 0, total: 0 },
|
|
116
|
+
tokenUsage: { input: 0, output: 0, total: 0, cost: 0 },
|
|
111
117
|
};
|
|
112
118
|
|
|
113
119
|
const agentRunner = options.agent ?? new WorkflowAgent(options);
|
|
@@ -161,13 +167,30 @@ export async function runWorkflow<T = unknown>(
|
|
|
161
167
|
|
|
162
168
|
const assignedPhase = agentOptions.phase ?? state.currentPhase;
|
|
163
169
|
const requestedLabel = agentOptions.label?.trim();
|
|
170
|
+
// Precedence: explicit agentOptions.model > phase model (meta.phases[].model).
|
|
171
|
+
const modelSpec = agentOptions.model ?? resolveModelForPhase(assignedPhase, routingConfig);
|
|
164
172
|
|
|
165
173
|
return limiter(async () => {
|
|
166
174
|
state.agentCount++;
|
|
167
175
|
const label = requestedLabel || defaultAgentLabel(assignedPhase, state.agentCount);
|
|
168
176
|
const timeout = agentOptions.timeoutMs ?? agentTimeoutMs;
|
|
169
177
|
|
|
170
|
-
options.onAgentStart?.({ label, phase: assignedPhase, prompt });
|
|
178
|
+
options.onAgentStart?.({ label, phase: assignedPhase, prompt, model: modelSpec });
|
|
179
|
+
|
|
180
|
+
// Captured from the subagent's real session usage; falls back to an
|
|
181
|
+
// estimate when the provider reports no usage (total === 0).
|
|
182
|
+
let usage: AgentUsage | undefined;
|
|
183
|
+
const recordTokens = (result: unknown): number => {
|
|
184
|
+
const tokens = usage && usage.total > 0 ? usage.total : estimateTokens(result) + estimateTokens(prompt);
|
|
185
|
+
if (usage) {
|
|
186
|
+
state.tokenUsage.input += usage.input;
|
|
187
|
+
state.tokenUsage.output += usage.output;
|
|
188
|
+
state.tokenUsage.cost += usage.cost;
|
|
189
|
+
}
|
|
190
|
+
state.tokenUsage.total += tokens;
|
|
191
|
+
state.spent += tokens;
|
|
192
|
+
return tokens;
|
|
193
|
+
};
|
|
171
194
|
|
|
172
195
|
try {
|
|
173
196
|
throwIfAborted();
|
|
@@ -179,6 +202,10 @@ export async function runWorkflow<T = unknown>(
|
|
|
179
202
|
schema: agentOptions.schema,
|
|
180
203
|
signal: options.signal,
|
|
181
204
|
instructions: buildAgentInstructions(assignedPhase, agentOptions),
|
|
205
|
+
model: modelSpec,
|
|
206
|
+
onUsage: (u: AgentUsage) => {
|
|
207
|
+
usage = u;
|
|
208
|
+
},
|
|
182
209
|
} as any),
|
|
183
210
|
timeout,
|
|
184
211
|
`Agent "${label}" timed out after ${timeout}ms`,
|
|
@@ -186,11 +213,7 @@ export async function runWorkflow<T = unknown>(
|
|
|
186
213
|
|
|
187
214
|
throwIfAborted();
|
|
188
215
|
|
|
189
|
-
|
|
190
|
-
const tokens = estimateTokens(result) + estimateTokens(prompt);
|
|
191
|
-
state.spent += tokens;
|
|
192
|
-
state.tokenUsage.total += tokens;
|
|
193
|
-
|
|
216
|
+
const tokens = recordTokens(result);
|
|
194
217
|
options.onAgentEnd?.({ label, phase: assignedPhase, result, tokens });
|
|
195
218
|
return result;
|
|
196
219
|
} catch (error) {
|
|
@@ -198,9 +221,8 @@ export async function runWorkflow<T = unknown>(
|
|
|
198
221
|
|
|
199
222
|
const workflowError = wrapError(error, { agentLabel: label });
|
|
200
223
|
logger.error(`agent ${label} failed: ${workflowError.message}`);
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
options.onAgentEnd?.({ label, phase: assignedPhase, result: null, tokens: errorTokens });
|
|
224
|
+
const tokens = recordTokens(null);
|
|
225
|
+
options.onAgentEnd?.({ label, phase: assignedPhase, result: null, tokens });
|
|
204
226
|
|
|
205
227
|
// Return null for recoverable errors
|
|
206
228
|
if (workflowError.recoverable) {
|
|
@@ -464,7 +486,7 @@ function buildAgentInstructions(phase: string | undefined, options: AgentOptions
|
|
|
464
486
|
if (phase) lines.push(`Workflow phase: ${phase}`);
|
|
465
487
|
if (options.agentType) lines.push(`Act as workflow subagent type: ${options.agentType}`);
|
|
466
488
|
if (options.isolation) lines.push(`Requested isolation: ${options.isolation}`);
|
|
467
|
-
|
|
489
|
+
// Note: options.model is applied for real via the session, not injected as prose.
|
|
468
490
|
return lines.length ? lines.join("\n") : undefined;
|
|
469
491
|
}
|
|
470
492
|
|