@osolmaz/pi-workflows 0.5.2 → 0.6.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.
Files changed (81) hide show
  1. package/README.md +13 -3
  2. package/dist/builtins/catalog.js +1 -1
  3. package/dist/builtins/monitor.workflow.d.ts +25 -69
  4. package/dist/builtins/monitor.workflow.js +194 -123
  5. package/dist/builtins/monitor.workflow.js.map +1 -1
  6. package/dist/extension/executor.d.ts +7 -2
  7. package/dist/extension/executor.js +20 -14
  8. package/dist/extension/executor.js.map +1 -1
  9. package/dist/extension/index.js +56 -15
  10. package/dist/extension/index.js.map +1 -1
  11. package/dist/extension/step-message.d.ts +24 -0
  12. package/dist/extension/step-message.js +106 -0
  13. package/dist/extension/step-message.js.map +1 -0
  14. package/dist/extension/widget.d.ts +4 -2
  15. package/dist/extension/widget.js +111 -17
  16. package/dist/extension/widget.js.map +1 -1
  17. package/dist/extension/workflow-tool.d.ts +9 -0
  18. package/dist/extension/workflow-tool.js +10 -0
  19. package/dist/extension/workflow-tool.js.map +1 -1
  20. package/dist/host/rpc-bridge.js +25 -11
  21. package/dist/host/rpc-bridge.js.map +1 -1
  22. package/dist/host/rpc-executor.d.ts +2 -2
  23. package/dist/host/rpc-executor.js +23 -12
  24. package/dist/host/rpc-executor.js.map +1 -1
  25. package/dist/viewer/cli.js +1 -1
  26. package/dist/viewer/cli.js.map +1 -1
  27. package/dist/viewer/render.js +14 -0
  28. package/dist/viewer/render.js.map +1 -1
  29. package/dist/viewer/tui.js +1 -1
  30. package/dist/viewer/tui.js.map +1 -1
  31. package/dist/workflows/engine.d.ts +6 -1
  32. package/dist/workflows/engine.js +88 -6
  33. package/dist/workflows/engine.js.map +1 -1
  34. package/dist/workflows/index.d.ts +4 -2
  35. package/dist/workflows/index.js +2 -0
  36. package/dist/workflows/index.js.map +1 -1
  37. package/dist/workflows/progress.d.ts +34 -0
  38. package/dist/workflows/progress.js +268 -0
  39. package/dist/workflows/progress.js.map +1 -0
  40. package/dist/workflows/schema.js +21 -1
  41. package/dist/workflows/schema.js.map +1 -1
  42. package/dist/workflows/shell.d.ts +2 -2
  43. package/dist/workflows/shell.js +103 -25
  44. package/dist/workflows/shell.js.map +1 -1
  45. package/dist/workflows/store.d.ts +15 -2
  46. package/dist/workflows/store.js +44 -2
  47. package/dist/workflows/store.js.map +1 -1
  48. package/dist/workflows/types.d.ts +52 -1
  49. package/dist/workflows/updates.d.ts +15 -0
  50. package/dist/workflows/updates.js +188 -0
  51. package/dist/workflows/updates.js.map +1 -0
  52. package/docs/DESIGN_PHILOSOPHY.md +51 -0
  53. package/docs/MONITOR.md +282 -0
  54. package/docs/WORKFLOW_STEP_MESSAGES.md +141 -0
  55. package/docs/WORKFLOW_UPDATES.md +416 -0
  56. package/docs/development.md +7 -3
  57. package/docs/plans/2026-08-13-responsive-workflow-widget-plan.md +11 -3
  58. package/docs/plans/2026-08-16-workflow-updates-plan.md +494 -0
  59. package/docs/run-bundles.md +10 -2
  60. package/docs/workflows.md +57 -17
  61. package/package.json +1 -1
  62. package/src/builtins/catalog.ts +1 -1
  63. package/src/builtins/monitor.workflow.ts +217 -148
  64. package/src/extension/executor.ts +36 -14
  65. package/src/extension/index.ts +89 -23
  66. package/src/extension/step-message.ts +145 -0
  67. package/src/extension/widget.ts +158 -14
  68. package/src/extension/workflow-tool.ts +22 -0
  69. package/src/host/rpc-bridge.ts +37 -14
  70. package/src/host/rpc-executor.ts +35 -14
  71. package/src/viewer/cli.ts +1 -1
  72. package/src/viewer/render.ts +27 -0
  73. package/src/viewer/tui.ts +1 -1
  74. package/src/workflows/engine.ts +117 -4
  75. package/src/workflows/index.ts +32 -0
  76. package/src/workflows/progress.ts +326 -0
  77. package/src/workflows/schema.ts +23 -1
  78. package/src/workflows/shell.ts +109 -26
  79. package/src/workflows/store.ts +67 -2
  80. package/src/workflows/types.ts +78 -1
  81. package/src/workflows/updates.ts +208 -0
@@ -1,52 +1,51 @@
1
- import { agent, compute, defineWorkflow, notify, shell } from "../workflows/definition.js";
2
- import type { WorkflowNodeContext } from "../workflows/types.js";
1
+ import { action, agent, compute, defineWorkflow, notify, shell } from "../workflows/definition.js";
2
+ import {
3
+ estimateProgress,
4
+ formatProgressReport,
5
+ type ProgressSample,
6
+ type ProgressTrackState,
7
+ } from "../workflows/progress.js";
8
+ import type {
9
+ WorkflowDefinition,
10
+ WorkflowNodeContext,
11
+ WorkflowProgressData,
12
+ } from "../workflows/types.js";
13
+ import { validateProgressData } from "../workflows/updates.js";
3
14
 
4
15
  const MIN_INTERVAL_MINUTES = 1;
5
16
  const MAX_INTERVAL_MINUTES = 24 * 60;
17
+ const DEFAULT_INTERVAL_MINUTES = 30;
6
18
  const MIN_CHECK_TIMEOUT_MINUTES = 5;
7
19
  const MAX_CHECK_TIMEOUT_MINUTES = 24 * 60;
8
20
  const DEFAULT_MIN_CHECK_TIMEOUT_MINUTES = 60;
9
21
  const DEFAULT_MAX_CHECKS = 1_000;
10
22
  const MAX_CHECKS = 1_000;
23
+ const MAX_TRACKS = 256;
11
24
  const MAX_OBSERVATION_CHARS = 8_000;
12
25
  const MAX_REPORT_CHARS = 4_000;
13
26
  const MAX_REASON_CHARS = 2_000;
14
27
  const SLEEP_TIMEOUT_MARGIN_MS = 60_000;
15
28
  const NODE_TIMEOUT_MARGIN_MS = 2 * 60_000;
16
29
 
17
- type MonitorInput = {
30
+ export type MonitorInput = {
18
31
  task: string;
19
- everyMinutes: number;
20
- reportWhen?: string;
32
+ everyMinutes?: number;
21
33
  stopWhen?: string;
22
34
  maxChecks?: number;
23
35
  checkTimeoutMinutes?: number;
24
36
  };
25
37
 
26
- type MonitorConfig = {
27
- task: string;
28
- everyMinutes: number;
29
- reportWhen: string;
30
- stopWhen: string;
31
- maxChecks: number;
32
- checkTimeoutMinutes: number;
33
- };
34
-
35
- type MonitorRoute = "continue_quiet" | "continue_report" | "stop_quiet" | "stop_report";
36
-
38
+ type MonitorConfig = Required<MonitorInput>;
39
+ type MonitorRoute = "continue" | "stop";
40
+ type MonitorTrack = { key: string; data: WorkflowProgressData };
37
41
  type MonitorCheck = {
38
42
  route: MonitorRoute;
39
43
  observation: string;
40
- report?: string;
44
+ report: string;
45
+ progress?: { tracks: MonitorTrack[] };
41
46
  reason: string;
42
47
  };
43
-
44
- const MONITOR_ROUTES = new Set<MonitorRoute>([
45
- "continue_quiet",
46
- "continue_report",
47
- "stop_quiet",
48
- "stop_report",
49
- ]);
48
+ type MonitorEstimate = { tracks: ProgressTrackState[] };
50
49
 
51
50
  function requireRecord(value: unknown, label: string): Record<string, unknown> {
52
51
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
@@ -56,27 +55,40 @@ function requireRecord(value: unknown, label: string): Record<string, unknown> {
56
55
  }
57
56
 
58
57
  function requireBoundedString(value: unknown, label: string, maxChars: number): string {
59
- if (typeof value !== "string") {
60
- throw new Error(`${label} must be a string`);
61
- }
58
+ if (typeof value !== "string") throw new Error(`${label} must be a string`);
62
59
  const trimmed = value.trim();
63
- if (trimmed.length === 0) {
64
- throw new Error(`${label} must not be empty`);
65
- }
66
- if (trimmed.length > maxChars) {
67
- throw new Error(`${label} must be at most ${maxChars} characters`);
68
- }
60
+ if (trimmed.length === 0) throw new Error(`${label} must not be empty`);
61
+ if (trimmed.length > maxChars) throw new Error(`${label} must be at most ${maxChars} characters`);
69
62
  return trimmed;
70
63
  }
71
64
 
72
- function prepareInput(input: unknown): MonitorConfig {
65
+ async function waitForUpdateSlot(signal: AbortSignal): Promise<void> {
66
+ await new Promise<void>((resolve, reject) => {
67
+ const onAbort = () => {
68
+ clearTimeout(timer);
69
+ reject(signal.reason ?? new Error("monitor progress publication was cancelled"));
70
+ };
71
+ const timer = setTimeout(() => {
72
+ signal.removeEventListener("abort", onAbort);
73
+ resolve();
74
+ }, 55);
75
+ if (signal.aborted) onAbort();
76
+ else signal.addEventListener("abort", onAbort, { once: true });
77
+ });
78
+ }
79
+
80
+ export function prepareMonitorInput(input: unknown): MonitorConfig {
73
81
  const value = requireRecord(input, "monitor input") as Partial<MonitorInput>;
82
+ const allowed = new Set(["task", "everyMinutes", "stopWhen", "maxChecks", "checkTimeoutMinutes"]);
83
+ for (const field of Object.keys(value)) {
84
+ if (!allowed.has(field)) throw new Error(`monitor input field ${field} is not supported`);
85
+ }
74
86
  const task = requireBoundedString(value.task, "task", 8_000);
87
+ const everyMinutes = value.everyMinutes ?? DEFAULT_INTERVAL_MINUTES;
75
88
  if (
76
- typeof value.everyMinutes !== "number" ||
77
- !Number.isInteger(value.everyMinutes) ||
78
- value.everyMinutes < MIN_INTERVAL_MINUTES ||
79
- value.everyMinutes > MAX_INTERVAL_MINUTES
89
+ !Number.isInteger(everyMinutes) ||
90
+ everyMinutes < MIN_INTERVAL_MINUTES ||
91
+ everyMinutes > MAX_INTERVAL_MINUTES
80
92
  ) {
81
93
  throw new Error(
82
94
  `everyMinutes must be an integer from ${MIN_INTERVAL_MINUTES} through ${MAX_INTERVAL_MINUTES}`,
@@ -87,7 +99,7 @@ function prepareInput(input: unknown): MonitorConfig {
87
99
  throw new Error(`maxChecks must be an integer from 1 through ${MAX_CHECKS}`);
88
100
  }
89
101
  const checkTimeoutMinutes =
90
- value.checkTimeoutMinutes ?? Math.max(DEFAULT_MIN_CHECK_TIMEOUT_MINUTES, value.everyMinutes);
102
+ value.checkTimeoutMinutes ?? Math.max(DEFAULT_MIN_CHECK_TIMEOUT_MINUTES, everyMinutes);
91
103
  if (
92
104
  !Number.isInteger(checkTimeoutMinutes) ||
93
105
  checkTimeoutMinutes < MIN_CHECK_TIMEOUT_MINUTES ||
@@ -99,14 +111,10 @@ function prepareInput(input: unknown): MonitorConfig {
99
111
  }
100
112
  return {
101
113
  task,
102
- everyMinutes: value.everyMinutes,
103
- reportWhen:
104
- value.reportWhen === undefined
105
- ? "The observed state changes materially or needs the user's attention."
106
- : requireBoundedString(value.reportWhen, "reportWhen", 4_000),
114
+ everyMinutes,
107
115
  stopWhen:
108
116
  value.stopWhen === undefined
109
- ? "The user cancels the monitor or it reaches its maximum check count."
117
+ ? "Stop only when the user explicitly asks to stop."
110
118
  : requireBoundedString(value.stopWhen, "stopWhen", 4_000),
111
119
  maxChecks,
112
120
  checkTimeoutMinutes,
@@ -117,124 +125,198 @@ function configFrom(outputs: Record<string, unknown>): MonitorConfig {
117
125
  return outputs.prepare as MonitorConfig;
118
126
  }
119
127
 
120
- function agentTimeoutMs({ outputs }: WorkflowNodeContext): number {
121
- return configFrom(outputs).checkTimeoutMinutes * 60_000;
122
- }
123
-
124
128
  function completedChecks(context: WorkflowNodeContext): number {
125
129
  return context.state.steps.filter((step) => step.nodeId === "check" && step.outcome === "ok")
126
130
  .length;
127
131
  }
128
132
 
129
- function validateCheck(output: unknown): MonitorCheck {
133
+ export function validateMonitorCheck(output: unknown): MonitorCheck {
130
134
  const value = requireRecord(output, "monitor check output");
131
- if (typeof value.route !== "string" || !MONITOR_ROUTES.has(value.route as MonitorRoute)) {
132
- throw new Error(`route must be one of ${[...MONITOR_ROUTES].join(", ")}`);
133
- }
134
- const route = value.route as MonitorRoute;
135
- const observation = requireBoundedString(value.observation, "observation", MAX_OBSERVATION_CHARS);
136
- const reason = requireBoundedString(value.reason, "reason", MAX_REASON_CHARS);
137
- const reports = route === "continue_report" || route === "stop_report";
138
- const report =
139
- value.report === undefined
140
- ? undefined
141
- : requireBoundedString(value.report, "report", MAX_REPORT_CHARS);
142
- if (reports && report === undefined) {
143
- throw new Error(`route ${route} requires a report`);
135
+ const allowed = new Set(["route", "observation", "report", "progress", "reason"]);
136
+ for (const key of Object.keys(value))
137
+ if (!allowed.has(key)) throw new Error(`monitor check field ${key} is not supported`);
138
+ if (value.route !== "continue" && value.route !== "stop")
139
+ throw new Error("route must be continue or stop");
140
+ const check: MonitorCheck = {
141
+ route: value.route,
142
+ observation: requireBoundedString(value.observation, "observation", MAX_OBSERVATION_CHARS),
143
+ report: requireBoundedString(value.report, "report", MAX_REPORT_CHARS),
144
+ reason: requireBoundedString(value.reason, "reason", MAX_REASON_CHARS),
145
+ };
146
+ if (value.progress !== undefined) check.progress = validateMonitorProgress(value.progress);
147
+ return check;
148
+ }
149
+
150
+ function validateMonitorProgress(input: unknown): { tracks: MonitorTrack[] } {
151
+ const value = requireRecord(input, "progress");
152
+ if (Object.keys(value).some((key) => key !== "tracks"))
153
+ throw new Error("progress only supports tracks");
154
+ if (!Array.isArray(value.tracks) || value.tracks.length < 1 || value.tracks.length > MAX_TRACKS) {
155
+ throw new Error(`progress.tracks must contain 1 through ${MAX_TRACKS} entries`);
144
156
  }
157
+ const keys = new Set<string>();
158
+ const tracks = value.tracks.map((raw, index) => {
159
+ const track = requireRecord(raw, `progress.tracks[${index}]`);
160
+ if (Object.keys(track).some((key) => key !== "key" && key !== "data")) {
161
+ throw new Error(`progress.tracks[${index}] has an unsupported field`);
162
+ }
163
+ const key = requireBoundedString(track.key, `progress.tracks[${index}].key`, 128);
164
+ if (!/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/.test(key))
165
+ throw new Error(`progress.tracks[${index}].key is invalid`);
166
+ if (keys.has(key)) throw new Error(`progress track key ${key} is duplicated`);
167
+ keys.add(key);
168
+ return {
169
+ key,
170
+ data: validateProgressData(requireRecord(track.data, `progress.tracks[${index}].data`)),
171
+ };
172
+ });
173
+ return { tracks };
174
+ }
175
+
176
+ function estimateTracks(outputs: Record<string, unknown>): MonitorEstimate {
177
+ const check = outputs.check as MonitorCheck;
178
+ if (check.progress === undefined) return { tracks: [] };
179
+ const previous = outputs.estimate as MonitorEstimate | undefined;
180
+ const previousByKey = new Map((previous?.tracks ?? []).map((track) => [track.key, track]));
181
+ const at = new Date().toISOString();
145
182
  return {
146
- route,
147
- observation,
148
- ...(report !== undefined ? { report } : {}),
149
- reason,
183
+ tracks: check.progress.tracks.map((track) => {
184
+ const samples: ProgressSample[] = [
185
+ ...(previousByKey.get(track.key)?.samples ?? []),
186
+ { at, data: track.data },
187
+ ].slice(-9);
188
+ return { key: track.key, samples, estimate: estimateProgress(track.key, samples) };
189
+ }),
150
190
  };
151
191
  }
152
192
 
153
- function reportMessage(outputs: Record<string, unknown>): string {
154
- const check = outputs.check as MonitorCheck;
155
- return check.report ?? check.observation;
193
+ function reportMessage(context: WorkflowNodeContext): string {
194
+ const check = context.outputs.check as MonitorCheck;
195
+ const estimate = context.outputs.estimate as MonitorEstimate;
196
+ const config = configFrom(context.outputs);
197
+ const suffix: string[] = [];
198
+ if (estimate.tracks.length > 0) {
199
+ suffix.push(
200
+ formatProgressReport(
201
+ estimate.tracks.map((track) => track.estimate),
202
+ check.route === "continue" ? config.everyMinutes : undefined,
203
+ new Date(),
204
+ 2_000,
205
+ ),
206
+ );
207
+ }
208
+ if (check.route === "continue" && completedChecks(context) >= config.maxChecks) {
209
+ suffix.push(`Reached the ${config.maxChecks}-check safety limit.`);
210
+ }
211
+ const suffixText = suffix.filter(Boolean).join("\n");
212
+ if (suffixText.length === 0) return check.report;
213
+ const reportBudget = Math.max(1, MAX_REPORT_CHARS - suffixText.length - 1);
214
+ const report =
215
+ check.report.length <= reportBudget
216
+ ? check.report
217
+ : `${check.report.slice(0, Math.max(0, reportBudget - 1))}…`;
218
+ return `${report}\n${suffixText}`;
156
219
  }
157
220
 
158
- export default defineWorkflow({
221
+ const monitorWorkflow: WorkflowDefinition = defineWorkflow({
159
222
  name: "monitor",
160
223
  title: ({ input }) => {
161
224
  try {
162
- const task = prepareInput(input).task;
163
- return `monitor: ${task.slice(0, 80)}`;
225
+ return `monitor: ${prepareMonitorInput(input).task.slice(0, 80)}`;
164
226
  } catch {
165
227
  return "monitor";
166
228
  }
167
229
  },
168
- presentationPrompt: ({ finalOutput }) => {
169
- const result = requireRecord(finalOutput, "monitor result");
170
- if (result.reported === true) {
171
- return undefined;
172
- }
173
- return `Tell the user concisely why this monitor stopped: ${String(result.reason ?? "monitor ended")}`;
174
- },
175
230
  startAt: "prepare",
176
- maxSteps: 5_010,
231
+ maxSteps: 9_010,
177
232
  nodes: {
178
- prepare: compute({
179
- run: ({ input }) => prepareInput(input),
180
- }),
181
- guard: compute({
182
- run: (context) => {
183
- const config = configFrom(context.outputs);
184
- const checks = completedChecks(context);
185
- return checks >= config.maxChecks
186
- ? { route: "stop", checks, reason: `Reached the ${config.maxChecks}-check limit.` }
187
- : { route: "check", checks };
188
- },
189
- }),
190
- continue_guard: compute({
191
- run: (context) => {
192
- const config = configFrom(context.outputs);
193
- const checks = completedChecks(context);
194
- return checks >= config.maxChecks
195
- ? { route: "stop", checks, reason: `Reached the ${config.maxChecks}-check limit.` }
196
- : { route: "sleep", checks };
197
- },
198
- }),
233
+ prepare: compute({ run: ({ input }) => prepareMonitorInput(input) }),
199
234
  check: agent({
200
235
  statusDetail: "checking monitored target",
201
- timeoutMs: agentTimeoutMs,
236
+ timeoutMs: ({ outputs }) => configFrom(outputs).checkTimeoutMinutes * 60_000,
202
237
  prompt: (context) => {
203
238
  const config = configFrom(context.outputs);
204
239
  const previous = context.outputs.check as MonitorCheck | undefined;
205
- const checkNumber = completedChecks(context) + 1;
240
+ const priorEstimate = context.outputs.estimate as MonitorEstimate | undefined;
206
241
  return [
207
- `Perform monitoring check ${checkNumber} of at most ${config.maxChecks}.`,
242
+ `Perform monitoring check ${completedChecks(context) + 1} of at most ${config.maxChecks}.`,
208
243
  `Task: ${config.task}`,
209
- `Report when: ${config.reportWhen}`,
210
244
  `Stop when: ${config.stopWhen}`,
211
245
  previous === undefined
212
- ? "There is no previous observation. Report the initial state only when the report condition calls for it."
246
+ ? "There is no previous observation."
213
247
  : `Previous accepted observation: ${previous.observation}`,
214
- "Use available tools to inspect the current state. Observe only unless the task explicitly authorizes a mutation.",
215
- "Choose continue_quiet, continue_report, stop_quiet, or stop_report. A report route requires concise report text.",
248
+ priorEstimate?.tracks.length
249
+ ? `Previous progress: ${formatProgressReport(priorEstimate.tracks.map((track) => track.estimate))}`
250
+ : "There is no previous measured progress.",
251
+ "Use available tools to inspect the current source of truth. Observe only unless the task explicitly authorizes a mutation.",
252
+ "Every accepted check must include a concise user-facing report. Add progress tracks only when the target provides measurable facts. Submit observed counts and target-provided finish times; do not invent rates or an ETA.",
253
+ "Choose route continue or stop.",
216
254
  ].join("\n\n");
217
255
  },
218
256
  expectedOutput:
219
- '{ "route": "continue_quiet" | "continue_report" | "stop_quiet" | "stop_report", "observation": "current factual state", "report": "required for report routes", "reason": "short reason" }',
220
- validate: (output) => validateCheck(output),
257
+ '{ "route": "continue" | "stop", "observation": "current factual state", "report": "concise status update", "progress": { "tracks": [{ "key": "stable-key", "data": { "schema": "pi-workflows.progress.v1", "status": "running", "completed": 1, "total": 2, "unit": "items" } }] } (optional), "reason": "short reason" }',
258
+ validate: (output) => validateMonitorCheck(output),
259
+ }),
260
+ estimate: compute({ run: ({ outputs }) => estimateTracks(outputs) }),
261
+ publish_progress: action({
262
+ statusDetail: "publishing monitor progress",
263
+ run: async ({ outputs, publishUpdate, signal }) => {
264
+ const check = outputs.check as MonitorCheck;
265
+ if (check.progress === undefined) return { published: 0 };
266
+ for (const track of check.progress.tracks) {
267
+ await waitForUpdateSlot(signal);
268
+ await publishUpdate({ type: "progress", key: track.key, data: track.data });
269
+ }
270
+ return { published: check.progress.tracks.length };
271
+ },
221
272
  }),
222
- report_continue: notify({
273
+ report: notify({
223
274
  statusDetail: "queueing monitor update",
224
- message: ({ outputs }) => reportMessage(outputs),
275
+ message: (context) => reportMessage(context),
225
276
  kind: "progress",
226
277
  }),
227
- report_stop: notify({
228
- statusDetail: "queueing final monitor update",
229
- message: ({ outputs }) => reportMessage(outputs),
230
- kind: "final",
278
+ decide: compute({
279
+ run: (context) => {
280
+ const check = context.outputs.check as MonitorCheck;
281
+ const config = configFrom(context.outputs);
282
+ const checks = completedChecks(context);
283
+ if (check.route === "stop") return { route: "stop", reason: check.reason, checks };
284
+ if (checks >= config.maxChecks) {
285
+ return {
286
+ route: "stop",
287
+ reason: `Reached the ${config.maxChecks}-check safety limit.`,
288
+ checks,
289
+ };
290
+ }
291
+ return { route: "continue", reason: check.reason, checks };
292
+ },
293
+ }),
294
+ schedule: action({
295
+ statusDetail: "scheduling next monitor check",
296
+ run: async ({ outputs, publishUpdate }) => {
297
+ const config = configFrom(outputs);
298
+ const lastCheckAt = new Date().toISOString();
299
+ const nextCheckAt = new Date(
300
+ Date.parse(lastCheckAt) + config.everyMinutes * 60_000,
301
+ ).toISOString();
302
+ await publishUpdate({
303
+ type: "monitor.schedule",
304
+ key: "next-check",
305
+ data: {
306
+ schema: "pi-workflows.monitor-schedule.v1",
307
+ lastCheckAt,
308
+ nextCheckAt,
309
+ everyMinutes: config.everyMinutes,
310
+ },
311
+ });
312
+ return { lastCheckAt, nextCheckAt, everyMinutes: config.everyMinutes };
313
+ },
231
314
  }),
232
315
  sleep: shell({
233
316
  statusDetail: "waiting for next monitor check",
234
317
  timeoutMs: MAX_INTERVAL_MINUTES * 60_000 + NODE_TIMEOUT_MARGIN_MS,
235
318
  exec: ({ outputs }) => {
236
- const config = configFrom(outputs);
237
- const sleepMs = config.everyMinutes * 60_000;
319
+ const sleepMs = configFrom(outputs).everyMinutes * 60_000;
238
320
  return {
239
321
  command: process.execPath,
240
322
  args: ["-e", "setTimeout(() => {}, Number(process.argv[1]))", String(sleepMs)],
@@ -246,40 +328,27 @@ export default defineWorkflow({
246
328
  }),
247
329
  finish: compute({
248
330
  run: ({ outputs }) => {
249
- const check = outputs.check as MonitorCheck | undefined;
250
- const guard = outputs.guard as { reason?: string } | undefined;
251
- const continueGuard = outputs.continue_guard as { reason?: string } | undefined;
331
+ const check = outputs.check as MonitorCheck;
332
+ const decision = outputs.decide as { reason?: string; checks?: number } | undefined;
252
333
  return {
253
- reason: continueGuard?.reason ?? guard?.reason ?? check?.reason ?? "Monitor finished.",
254
- observation: check?.observation ?? null,
255
- reported:
256
- outputs.report_stop !== undefined ||
257
- (check !== undefined && check.route === "stop_report"),
334
+ reason: decision?.reason ?? check.reason,
335
+ observation: check.observation,
336
+ checks: decision?.checks ?? 1,
337
+ reported: true,
258
338
  };
259
339
  },
260
340
  }),
261
341
  },
262
342
  edges: [
263
- { from: "prepare", to: "guard" },
264
- { from: "guard", switch: { on: "$.route", cases: { check: "check", stop: "finish" } } },
265
- {
266
- from: "check",
267
- switch: {
268
- on: "$.route",
269
- cases: {
270
- continue_quiet: "continue_guard",
271
- continue_report: "report_continue",
272
- stop_quiet: "finish",
273
- stop_report: "report_stop",
274
- },
275
- },
276
- },
277
- { from: "report_continue", to: "continue_guard" },
278
- { from: "report_stop", to: "finish" },
279
- {
280
- from: "continue_guard",
281
- switch: { on: "$.route", cases: { sleep: "sleep", stop: "finish" } },
282
- },
283
- { from: "sleep", to: "guard" },
343
+ { from: "prepare", to: "check" },
344
+ { from: "check", to: "estimate" },
345
+ { from: "estimate", to: "publish_progress" },
346
+ { from: "publish_progress", to: "report" },
347
+ { from: "report", to: "decide" },
348
+ { from: "decide", switch: { on: "$.route", cases: { stop: "finish", continue: "schedule" } } },
349
+ { from: "schedule", to: "sleep" },
350
+ { from: "sleep", to: "check" },
284
351
  ],
285
352
  });
353
+
354
+ export default monitorWorkflow;
@@ -9,8 +9,13 @@ export type SubmissionResult =
9
9
  | { accepted: true; message: string }
10
10
  | { accepted: false; message: string };
11
11
 
12
+ export type PromptDeliveryKind = "step" | "reminder" | "resume";
13
+
12
14
  export type PromptDelivery = {
13
15
  prompt: string;
16
+ contract: AgentStepRequest["contract"];
17
+ presentation?: AgentStepRequest["presentation"];
18
+ kind: PromptDeliveryKind;
14
19
  /** True when the agent is known to be mid-run, so delivery must be queued. */
15
20
  streaming: boolean;
16
21
  };
@@ -54,8 +59,8 @@ const DEFAULT_MAX_NUDGES = 2;
54
59
 
55
60
  /**
56
61
  * AgentStepExecutor that runs steps inside the current pi conversation. The
57
- * engine hands it a prompt; it delivers the prompt as a user message and
58
- * resolves once the model submits an accepted output through the `workflow`
62
+ * engine hands it a prompt; it delivers the prompt as a model-facing workflow
63
+ * message and resolves once the model submits an accepted output through the `workflow`
59
64
  * tool. If the agent settles without submitting, it nudges the model a
60
65
  * bounded number of times before failing the step.
61
66
  */
@@ -111,7 +116,7 @@ export class ConversationStepExecutor implements AgentStepExecutor {
111
116
  }
112
117
  try {
113
118
  this.conversation?.beginAttempt?.(pending.request.contract);
114
- this.sendPrompt({ prompt: pending.request.prompt, streaming: this.streaming });
119
+ this.sendPrompt(this.delivery(pending.request, pending.request.prompt, "resume"));
115
120
  } catch (error) {
116
121
  this.clearPending();
117
122
  pending.reject(error);
@@ -156,7 +161,7 @@ export class ConversationStepExecutor implements AgentStepExecutor {
156
161
  return;
157
162
  }
158
163
  try {
159
- this.sendPrompt({ prompt: request.prompt, streaming: this.streaming });
164
+ this.sendPrompt(this.delivery(request, request.prompt, "step"));
160
165
  } catch (error) {
161
166
  // A failed delivery must not leave the step installed, or every
162
167
  // subsequent agent node would fail with "already awaiting output".
@@ -227,7 +232,7 @@ export class ConversationStepExecutor implements AgentStepExecutor {
227
232
  accepted: true,
228
233
  message: [
229
234
  `Output accepted for step ${JSON.stringify(stepId)}.`,
230
- "If the workflow continues, the next step arrives as a new user message. End your turn now.",
235
+ "If the workflow continues, the next step arrives as a new workflow message. End your turn now.",
231
236
  ].join(" "),
232
237
  };
233
238
  }
@@ -262,15 +267,18 @@ export class ConversationStepExecutor implements AgentStepExecutor {
262
267
  const { nodeId, attemptId } = pending.request.contract;
263
268
  try {
264
269
  this.conversation?.beginAttempt?.(pending.request.contract);
265
- this.sendPrompt({
266
- prompt: [
267
- `Reminder: workflow step ${JSON.stringify(nodeId)} is still awaiting your output.`,
268
- "Complete it by calling the `workflow` tool with:",
269
- `{"action": "submit", "step": ${JSON.stringify(nodeId)}, "attempt": ${JSON.stringify(attemptId)}, "output": <your result>}`,
270
- `Expected output: ${pending.request.contract.expectedOutput ?? "a JSON object with your result"}`,
271
- ].join("\n"),
272
- streaming: this.streaming,
273
- });
270
+ this.sendPrompt(
271
+ this.delivery(
272
+ pending.request,
273
+ [
274
+ `Reminder: workflow step ${JSON.stringify(nodeId)} is still awaiting your output.`,
275
+ "Complete it by calling the `workflow` tool with:",
276
+ `{"action": "submit", "step": ${JSON.stringify(nodeId)}, "attempt": ${JSON.stringify(attemptId)}, "output": <your result>}`,
277
+ `Expected output: ${pending.request.contract.expectedOutput ?? "a JSON object with your result"}`,
278
+ ].join("\n"),
279
+ "reminder",
280
+ ),
281
+ );
274
282
  } catch (error) {
275
283
  // No reminder turn was started, so nothing would settle the step; fail
276
284
  // it promptly instead of waiting out the node timeout.
@@ -281,6 +289,20 @@ export class ConversationStepExecutor implements AgentStepExecutor {
281
289
  return true;
282
290
  }
283
291
 
292
+ private delivery(
293
+ request: AgentStepRequest,
294
+ prompt: string,
295
+ kind: PromptDeliveryKind,
296
+ ): PromptDelivery {
297
+ return {
298
+ prompt,
299
+ contract: request.contract,
300
+ ...(request.presentation !== undefined ? { presentation: request.presentation } : {}),
301
+ kind,
302
+ streaming: this.streaming,
303
+ };
304
+ }
305
+
284
306
  private clearPending(): void {
285
307
  this.pending?.cleanup();
286
308
  this.pending?.markCleared();