pi-long-task 0.6.0 → 0.7.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/CHANGELOG.md +22 -0
- package/README.md +72 -5
- package/package.json +1 -1
- package/src/coordinator.ts +433 -50
- package/src/goal_orchestrator.ts +11 -0
- package/src/goal_todo_execution.ts +4 -0
- package/src/goal_todo_generation.ts +12 -10
- package/src/index.ts +13 -1
- package/src/network_recovery.ts +2 -2
- package/src/planner_config.ts +214 -0
- package/src/planner_progress.ts +156 -0
- package/src/render.ts +36 -0
- package/src/session_guard.ts +113 -7
- package/src/todo_generator.ts +82 -5
- package/src/types.ts +36 -0
- package/src/worker_capabilities.ts +103 -0
- package/src/worker_config.ts +74 -7
package/src/session_guard.ts
CHANGED
|
@@ -15,13 +15,28 @@ export interface GuardedSessionPromptOptions {
|
|
|
15
15
|
gracefulShutdownPrompt?: string;
|
|
16
16
|
diagnostics?: string[];
|
|
17
17
|
onEvent?: (event: unknown) => void;
|
|
18
|
+
/** Bounded elapsed-time checkpoints while the primary deadline is active. */
|
|
19
|
+
progressCheckpointsMs?: readonly number[];
|
|
20
|
+
onProgressCheckpoint?: (elapsedMs: number) => void;
|
|
21
|
+
/** Called exactly when a positive grace period begins. */
|
|
22
|
+
onGracePeriodStart?: (gracePeriodMs: number) => void;
|
|
18
23
|
dispose?: boolean;
|
|
19
24
|
}
|
|
20
25
|
|
|
21
26
|
export interface GuardedSessionPromptResult {
|
|
22
27
|
assistantText: string;
|
|
28
|
+
/** True when the primary prompt deadline elapsed, even if the prompt safely completed during grace. */
|
|
23
29
|
timedOut: boolean;
|
|
30
|
+
/** True only when a timed-out prompt settled during the configured grace period. */
|
|
31
|
+
completedDuringGrace: boolean;
|
|
32
|
+
/** True when non-whitespace assistant output was observed before prompt termination. */
|
|
33
|
+
outputObserved: boolean;
|
|
34
|
+
/** True when the session had to be stopped because its grace period expired. */
|
|
35
|
+
graceExpired: boolean;
|
|
36
|
+
/** True when the session was stopped for any reason, including hard timeout. */
|
|
24
37
|
aborted: boolean;
|
|
38
|
+
/** True only when the caller's AbortSignal cancelled the prompt. */
|
|
39
|
+
cancelled: boolean;
|
|
25
40
|
error?: string;
|
|
26
41
|
/** Untouched prompt failure for coordinator-level provider/transport classification. */
|
|
27
42
|
failure?: unknown;
|
|
@@ -39,14 +54,19 @@ export async function runGuardedSessionPrompt(
|
|
|
39
54
|
const events: unknown[] = [];
|
|
40
55
|
const timers = new Set<ReturnType<typeof setTimeout>>();
|
|
41
56
|
let assistantText = "";
|
|
57
|
+
let currentAssistantText = "";
|
|
58
|
+
let outputObserved = false;
|
|
42
59
|
let timedOut = false;
|
|
60
|
+
let graceExpired = false;
|
|
43
61
|
let aborted = false;
|
|
62
|
+
let cancelled = false;
|
|
44
63
|
let error: string | undefined;
|
|
45
64
|
let failure: unknown;
|
|
46
65
|
let promptSettled = false;
|
|
47
66
|
let finished = false;
|
|
48
67
|
let unsubscribe: (() => void) | undefined;
|
|
49
68
|
let complete: (() => void) | undefined;
|
|
69
|
+
const assistantTextAtStart = latestAssistantText(session, [], "");
|
|
50
70
|
|
|
51
71
|
const completed = new Promise<void>((resolve) => {
|
|
52
72
|
complete = resolve;
|
|
@@ -90,6 +110,14 @@ export async function runGuardedSessionPrompt(
|
|
|
90
110
|
}
|
|
91
111
|
};
|
|
92
112
|
|
|
113
|
+
const notifyTiming = (callback: (() => void) | undefined, label: string) => {
|
|
114
|
+
try {
|
|
115
|
+
callback?.();
|
|
116
|
+
} catch (exc) {
|
|
117
|
+
diagnostics.push(`${label} listener failed: ${errorMessage(exc)}`);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
93
121
|
const requestGracefulShutdown = () => {
|
|
94
122
|
const message = options.gracefulShutdownPrompt?.trim();
|
|
95
123
|
if (!message || finished || promptSettled || aborted) {
|
|
@@ -130,13 +158,18 @@ export async function runGuardedSessionPrompt(
|
|
|
130
158
|
}
|
|
131
159
|
timedOut = true;
|
|
132
160
|
diagnostics.push(`session prompt timed out after ${formatMilliseconds(timeoutMs(options.timeoutMs))}`);
|
|
133
|
-
requestGracefulShutdown();
|
|
134
161
|
|
|
135
162
|
const graceMs = nonNegativeMilliseconds(options.gracefulShutdownMs);
|
|
163
|
+
if (graceMs > 0) {
|
|
164
|
+
notifyTiming(() => options.onGracePeriodStart?.(graceMs), "grace-period progress");
|
|
165
|
+
}
|
|
166
|
+
requestGracefulShutdown();
|
|
167
|
+
|
|
136
168
|
const hardAbort = () => {
|
|
137
169
|
if (finished || promptSettled) {
|
|
138
170
|
return;
|
|
139
171
|
}
|
|
172
|
+
graceExpired = true;
|
|
140
173
|
abortSession(`session prompt exceeded ${formatMilliseconds(timeoutMs(options.timeoutMs))} timeout`);
|
|
141
174
|
resolveCompleted();
|
|
142
175
|
};
|
|
@@ -152,20 +185,34 @@ export async function runGuardedSessionPrompt(
|
|
|
152
185
|
if (finished || promptSettled) {
|
|
153
186
|
return;
|
|
154
187
|
}
|
|
155
|
-
|
|
188
|
+
cancelled = true;
|
|
189
|
+
abortSession(abortReason(options.abortSignal, "session prompt cancelled by outer signal"));
|
|
156
190
|
resolveCompleted();
|
|
157
191
|
};
|
|
158
192
|
|
|
159
193
|
try {
|
|
160
194
|
if (options.abortSignal?.aborted) {
|
|
161
195
|
aborted = true;
|
|
162
|
-
|
|
196
|
+
cancelled = true;
|
|
197
|
+
error = abortReason(options.abortSignal, "session prompt cancelled before start");
|
|
163
198
|
} else {
|
|
164
199
|
unsubscribe = session.subscribe((event: unknown) => {
|
|
165
200
|
events.push(event);
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
201
|
+
if (isAssistantMessageStart(event)) {
|
|
202
|
+
currentAssistantText = "";
|
|
203
|
+
}
|
|
204
|
+
const delta = assistantTextDeltaFromEvent(event);
|
|
205
|
+
if (delta !== undefined) {
|
|
206
|
+
currentAssistantText += delta;
|
|
207
|
+
assistantText = currentAssistantText || assistantText;
|
|
208
|
+
outputObserved ||= delta.trim().length > 0;
|
|
209
|
+
} else {
|
|
210
|
+
const text = assistantTextFromEvent(event);
|
|
211
|
+
if (text) {
|
|
212
|
+
currentAssistantText = text;
|
|
213
|
+
assistantText = text;
|
|
214
|
+
outputObserved ||= text.trim().length > 0;
|
|
215
|
+
}
|
|
169
216
|
}
|
|
170
217
|
try {
|
|
171
218
|
options.onEvent?.(event);
|
|
@@ -192,6 +239,13 @@ export async function runGuardedSessionPrompt(
|
|
|
192
239
|
|
|
193
240
|
const limitMs = timeoutMs(options.timeoutMs);
|
|
194
241
|
if (limitMs > 0) {
|
|
242
|
+
for (const checkpoint of normalizedProgressCheckpoints(options.progressCheckpointsMs, limitMs)) {
|
|
243
|
+
schedule(() => {
|
|
244
|
+
if (!finished && !promptSettled && !timedOut && !aborted) {
|
|
245
|
+
notifyTiming(() => options.onProgressCheckpoint?.(checkpoint), "timing progress");
|
|
246
|
+
}
|
|
247
|
+
}, checkpoint);
|
|
248
|
+
}
|
|
195
249
|
schedule(triggerTimeout, limitMs);
|
|
196
250
|
}
|
|
197
251
|
|
|
@@ -206,6 +260,7 @@ export async function runGuardedSessionPrompt(
|
|
|
206
260
|
options.abortSignal?.removeEventListener("abort", abortListener);
|
|
207
261
|
unsubscribe?.();
|
|
208
262
|
assistantText = latestAssistantText(session, events, assistantText);
|
|
263
|
+
outputObserved ||= assistantText.trim().length > 0 && assistantText !== assistantTextAtStart;
|
|
209
264
|
if (options.dispose !== false) {
|
|
210
265
|
try {
|
|
211
266
|
const disposeResult = (session.dispose as (() => unknown) | undefined)?.();
|
|
@@ -220,7 +275,20 @@ export async function runGuardedSessionPrompt(
|
|
|
220
275
|
}
|
|
221
276
|
}
|
|
222
277
|
|
|
223
|
-
return buildResult(
|
|
278
|
+
return buildResult(
|
|
279
|
+
session,
|
|
280
|
+
events,
|
|
281
|
+
assistantText,
|
|
282
|
+
timedOut,
|
|
283
|
+
timedOut && promptSettled && !graceExpired && !aborted && failure === undefined,
|
|
284
|
+
outputObserved,
|
|
285
|
+
graceExpired,
|
|
286
|
+
aborted,
|
|
287
|
+
cancelled,
|
|
288
|
+
error,
|
|
289
|
+
failure,
|
|
290
|
+
diagnostics,
|
|
291
|
+
);
|
|
224
292
|
}
|
|
225
293
|
|
|
226
294
|
function buildResult(
|
|
@@ -228,7 +296,11 @@ function buildResult(
|
|
|
228
296
|
events: unknown[],
|
|
229
297
|
assistantText: string,
|
|
230
298
|
timedOut: boolean,
|
|
299
|
+
completedDuringGrace: boolean,
|
|
300
|
+
outputObserved: boolean,
|
|
301
|
+
graceExpired: boolean,
|
|
231
302
|
aborted: boolean,
|
|
303
|
+
cancelled: boolean,
|
|
232
304
|
error: string | undefined,
|
|
233
305
|
failure: unknown,
|
|
234
306
|
diagnostics: string[],
|
|
@@ -236,7 +308,11 @@ function buildResult(
|
|
|
236
308
|
return {
|
|
237
309
|
assistantText: latestAssistantText(session, events, assistantText),
|
|
238
310
|
timedOut,
|
|
311
|
+
completedDuringGrace,
|
|
312
|
+
outputObserved,
|
|
313
|
+
graceExpired,
|
|
239
314
|
aborted,
|
|
315
|
+
cancelled,
|
|
240
316
|
error,
|
|
241
317
|
...(failure === undefined ? {} : { failure }),
|
|
242
318
|
diagnostics: [...diagnostics],
|
|
@@ -266,6 +342,16 @@ function timeoutMs(value: number | undefined): number {
|
|
|
266
342
|
return Math.max(0, value);
|
|
267
343
|
}
|
|
268
344
|
|
|
345
|
+
function normalizedProgressCheckpoints(values: readonly number[] | undefined, limitMs: number): number[] {
|
|
346
|
+
if (!values) {
|
|
347
|
+
return [];
|
|
348
|
+
}
|
|
349
|
+
return [...new Set(values)]
|
|
350
|
+
.filter((value) => Number.isFinite(value) && value > 0 && value < limitMs)
|
|
351
|
+
.map((value) => Math.floor(value))
|
|
352
|
+
.sort((left, right) => left - right);
|
|
353
|
+
}
|
|
354
|
+
|
|
269
355
|
function nonNegativeMilliseconds(value: number | undefined): number {
|
|
270
356
|
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
|
271
357
|
return 0;
|
|
@@ -277,6 +363,26 @@ function formatMilliseconds(ms: number): string {
|
|
|
277
363
|
return `${(ms / 1000).toFixed(3)}s`;
|
|
278
364
|
}
|
|
279
365
|
|
|
366
|
+
function isAssistantMessageStart(event: unknown): boolean {
|
|
367
|
+
return (
|
|
368
|
+
isRecord(event) && event.type === "message_start" && isRecord(event.message) && event.message.role === "assistant"
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function assistantTextDeltaFromEvent(event: unknown): string | undefined {
|
|
373
|
+
if (!isRecord(event) || event.type !== "message_update" || !isRecord(event.assistantMessageEvent)) {
|
|
374
|
+
return undefined;
|
|
375
|
+
}
|
|
376
|
+
const assistantEvent = event.assistantMessageEvent;
|
|
377
|
+
return assistantEvent.type === "text_delta" && typeof assistantEvent.delta === "string"
|
|
378
|
+
? assistantEvent.delta
|
|
379
|
+
: undefined;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
383
|
+
return typeof value === "object" && value !== null;
|
|
384
|
+
}
|
|
385
|
+
|
|
280
386
|
function abortReason(signal: AbortSignal | undefined, fallback: string): string {
|
|
281
387
|
const reason = signal?.reason;
|
|
282
388
|
if (reason === undefined) {
|
package/src/todo_generator.ts
CHANGED
|
@@ -133,7 +133,7 @@ export function applyGoalInstructionsToTodoMarkdown(markdown: string, goal?: str
|
|
|
133
133
|
return markdown;
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
-
let next =
|
|
136
|
+
let next = insertGlobalInstructions(markdown, goalInstructionLines(trimmedGoal));
|
|
137
137
|
const coverageGoal = parseCoverageGoal(trimmedGoal);
|
|
138
138
|
if (coverageGoal) {
|
|
139
139
|
next = appendCoverageVerificationToTasks(next, coverageGoalVerifyBullet(coverageGoal));
|
|
@@ -142,14 +142,33 @@ export function applyGoalInstructionsToTodoMarkdown(markdown: string, goal?: str
|
|
|
142
142
|
return next;
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
-
function
|
|
145
|
+
export function applyWorkerCapabilityConstraintsToTodoMarkdown(
|
|
146
|
+
markdown: string,
|
|
147
|
+
constraints: readonly string[],
|
|
148
|
+
): string {
|
|
149
|
+
const additions = constraints
|
|
150
|
+
.map(oneLine)
|
|
151
|
+
.filter(Boolean)
|
|
152
|
+
.map((item) => `- ${item}`);
|
|
153
|
+
if (additions.length === 0) {
|
|
154
|
+
return markdown;
|
|
155
|
+
}
|
|
156
|
+
const next = insertGlobalInstructions(markdown, additions);
|
|
157
|
+
validateTodoMarkdown(next);
|
|
158
|
+
return next;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function goalInstructionLines(goal: string): string[] {
|
|
146
162
|
const coverageGoal = parseCoverageGoal(goal);
|
|
147
163
|
const additions = [`- Long task goal: ${goal}`];
|
|
148
164
|
if (coverageGoal) {
|
|
149
165
|
additions.push(`- Coverage goal: ${coverageGoalAction(coverageGoal)}`);
|
|
150
166
|
additions.push(`- Coverage verification: ${coverageGoalVerification(coverageGoal)}`);
|
|
151
167
|
}
|
|
168
|
+
return additions;
|
|
169
|
+
}
|
|
152
170
|
|
|
171
|
+
function insertGlobalInstructions(markdown: string, additions: readonly string[]): string {
|
|
153
172
|
const lines = markdown.replace(/\r\n?/g, "\n").split("\n");
|
|
154
173
|
const progressIndex = lines.findIndex((line) => /^##\s+Progress\s*$/i.test(line.trim()));
|
|
155
174
|
if (progressIndex < 0) {
|
|
@@ -229,9 +248,43 @@ function oneLine(value: string): string {
|
|
|
229
248
|
return value.replace(/\s+/g, " ").trim();
|
|
230
249
|
}
|
|
231
250
|
|
|
232
|
-
export function
|
|
251
|
+
export function todoPlanningOnlyPromptBlock(capabilityConstraints: readonly string[] = []): string {
|
|
252
|
+
const capabilityBlock = capabilityConstraints.length
|
|
253
|
+
? `\n\nWorker capability constraints (preserve these above ## Progress and in affected tasks):\n${capabilityConstraints
|
|
254
|
+
.map((constraint) => `- ${oneLine(constraint)}`)
|
|
255
|
+
.join("\n")}`
|
|
256
|
+
: "";
|
|
257
|
+
return `Planning-only boundary:
|
|
258
|
+
- Produce only a concise executable plan for future workers.
|
|
259
|
+
- Do not perform requested end work: do not implement or write code, execute research or report findings, create requested creative output (prose, stories, copy, designs, or assets), or produce any other final deliverable.
|
|
260
|
+
- Use future-worker action language; do not claim work is complete or invent results.
|
|
261
|
+
- Keep repeated task sections compact: use a one-sentence Goal and Done when, plus only the necessary Status and Verify bullets. Omit rationale, lengthy analysis, summaries, duplicated context, unrequested examples, and boilerplate.
|
|
262
|
+
- Preserve every instruction, constraint, required deliverable, and acceptance condition from the source request and supplied planning context. Put shared constraints above ## Progress and task-specific requirements in the relevant task.${capabilityBlock}`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function buildTodoCreationPrompt(
|
|
266
|
+
rawInput: string,
|
|
267
|
+
goal?: string,
|
|
268
|
+
capabilityConstraints: readonly string[] = [],
|
|
269
|
+
): string {
|
|
233
270
|
const goalBlock = todoGoalPromptBlock(goal);
|
|
234
|
-
return `Convert the following raw project request into Pi Long Task-compatible TODO markdown
|
|
271
|
+
return `Convert the following raw project request into Pi Long Task-compatible TODO markdown.
|
|
272
|
+
|
|
273
|
+
${todoPlanningOnlyPromptBlock(capabilityConstraints)}
|
|
274
|
+
|
|
275
|
+
Required format:
|
|
276
|
+
- Output only markdown, with no commentary and no code fence.
|
|
277
|
+
- Start with exactly: # Pi Long Task TODO
|
|
278
|
+
- Include a ## Progress section with one unchecked line per task: - [ ] TODO N — Title
|
|
279
|
+
- Include a --- separator before task sections.
|
|
280
|
+
- Create sequential sections named ## TODO N — Title.
|
|
281
|
+
- Each task section must include **Goal:**, **Status:** with unchecked checkbox items, **Verify:** with concrete verification guidance, and **Done when:**.
|
|
282
|
+
- Keep tasks focused and independently assignable to worker sessions.
|
|
283
|
+
${goalBlock}
|
|
284
|
+
Raw input:
|
|
285
|
+
|
|
286
|
+
${rawInput.trim()}
|
|
287
|
+
`;
|
|
235
288
|
}
|
|
236
289
|
|
|
237
290
|
export function buildTodoRepairPrompt(
|
|
@@ -239,9 +292,33 @@ export function buildTodoRepairPrompt(
|
|
|
239
292
|
invalidOutput: string,
|
|
240
293
|
validationError: string,
|
|
241
294
|
goal?: string,
|
|
295
|
+
capabilityConstraints: readonly string[] = [],
|
|
242
296
|
): string {
|
|
243
297
|
const goalBlock = todoGoalPromptBlock(goal);
|
|
244
|
-
return `
|
|
298
|
+
return `Repair the previous response into valid Pi Long Task TODO markdown. Correct its plan and format only; do not continue or perform any attempted end work.
|
|
299
|
+
|
|
300
|
+
${todoPlanningOnlyPromptBlock(capabilityConstraints)}
|
|
301
|
+
|
|
302
|
+
Validation/extraction error:
|
|
303
|
+
${validationError.trim() || "Unknown validation error."}
|
|
304
|
+
|
|
305
|
+
Required format:
|
|
306
|
+
- Output only corrected markdown, with no commentary and no code fence.
|
|
307
|
+
- Start with exactly: # Pi Long Task TODO
|
|
308
|
+
- Include a ## Progress section with one unchecked line per task: - [ ] TODO N — Title
|
|
309
|
+
- Include a --- separator before task sections.
|
|
310
|
+
- Create sequential sections named ## TODO N — Title.
|
|
311
|
+
- Each task section must include **Goal:**, **Status:** with unchecked checkbox items, **Verify:** with concrete verification guidance, and **Done when:**.
|
|
312
|
+
- Keep tasks focused and independently assignable to worker sessions.
|
|
313
|
+
${goalBlock}
|
|
314
|
+
Original raw input:
|
|
315
|
+
|
|
316
|
+
${rawInput.trim()}
|
|
317
|
+
|
|
318
|
+
Previous invalid output (repair its planning content; do not extend its end work):
|
|
319
|
+
|
|
320
|
+
${invalidOutput.trim()}
|
|
321
|
+
`;
|
|
245
322
|
}
|
|
246
323
|
|
|
247
324
|
function todoGoalPromptBlock(goal: string | undefined): string {
|
package/src/types.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { Static } from "typebox";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
3
|
|
|
4
|
+
import { MAX_PLANNER_DURATION_MS, type PlannerBudget } from "./planner_config.ts";
|
|
4
5
|
import type { TaskProgressModel } from "./task_progress.ts";
|
|
6
|
+
import type { WorkerCapabilityWarning } from "./worker_capabilities.ts";
|
|
5
7
|
import type { SessionOutcome } from "./worker_session.ts";
|
|
6
8
|
|
|
7
9
|
const NetworkRecoveryParams = Type.Object(
|
|
@@ -51,6 +53,22 @@ export const PiLongTaskParams = Type.Object(
|
|
|
51
53
|
description: "Optional high-level goal or desired outcome for the long-task run.",
|
|
52
54
|
}),
|
|
53
55
|
),
|
|
56
|
+
todoTimeoutMs: Type.Optional(
|
|
57
|
+
Type.Integer({
|
|
58
|
+
minimum: 1,
|
|
59
|
+
maximum: MAX_PLANNER_DURATION_MS,
|
|
60
|
+
description:
|
|
61
|
+
"Explicit TODO-planner timeout in milliseconds. When omitted, the 5-minute default adapts deterministically for explicit item counts, enumerated deliverables, and separately planned tasks, up to 15 minutes.",
|
|
62
|
+
}),
|
|
63
|
+
),
|
|
64
|
+
todoGracefulShutdownMs: Type.Optional(
|
|
65
|
+
Type.Integer({
|
|
66
|
+
minimum: 0,
|
|
67
|
+
maximum: MAX_PLANNER_DURATION_MS,
|
|
68
|
+
description:
|
|
69
|
+
"Grace period in milliseconds after the TODO-planner timeout. Defaults to 15000 (15 seconds); use 0 to disable the grace period.",
|
|
70
|
+
}),
|
|
71
|
+
),
|
|
54
72
|
networkRecovery: Type.Optional(NetworkRecoveryParams),
|
|
55
73
|
},
|
|
56
74
|
{ additionalProperties: false },
|
|
@@ -113,6 +131,22 @@ export const PiGoalTaskParams = Type.Object(
|
|
|
113
131
|
description: "Maximum bash command timeout in milliseconds allowed in worker sessions.",
|
|
114
132
|
}),
|
|
115
133
|
),
|
|
134
|
+
todoTimeoutMs: Type.Optional(
|
|
135
|
+
Type.Integer({
|
|
136
|
+
minimum: 1,
|
|
137
|
+
maximum: MAX_PLANNER_DURATION_MS,
|
|
138
|
+
description:
|
|
139
|
+
"Explicit TODO-planner timeout in milliseconds for child long-task planning and plan revisions. When omitted, the 5-minute default adapts deterministically for explicit item counts, enumerated deliverables, and separately planned tasks, up to 15 minutes.",
|
|
140
|
+
}),
|
|
141
|
+
),
|
|
142
|
+
todoGracefulShutdownMs: Type.Optional(
|
|
143
|
+
Type.Integer({
|
|
144
|
+
minimum: 0,
|
|
145
|
+
maximum: MAX_PLANNER_DURATION_MS,
|
|
146
|
+
description:
|
|
147
|
+
"Grace period in milliseconds after a child TODO-planner timeout. Defaults to 15000 (15 seconds); use 0 to disable the grace period.",
|
|
148
|
+
}),
|
|
149
|
+
),
|
|
116
150
|
networkRecovery: Type.Optional(NetworkRecoveryParams),
|
|
117
151
|
},
|
|
118
152
|
{ additionalProperties: false },
|
|
@@ -165,6 +199,8 @@ export interface PiLongTaskResult {
|
|
|
165
199
|
}>;
|
|
166
200
|
taskProgress: TaskProgressModel;
|
|
167
201
|
workerCostTotal: number;
|
|
202
|
+
plannerBudget?: Readonly<PlannerBudget>;
|
|
203
|
+
capabilityWarnings?: readonly WorkerCapabilityWarning[];
|
|
168
204
|
commit: boolean;
|
|
169
205
|
goal?: string;
|
|
170
206
|
error?: string;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
export interface IsolatedWorkerCapabilities {
|
|
2
|
+
/** Tool names exposed directly to the worker session. */
|
|
3
|
+
tools: readonly string[];
|
|
4
|
+
/** Pi Long Task isolated sessions deliberately disable extension runtimes. */
|
|
5
|
+
extensionsEnabled: boolean;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type WorkerCapabilityWarningCode = "unavailable_browser_capability";
|
|
9
|
+
|
|
10
|
+
export interface WorkerCapabilityWarning {
|
|
11
|
+
code: WorkerCapabilityWarningCode;
|
|
12
|
+
requestedCapabilities: readonly string[];
|
|
13
|
+
availableTools: readonly string[];
|
|
14
|
+
message: string;
|
|
15
|
+
planningConstraint: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const BROWSER_EXTENSION_REQUIREMENT_RE =
|
|
19
|
+
/\b(?:must(?:\s+use)?|need(?:s|ed)?(?:\s+to\s+use)?|require(?:s|d)?|rely(?:ing)?\s+on|use|using|via|through|with)\s+(?:the\s+|an?\s+)?(?:google\s+)?(?:chrome|chromium|firefox|edge|browser)(?:\s+(?:browser|devtools?))?\s+(?:extension|mcp|connector)\b/i;
|
|
20
|
+
const REQUIRED_NAMED_BROWSER_EXTENSION_RE =
|
|
21
|
+
/\b(?:chrome\s+devtools\s+(?:mcp|extension|tool)|(?:chrome|browser)\s+(?:mcp|extension(?:\s+tool)?|tool\s+extension))\s+(?:is\s+(?:required|needed)|must\s+be\s+used)\b/i;
|
|
22
|
+
const IMPERATIVE_CHROME_RE =
|
|
23
|
+
/\b(?:open|launch|control|drive|browse\s+with|inspect\s+(?:with|using)|scrape\s+(?:with|using)|fetch\s+(?:with|using)|test\s+(?:with|using))\s+(?:google\s+)?chrome\b/i;
|
|
24
|
+
const EXPLICIT_BROWSER_TOOL_RE =
|
|
25
|
+
/\b(?:must(?:\s+use)?|need(?:s|ed)?(?:\s+to\s+use)?|require(?:s|d)?|use|using|via|through|with)\s+(?:the\s+)?(browser|chrome|web[_-]?fetch|playwright|puppeteer)\s+tool\b/i;
|
|
26
|
+
const EXPLICIT_CHROME_RUNTIME_RE =
|
|
27
|
+
/(?:\b(?:must\s+use|need(?:s|ed)?\s+to\s+use|use|using)\s+(?:google\s+)?chrome(?:\s+devtools?)?(?=\s+(?:to|for)\b|\s*[.,;:]|\s*$)|\b(?:via|through)\s+(?:google\s+)?chrome\b|\b(?:google\s+)?chrome(?:\s+devtools?)?\s+(?:is\s+required|must\s+be\s+used)\b)/i;
|
|
28
|
+
const NEGATED_CAPABILITY_RE =
|
|
29
|
+
/\b(?:do\s+not|don't|dont|never|avoid|without)\s+(?:use|using|rely(?:ing)?\s+on|requiring?)?\s*(?:the\s+|an?\s+)?(?:google\s+)?(?:chrome(?:\s+(?:browser|devtools?))?|chromium|firefox|edge|browser|web[_-]?fetch|playwright|puppeteer)(?:\s+(?:extension|mcp|tool(?:ing)?|connector))?/gi;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Detect only explicit requests to invoke a browser capability. Merely asking
|
|
33
|
+
* workers to build or support a browser extension is implementation work and
|
|
34
|
+
* does not imply that the extension must be loaded during the run.
|
|
35
|
+
*/
|
|
36
|
+
export function detectUnavailableWorkerCapabilities(
|
|
37
|
+
requestText: string,
|
|
38
|
+
capabilities: Readonly<IsolatedWorkerCapabilities>,
|
|
39
|
+
): WorkerCapabilityWarning[] {
|
|
40
|
+
const text = requestText.replace(NEGATED_CAPABILITY_RE, " ");
|
|
41
|
+
const extensionRequested =
|
|
42
|
+
BROWSER_EXTENSION_REQUIREMENT_RE.test(text) ||
|
|
43
|
+
REQUIRED_NAMED_BROWSER_EXTENSION_RE.test(text) ||
|
|
44
|
+
IMPERATIVE_CHROME_RE.test(text);
|
|
45
|
+
const browserToolMatch = EXPLICIT_BROWSER_TOOL_RE.exec(text);
|
|
46
|
+
const requestedDirectTools = [
|
|
47
|
+
browserToolMatch?.[1],
|
|
48
|
+
EXPLICIT_CHROME_RUNTIME_RE.test(text) ? "chrome" : undefined,
|
|
49
|
+
].filter((item, index, all): item is string => Boolean(item) && all.indexOf(item) === index);
|
|
50
|
+
const unavailableDirectTools = requestedDirectTools.filter(
|
|
51
|
+
(requested) =>
|
|
52
|
+
!capabilities.tools.some((available) => canonicalToolName(available) === canonicalToolName(requested)),
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
if ((!extensionRequested || capabilities.extensionsEnabled) && unavailableDirectTools.length === 0) {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const requestedCapabilities = [
|
|
60
|
+
extensionRequested && !capabilities.extensionsEnabled ? "Chrome/browser extension runtime" : undefined,
|
|
61
|
+
unavailableDirectTools.length > 0 ? `${unavailableDirectTools.join("/")} direct tool` : undefined,
|
|
62
|
+
].filter((item): item is string => Boolean(item));
|
|
63
|
+
const availableTools = [...capabilities.tools];
|
|
64
|
+
const toolList = availableTools.length > 0 ? availableTools.join(", ") : "none";
|
|
65
|
+
const alternatives = availableWorkerAlternatives(capabilities);
|
|
66
|
+
const alternativeText = alternatives.join(", or ");
|
|
67
|
+
|
|
68
|
+
return [
|
|
69
|
+
{
|
|
70
|
+
code: "unavailable_browser_capability",
|
|
71
|
+
requestedCapabilities,
|
|
72
|
+
availableTools,
|
|
73
|
+
message:
|
|
74
|
+
`Worker capability warning: isolated Pi Long Task workers disable extensions and expose only these direct tools: ${toolList}. ` +
|
|
75
|
+
`They cannot silently use the requested ${requestedCapabilities.join(" or ")}. The run will continue, but it must use an available safe alternative when that satisfies the request: ${alternativeText}. ` +
|
|
76
|
+
"If the exact extension or browser tool is mandatory, the affected task must report blocked instead of claiming it used that capability.",
|
|
77
|
+
planningConstraint:
|
|
78
|
+
`Isolated-worker capability constraint: extensions are disabled and workers have only these direct tools: ${toolList}. ` +
|
|
79
|
+
`Do not create or execute tasks that assume the requested ${requestedCapabilities.join(" or ")} is available, and never claim it was used. ` +
|
|
80
|
+
`When equivalent, ${alternativeText}. If the exact unavailable capability is mandatory, make the affected task report blocked with the required user action.`,
|
|
81
|
+
},
|
|
82
|
+
];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function canonicalToolName(tool: string): string {
|
|
86
|
+
return tool.toLowerCase().replace(/[-_]/g, "");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function availableWorkerAlternatives(capabilities: Readonly<IsolatedWorkerCapabilities>): string[] {
|
|
90
|
+
const alternatives: string[] = [];
|
|
91
|
+
if (capabilities.tools.includes("bash")) {
|
|
92
|
+
alternatives.push(
|
|
93
|
+
"fetch public content through a supported command-line mechanism via bash or run project-provided browser automation via bash when available",
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
if (capabilities.tools.includes("read")) {
|
|
97
|
+
alternatives.push("supply the page or source content to the run so workers can read it locally");
|
|
98
|
+
}
|
|
99
|
+
if (alternatives.length === 0) {
|
|
100
|
+
alternatives.push("supply the needed source content to the run");
|
|
101
|
+
}
|
|
102
|
+
return alternatives;
|
|
103
|
+
}
|
package/src/worker_config.ts
CHANGED
|
@@ -3,11 +3,14 @@ import {
|
|
|
3
3
|
resolveNetworkRecoveryConfig,
|
|
4
4
|
type NetworkRecoveryConfigInput,
|
|
5
5
|
} from "./network_recovery_config.ts";
|
|
6
|
+
import { MAX_PLANNER_DURATION_MS, PlannerDurationConfigError } from "./planner_config.ts";
|
|
6
7
|
|
|
7
8
|
export interface ParsedWorkerRuntimeConfig {
|
|
8
9
|
modelName?: string;
|
|
9
10
|
maxAttemptsPerTask?: number;
|
|
10
11
|
taskTimeoutMs?: number;
|
|
12
|
+
todoTimeoutMs?: number;
|
|
13
|
+
todoGracefulShutdownMs?: number;
|
|
11
14
|
maxBashTimeoutMs?: number;
|
|
12
15
|
workerSessionReuseEnabled?: boolean;
|
|
13
16
|
workerSessionReuseContextThresholdPercent?: number;
|
|
@@ -52,6 +55,8 @@ export function parseWorkerRuntimeConfig(text: string): ParsedWorkerRuntimeConfi
|
|
|
52
55
|
...(modelName ? { modelName } : {}),
|
|
53
56
|
...(state.maxAttemptsPerTask !== undefined ? { maxAttemptsPerTask: state.maxAttemptsPerTask } : {}),
|
|
54
57
|
...(state.taskTimeoutMs !== undefined ? { taskTimeoutMs: state.taskTimeoutMs } : {}),
|
|
58
|
+
...(state.todoTimeoutMs !== undefined ? { todoTimeoutMs: state.todoTimeoutMs } : {}),
|
|
59
|
+
...(state.todoGracefulShutdownMs !== undefined ? { todoGracefulShutdownMs: state.todoGracefulShutdownMs } : {}),
|
|
55
60
|
...(state.maxBashTimeoutMs !== undefined ? { maxBashTimeoutMs: state.maxBashTimeoutMs } : {}),
|
|
56
61
|
...(state.workerSessionReuseEnabled !== undefined
|
|
57
62
|
? { workerSessionReuseEnabled: state.workerSessionReuseEnabled }
|
|
@@ -135,14 +140,36 @@ function parseNaturalLanguageDirectives(text: string, state: MutableWorkerRuntim
|
|
|
135
140
|
|
|
136
141
|
captureDurations(
|
|
137
142
|
text,
|
|
138
|
-
/\b(
|
|
143
|
+
/\b(?:todo\s+)?(?:planner|planning)\s+timeout\s*(?:is|=|:|to|of)?\s*(\d+(?:\.\d+)?\s*(?:milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h)?)/gi,
|
|
144
|
+
(value) => {
|
|
145
|
+
state.todoTimeoutMs = value;
|
|
146
|
+
},
|
|
147
|
+
);
|
|
148
|
+
captureDurations(
|
|
149
|
+
text,
|
|
150
|
+
/\b(\d+(?:\.\d+)?\s*(?:milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h))\s+(?:todo\s+)?(?:planner|planning)\s+timeout\b/gi,
|
|
151
|
+
(value) => {
|
|
152
|
+
state.todoTimeoutMs = value;
|
|
153
|
+
},
|
|
154
|
+
);
|
|
155
|
+
captureDurations(
|
|
156
|
+
text,
|
|
157
|
+
/\b(?:todo\s+)?(?:planner|planning)\s+(?:grace(?:ful)?(?:\s+shutdown|\s+period)?|shutdown\s+grace(?:\s+period)?)\s*(?:duration\s*)?(?:is|=|:|to|of)?\s*(\d+(?:\.\d+)?\s*(?:milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h)?)/gi,
|
|
158
|
+
(value) => {
|
|
159
|
+
state.todoGracefulShutdownMs = value;
|
|
160
|
+
},
|
|
161
|
+
{ allowZero: true },
|
|
162
|
+
);
|
|
163
|
+
captureDurations(
|
|
164
|
+
text,
|
|
165
|
+
/\b(?<!bash\s)(?<!max\s)(?<!planner\s)(?<!planning\s)(?:worker\s+|task\s+)?timeout\s*(?:is|=|:|to|of)?\s*(\d+(?:\.\d+)?\s*(?:milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h)?)/gi,
|
|
139
166
|
(value) => {
|
|
140
167
|
state.taskTimeoutMs = value;
|
|
141
168
|
},
|
|
142
169
|
);
|
|
143
170
|
captureDurations(
|
|
144
171
|
text,
|
|
145
|
-
/\b(\d+(?:\.\d+)?\s*(?:milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h))\
|
|
172
|
+
/\b(\d+(?:\.\d+)?\s*(?:milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h))[ \t]+(?:worker[ \t]+|task[ \t]+)?timeout\b/gi,
|
|
146
173
|
(value) => {
|
|
147
174
|
state.taskTimeoutMs = value;
|
|
148
175
|
},
|
|
@@ -215,6 +242,16 @@ function applyDirective(key: string, value: string, state: MutableWorkerRuntimeC
|
|
|
215
242
|
return;
|
|
216
243
|
}
|
|
217
244
|
|
|
245
|
+
if (/\b(?:planner|planning)\b/.test(key) && /\b(?:grace|graceful|shutdown)\b/.test(key)) {
|
|
246
|
+
state.todoGracefulShutdownMs = requiredPlannerDuration("graceful-shutdown duration", value, true);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (/\b(?:planner|planning)\b/.test(key) && /\btimeout\b/.test(key)) {
|
|
251
|
+
state.todoTimeoutMs = requiredPlannerDuration("timeout", value, false);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
218
255
|
if (/\bbash\b/.test(key) && /\btimeout\b/.test(key)) {
|
|
219
256
|
const timeout = durationMsFromText(value, { allowBareSeconds: true });
|
|
220
257
|
if (timeout !== undefined) {
|
|
@@ -268,6 +305,23 @@ function applyNetworkRecoveryDirective(key: string, value: string, state: Mutabl
|
|
|
268
305
|
throw new NetworkRecoveryConfigError(`Unknown network recovery configuration directive: ${key}.`);
|
|
269
306
|
}
|
|
270
307
|
|
|
308
|
+
function requiredPlannerDuration(label: string, value: string, allowZero: boolean): number {
|
|
309
|
+
const trimmed = trimDirectiveValue(value)
|
|
310
|
+
.replace(/[.!]+$/g, "")
|
|
311
|
+
.trim();
|
|
312
|
+
const match = /^(\d+(?:\.\d+)?)\s*(milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h)?$/i.exec(
|
|
313
|
+
trimmed,
|
|
314
|
+
);
|
|
315
|
+
const milliseconds = match ? durationMsFromText(trimmed, { allowBareSeconds: true, allowZero }) : undefined;
|
|
316
|
+
if (milliseconds === undefined || milliseconds > MAX_PLANNER_DURATION_MS) {
|
|
317
|
+
const minimum = allowZero ? "non-negative" : "positive";
|
|
318
|
+
throw new PlannerDurationConfigError(
|
|
319
|
+
`TODO planner ${label} must be a ${minimum} finite duration no greater than about 24.9 days (${MAX_PLANNER_DURATION_MS} milliseconds), for example 30s or 5m.`,
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
return milliseconds;
|
|
323
|
+
}
|
|
324
|
+
|
|
271
325
|
function requiredNetworkRecoveryDuration(label: string, value: string): number {
|
|
272
326
|
const trimmed = trimDirectiveValue(value)
|
|
273
327
|
.replace(/[.!]+$/g, "")
|
|
@@ -307,9 +361,17 @@ function captureNumbers(text: string, pattern: RegExp, apply: (value: number) =>
|
|
|
307
361
|
}
|
|
308
362
|
}
|
|
309
363
|
|
|
310
|
-
function captureDurations(
|
|
364
|
+
function captureDurations(
|
|
365
|
+
text: string,
|
|
366
|
+
pattern: RegExp,
|
|
367
|
+
apply: (value: number) => void,
|
|
368
|
+
options: { allowZero?: boolean } = {},
|
|
369
|
+
): void {
|
|
311
370
|
for (const match of text.matchAll(pattern)) {
|
|
312
|
-
const value = durationMsFromText(match[1] ?? "", {
|
|
371
|
+
const value = durationMsFromText(match[1] ?? "", {
|
|
372
|
+
allowBareSeconds: true,
|
|
373
|
+
allowZero: options.allowZero,
|
|
374
|
+
});
|
|
313
375
|
if (value !== undefined) {
|
|
314
376
|
apply(value);
|
|
315
377
|
}
|
|
@@ -373,7 +435,10 @@ function booleanSetting(value: string): boolean | undefined {
|
|
|
373
435
|
return undefined;
|
|
374
436
|
}
|
|
375
437
|
|
|
376
|
-
function durationMsFromText(
|
|
438
|
+
function durationMsFromText(
|
|
439
|
+
value: string,
|
|
440
|
+
options: { allowBareSeconds: boolean; allowZero?: boolean },
|
|
441
|
+
): number | undefined {
|
|
377
442
|
const match = /(\d+(?:\.\d+)?)\s*(milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h)?\b/i.exec(
|
|
378
443
|
value,
|
|
379
444
|
);
|
|
@@ -382,7 +447,7 @@ function durationMsFromText(value: string, options: { allowBareSeconds: boolean
|
|
|
382
447
|
}
|
|
383
448
|
|
|
384
449
|
const amount = Number.parseFloat(match[1] ?? "");
|
|
385
|
-
if (!Number.isFinite(amount) || amount
|
|
450
|
+
if (!Number.isFinite(amount) || amount < 0 || (!options.allowZero && amount === 0)) {
|
|
386
451
|
return undefined;
|
|
387
452
|
}
|
|
388
453
|
|
|
@@ -393,7 +458,9 @@ function durationMsFromText(value: string, options: { allowBareSeconds: boolean
|
|
|
393
458
|
|
|
394
459
|
const multiplier = durationMultiplier(unit || "seconds");
|
|
395
460
|
const milliseconds = Math.round(amount * multiplier);
|
|
396
|
-
return Number.isSafeInteger(milliseconds) && milliseconds
|
|
461
|
+
return Number.isSafeInteger(milliseconds) && (options.allowZero ? milliseconds >= 0 : milliseconds > 0)
|
|
462
|
+
? milliseconds
|
|
463
|
+
: undefined;
|
|
397
464
|
}
|
|
398
465
|
|
|
399
466
|
function durationMultiplier(unit: string): number {
|