@ryanfw/prompt-orchestration-pipeline 0.14.0 → 0.14.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ryanfw/prompt-orchestration-pipeline",
3
- "version": "0.14.0",
3
+ "version": "0.14.2",
4
4
  "description": "A Prompt-orchestration pipeline (POP) is a framework for building, running, and experimenting with complex chains of LLM tasks.",
5
5
  "type": "module",
6
6
  "main": "src/ui/server.js",
@@ -130,25 +130,35 @@ export function RestartJobModal({
130
130
  Cancel
131
131
  </Button>
132
132
 
133
- {taskId && (
133
+ {taskId ? (
134
+ <>
135
+ <Button
136
+ variant="outline"
137
+ onClick={() => onConfirm({ singleTask: false })}
138
+ disabled={isSubmitting}
139
+ className="min-w-[120px]"
140
+ >
141
+ {isSubmitting ? "Restarting..." : "Restart entire pipeline"}
142
+ </Button>
143
+ <Button
144
+ variant="default"
145
+ onClick={() => onConfirm({ singleTask: true })}
146
+ disabled={isSubmitting}
147
+ className="min-w-[120px]"
148
+ >
149
+ {isSubmitting ? "Running..." : "Re-run this task"}
150
+ </Button>
151
+ </>
152
+ ) : (
134
153
  <Button
135
- variant="outline"
136
- onClick={() => onConfirm({ singleTask: true })}
154
+ variant="destructive"
155
+ onClick={() => onConfirm({ singleTask: false })}
137
156
  disabled={isSubmitting}
138
- className="min-w-[120px]"
157
+ className="min-w-[80px]"
139
158
  >
140
- {isSubmitting ? "Running..." : "Just this task"}
159
+ {isSubmitting ? "Restarting..." : "Restart"}
141
160
  </Button>
142
161
  )}
143
-
144
- <Button
145
- variant="destructive"
146
- onClick={() => onConfirm({ singleTask: false })}
147
- disabled={isSubmitting}
148
- className="min-w-[80px]"
149
- >
150
- {isSubmitting ? "Restarting..." : "Restart"}
151
- </Button>
152
162
  </Flex>
153
163
  </div>
154
164
  </div>
@@ -18,6 +18,8 @@ import { createJobLogger } from "./logger.js";
18
18
  import { LogEvent, LogFileExtension } from "../config/log-events.js";
19
19
  import { decideTransition } from "./lifecycle-policy.js";
20
20
 
21
+ const getTaskName = (t) => (typeof t === "string" ? t : t.name);
22
+
21
23
  const ROOT = process.env.PO_ROOT || process.cwd();
22
24
  const DATA_DIR = path.join(ROOT, process.env.PO_DATA_DIR || "pipeline-data");
23
25
  const CURRENT_DIR =
@@ -104,6 +106,8 @@ const pipeline = JSON.parse(await fs.readFile(PIPELINE_DEF_PATH, "utf8"));
104
106
  // Validate pipeline format early with a friendly error message
105
107
  validatePipelineOrThrow(pipeline, PIPELINE_DEF_PATH);
106
108
 
109
+ const taskNames = pipeline.tasks.map(getTaskName);
110
+
107
111
  const tasks = (await loadFreshModule(TASK_REGISTRY)).default;
108
112
 
109
113
  const status = JSON.parse(await fs.readFile(tasksStatusPath, "utf8"));
@@ -123,21 +127,21 @@ logger.group("Pipeline execution", {
123
127
 
124
128
  // Helper function to check if all upstream dependencies are completed
125
129
  function areDependenciesReady(taskName) {
126
- const taskIndex = pipeline.tasks.indexOf(taskName);
130
+ const taskIndex = taskNames.indexOf(taskName);
127
131
  if (taskIndex === -1) return false;
128
132
 
129
- const upstreamTasks = pipeline.tasks.slice(0, taskIndex);
133
+ const upstreamTasks = taskNames.slice(0, taskIndex);
130
134
  return upstreamTasks.every(
131
135
  (upstreamTask) => status.tasks[upstreamTask]?.state === TaskState.DONE
132
136
  );
133
137
  }
134
138
 
135
139
  try {
136
- for (const taskName of pipeline.tasks) {
140
+ for (const taskName of taskNames) {
137
141
  // Skip tasks before startFromTask when targeting a specific restart point
138
142
  if (
139
143
  startFromTask &&
140
- pipeline.tasks.indexOf(taskName) < pipeline.tasks.indexOf(startFromTask)
144
+ taskNames.indexOf(taskName) < taskNames.indexOf(startFromTask)
141
145
  ) {
142
146
  logger.log("Skipping task before restart point", {
143
147
  taskName,
@@ -158,30 +162,32 @@ try {
158
162
  continue;
159
163
  }
160
164
 
161
- // Check lifecycle policy before starting task
162
- const currentTaskState = status.tasks[taskName]?.state || "pending";
163
- const dependenciesReady = areDependenciesReady(taskName);
164
-
165
- const lifecycleDecision = decideTransition({
166
- op: "start",
167
- taskState: currentTaskState,
168
- dependenciesReady,
169
- });
165
+ // Check lifecycle policy before starting task (skip when startFromTask is set - user explicitly requested this task)
166
+ if (!startFromTask) {
167
+ const currentTaskState = status.tasks[taskName]?.state || "pending";
168
+ const dependenciesReady = areDependenciesReady(taskName);
170
169
 
171
- if (!lifecycleDecision.ok) {
172
- logger.warn("lifecycle_block", {
173
- jobId,
174
- taskId: taskName,
170
+ const lifecycleDecision = decideTransition({
175
171
  op: "start",
176
- reason: lifecycleDecision.reason,
172
+ taskState: currentTaskState,
173
+ dependenciesReady,
177
174
  });
178
175
 
179
- // Create typed error for endpoints to handle
180
- const lifecycleError = new Error(lifecycleDecision.reason);
181
- lifecycleError.httpStatus = 409;
182
- lifecycleError.error = "unsupported_lifecycle";
183
- lifecycleError.reason = lifecycleDecision.reason;
184
- throw lifecycleError;
176
+ if (!lifecycleDecision.ok) {
177
+ logger.warn("lifecycle_block", {
178
+ jobId,
179
+ taskId: taskName,
180
+ op: "start",
181
+ reason: lifecycleDecision.reason,
182
+ });
183
+
184
+ // Create typed error for endpoints to handle
185
+ const lifecycleError = new Error(lifecycleDecision.reason);
186
+ lifecycleError.httpStatus = 409;
187
+ lifecycleError.error = "unsupported_lifecycle";
188
+ lifecycleError.reason = lifecycleDecision.reason;
189
+ throw lifecycleError;
190
+ }
185
191
  }
186
192
 
187
193
  logger.log("Starting task", { taskName });
package/src/llm/index.js CHANGED
@@ -407,6 +407,17 @@ export async function chat(options) {
407
407
  }
408
408
  } else if (provider === "gemini") {
409
409
  console.log("[llm] Using Gemini provider");
410
+
411
+ // Infer JSON format if not explicitly provided
412
+ const effectiveResponseFormat =
413
+ responseFormat === undefined ||
414
+ responseFormat === null ||
415
+ responseFormat === ""
416
+ ? shouldInferJsonFormat(messages)
417
+ ? "json_object"
418
+ : undefined
419
+ : responseFormat;
420
+
410
421
  const geminiArgs = {
411
422
  messages,
412
423
  model: model || "gemini-2.5-flash",
@@ -421,8 +432,8 @@ export async function chat(options) {
421
432
  });
422
433
  if (topP !== undefined) geminiArgs.topP = topP;
423
434
  if (stop !== undefined) geminiArgs.stop = stop;
424
- if (responseFormat !== undefined) {
425
- geminiArgs.responseFormat = responseFormat;
435
+ if (effectiveResponseFormat !== undefined) {
436
+ geminiArgs.responseFormat = effectiveResponseFormat;
426
437
  }
427
438
 
428
439
  console.log("[llm] Calling geminiChat()...");
@@ -2,6 +2,7 @@ import {
2
2
  extractMessages,
3
3
  isRetryableError,
4
4
  sleep,
5
+ stripMarkdownFences,
5
6
  tryParseJSON,
6
7
  ensureJsonResponseFormat,
7
8
  ProviderJsonParseError,
@@ -77,10 +78,12 @@ export async function anthropicChat({
77
78
 
78
79
  // Extract text from response.content blocks
79
80
  const blocks = Array.isArray(data?.content) ? data.content : [];
80
- const text = blocks
81
+ const rawText = blocks
81
82
  .filter((b) => b?.type === "text" && typeof b.text === "string")
82
83
  .map((b) => b.text)
83
84
  .join("");
85
+ // Always strip markdown fences first to prevent parse failures
86
+ const text = stripMarkdownFences(rawText);
84
87
  console.log("[Anthropic] Response text length:", text.length);
85
88
 
86
89
  // Parse JSON - this is required for all calls
@@ -33,6 +33,25 @@ export async function sleep(ms) {
33
33
  return new Promise((r) => setTimeout(r, ms));
34
34
  }
35
35
 
36
+ /**
37
+ * Strip markdown code fences from text unconditionally.
38
+ * Handles ```json, ```JSON, and plain ``` with or without newlines.
39
+ * @param {string} text - The text to strip fences from
40
+ * @returns {string} The cleaned text, or original if not a string
41
+ */
42
+ export function stripMarkdownFences(text) {
43
+ if (typeof text !== "string") return text;
44
+ const trimmed = text.trim();
45
+ if (trimmed.startsWith("```")) {
46
+ // Remove opening fence (```json, ```JSON, or just ```)
47
+ let cleaned = trimmed.replace(/^```(?:json|JSON)?\s*\n?/, "");
48
+ // Remove closing fence
49
+ cleaned = cleaned.replace(/\n?```\s*$/, "");
50
+ return cleaned.trim();
51
+ }
52
+ return text;
53
+ }
54
+
36
55
  export function tryParseJSON(text) {
37
56
  try {
38
57
  return JSON.parse(text);
@@ -85,7 +104,12 @@ export class ProviderJsonModeError extends Error {
85
104
  * Error thrown when JSON parsing fails and should not be retried
86
105
  */
87
106
  export class ProviderJsonParseError extends Error {
88
- constructor(provider, model, sample, message = "Failed to parse JSON response") {
107
+ constructor(
108
+ provider,
109
+ model,
110
+ sample,
111
+ message = "Failed to parse JSON response"
112
+ ) {
89
113
  super(message);
90
114
  this.name = "ProviderJsonParseError";
91
115
  this.provider = provider;
@@ -109,8 +133,9 @@ export function ensureJsonResponseFormat(responseFormat, providerName) {
109
133
  }
110
134
 
111
135
  // Check for valid JSON format types
112
- const isValidJsonFormat =
136
+ const isValidJsonFormat =
113
137
  responseFormat === "json" ||
138
+ responseFormat === "json_object" ||
114
139
  responseFormat?.type === "json_object" ||
115
140
  responseFormat?.type === "json_schema";
116
141
 
@@ -2,6 +2,7 @@ import {
2
2
  extractMessages,
3
3
  isRetryableError,
4
4
  sleep,
5
+ stripMarkdownFences,
5
6
  tryParseJSON,
6
7
  ensureJsonResponseFormat,
7
8
  ProviderJsonParseError,
@@ -24,11 +25,12 @@ export async function deepseekChat({
24
25
  throw new Error("DeepSeek API key not configured");
25
26
  }
26
27
 
27
- // Determine if JSON mode is requested
28
+ // Determine if JSON mode is requested (handle both object and string formats)
28
29
  const isJsonMode =
29
30
  responseFormat?.type === "json_object" ||
30
31
  responseFormat?.type === "json_schema" ||
31
- responseFormat === "json";
32
+ responseFormat === "json" ||
33
+ responseFormat === "json_object";
32
34
 
33
35
  const { systemMsg, userMsg } = extractMessages(messages);
34
36
 
@@ -84,7 +86,10 @@ export async function deepseekChat({
84
86
  }
85
87
 
86
88
  const data = await response.json();
87
- const content = data.choices[0].message.content;
89
+ const rawContent = data.choices[0].message.content;
90
+
91
+ // Always strip markdown fences first to prevent parse failures
92
+ const content = stripMarkdownFences(rawContent);
88
93
 
89
94
  // Parse JSON only in JSON mode; return raw string for text mode
90
95
  if (isJsonMode) {
@@ -2,6 +2,7 @@ import {
2
2
  extractMessages,
3
3
  isRetryableError,
4
4
  sleep,
5
+ stripMarkdownFences,
5
6
  tryParseJSON,
6
7
  ensureJsonResponseFormat,
7
8
  ProviderJsonParseError,
@@ -32,7 +33,7 @@ export async function geminiChat(options) {
32
33
  frequencyPenalty,
33
34
  presencePenalty,
34
35
  stop,
35
- maxRetries = 3
36
+ maxRetries = 3,
36
37
  } = options;
37
38
 
38
39
  // Validate response format (Gemini only supports JSON mode)
@@ -171,7 +172,9 @@ export async function geminiChat(options) {
171
172
  throw new Error("No content returned from Gemini API");
172
173
  }
173
174
 
174
- const text = candidate.content.parts[0].text;
175
+ const rawText = candidate.content.parts[0].text;
176
+ // Always strip markdown fences first to prevent parse failures
177
+ const text = stripMarkdownFences(rawText);
175
178
  console.log(`[Gemini] Text length: ${text.length}`);
176
179
 
177
180
  // Parse JSON if required
@@ -3,6 +3,7 @@ import {
3
3
  extractMessages,
4
4
  isRetryableError,
5
5
  sleep,
6
+ stripMarkdownFences,
6
7
  tryParseJSON,
7
8
  ensureJsonResponseFormat,
8
9
  ProviderJsonParseError,
@@ -53,11 +54,12 @@ export async function openaiChat({
53
54
  console.log("[OpenAI] System message length:", systemMsg.length);
54
55
  console.log("[OpenAI] User message length:", userMsg.length);
55
56
 
56
- // Determine if JSON mode is requested
57
+ // Determine if JSON mode is requested (handle both object and string formats)
57
58
  const isJsonMode =
58
59
  responseFormat?.json_schema ||
59
60
  responseFormat?.type === "json_object" ||
60
- responseFormat === "json";
61
+ responseFormat === "json" ||
62
+ responseFormat === "json_object";
61
63
 
62
64
  let lastError;
63
65
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
@@ -100,7 +102,9 @@ export async function openaiChat({
100
102
 
101
103
  console.log("[OpenAI] Calling responses.create...");
102
104
  const resp = await openai.responses.create(responsesReq);
103
- const text = resp.output_text ?? "";
105
+ const rawText = resp.output_text ?? "";
106
+ // Always strip markdown fences first to prevent parse failures
107
+ const text = stripMarkdownFences(rawText);
104
108
  console.log("[OpenAI] Response received, text length:", text.length);
105
109
 
106
110
  // Approximate usage (tests don't assert exact values)
@@ -161,7 +165,9 @@ export async function openaiChat({
161
165
 
162
166
  console.log("[OpenAI] Calling chat.completions.create...");
163
167
  const classicRes = await openai.chat.completions.create(classicReq);
164
- const classicText = classicRes?.choices?.[0]?.message?.content ?? "";
168
+ const rawClassicText = classicRes?.choices?.[0]?.message?.content ?? "";
169
+ // Always strip markdown fences first to prevent parse failures
170
+ const classicText = stripMarkdownFences(rawClassicText);
165
171
  console.log(
166
172
  "[OpenAI] Response received, text length:",
167
173
  classicText.length
@@ -228,7 +234,9 @@ export async function openaiChat({
228
234
  }
229
235
 
230
236
  const classicRes = await openai.chat.completions.create(classicReq);
231
- const text = classicRes?.choices?.[0]?.message?.content ?? "";
237
+ const rawText = classicRes?.choices?.[0]?.message?.content ?? "";
238
+ // Always strip markdown fences first to prevent parse failures
239
+ const text = stripMarkdownFences(rawText);
232
240
 
233
241
  // Parse JSON only in JSON mode; return raw string for text mode
234
242
  if (isJsonMode) {
@@ -2,6 +2,7 @@ import {
2
2
  extractMessages,
3
3
  isRetryableError,
4
4
  sleep,
5
+ stripMarkdownFences,
5
6
  tryParseJSON,
6
7
  ensureJsonResponseFormat,
7
8
  ProviderJsonParseError,
@@ -104,7 +105,9 @@ export async function zhipuChat({
104
105
  console.log("[Zhipu] Response received from Zhipu API");
105
106
 
106
107
  // Extract text from response
107
- const text = data?.choices?.[0]?.message?.content || "";
108
+ const rawText = data?.choices?.[0]?.message?.content || "";
109
+ // Always strip markdown fences first to prevent parse failures
110
+ const text = stripMarkdownFences(rawText);
108
111
  console.log("[Zhipu] Response text length:", text.length);
109
112
 
110
113
  // Parse JSON - this is required for all calls
@@ -24711,17 +24711,28 @@ function RestartJobModal({
24711
24711
  children: "Cancel"
24712
24712
  }
24713
24713
  ),
24714
- taskId && /* @__PURE__ */ jsxRuntimeExports.jsx(
24715
- Button,
24716
- {
24717
- variant: "outline",
24718
- onClick: () => onConfirm({ singleTask: true }),
24719
- disabled: isSubmitting,
24720
- className: "min-w-[120px]",
24721
- children: isSubmitting ? "Running..." : "Just this task"
24722
- }
24723
- ),
24724
- /* @__PURE__ */ jsxRuntimeExports.jsx(
24714
+ taskId ? /* @__PURE__ */ jsxRuntimeExports.jsxs(jsxRuntimeExports.Fragment, { children: [
24715
+ /* @__PURE__ */ jsxRuntimeExports.jsx(
24716
+ Button,
24717
+ {
24718
+ variant: "outline",
24719
+ onClick: () => onConfirm({ singleTask: false }),
24720
+ disabled: isSubmitting,
24721
+ className: "min-w-[120px]",
24722
+ children: isSubmitting ? "Restarting..." : "Restart entire pipeline"
24723
+ }
24724
+ ),
24725
+ /* @__PURE__ */ jsxRuntimeExports.jsx(
24726
+ Button,
24727
+ {
24728
+ variant: "default",
24729
+ onClick: () => onConfirm({ singleTask: true }),
24730
+ disabled: isSubmitting,
24731
+ className: "min-w-[120px]",
24732
+ children: isSubmitting ? "Running..." : "Re-run this task"
24733
+ }
24734
+ )
24735
+ ] }) : /* @__PURE__ */ jsxRuntimeExports.jsx(
24725
24736
  Button,
24726
24737
  {
24727
24738
  variant: "destructive",