pi-long-task 0.1.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/LICENSE +21 -0
- package/README.md +126 -0
- package/package.json +63 -0
- package/scripts/native_smoke.mjs +323 -0
- package/src/coordinator.ts +633 -0
- package/src/git.ts +262 -0
- package/src/index.ts +63 -0
- package/src/render.ts +270 -0
- package/src/result_writer.ts +96 -0
- package/src/todo_generator.ts +304 -0
- package/src/todo_parser.ts +229 -0
- package/src/types.ts +60 -0
- package/src/worker_session.ts +836 -0
|
@@ -0,0 +1,836 @@
|
|
|
1
|
+
import { hasTaskResult, hasTaskResultStatus, isDoneStatus, parseReportedStatus } from "./result_writer.ts";
|
|
2
|
+
import type { Task } from "./todo_parser.ts";
|
|
3
|
+
|
|
4
|
+
export interface WorkerTaskPromptOptions {
|
|
5
|
+
todoPath: string;
|
|
6
|
+
task: Pick<Task, "taskId" | "title" | "section">;
|
|
7
|
+
attempt: number;
|
|
8
|
+
commitRequested: boolean;
|
|
9
|
+
previousAttempts?: string;
|
|
10
|
+
globalInstructions?: string;
|
|
11
|
+
maxBashTimeoutSeconds: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface AssistantMessageLike {
|
|
15
|
+
role?: unknown;
|
|
16
|
+
content?: unknown;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function taskLabel(task: Pick<Task, "taskId" | "title">): string {
|
|
20
|
+
return `TODO ${task.taskId} — ${task.title}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function buildTaskPrompt(options: WorkerTaskPromptOptions): string {
|
|
24
|
+
const commitText = options.commitRequested
|
|
25
|
+
? "Pi Long Task will commit after your session if needed. Do not run git commit."
|
|
26
|
+
: "Do not run git commit. Pi Long Task was started with commits disabled.";
|
|
27
|
+
|
|
28
|
+
const previousAttempts = (options.previousAttempts || "").trim();
|
|
29
|
+
const previousText = previousAttempts
|
|
30
|
+
? `
|
|
31
|
+
Previous attempts for this same assigned task are below. Use them only as continuity for this task:
|
|
32
|
+
|
|
33
|
+
\`\`\`text
|
|
34
|
+
${previousAttempts}
|
|
35
|
+
\`\`\`
|
|
36
|
+
`
|
|
37
|
+
: "";
|
|
38
|
+
|
|
39
|
+
const globalInstructions = (options.globalInstructions || "").trim();
|
|
40
|
+
const globalText = globalInstructions
|
|
41
|
+
? `
|
|
42
|
+
Global instructions from the TODO file apply to this task:
|
|
43
|
+
|
|
44
|
+
\`\`\`markdown
|
|
45
|
+
${globalInstructions}
|
|
46
|
+
\`\`\`
|
|
47
|
+
`
|
|
48
|
+
: "";
|
|
49
|
+
|
|
50
|
+
return `You are one Pi SDK worker session assigned to exactly one TODO task.
|
|
51
|
+
|
|
52
|
+
Assigned TODO file path: \`${options.todoPath}\`
|
|
53
|
+
Assigned task: \`${taskLabel(options.task)}\`
|
|
54
|
+
Attempt: ${options.attempt}
|
|
55
|
+
|
|
56
|
+
Rules:
|
|
57
|
+
- Work only on the assigned task below. Do not start or fix other TODO tasks.
|
|
58
|
+
- Pi Long Task is responsible for marking TODO progress. Do not edit \`${options.todoPath}\` unless it is directly necessary for the assigned task implementation itself.
|
|
59
|
+
- Do not edit \`TASK_RESULT.md\`; Pi Long Task writes it.
|
|
60
|
+
- ${commitText}
|
|
61
|
+
- If you need to stop because context is high or the work is blocked, leave the repository in a safe state and report \`status: partial\` or \`status: blocked\`.
|
|
62
|
+
- Use the repository's AGENTS.md/project instructions.
|
|
63
|
+
- Run focused verification commands when practical.
|
|
64
|
+
- Do not run bash commands with timeout greater than ${options.maxBashTimeoutSeconds.toFixed(0)} seconds. For long full-suite checks, run once with a bounded timeout and report any timeout/failure in TASK_RESULT instead of continuing indefinitely.
|
|
65
|
+
- If TODO-file global instructions restrict scope, obey them strictly. If the task appears to require out-of-scope code changes, stop and report \`status: blocked\` instead of changing those files.
|
|
66
|
+
|
|
67
|
+
${globalText}Assigned task content only:
|
|
68
|
+
|
|
69
|
+
\`\`\`markdown
|
|
70
|
+
${options.task.section.trimEnd()}
|
|
71
|
+
\`\`\`
|
|
72
|
+
${previousText}
|
|
73
|
+
When you are finished, your final assistant message must end with this machine-readable block:
|
|
74
|
+
|
|
75
|
+
TASK_RESULT:
|
|
76
|
+
status: done|partial|blocked|failed
|
|
77
|
+
summary: <short summary>
|
|
78
|
+
changes:
|
|
79
|
+
- <changed item or "none">
|
|
80
|
+
verification:
|
|
81
|
+
- <command/result or "not run">
|
|
82
|
+
remaining:
|
|
83
|
+
- <remaining item or "none">
|
|
84
|
+
|
|
85
|
+
Only use \`status: done\` if the assigned task is fully complete and verified as far as practical.`.trim();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const buildAssignedTaskPrompt = buildTaskPrompt;
|
|
89
|
+
|
|
90
|
+
export function buildTimeLimitMessage(seconds: number): string {
|
|
91
|
+
return `Pi Long Task notice: this worker session has reached its ${seconds.toFixed(0)}s time budget.
|
|
92
|
+
Stop after the current safe point. Do not start more implementation work.
|
|
93
|
+
Finish with the required TASK_RESULT block now.
|
|
94
|
+
Use \`status: done\` only if the assigned task is actually complete; otherwise use \`status: partial\`.`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function buildShutdownMessage(percent: number): string {
|
|
98
|
+
return `Pi Long Task notice: context usage is ${percent.toFixed(1)}%, above the 85% shutdown threshold.
|
|
99
|
+
Stop after the current safe point. Do not start more implementation work.
|
|
100
|
+
Leave files in a safe state and finish with the required TASK_RESULT block.
|
|
101
|
+
Use \`status: done\` only if the assigned task is actually complete; otherwise use \`status: partial\`.`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function buildCompactionInstructions(task: Pick<Task, "taskId" | "title">): string {
|
|
105
|
+
return `Keep only information needed to finish assigned task ${taskLabel(task)}: relevant files inspected,
|
|
106
|
+
edits made, verification run, failures, and remaining steps. Drop unrelated details.`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function assistantMessageText(message: unknown): string {
|
|
110
|
+
if (!isRecord(message) || message.role !== "assistant") {
|
|
111
|
+
return "";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const { content } = message;
|
|
115
|
+
if (typeof content === "string") {
|
|
116
|
+
return content;
|
|
117
|
+
}
|
|
118
|
+
if (!Array.isArray(content)) {
|
|
119
|
+
return "";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const parts: string[] = [];
|
|
123
|
+
for (const item of content) {
|
|
124
|
+
const text = textFromContentPart(item);
|
|
125
|
+
if (text) {
|
|
126
|
+
parts.push(text);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return parts.join("");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function lastAssistantTextFromMessages(messages: unknown): string {
|
|
133
|
+
if (!Array.isArray(messages)) {
|
|
134
|
+
return "";
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
for (let idx = messages.length - 1; idx >= 0; idx -= 1) {
|
|
138
|
+
const text = assistantMessageText(messages[idx]);
|
|
139
|
+
if (text) {
|
|
140
|
+
return text;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return "";
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function assistantTextFromEvent(event: unknown): string {
|
|
147
|
+
if (!isRecord(event)) {
|
|
148
|
+
return "";
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const fromMessages = lastAssistantTextFromMessages(event.messages);
|
|
152
|
+
if (fromMessages) {
|
|
153
|
+
return fromMessages;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const fromMessage = assistantMessageText(event.message);
|
|
157
|
+
if (fromMessage) {
|
|
158
|
+
return fromMessage;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const fromDelta = textFromDeltaEvent(event);
|
|
162
|
+
if (fromDelta) {
|
|
163
|
+
return fromDelta;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return assistantMessageText(event);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function lastAssistantTextFromEvents(events: unknown): string {
|
|
170
|
+
if (!Array.isArray(events)) {
|
|
171
|
+
return "";
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
for (let idx = events.length - 1; idx >= 0; idx -= 1) {
|
|
175
|
+
const text = assistantTextFromEvent(events[idx]);
|
|
176
|
+
if (text) {
|
|
177
|
+
return text;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return "";
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export const extractAssistantTextFromMessage = assistantMessageText;
|
|
184
|
+
export const extractLastAssistantTextFromMessages = lastAssistantTextFromMessages;
|
|
185
|
+
export const extractAssistantTextFromEvent = assistantTextFromEvent;
|
|
186
|
+
export const extractLastAssistantTextFromEvents = lastAssistantTextFromEvents;
|
|
187
|
+
|
|
188
|
+
export const DEFAULT_WORKER_TOOLS = ["read", "bash", "edit", "write", "grep", "find", "ls"] as const;
|
|
189
|
+
export const DEFAULT_WORKER_MODEL = "openai-codex/gpt-5.5";
|
|
190
|
+
export const DEFAULT_WORKER_THINKING_LEVEL = "high";
|
|
191
|
+
export const DEFAULT_TASK_TIMEOUT_SECONDS = 60 * 60;
|
|
192
|
+
export const DEFAULT_GRACEFUL_SHUTDOWN_SECONDS = 60;
|
|
193
|
+
|
|
194
|
+
export interface WorkerSessionLike {
|
|
195
|
+
prompt(text: string, options?: Record<string, unknown>): Promise<void>;
|
|
196
|
+
steer?(text: string): Promise<void>;
|
|
197
|
+
followUp?(text: string): Promise<void>;
|
|
198
|
+
subscribe(listener: (event: unknown) => void): () => void;
|
|
199
|
+
abort?(): Promise<void> | void;
|
|
200
|
+
abortBash?(): void;
|
|
201
|
+
compact?(customInstructions?: string): Promise<unknown>;
|
|
202
|
+
dispose?(): void;
|
|
203
|
+
getLastAssistantText?(): string | undefined;
|
|
204
|
+
getSessionStats?(): unknown;
|
|
205
|
+
getContextUsage?(): unknown;
|
|
206
|
+
sessionFile?: string;
|
|
207
|
+
sessionId?: string;
|
|
208
|
+
isStreaming?: boolean;
|
|
209
|
+
isBashRunning?: boolean;
|
|
210
|
+
messages?: unknown[];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export interface WorkerSessionFactoryResult {
|
|
214
|
+
session: WorkerSessionLike;
|
|
215
|
+
modelFallbackMessage?: string;
|
|
216
|
+
diagnostics?: string[];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export interface CreateWorkerSessionOptions {
|
|
220
|
+
cwd: string;
|
|
221
|
+
agentDir?: string;
|
|
222
|
+
tools?: readonly string[];
|
|
223
|
+
model?: unknown;
|
|
224
|
+
modelName?: string;
|
|
225
|
+
thinkingLevel?: string;
|
|
226
|
+
authStorage?: unknown;
|
|
227
|
+
modelRegistry?: unknown;
|
|
228
|
+
settingsManager?: unknown;
|
|
229
|
+
resourceLoader?: unknown;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export type WorkerSessionFactory = (options: CreateWorkerSessionOptions) => Promise<WorkerSessionFactoryResult>;
|
|
233
|
+
|
|
234
|
+
export interface RunWorkerTaskOptions extends WorkerTaskPromptOptions, CreateWorkerSessionOptions {
|
|
235
|
+
taskTimeoutSeconds?: number;
|
|
236
|
+
gracefulShutdownSeconds?: number;
|
|
237
|
+
abortSignal?: AbortSignal;
|
|
238
|
+
sessionFactory?: WorkerSessionFactory;
|
|
239
|
+
onEvent?: (event: CapturedWorkerEvent) => void;
|
|
240
|
+
now?: () => Date;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export interface CapturedWorkerEvent {
|
|
244
|
+
type: string;
|
|
245
|
+
textDelta?: string;
|
|
246
|
+
toolName?: string;
|
|
247
|
+
isError?: boolean;
|
|
248
|
+
note?: string;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export interface SessionOutcome {
|
|
252
|
+
task: Pick<Task, "taskId" | "title" | "section">;
|
|
253
|
+
attempt: number;
|
|
254
|
+
startedAt: string;
|
|
255
|
+
endedAt: string;
|
|
256
|
+
reportedStatus: string;
|
|
257
|
+
done: boolean;
|
|
258
|
+
assistantText: string;
|
|
259
|
+
sessionFile?: string;
|
|
260
|
+
sessionId?: string;
|
|
261
|
+
contextObservations: string[];
|
|
262
|
+
compactionEvents: string[];
|
|
263
|
+
events: CapturedWorkerEvent[];
|
|
264
|
+
shutdownRequested: boolean;
|
|
265
|
+
timedOut: boolean;
|
|
266
|
+
aborted: boolean;
|
|
267
|
+
error?: string;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function buildMissingTaskResultMessage(): string {
|
|
271
|
+
return `Pi Long Task notice: your previous response did not include a complete machine-readable TASK_RESULT block.
|
|
272
|
+
Reply now with only the required block:
|
|
273
|
+
|
|
274
|
+
TASK_RESULT:
|
|
275
|
+
status: done|partial|blocked|failed
|
|
276
|
+
summary: <short summary>
|
|
277
|
+
changes:
|
|
278
|
+
- <changed item or "none">
|
|
279
|
+
verification:
|
|
280
|
+
- <command/result or "not run">
|
|
281
|
+
remaining:
|
|
282
|
+
- <remaining item or "none">`;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export async function createIsolatedWorkerSession(
|
|
286
|
+
options: CreateWorkerSessionOptions,
|
|
287
|
+
): Promise<WorkerSessionFactoryResult> {
|
|
288
|
+
const pi = await import("@earendil-works/pi-coding-agent");
|
|
289
|
+
const cwd = options.cwd;
|
|
290
|
+
const agentDir = options.agentDir ?? pi.getAgentDir();
|
|
291
|
+
const authStorage = options.authStorage ?? pi.AuthStorage.create();
|
|
292
|
+
const modelRegistry = options.modelRegistry ?? pi.ModelRegistry.create(authStorage as never);
|
|
293
|
+
const settingsManager = options.settingsManager ?? pi.SettingsManager.create(cwd, agentDir);
|
|
294
|
+
|
|
295
|
+
applyWorkerSettingsDefaults(settingsManager);
|
|
296
|
+
|
|
297
|
+
const discoveredResourceLoader =
|
|
298
|
+
options.resourceLoader ??
|
|
299
|
+
new pi.DefaultResourceLoader({
|
|
300
|
+
cwd,
|
|
301
|
+
agentDir,
|
|
302
|
+
settingsManager: settingsManager as never,
|
|
303
|
+
noExtensions: true,
|
|
304
|
+
});
|
|
305
|
+
const resourceLoader = disableExtensionsForWorker(discoveredResourceLoader, () => pi.createExtensionRuntime());
|
|
306
|
+
await resourceLoader.reload();
|
|
307
|
+
|
|
308
|
+
const model = options.model ?? (await resolveWorkerModel(modelRegistry, options.modelName ?? DEFAULT_WORKER_MODEL));
|
|
309
|
+
const createOptions: Record<string, unknown> = {
|
|
310
|
+
cwd,
|
|
311
|
+
agentDir,
|
|
312
|
+
authStorage,
|
|
313
|
+
modelRegistry,
|
|
314
|
+
settingsManager,
|
|
315
|
+
resourceLoader,
|
|
316
|
+
tools: [...(options.tools ?? DEFAULT_WORKER_TOOLS)],
|
|
317
|
+
thinkingLevel: options.thinkingLevel ?? DEFAULT_WORKER_THINKING_LEVEL,
|
|
318
|
+
sessionManager: pi.SessionManager.inMemory(cwd),
|
|
319
|
+
};
|
|
320
|
+
if (model) {
|
|
321
|
+
createOptions.model = model;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const result = await pi.createAgentSession(createOptions as never);
|
|
325
|
+
return {
|
|
326
|
+
session: result.session,
|
|
327
|
+
modelFallbackMessage: result.modelFallbackMessage,
|
|
328
|
+
diagnostics: extensionDiagnostics(result.extensionsResult),
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export async function runWorkerTask(options: RunWorkerTaskOptions): Promise<SessionOutcome> {
|
|
333
|
+
const now = options.now ?? (() => new Date());
|
|
334
|
+
const startedAt = now().toISOString();
|
|
335
|
+
const contextObservations: string[] = [];
|
|
336
|
+
const compactionEvents: string[] = [];
|
|
337
|
+
const events: CapturedWorkerEvent[] = [];
|
|
338
|
+
let assistantText = "";
|
|
339
|
+
let currentAssistantText = "";
|
|
340
|
+
let sessionFile: string | undefined;
|
|
341
|
+
let sessionId: string | undefined;
|
|
342
|
+
let shutdownRequested = false;
|
|
343
|
+
let timedOut = false;
|
|
344
|
+
let aborted = false;
|
|
345
|
+
let error: string | undefined;
|
|
346
|
+
let finished = false;
|
|
347
|
+
let turnCount = 0;
|
|
348
|
+
|
|
349
|
+
const prompt = buildTaskPrompt(options);
|
|
350
|
+
const taskTimeoutSeconds = options.taskTimeoutSeconds ?? DEFAULT_TASK_TIMEOUT_SECONDS;
|
|
351
|
+
const gracefulShutdownSeconds = options.gracefulShutdownSeconds ?? DEFAULT_GRACEFUL_SHUTDOWN_SECONDS;
|
|
352
|
+
const sessionFactory = options.sessionFactory ?? createIsolatedWorkerSession;
|
|
353
|
+
let session: WorkerSessionLike | undefined;
|
|
354
|
+
let unsubscribe: (() => void) | undefined;
|
|
355
|
+
const timers = new Set<ReturnType<typeof setTimeout>>();
|
|
356
|
+
|
|
357
|
+
const capture = (event: CapturedWorkerEvent) => {
|
|
358
|
+
events.push(event);
|
|
359
|
+
options.onEvent?.(event);
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
const clearTimers = () => {
|
|
363
|
+
for (const timer of timers) {
|
|
364
|
+
clearTimeout(timer);
|
|
365
|
+
}
|
|
366
|
+
timers.clear();
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
const schedule = (fn: () => void | Promise<void>, ms: number) => {
|
|
370
|
+
const timer = setTimeout(() => {
|
|
371
|
+
timers.delete(timer);
|
|
372
|
+
void Promise.resolve(fn()).catch((exc: unknown) => {
|
|
373
|
+
compactionEvents.push(`timer action failed: ${errorMessage(exc)}`);
|
|
374
|
+
});
|
|
375
|
+
}, ms);
|
|
376
|
+
timers.add(timer);
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
const requestGracefulTaskResult = async (message: string, options: { shutdown?: boolean } = {}) => {
|
|
380
|
+
if (!session || finished || aborted) {
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
if (options.shutdown) {
|
|
384
|
+
shutdownRequested = true;
|
|
385
|
+
}
|
|
386
|
+
if (session.isBashRunning && session.abortBash) {
|
|
387
|
+
session.abortBash();
|
|
388
|
+
compactionEvents.push("aborted running bash before graceful shutdown request");
|
|
389
|
+
}
|
|
390
|
+
if ((session.isStreaming || session.isBashRunning) && session.steer) {
|
|
391
|
+
await session.steer(message);
|
|
392
|
+
} else {
|
|
393
|
+
await session.prompt(message);
|
|
394
|
+
}
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
const abortSession = async (reason: string) => {
|
|
398
|
+
if (!session || finished || aborted) {
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
aborted = true;
|
|
402
|
+
shutdownRequested = true;
|
|
403
|
+
error = error ?? reason;
|
|
404
|
+
await session.abort?.();
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
const abortListener = () => {
|
|
408
|
+
void abortSession("worker session aborted by outer signal").catch((exc: unknown) => {
|
|
409
|
+
compactionEvents.push(`abort by outer signal failed: ${errorMessage(exc)}`);
|
|
410
|
+
});
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
try {
|
|
414
|
+
if (options.abortSignal?.aborted) {
|
|
415
|
+
throw new Error("worker session aborted before start");
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const factoryResult = await sessionFactory(options);
|
|
419
|
+
session = factoryResult.session;
|
|
420
|
+
sessionFile = session.sessionFile;
|
|
421
|
+
sessionId = session.sessionId;
|
|
422
|
+
if (factoryResult.modelFallbackMessage) {
|
|
423
|
+
contextObservations.push(`model fallback: ${factoryResult.modelFallbackMessage}`);
|
|
424
|
+
}
|
|
425
|
+
for (const diagnostic of factoryResult.diagnostics ?? []) {
|
|
426
|
+
contextObservations.push(diagnostic);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
unsubscribe = session.subscribe((event: unknown) => {
|
|
430
|
+
const summary = summarizeWorkerEvent(event);
|
|
431
|
+
if (summary) {
|
|
432
|
+
capture(summary);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (!isRecord(event) || typeof event.type !== "string") {
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
switch (event.type) {
|
|
440
|
+
case "message_start": {
|
|
441
|
+
const message = event.message;
|
|
442
|
+
if (isRecord(message) && message.role === "assistant") {
|
|
443
|
+
currentAssistantText = "";
|
|
444
|
+
}
|
|
445
|
+
break;
|
|
446
|
+
}
|
|
447
|
+
case "message_update": {
|
|
448
|
+
const assistantEvent = event.assistantMessageEvent;
|
|
449
|
+
if (isRecord(assistantEvent) && assistantEvent.type === "text_delta") {
|
|
450
|
+
const delta = typeof assistantEvent.delta === "string" ? assistantEvent.delta : "";
|
|
451
|
+
currentAssistantText += delta;
|
|
452
|
+
assistantText = currentAssistantText || assistantText;
|
|
453
|
+
}
|
|
454
|
+
break;
|
|
455
|
+
}
|
|
456
|
+
case "message_end": {
|
|
457
|
+
const messageText = assistantMessageText(event.message);
|
|
458
|
+
if (messageText) {
|
|
459
|
+
assistantText = messageText;
|
|
460
|
+
}
|
|
461
|
+
break;
|
|
462
|
+
}
|
|
463
|
+
case "turn_end": {
|
|
464
|
+
turnCount += 1;
|
|
465
|
+
const messageText = assistantMessageText(event.message);
|
|
466
|
+
if (messageText) {
|
|
467
|
+
assistantText = messageText;
|
|
468
|
+
}
|
|
469
|
+
captureContextUsage(session, turnCount, contextObservations);
|
|
470
|
+
break;
|
|
471
|
+
}
|
|
472
|
+
case "tool_execution_start": {
|
|
473
|
+
const toolName = typeof event.toolName === "string" ? event.toolName : "";
|
|
474
|
+
if (toolName === "bash") {
|
|
475
|
+
const requestedTimeout = requestedBashTimeout(event);
|
|
476
|
+
if (requestedTimeout !== undefined && requestedTimeout > options.maxBashTimeoutSeconds) {
|
|
477
|
+
const command = isRecord(event.args) && typeof event.args.command === "string" ? event.args.command : "";
|
|
478
|
+
compactionEvents.push(
|
|
479
|
+
`aborted bash command with timeout ${requestedTimeout.toFixed(0)}s > max ${options.maxBashTimeoutSeconds.toFixed(0)}s: ${command.slice(0, 160)}`,
|
|
480
|
+
);
|
|
481
|
+
session?.abortBash?.();
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
break;
|
|
485
|
+
}
|
|
486
|
+
case "compaction_end": {
|
|
487
|
+
compactionEvents.push(formatCompactionEndEvent(event));
|
|
488
|
+
break;
|
|
489
|
+
}
|
|
490
|
+
case "agent_end": {
|
|
491
|
+
const eventText = lastAssistantTextFromMessages(event.messages);
|
|
492
|
+
if (eventText) {
|
|
493
|
+
assistantText = eventText;
|
|
494
|
+
}
|
|
495
|
+
break;
|
|
496
|
+
}
|
|
497
|
+
case "extension_error": {
|
|
498
|
+
compactionEvents.push(`extension_error: ${String(event.error ?? "unknown")}`);
|
|
499
|
+
break;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
options.abortSignal?.addEventListener("abort", abortListener, { once: true });
|
|
505
|
+
|
|
506
|
+
if (taskTimeoutSeconds > 0) {
|
|
507
|
+
schedule(async () => {
|
|
508
|
+
if (finished || hasTaskResultStatus(assistantText)) {
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
timedOut = true;
|
|
512
|
+
await requestGracefulTaskResult(buildTimeLimitMessage(taskTimeoutSeconds), { shutdown: true });
|
|
513
|
+
if (gracefulShutdownSeconds > 0) {
|
|
514
|
+
schedule(
|
|
515
|
+
() => abortSession(`task exceeded ${taskTimeoutSeconds.toFixed(0)}s timeout`),
|
|
516
|
+
gracefulShutdownSeconds * 1000,
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
}, taskTimeoutSeconds * 1000);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
await session.prompt(prompt);
|
|
523
|
+
assistantText = latestAssistantText(session, assistantText);
|
|
524
|
+
|
|
525
|
+
if (!hasTaskResultStatus(assistantText) && !aborted && !options.abortSignal?.aborted) {
|
|
526
|
+
contextObservations.push("missing TASK_RESULT status after initial prompt; requested required block once");
|
|
527
|
+
await requestGracefulTaskResult(buildMissingTaskResultMessage());
|
|
528
|
+
assistantText = latestAssistantText(session, assistantText);
|
|
529
|
+
}
|
|
530
|
+
} catch (exc) {
|
|
531
|
+
error = error ?? errorMessage(exc);
|
|
532
|
+
} finally {
|
|
533
|
+
finished = true;
|
|
534
|
+
clearTimers();
|
|
535
|
+
options.abortSignal?.removeEventListener("abort", abortListener);
|
|
536
|
+
unsubscribe?.();
|
|
537
|
+
if (session) {
|
|
538
|
+
assistantText = latestAssistantText(session, assistantText);
|
|
539
|
+
sessionFile = session.sessionFile ?? sessionFile;
|
|
540
|
+
sessionId = session.sessionId ?? sessionId;
|
|
541
|
+
session.dispose?.();
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if ((error || aborted || timedOut) && !hasTaskResult(assistantText)) {
|
|
546
|
+
assistantText = buildLongTaskFailureTaskResult(error ?? (timedOut ? "task timed out" : "worker session aborted"));
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const reportedStatus = parseReportedStatus(assistantText);
|
|
550
|
+
return {
|
|
551
|
+
task: options.task,
|
|
552
|
+
attempt: options.attempt,
|
|
553
|
+
startedAt,
|
|
554
|
+
endedAt: now().toISOString(),
|
|
555
|
+
reportedStatus,
|
|
556
|
+
done: isDoneStatus(reportedStatus),
|
|
557
|
+
assistantText,
|
|
558
|
+
sessionFile,
|
|
559
|
+
sessionId,
|
|
560
|
+
contextObservations,
|
|
561
|
+
compactionEvents,
|
|
562
|
+
events,
|
|
563
|
+
shutdownRequested,
|
|
564
|
+
timedOut,
|
|
565
|
+
aborted: aborted || Boolean(options.abortSignal?.aborted),
|
|
566
|
+
error,
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function textFromContentPart(item: unknown): string {
|
|
571
|
+
if (!isRecord(item)) {
|
|
572
|
+
return "";
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const type = typeof item.type === "string" ? item.type : "";
|
|
576
|
+
if (type && type !== "text" && type !== "output_text") {
|
|
577
|
+
return "";
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
if (typeof item.text === "string") {
|
|
581
|
+
return item.text;
|
|
582
|
+
}
|
|
583
|
+
if (isRecord(item.text) && typeof item.text.value === "string") {
|
|
584
|
+
return item.text.value;
|
|
585
|
+
}
|
|
586
|
+
if (typeof item.content === "string") {
|
|
587
|
+
return item.content;
|
|
588
|
+
}
|
|
589
|
+
return "";
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function textFromDeltaEvent(event: Record<string, unknown>): string {
|
|
593
|
+
if (typeof event.text === "string") {
|
|
594
|
+
return event.text;
|
|
595
|
+
}
|
|
596
|
+
if (typeof event.delta === "string") {
|
|
597
|
+
return event.delta;
|
|
598
|
+
}
|
|
599
|
+
if (isRecord(event.delta)) {
|
|
600
|
+
return textFromContentPart(event.delta);
|
|
601
|
+
}
|
|
602
|
+
if (isRecord(event.content)) {
|
|
603
|
+
return textFromContentPart(event.content);
|
|
604
|
+
}
|
|
605
|
+
return "";
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function applyWorkerSettingsDefaults(settingsManager: unknown): void {
|
|
609
|
+
if (!isRecord(settingsManager) || typeof settingsManager.applyOverrides !== "function") {
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
try {
|
|
614
|
+
settingsManager.applyOverrides({
|
|
615
|
+
retry: { enabled: true, maxRetries: 2 },
|
|
616
|
+
compaction: { enabled: true },
|
|
617
|
+
});
|
|
618
|
+
} catch {
|
|
619
|
+
// Settings defaults are best-effort; createAgentSession can still use its own defaults.
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
async function resolveWorkerModel(modelRegistry: unknown, modelName: string): Promise<unknown> {
|
|
624
|
+
const [provider, ...modelIdParts] = modelName.split("/");
|
|
625
|
+
const modelId = modelIdParts.join("/");
|
|
626
|
+
if (provider && modelId && isRecord(modelRegistry) && typeof modelRegistry.find === "function") {
|
|
627
|
+
const registryModel = modelRegistry.find(provider, modelId);
|
|
628
|
+
if (registryModel) {
|
|
629
|
+
return registryModel;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
try {
|
|
634
|
+
const ai = await import("@earendil-works/pi-ai");
|
|
635
|
+
if (typeof ai.getModel === "function" && provider && modelId) {
|
|
636
|
+
const getModel = ai.getModel as (providerName: string, modelId: string) => unknown;
|
|
637
|
+
return getModel(provider, modelId);
|
|
638
|
+
}
|
|
639
|
+
} catch {
|
|
640
|
+
// Optional peer resolution can fail in tests that inject a session factory.
|
|
641
|
+
}
|
|
642
|
+
return undefined;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
export function disableExtensionsForWorker(
|
|
646
|
+
resourceLoader: unknown,
|
|
647
|
+
createRuntime: () => unknown,
|
|
648
|
+
): {
|
|
649
|
+
getExtensions: () => { extensions: unknown[]; errors: unknown[]; runtime: unknown };
|
|
650
|
+
getSkills: () => unknown;
|
|
651
|
+
getPrompts: () => unknown;
|
|
652
|
+
getThemes: () => unknown;
|
|
653
|
+
getAgentsFiles: () => unknown;
|
|
654
|
+
getSystemPrompt: () => unknown;
|
|
655
|
+
getAppendSystemPrompt: () => unknown[];
|
|
656
|
+
extendResources: (paths: unknown) => void;
|
|
657
|
+
reload: () => Promise<void>;
|
|
658
|
+
} {
|
|
659
|
+
const loader = resourceLoader as Record<string, unknown>;
|
|
660
|
+
return {
|
|
661
|
+
getExtensions: () => ({ extensions: [], errors: [], runtime: createRuntime() }),
|
|
662
|
+
getSkills: () => callLoaderMethod(loader, "getSkills", { skills: [], diagnostics: [] }),
|
|
663
|
+
getPrompts: () => callLoaderMethod(loader, "getPrompts", { prompts: [], diagnostics: [] }),
|
|
664
|
+
getThemes: () => callLoaderMethod(loader, "getThemes", { themes: [], diagnostics: [] }),
|
|
665
|
+
getAgentsFiles: () => callLoaderMethod(loader, "getAgentsFiles", { agentsFiles: [] }),
|
|
666
|
+
getSystemPrompt: () => callLoaderMethod(loader, "getSystemPrompt", undefined),
|
|
667
|
+
getAppendSystemPrompt: () => callLoaderMethod(loader, "getAppendSystemPrompt", []) as unknown[],
|
|
668
|
+
extendResources: (paths: unknown) => {
|
|
669
|
+
if (typeof loader.extendResources === "function") {
|
|
670
|
+
loader.extendResources(paths);
|
|
671
|
+
}
|
|
672
|
+
},
|
|
673
|
+
reload: async () => {
|
|
674
|
+
if (typeof loader.reload === "function") {
|
|
675
|
+
await loader.reload();
|
|
676
|
+
}
|
|
677
|
+
},
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function callLoaderMethod(loader: Record<string, unknown>, name: string, fallback: unknown): unknown {
|
|
682
|
+
const method = loader[name];
|
|
683
|
+
return typeof method === "function" ? method.call(loader) : fallback;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function extensionDiagnostics(extensionsResult: unknown): string[] {
|
|
687
|
+
if (!isRecord(extensionsResult) || !Array.isArray(extensionsResult.errors)) {
|
|
688
|
+
return [];
|
|
689
|
+
}
|
|
690
|
+
return extensionsResult.errors.map((item) => {
|
|
691
|
+
if (!isRecord(item)) {
|
|
692
|
+
return `extension diagnostic: ${String(item)}`;
|
|
693
|
+
}
|
|
694
|
+
return `extension diagnostic: ${String(item.path ?? "unknown")}: ${String(item.error ?? "unknown error")}`;
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function errorMessage(error: unknown): string {
|
|
699
|
+
return error instanceof Error ? error.message : String(error);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function summarizeWorkerEvent(event: unknown): CapturedWorkerEvent | undefined {
|
|
703
|
+
if (!isRecord(event) || typeof event.type !== "string") {
|
|
704
|
+
return undefined;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
if (event.type === "message_update" && isRecord(event.assistantMessageEvent)) {
|
|
708
|
+
const assistantEvent = event.assistantMessageEvent;
|
|
709
|
+
if (assistantEvent.type === "text_delta") {
|
|
710
|
+
return {
|
|
711
|
+
type: event.type,
|
|
712
|
+
textDelta: typeof assistantEvent.delta === "string" ? assistantEvent.delta : "",
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
return { type: event.type, note: String(assistantEvent.type ?? "assistant_update") };
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
if (event.type.startsWith("tool_execution_")) {
|
|
719
|
+
return {
|
|
720
|
+
type: event.type,
|
|
721
|
+
toolName: typeof event.toolName === "string" ? event.toolName : undefined,
|
|
722
|
+
isError: typeof event.isError === "boolean" ? event.isError : undefined,
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
if (
|
|
727
|
+
event.type === "turn_end" ||
|
|
728
|
+
event.type === "message_end" ||
|
|
729
|
+
event.type === "compaction_start" ||
|
|
730
|
+
event.type === "compaction_end" ||
|
|
731
|
+
event.type === "agent_end" ||
|
|
732
|
+
event.type === "auto_retry_start" ||
|
|
733
|
+
event.type === "auto_retry_end"
|
|
734
|
+
) {
|
|
735
|
+
return { type: event.type };
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
return undefined;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function captureContextUsage(
|
|
742
|
+
session: WorkerSessionLike | undefined,
|
|
743
|
+
turnCount: number,
|
|
744
|
+
contextObservations: string[],
|
|
745
|
+
): void {
|
|
746
|
+
const usage = session?.getContextUsage?.() ?? contextUsageFromStats(session?.getSessionStats?.());
|
|
747
|
+
const percent = contextPercent(usage);
|
|
748
|
+
if (percent === undefined) {
|
|
749
|
+
contextObservations.push(`turn ${turnCount}: context usage unavailable`);
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
contextObservations.push(`turn ${turnCount}: ${percent.toFixed(1)}%`);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function contextUsageFromStats(stats: unknown): unknown {
|
|
756
|
+
return isRecord(stats) ? stats.contextUsage : undefined;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function contextPercent(usage: unknown): number | undefined {
|
|
760
|
+
if (!isRecord(usage)) {
|
|
761
|
+
return undefined;
|
|
762
|
+
}
|
|
763
|
+
for (const key of ["percent", "percentage", "contextPercent", "contextPercentage"] as const) {
|
|
764
|
+
const value = usage[key];
|
|
765
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
766
|
+
return value > 1 ? value : value * 100;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
const used = numericProperty(usage, ["used", "tokens", "contextTokens"]);
|
|
771
|
+
const limit = numericProperty(usage, ["limit", "max", "contextWindow", "window"]);
|
|
772
|
+
if (used !== undefined && limit !== undefined && limit > 0) {
|
|
773
|
+
return (used / limit) * 100;
|
|
774
|
+
}
|
|
775
|
+
return undefined;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function numericProperty(record: Record<string, unknown>, keys: readonly string[]): number | undefined {
|
|
779
|
+
for (const key of keys) {
|
|
780
|
+
const value = record[key];
|
|
781
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
782
|
+
return value;
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
return undefined;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function requestedBashTimeout(event: Record<string, unknown>): number | undefined {
|
|
789
|
+
if (!isRecord(event.args)) {
|
|
790
|
+
return undefined;
|
|
791
|
+
}
|
|
792
|
+
const timeout = event.args.timeout;
|
|
793
|
+
if (typeof timeout === "number" && Number.isFinite(timeout)) {
|
|
794
|
+
return timeout;
|
|
795
|
+
}
|
|
796
|
+
if (typeof timeout === "string") {
|
|
797
|
+
const parsed = Number.parseFloat(timeout);
|
|
798
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
799
|
+
}
|
|
800
|
+
return undefined;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function formatCompactionEndEvent(event: Record<string, unknown>): string {
|
|
804
|
+
const reason = String(event.reason ?? "unknown");
|
|
805
|
+
const aborted = Boolean(event.aborted);
|
|
806
|
+
if (isRecord(event.result)) {
|
|
807
|
+
const tokensBefore = event.result.tokensBefore;
|
|
808
|
+
return `compaction_end reason=${reason} aborted=${aborted} tokensBefore=${String(tokensBefore ?? "unknown")}`;
|
|
809
|
+
}
|
|
810
|
+
return `compaction_end reason=${reason} aborted=${aborted} error=${String(event.errorMessage ?? "unknown")}`;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function latestAssistantText(session: WorkerSessionLike, fallback: string): string {
|
|
814
|
+
const direct = session.getLastAssistantText?.();
|
|
815
|
+
if (direct) {
|
|
816
|
+
return direct;
|
|
817
|
+
}
|
|
818
|
+
const fromMessages = lastAssistantTextFromMessages(session.messages);
|
|
819
|
+
return fromMessages || fallback;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function buildLongTaskFailureTaskResult(reason: string): string {
|
|
823
|
+
return `TASK_RESULT:
|
|
824
|
+
status: partial
|
|
825
|
+
summary: Pi Long Task stopped the session before the worker produced a final result.
|
|
826
|
+
changes:
|
|
827
|
+
- unknown; inspect git diff and session state
|
|
828
|
+
verification:
|
|
829
|
+
- not completed by worker
|
|
830
|
+
remaining:
|
|
831
|
+
- Pi Long Task/session error: ${reason}`;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
835
|
+
return typeof value === "object" && value !== null;
|
|
836
|
+
}
|