pi-harness-runtime 0.10.13 → 0.10.14

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.
@@ -0,0 +1,531 @@
1
+ /**
2
+ * Loop Runtime — RFC-0001
3
+ *
4
+ * Core execution loop for the harness runtime.
5
+ * Runs the repeated cycle:
6
+ * pick task -> assign model -> code -> run tests -> review diff
7
+ * -> if failed: repair
8
+ * -> if quota_limit: pause and resume later
9
+ * -> if context_full: compact + auto-resume
10
+ *
11
+ * Compact integration (RFC-0028):
12
+ * Uses CompactOrchestrator to proactively manage context window.
13
+ * On context_full: full compact + continue prompt + auto-retry
14
+ * On quota: pause job for later resume
15
+ */
16
+ import { MirrorStore } from "../mirror.js";
17
+ import { JobStateMachine, } from "./job-state-machine.js";
18
+ import { CompactOrchestrator, } from "./context-compact-orchestrator.js";
19
+ import { createSessionMemoryManager, } from "./session-memory.js";
20
+ import { AutoCompactEngine } from "./auto-compact.js";
21
+ import { OutputLimitHandler } from "./output-limit-handler.js";
22
+ import { createForkedSummarizer, } from "./forked-summarizer.js";
23
+ // --- Constants ----------------------------------------------------------------
24
+ const DEFAULT_MAX_COMPACT_RETRIES = 3;
25
+ const DEFAULT_MAX_OUTPUT_TOKENS = 4096;
26
+ // --- Loop Runtime -----------------------------------------------------------
27
+ export class LoopRuntime {
28
+ config;
29
+ state;
30
+ jobState;
31
+ mirrorStore;
32
+ callbacks;
33
+ running = false;
34
+ paused = false;
35
+ // Compact-related state
36
+ maxCompactRetries;
37
+ maxOutputTokens;
38
+ compactOrchestrator;
39
+ forkedSummarizer;
40
+ sessionMemory;
41
+ autoCompactEngine;
42
+ totalCompactions = 0;
43
+ constructor(config, callbacks) {
44
+ this.callbacks = callbacks;
45
+ this.maxCompactRetries =
46
+ config.maxRepairAttempts ?? DEFAULT_MAX_COMPACT_RETRIES;
47
+ this.maxOutputTokens = DEFAULT_MAX_OUTPUT_TOKENS;
48
+ this.config = {
49
+ jobId: config.jobId,
50
+ requirement: config.requirement,
51
+ providerPolicy: config.providerPolicy ?? {
52
+ plannerProvider: "openai",
53
+ codeProviders: ["openai"],
54
+ reviewProvider: "openai",
55
+ fallbackProviders: [],
56
+ },
57
+ maxIterations: config.maxIterations ?? 1000,
58
+ autoCheckpoint: config.autoCheckpoint ?? true,
59
+ checkpointInterval: config.checkpointInterval ?? 50,
60
+ pauseOnQuota: config.pauseOnQuota ?? true,
61
+ maxRepairAttempts: config.maxRepairAttempts ?? 3,
62
+ };
63
+ this.state = {
64
+ jobId: config.jobId,
65
+ iteration: 0,
66
+ status: "created",
67
+ currentTaskId: null,
68
+ lastCheckpoint: null,
69
+ };
70
+ this.jobState = new JobStateMachine({
71
+ checkpointManager: {
72
+ async save(ckpt) {
73
+ await callbacks.onCheckpoint?.(ckpt);
74
+ },
75
+ async load(_jobId) {
76
+ return null;
77
+ },
78
+ async appendEvent(_jobId, _event) {
79
+ // no-op for now
80
+ },
81
+ },
82
+ });
83
+ this.mirrorStore = new MirrorStore();
84
+ // Initialize session memory
85
+ this.sessionMemory = createSessionMemoryManager(config.jobId);
86
+ // Initialize auto-compact engine
87
+ this.autoCompactEngine = new AutoCompactEngine({
88
+ jobId: config.jobId,
89
+ requirement: config.requirement,
90
+ });
91
+ // Initialize compact orchestrator if agent callback provided
92
+ if (callbacks.onInvokeAgent) {
93
+ this.compactOrchestrator = new CompactOrchestrator({
94
+ jobId: config.jobId,
95
+ provider: callbacks.provider ?? "openai",
96
+ model: callbacks.summarizerModel ?? "gpt-4",
97
+ contextWindowSize: callbacks.contextWindowSize,
98
+ });
99
+ // Initialize forked summarizer if model provided
100
+ if (callbacks.summarizerModel && callbacks.onInvokeAgent) {
101
+ // Wrap onInvokeAgent to match ForkedSummarizer's InvokeOptions
102
+ const wrappedInvoke = async (opts) => {
103
+ return callbacks.onInvokeAgent(opts);
104
+ };
105
+ this.forkedSummarizer = createForkedSummarizer(callbacks.summarizerModel, wrappedInvoke);
106
+ }
107
+ }
108
+ }
109
+ // --- Public API ---------------------------------------------------------
110
+ async run() {
111
+ this.running = true;
112
+ // --- Auto-resume from quota pause ----------------------------------
113
+ const checkpoint = await this.callbacks.onGetCheckpoint?.();
114
+ if (checkpoint?.status === "paused_quota" && checkpoint.resumeAt) {
115
+ const resumeMs = new Date(checkpoint.resumeAt).getTime();
116
+ const now = Date.now();
117
+ const waitMs = resumeMs - now;
118
+ if (waitMs > 0) {
119
+ const waitMin = Math.ceil(waitMs / 60_000);
120
+ const notify = this.callbacks.onNotify ?? (() => { });
121
+ await notify(`Quota reset in ${waitMin} min — auto-resuming at ${checkpoint.resumeAt}`, "info");
122
+ // Wait in 5-minute chunks so we don't block forever if quota resets early
123
+ while (this.running && Date.now() < resumeMs) {
124
+ await new Promise((r) => setTimeout(r, 5 * 60_000)); // 5 min
125
+ // Re-check mirror — quota may have reset early
126
+ const fresh = await this.callbacks.onCheckMirror?.("minimax");
127
+ if (fresh &&
128
+ fresh.h5_used_pct !== undefined &&
129
+ fresh.h5_used_pct < 100) {
130
+ break; // quota reset early, resume now
131
+ }
132
+ }
133
+ // Transition back to running
134
+ await this.jobState.transition("running");
135
+ this.callbacks.onQuotaEvent?.("resumed");
136
+ }
137
+ }
138
+ this.state.status = "running";
139
+ try {
140
+ while (this.running) {
141
+ // Check for pause
142
+ if (this.paused) {
143
+ await this.saveCheckpoint();
144
+ break;
145
+ }
146
+ this.state.iteration++;
147
+ // Check iteration limit
148
+ if (this.state.iteration > this.config.maxIterations) {
149
+ return {
150
+ success: false,
151
+ completed: false,
152
+ iterations: this.state.iteration,
153
+ error: "Max iterations exceeded",
154
+ totalCompactions: this.totalCompactions,
155
+ };
156
+ }
157
+ this.callbacks.onIteration?.(this.state.iteration, this.state.status);
158
+ // Check job status
159
+ const statusSummary = await this.jobState.getStatusSummary();
160
+ if (statusSummary?.status === "paused_quota") {
161
+ await this.handleQuotaPause();
162
+ continue;
163
+ }
164
+ // Pick a task
165
+ const task = await this.callbacks.onPickTask?.();
166
+ if (!task) {
167
+ this.state.status = "archived";
168
+ break;
169
+ }
170
+ this.state.currentTaskId = task.id;
171
+ await this.executeTaskWithCompact(task);
172
+ // Auto-checkpoint
173
+ if (this.config.autoCheckpoint &&
174
+ this.state.iteration % this.config.checkpointInterval === 0) {
175
+ await this.saveCheckpoint();
176
+ }
177
+ }
178
+ return {
179
+ success: this.state.status !== "archived",
180
+ completed: this.state.status === "archived",
181
+ iterations: this.state.iteration,
182
+ finalCheckpoint: this.state.lastCheckpoint ?? undefined,
183
+ totalCompactions: this.totalCompactions,
184
+ };
185
+ }
186
+ finally {
187
+ this.running = false;
188
+ }
189
+ }
190
+ pause() {
191
+ this.paused = true;
192
+ }
193
+ resume() {
194
+ this.paused = false;
195
+ this.state.status = "running";
196
+ }
197
+ stop() {
198
+ this.running = false;
199
+ }
200
+ getState() {
201
+ return { ...this.state };
202
+ }
203
+ /** Get compact orchestrator for external access */
204
+ getCompactOrchestrator() {
205
+ return this.compactOrchestrator;
206
+ }
207
+ /** Get session memory manager */
208
+ getSessionMemory() {
209
+ return this.sessionMemory;
210
+ }
211
+ // --- Task Execution with Compact ---------------------------------------
212
+ async executeTaskWithCompact(task) {
213
+ // 1. Pick model
214
+ const model = (await this.callbacks.onPickModel?.(task)) ?? "claude";
215
+ // 2. Build compactable messages
216
+ const compactMessages = [
217
+ {
218
+ role: "system",
219
+ content: this.buildSystemPrompt(task),
220
+ timestamp: Date.now(),
221
+ },
222
+ {
223
+ role: "user",
224
+ content: `Task: ${task.id}\nObjective: ${task.description ?? "See task graph."}`,
225
+ timestamp: Date.now(),
226
+ },
227
+ ];
228
+ // 3. Inject session memory if available
229
+ const sessionMemoryContext = this.sessionMemory?.getMemoryForContext();
230
+ if (sessionMemoryContext) {
231
+ compactMessages.push({
232
+ role: "system",
233
+ content: sessionMemoryContext,
234
+ timestamp: Date.now(),
235
+ metadata: { sessionMemory: true },
236
+ });
237
+ }
238
+ const outputLimitHandler = new OutputLimitHandler({
239
+ jobId: this.config.jobId,
240
+ taskId: task.id,
241
+ });
242
+ // 4. Execute with compact support
243
+ let compactRetries = 0;
244
+ while (compactRetries < this.maxCompactRetries) {
245
+ // Build invoke options
246
+ const invokeOptions = {
247
+ messages: compactMessages,
248
+ model,
249
+ maxOutputTokens: this.maxOutputTokens,
250
+ tools: task.tools,
251
+ systemPrompt: task.instructions,
252
+ };
253
+ // Use compact orchestrator if available
254
+ if (this.compactOrchestrator && this.callbacks.onInvokeAgent) {
255
+ const orchestratorCallbacks = this.buildCompactCallbacks(task, model);
256
+ const result = await this.compactOrchestrator.invokeWithCompact(invokeOptions, orchestratorCallbacks);
257
+ if (result.success) {
258
+ const continued = await this.handleAutoContinuation(compactMessages, result, task, outputLimitHandler);
259
+ if (continued) {
260
+ continue;
261
+ }
262
+ this.handleInvokeSuccess(compactMessages, result, task);
263
+ outputLimitHandler.reset();
264
+ await this.runTestsAndReview(task, compactMessages);
265
+ return;
266
+ }
267
+ // Check for circuit breaker
268
+ if (this.compactOrchestrator.isCircuitBroken()) {
269
+ this.state.status = "blocked";
270
+ throw new Error(`Compact circuit breaker engaged after ${this.compactOrchestrator.getConsecutiveFailures()} failures.`);
271
+ }
272
+ // Compact happened — retry
273
+ if (result.compactResult) {
274
+ compactRetries++;
275
+ this.totalCompactions++;
276
+ this.onCompactionOccurred(result.compactResult);
277
+ // Append continue message
278
+ if (result.continueMessage) {
279
+ compactMessages.push({
280
+ role: "user",
281
+ content: result.continueMessage,
282
+ timestamp: Date.now(),
283
+ });
284
+ }
285
+ continue;
286
+ }
287
+ // Error without compact — surface it
288
+ throw new Error(result.error ?? "Unknown invoke error");
289
+ }
290
+ // Fallback: direct invoke without orchestrator
291
+ const result = await this.callbacks.onInvokeAgent?.(invokeOptions);
292
+ if (!result || !result.success) {
293
+ throw new Error(result?.error ?? "Invoke failed");
294
+ }
295
+ const continued = await this.handleAutoContinuation(compactMessages, result, task, outputLimitHandler);
296
+ if (continued) {
297
+ continue;
298
+ }
299
+ this.handleInvokeSuccess(compactMessages, result, task);
300
+ outputLimitHandler.reset();
301
+ await this.runTestsAndReview(task, compactMessages);
302
+ return;
303
+ }
304
+ // Max compact retries exceeded
305
+ this.state.status = "blocked";
306
+ throw new Error(`Max compact retries (${this.maxCompactRetries}) exceeded`);
307
+ }
308
+ async handleAutoContinuation(messages, result, task, outputLimitHandler) {
309
+ const output = result.output ?? "";
310
+ const hitOutputLimit = outputLimitHandler.detectOutputLimit(result.error, {
311
+ finishReason: result.finishReason,
312
+ });
313
+ const hitCompactionBoundary = this.autoCompactEngine?.detectCompaction(output) ?? false;
314
+ if (!hitOutputLimit && !hitCompactionBoundary) {
315
+ return false;
316
+ }
317
+ if (!outputLimitHandler.shouldContinue()) {
318
+ throw new Error(`Output limit recovery exhausted after ${outputLimitHandler.getAttempts()} attempts for task ${task.id}.`);
319
+ }
320
+ this.handleInvokeSuccess(messages, result, task);
321
+ await outputLimitHandler.handleOutputLimit(output, result.finishReason ?? "length");
322
+ const continueMessage = hitCompactionBoundary
323
+ ? (this.autoCompactEngine?.buildContinueMessage() ?? "continue")
324
+ : "Output token limit hit. Resume directly — no apology, no recap of what you were doing. Pick up mid-thought if needed and continue the same task.";
325
+ messages.push({
326
+ role: "user",
327
+ content: continueMessage,
328
+ timestamp: Date.now(),
329
+ metadata: {
330
+ autoContinuation: true,
331
+ reason: hitCompactionBoundary ? "compaction" : "output_limit",
332
+ attempt: outputLimitHandler.getAttempts(),
333
+ },
334
+ });
335
+ return true;
336
+ }
337
+ /**
338
+ * Build compact callbacks for the orchestrator
339
+ */
340
+ buildCompactCallbacks(task, _model) {
341
+ return {
342
+ invokeAgent: async (opts) => {
343
+ return ((await this.callbacks.onInvokeAgent?.(opts)) ?? {
344
+ success: false,
345
+ error: "No agent",
346
+ });
347
+ },
348
+ onCheckpoint: async (result) => {
349
+ this.callbacks.onCompaction?.(result);
350
+ await this.saveCheckpoint();
351
+ },
352
+ onPreCompact: async (_reason) => {
353
+ // Save partial artifacts before compact
354
+ // Note: we don't have beforeTokens here, but we save the state
355
+ this.autoCompactEngine?.saveCompactionArtifact({
356
+ timestamp: new Date().toISOString(),
357
+ jobId: this.config.jobId,
358
+ taskId: task.id,
359
+ compactedFromTokens: 0,
360
+ reason: _reason,
361
+ });
362
+ },
363
+ onPostCompact: async (result) => {
364
+ // Extract facts from compacted messages for session memory
365
+ this.sessionMemory?.extractFacts([]);
366
+ this.callbacks.onCompaction?.(result);
367
+ },
368
+ onQuotaEvent: this.callbacks.onQuotaEvent,
369
+ summarizeViaForkedAgent: async (messages, reason) => {
370
+ if (this.forkedSummarizer) {
371
+ return this.forkedSummarizer.summarize(messages, {
372
+ focusOn: "work_done",
373
+ });
374
+ }
375
+ // Fallback to heuristic
376
+ return {
377
+ summary: `[Compact: ${reason}] Conversation summarised.`,
378
+ droppedCount: messages.length,
379
+ };
380
+ },
381
+ };
382
+ }
383
+ /**
384
+ * Handle successful invoke result
385
+ */
386
+ handleInvokeSuccess(messages, result, _task) {
387
+ // Append assistant response to messages
388
+ messages.push({
389
+ role: "assistant",
390
+ content: result.output ?? "",
391
+ timestamp: Date.now(),
392
+ metadata: {
393
+ model: result.model,
394
+ usage: result.usage,
395
+ },
396
+ });
397
+ // Extract facts from output for session memory
398
+ if (result.output) {
399
+ this.sessionMemory?.extractFacts([
400
+ {
401
+ role: "assistant",
402
+ content: result.output,
403
+ timestamp: Date.now(),
404
+ },
405
+ ]);
406
+ }
407
+ }
408
+ /**
409
+ * Called when compaction occurs
410
+ */
411
+ onCompactionOccurred(result) {
412
+ // Update session memory with compacted content
413
+ this.sessionMemory?.extractFacts([]);
414
+ // Save compaction event
415
+ this.autoCompactEngine?.saveCompactionArtifact({
416
+ timestamp: new Date().toISOString(),
417
+ jobId: this.config.jobId,
418
+ taskId: this.state.currentTaskId ?? "unknown",
419
+ compactedFromTokens: result.beforeTokens,
420
+ reason: result.trigger,
421
+ continuePrompt: result.summary,
422
+ });
423
+ // Notify callbacks
424
+ this.callbacks.onCompaction?.(result);
425
+ }
426
+ /**
427
+ * Run tests and review
428
+ */
429
+ async runTestsAndReview(task, messages) {
430
+ // Run tests
431
+ const unitPassed = (await this.callbacks.onRunTests?.(task)) ?? true;
432
+ const e2ePassed = (await this.callbacks.onRunE2E?.(task)) ?? true;
433
+ // Update session memory with test results
434
+ this.sessionMemory?.addTestResult(task.id, unitPassed && e2ePassed);
435
+ // Review
436
+ const verdict = await this.callbacks.onReview?.(task, {
437
+ unit: unitPassed,
438
+ e2e: e2ePassed,
439
+ });
440
+ if (verdict === "escalate") {
441
+ this.state.status = "waiting_human";
442
+ await this.callbacks.onEscalate?.(task, "Human review required");
443
+ this.paused = true;
444
+ }
445
+ else if (verdict === "repair") {
446
+ // Append repair instruction
447
+ messages.push({
448
+ role: "user",
449
+ content: "Tests failed. Please fix the issues and ensure all tests pass.",
450
+ timestamp: Date.now(),
451
+ });
452
+ }
453
+ }
454
+ /**
455
+ * Build system prompt for task
456
+ */
457
+ buildSystemPrompt(task) {
458
+ const parts = [
459
+ task.instructions ?? "Complete the assigned task.",
460
+ "",
461
+ "## Context Management",
462
+ "- Keep responses concise and focused",
463
+ "- If context becomes full, the system will compact automatically",
464
+ "- Do not repeat work already done",
465
+ ];
466
+ if (task.acceptanceCriteria && task.acceptanceCriteria.length > 0) {
467
+ parts.push("", "## Acceptance Criteria");
468
+ for (const criteria of task.acceptanceCriteria) {
469
+ parts.push(`- ${criteria}`);
470
+ }
471
+ }
472
+ return parts.join("\n");
473
+ }
474
+ // --- Quota Handling ----------------------------------------------------
475
+ async handleQuotaPause() {
476
+ this.paused = true;
477
+ this.state.status = "paused_quota";
478
+ this.jobState.transition("paused_quota");
479
+ // Set resumeAt from the mirror's known reset epoch so the auto-resume
480
+ // timer knows when to wake this job up.
481
+ const mirror = this.mirrorStore.readProvider("minimax");
482
+ const epoch = mirror?.h5_resets_at_epoch;
483
+ if (epoch) {
484
+ const resumeAt = new Date(epoch).toISOString();
485
+ await this.jobState.setResumeTime(resumeAt);
486
+ // console.log(
487
+ // `[LoopRuntime] Paused for 5h quota. Auto-resume at ${resumeAt}`,
488
+ // );
489
+ }
490
+ else {
491
+ // console.log(
492
+ // "[LoopRuntime] Paused for quota but no reset time known yet.",
493
+ // );
494
+ }
495
+ await this.callbacks.onQuotaEvent?.("paused");
496
+ await this.saveCheckpoint();
497
+ }
498
+ // --- Persistence --------------------------------------------------------
499
+ async saveCheckpoint() {
500
+ const now = new Date().toISOString();
501
+ const checkpoint = {
502
+ version: 1,
503
+ jobId: this.state.jobId,
504
+ requirement: this.config.requirement,
505
+ taskId: this.state.currentTaskId ?? undefined,
506
+ iteration: this.state.iteration,
507
+ status: this.state.status,
508
+ createdAt: now,
509
+ updatedAt: now,
510
+ };
511
+ this.state.lastCheckpoint = checkpoint;
512
+ await this.jobState.transition(this.state.status);
513
+ await this.callbacks.onCheckpoint?.(checkpoint);
514
+ }
515
+ // --- Resume from Checkpoint --------------------------------------------
516
+ async resumeFromCheckpoint(checkpoint) {
517
+ this.state.iteration = checkpoint.iteration ?? 0;
518
+ this.state.currentTaskId = checkpoint.taskId ?? null;
519
+ this.state.status = checkpoint.status ?? "running";
520
+ this.paused = false;
521
+ // Check for pending continue prompt
522
+ if (this.autoCompactEngine?.hasContinuePrompt()) {
523
+ const continuePrompt = this.autoCompactEngine.loadContinuePrompt();
524
+ if (continuePrompt) {
525
+ // Resume will inject the continue prompt
526
+ this.callbacks.onIteration?.(this.state.iteration, this.state.status);
527
+ }
528
+ }
529
+ return this.run();
530
+ }
531
+ }