pi-harness-runtime 0.3.2-beta.2 → 0.5.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 +20 -4
- package/README.md +20 -0
- package/harness/auto-compact.ts +326 -0
- package/harness/e2e/index.ts +44 -0
- package/harness/e2e/playwright-runner.ts +92 -53
- package/harness/e2e/test-engine.ts +9 -13
- package/harness/index.ts +105 -0
- package/harness/notification-events.ts +299 -0
- package/harness/output-limit-handler.ts +316 -0
- package/harness/partial-recovery.ts +407 -0
- package/package.json +5 -2
- package/packages/auth/src/minimax-browser-auth.ts +830 -180
- package/packages/auth/src/run-minimax-auth.ts +55 -7
- package/packages/notification/adapters/email-adapter.ts +126 -0
- package/packages/notification/adapters/ntfy-adapter.ts +100 -0
- package/packages/notification/adapters/telegram-adapter.ts +107 -0
- package/packages/notification/adapters/webhook-adapter.ts +75 -0
- package/packages/notification/base-adapter.ts +75 -0
- package/packages/notification/index.ts +51 -0
- package/packages/notification/notification-center.ts +286 -0
- package/packages/notification/types.ts +89 -0
- package/packages/providers/adapters.ts +102 -1
- package/packages/types/src/runtime-types.ts +3 -1
- package/skills/harness-runtime/SKILL.md +5 -1
package/harness/index.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Harness Runtime — Core Module Index
|
|
3
|
+
*
|
|
4
|
+
* Re-exports all harness components for easy importing.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// Re-export types from the types package
|
|
8
|
+
export type {
|
|
9
|
+
JobStatus,
|
|
10
|
+
TaskStatus,
|
|
11
|
+
RuntimeCheckpoint,
|
|
12
|
+
RuntimeTask,
|
|
13
|
+
RuntimeEvent,
|
|
14
|
+
TaskGraph,
|
|
15
|
+
TaskNode,
|
|
16
|
+
RepairTask,
|
|
17
|
+
FailureType,
|
|
18
|
+
AttemptedFix,
|
|
19
|
+
RetryPolicy,
|
|
20
|
+
QuotaSignal,
|
|
21
|
+
QuotaState,
|
|
22
|
+
ProviderConfig,
|
|
23
|
+
ProviderCapability,
|
|
24
|
+
ProviderRequest,
|
|
25
|
+
ProviderResponse,
|
|
26
|
+
E2EScenario,
|
|
27
|
+
E2EStep,
|
|
28
|
+
E2EResult,
|
|
29
|
+
E2EReport,
|
|
30
|
+
} from "../packages/types/src/runtime-types.js";
|
|
31
|
+
|
|
32
|
+
// State Machine
|
|
33
|
+
export { JobStateMachine } from "./job-state-machine.js";
|
|
34
|
+
export type {
|
|
35
|
+
StateTransition,
|
|
36
|
+
TransitionResult,
|
|
37
|
+
} from "./job-state-machine.js";
|
|
38
|
+
export type { CheckpointManager } from "./job-state-machine.js";
|
|
39
|
+
|
|
40
|
+
// Task Graph
|
|
41
|
+
export { TaskGraphManager } from "./task-graph.js";
|
|
42
|
+
export type { TaskGraphOptions } from "./task-graph.js";
|
|
43
|
+
|
|
44
|
+
// Loop Runtime
|
|
45
|
+
export { LoopRuntime } from "./loop-runtime.js";
|
|
46
|
+
export type { LoopResult } from "./loop-runtime.js";
|
|
47
|
+
|
|
48
|
+
// Repair Engine
|
|
49
|
+
export { RepairEngine } from "./repair-engine.js";
|
|
50
|
+
export type { RepairResult } from "./repair-engine.js";
|
|
51
|
+
|
|
52
|
+
// Master Planner
|
|
53
|
+
export { MasterPlanner } from "./master-planner.js";
|
|
54
|
+
export type { PlanResult } from "./master-planner.js";
|
|
55
|
+
|
|
56
|
+
// Context Window Manager
|
|
57
|
+
export { ContextWindowManager } from "./context-window-manager.js";
|
|
58
|
+
|
|
59
|
+
// Blackboard
|
|
60
|
+
export { createBlackboard, SharedBlackboard } from "./blackboard.js";
|
|
61
|
+
|
|
62
|
+
// Agent Handoff
|
|
63
|
+
export { AgentHandoffProtocol } from "./agent-handoff.js";
|
|
64
|
+
|
|
65
|
+
// Auto Compact (RFC-0019)
|
|
66
|
+
export { AutoCompactEngine } from "./auto-compact.js";
|
|
67
|
+
export type {
|
|
68
|
+
CompactionEvent,
|
|
69
|
+
CompactionConfig,
|
|
70
|
+
ContinuePrompt,
|
|
71
|
+
} from "./auto-compact.js";
|
|
72
|
+
|
|
73
|
+
// Output Limit Handler (RFC-0020)
|
|
74
|
+
export { OutputLimitHandler } from "./output-limit-handler.js";
|
|
75
|
+
export type {
|
|
76
|
+
OutputLimitConfig,
|
|
77
|
+
OutputLimitEvent,
|
|
78
|
+
ExpectedOutput,
|
|
79
|
+
} from "./output-limit-handler.js";
|
|
80
|
+
|
|
81
|
+
// Partial Recovery (RFC-0021)
|
|
82
|
+
export { PartialRecovery } from "./partial-recovery.js";
|
|
83
|
+
export type {
|
|
84
|
+
PartialResponse,
|
|
85
|
+
RecoveryStatus,
|
|
86
|
+
MergeOptions,
|
|
87
|
+
} from "./partial-recovery.js";
|
|
88
|
+
|
|
89
|
+
// Notification Events (RFC-0022)
|
|
90
|
+
export {
|
|
91
|
+
HarnessNotificationEvents,
|
|
92
|
+
createNotificationConfigFromEnv,
|
|
93
|
+
} from "./notification-events.js";
|
|
94
|
+
|
|
95
|
+
// E2E Testing
|
|
96
|
+
export { E2ETestEngine } from "./e2e/test-engine.js";
|
|
97
|
+
export { PlaywrightE2ERunner } from "./e2e/playwright-runner.js";
|
|
98
|
+
export { MiniMaxQuotaScraper } from "./e2e/playwright-runner.js";
|
|
99
|
+
export type {
|
|
100
|
+
E2ERunner,
|
|
101
|
+
PlaywrightRunnerConfig,
|
|
102
|
+
} from "./e2e/playwright-runner.js";
|
|
103
|
+
|
|
104
|
+
// Project Detector
|
|
105
|
+
export { ProjectDetector } from "./project-detector/detector.js";
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Harness Notification Events — RFC-0022
|
|
3
|
+
*
|
|
4
|
+
* Integrates NotificationCenter with the harness runtime.
|
|
5
|
+
* Sends mobile alerts for all runtime events.
|
|
6
|
+
*
|
|
7
|
+
* Events:
|
|
8
|
+
* JobStarted, TaskCompleted, TaskFailed, QuotaPaused,
|
|
9
|
+
* ResumeScheduled, ContextCompacted, OutputLimitContinued,
|
|
10
|
+
* E2EFailed, HumanReviewNeeded, ReadyForClient, JobCancelled, Error
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { NotificationCenter } from "../packages/notification/notification-center.js";
|
|
14
|
+
import type {
|
|
15
|
+
NotificationConfig,
|
|
16
|
+
NotificationEvent,
|
|
17
|
+
NotificationContext,
|
|
18
|
+
} from "../packages/notification/types.js";
|
|
19
|
+
import type { JobStateMachine } from "./job-state-machine.js";
|
|
20
|
+
import type { TaskGraphManager } from "./task-graph.js";
|
|
21
|
+
|
|
22
|
+
export interface NotificationEventsConfig {
|
|
23
|
+
jobId: string;
|
|
24
|
+
requirement: string;
|
|
25
|
+
notificationConfig?: NotificationConfig;
|
|
26
|
+
// Optional: path to config file for persistent settings
|
|
27
|
+
configPath?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class HarnessNotificationEvents {
|
|
31
|
+
private center: NotificationCenter;
|
|
32
|
+
private config: NotificationEventsConfig;
|
|
33
|
+
private machine: JobStateMachine | null = null;
|
|
34
|
+
private graph: TaskGraphManager | null = null;
|
|
35
|
+
|
|
36
|
+
constructor(config: NotificationEventsConfig) {
|
|
37
|
+
this.config = config;
|
|
38
|
+
this.center = new NotificationCenter(config.notificationConfig);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Initialize the notification center
|
|
43
|
+
*/
|
|
44
|
+
async initialize(): Promise<void> {
|
|
45
|
+
await this.center.initialize();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Attach to job state machine to receive events
|
|
50
|
+
*/
|
|
51
|
+
attachToMachine(machine: JobStateMachine): void {
|
|
52
|
+
this.machine = machine;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Attach to task graph to get task info
|
|
57
|
+
*/
|
|
58
|
+
attachToGraph(graph: TaskGraphManager): void {
|
|
59
|
+
this.graph = graph;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Check if notifications are configured
|
|
64
|
+
*/
|
|
65
|
+
hasChannels(): boolean {
|
|
66
|
+
return this.center.hasChannels();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* List configured channels
|
|
71
|
+
*/
|
|
72
|
+
listChannels(): string[] {
|
|
73
|
+
return this.center.listChannels();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ─── Event Emitters ────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Emit JobStarted event
|
|
80
|
+
*/
|
|
81
|
+
async emitJobStarted(): Promise<void> {
|
|
82
|
+
await this.emit("JobStarted");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Emit TaskCompleted event
|
|
87
|
+
*/
|
|
88
|
+
async emitTaskCompleted(taskId: string, taskTitle?: string): Promise<void> {
|
|
89
|
+
await this.emit("TaskCompleted", { taskId, taskTitle });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Emit TaskFailed event
|
|
94
|
+
*/
|
|
95
|
+
async emitTaskFailed(
|
|
96
|
+
taskId: string,
|
|
97
|
+
taskTitle?: string,
|
|
98
|
+
error?: string,
|
|
99
|
+
): Promise<void> {
|
|
100
|
+
await this.emit("TaskFailed", { taskId, taskTitle, error });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Emit QuotaPaused event
|
|
105
|
+
*/
|
|
106
|
+
async emitQuotaPaused(resumeAt?: string): Promise<void> {
|
|
107
|
+
await this.emit("QuotaPaused", {
|
|
108
|
+
error: resumeAt ? `Resumes at ${resumeAt}` : undefined,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Emit ResumeScheduled event
|
|
114
|
+
*/
|
|
115
|
+
async emitResumeScheduled(resumeAt: string): Promise<void> {
|
|
116
|
+
await this.emit("ResumeScheduled", { error: resumeAt });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Emit ContextCompacted event
|
|
121
|
+
*/
|
|
122
|
+
async emitContextCompacted(tokensCompacted?: number): Promise<void> {
|
|
123
|
+
await this.emit("ContextCompacted", {
|
|
124
|
+
error: tokensCompacted
|
|
125
|
+
? `Compacted ${tokensCompacted.toLocaleString()} tokens`
|
|
126
|
+
: undefined,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Emit OutputLimitContinued event
|
|
132
|
+
*/
|
|
133
|
+
async emitOutputLimitContinued(attempt: number): Promise<void> {
|
|
134
|
+
await this.emit("OutputLimitContinued", {
|
|
135
|
+
error: `Attempt ${attempt}`,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Emit E2EFailed event
|
|
141
|
+
*/
|
|
142
|
+
async emitE2EFailed(scenarioId?: string): Promise<void> {
|
|
143
|
+
await this.emit("E2EFailed", {
|
|
144
|
+
error: scenarioId ? `Scenario: ${scenarioId}` : undefined,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Emit HumanReviewNeeded event
|
|
150
|
+
*/
|
|
151
|
+
async emitHumanReviewNeeded(taskId: string, reason: string): Promise<void> {
|
|
152
|
+
await this.emit("HumanReviewNeeded", { taskId, error: reason });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Emit ReadyForClient event
|
|
157
|
+
*/
|
|
158
|
+
async emitReadyForClient(): Promise<void> {
|
|
159
|
+
await this.emit("ReadyForClient");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Emit JobCancelled event
|
|
164
|
+
*/
|
|
165
|
+
async emitJobCancelled(reason?: string): Promise<void> {
|
|
166
|
+
await this.emit("JobCancelled", { error: reason });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Emit Error event
|
|
171
|
+
*/
|
|
172
|
+
async emitError(error: string): Promise<void> {
|
|
173
|
+
await this.emit("Error", { error });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ─── State Machine Event Listeners ─────────────────────────────────
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Wire up with JobStateMachine to emit events on transitions
|
|
180
|
+
*/
|
|
181
|
+
wireWithStateMachine(machine: JobStateMachine): void {
|
|
182
|
+
this.machine = machine;
|
|
183
|
+
|
|
184
|
+
// Listen to state machine events via polling or callback
|
|
185
|
+
// This would need the state machine to emit events
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Check current state and emit appropriate events
|
|
190
|
+
*/
|
|
191
|
+
async checkAndEmitStateChange(
|
|
192
|
+
oldState: string,
|
|
193
|
+
newState: string,
|
|
194
|
+
): Promise<void> {
|
|
195
|
+
// Map state machine states to notification events
|
|
196
|
+
const stateEventMap: Record<string, NotificationEvent> = {
|
|
197
|
+
paused_quota: "QuotaPaused",
|
|
198
|
+
waiting_human: "HumanReviewNeeded",
|
|
199
|
+
ready_for_client: "ReadyForClient",
|
|
200
|
+
cancelled: "JobCancelled",
|
|
201
|
+
planning: "JobStarted",
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const event = stateEventMap[newState];
|
|
205
|
+
if (event) {
|
|
206
|
+
await this.emit(event);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ─── Private Methods ───────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
private async emit(
|
|
213
|
+
event: NotificationEvent,
|
|
214
|
+
extra?: Partial<NotificationContext>,
|
|
215
|
+
): Promise<void> {
|
|
216
|
+
if (!this.center.hasChannels()) {
|
|
217
|
+
return; // No channels configured, skip
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const context: NotificationContext = {
|
|
221
|
+
jobId: this.config.jobId,
|
|
222
|
+
requirement: this.config.requirement,
|
|
223
|
+
...extra,
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
const results = await this.center.notify(event, context);
|
|
228
|
+
|
|
229
|
+
// Log results (but don't fail if notification fails)
|
|
230
|
+
for (const result of results) {
|
|
231
|
+
if (!result.success) {
|
|
232
|
+
console.warn(
|
|
233
|
+
`[NotificationEvents] Failed to send ${event} to ${result.channel}: ${result.error}`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
} catch (error) {
|
|
238
|
+
// Never crash the runtime due to notification failure
|
|
239
|
+
console.error(`[NotificationEvents] Notification error: ${error}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Create notification config from environment variables
|
|
246
|
+
*/
|
|
247
|
+
export function createNotificationConfigFromEnv():
|
|
248
|
+
| NotificationConfig
|
|
249
|
+
| undefined {
|
|
250
|
+
const channels: NotificationConfig["channels"] = [];
|
|
251
|
+
|
|
252
|
+
// Telegram
|
|
253
|
+
if (process.env.TELEGRAM_BOT_TOKEN && process.env.TELEGRAM_CHAT_ID) {
|
|
254
|
+
channels.push({
|
|
255
|
+
id: "telegram",
|
|
256
|
+
type: "telegram",
|
|
257
|
+
enabled: true,
|
|
258
|
+
config: {
|
|
259
|
+
botToken: process.env.TELEGRAM_BOT_TOKEN,
|
|
260
|
+
chatId: process.env.TELEGRAM_CHAT_ID,
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Ntfy
|
|
266
|
+
if (process.env.NTFY_TOPIC) {
|
|
267
|
+
channels.push({
|
|
268
|
+
id: "ntfy",
|
|
269
|
+
type: "ntfy",
|
|
270
|
+
enabled: true,
|
|
271
|
+
config: {
|
|
272
|
+
server: process.env.NTFY_SERVER ?? "https://ntfy.sh",
|
|
273
|
+
topic: process.env.NTFY_TOPIC,
|
|
274
|
+
authToken: process.env.NTFY_TOKEN,
|
|
275
|
+
},
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Webhook
|
|
280
|
+
if (process.env.NOTIFICATION_WEBHOOK_URL) {
|
|
281
|
+
channels.push({
|
|
282
|
+
id: "webhook",
|
|
283
|
+
type: "webhook",
|
|
284
|
+
enabled: true,
|
|
285
|
+
config: {
|
|
286
|
+
url: process.env.NOTIFICATION_WEBHOOK_URL,
|
|
287
|
+
method:
|
|
288
|
+
(process.env.NOTIFICATION_WEBHOOK_METHOD as "POST" | "PUT") ?? "POST",
|
|
289
|
+
authToken: process.env.NOTIFICATION_WEBHOOK_TOKEN,
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (channels.length === 0) {
|
|
295
|
+
return undefined;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return { channels, enabled: true };
|
|
299
|
+
}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output Token Limit Handler — RFC-0020
|
|
3
|
+
*
|
|
4
|
+
* Handles model responses that stop because the maximum output token limit was reached.
|
|
5
|
+
* Coordinates with AutoCompactEngine (RFC-0019) and PartialRecovery (RFC-0021).
|
|
6
|
+
*
|
|
7
|
+
* Classification Matrix:
|
|
8
|
+
* | Failure Type | Runtime Action |
|
|
9
|
+
* |--------------------|--------------------------|
|
|
10
|
+
* | Quota exhausted | pause until reset |
|
|
11
|
+
* | Context full | compact and resume |
|
|
12
|
+
* | Output token limit | continue same task |
|
|
13
|
+
* | Unknown error | retry or escalate |
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import type { CompactionEvent } from "./auto-compact.js";
|
|
20
|
+
|
|
21
|
+
export interface OutputLimitConfig {
|
|
22
|
+
jobId: string;
|
|
23
|
+
taskId: string;
|
|
24
|
+
rootDir?: string;
|
|
25
|
+
maxContinueAttempts?: number;
|
|
26
|
+
continuationBackoffMs?: number;
|
|
27
|
+
requireExpectedOutputValidation?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface OutputLimitEvent {
|
|
31
|
+
timestamp: string;
|
|
32
|
+
jobId: string;
|
|
33
|
+
taskId: string;
|
|
34
|
+
reason: string;
|
|
35
|
+
partialContent: string;
|
|
36
|
+
finishReason: "length" | "stop" | "content_filter" | "error";
|
|
37
|
+
attempts: number;
|
|
38
|
+
continueSucceeded?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ExpectedOutput {
|
|
42
|
+
description: string;
|
|
43
|
+
validate?: (content: string) => boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const OUTPUT_LIMIT_PATTERNS = [
|
|
47
|
+
/reached the maximum output token limit/i,
|
|
48
|
+
/output token limit/i,
|
|
49
|
+
/response may be incomplete/i,
|
|
50
|
+
/stop_reason.*length/i,
|
|
51
|
+
/finish_reason.*length/i,
|
|
52
|
+
/max_tokens.*exceeded/i,
|
|
53
|
+
/completion.*truncated/i,
|
|
54
|
+
/model.*stopped.*token/i,
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
export class OutputLimitHandler {
|
|
58
|
+
private readonly rootDir: string;
|
|
59
|
+
private readonly jobId: string;
|
|
60
|
+
private readonly taskId: string;
|
|
61
|
+
private readonly maxAttempts: number;
|
|
62
|
+
private readonly backoffMs: number;
|
|
63
|
+
private readonly requireValidation: boolean;
|
|
64
|
+
private attempts = 0;
|
|
65
|
+
private partials: string[] = [];
|
|
66
|
+
|
|
67
|
+
constructor(config: OutputLimitConfig) {
|
|
68
|
+
this.rootDir =
|
|
69
|
+
config.rootDir ??
|
|
70
|
+
join(homedir(), ".pi", "harness", config.jobId, "partial", config.taskId);
|
|
71
|
+
this.jobId = config.jobId;
|
|
72
|
+
this.taskId = config.taskId;
|
|
73
|
+
this.maxAttempts = config.maxContinueAttempts ?? 5;
|
|
74
|
+
this.backoffMs = config.continuationBackoffMs ?? 1000;
|
|
75
|
+
this.requireValidation = config.requireExpectedOutputValidation ?? true;
|
|
76
|
+
this.ensureDir();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Detect if an error or response indicates output limit was reached
|
|
81
|
+
*/
|
|
82
|
+
detectOutputLimit(
|
|
83
|
+
error: unknown,
|
|
84
|
+
response?: { finishReason?: string },
|
|
85
|
+
): boolean {
|
|
86
|
+
// Check error message
|
|
87
|
+
if (error) {
|
|
88
|
+
const errorStr = String(error).toLowerCase();
|
|
89
|
+
for (const pattern of OUTPUT_LIMIT_PATTERNS) {
|
|
90
|
+
if (pattern.test(errorStr)) {
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Check finish reason
|
|
97
|
+
if (response?.finishReason === "length") {
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Classify the type of failure for proper handling
|
|
106
|
+
*/
|
|
107
|
+
classifyFailure(
|
|
108
|
+
error: unknown,
|
|
109
|
+
response?: { finishReason?: string; content?: string },
|
|
110
|
+
): "quota_exhausted" | "context_full" | "output_limit" | "unknown" {
|
|
111
|
+
const errorStr = String(error ?? "").toLowerCase();
|
|
112
|
+
|
|
113
|
+
// Check for quota exhaustion
|
|
114
|
+
if (
|
|
115
|
+
/error.*quota/i.test(errorStr) ||
|
|
116
|
+
/error.*2056/i.test(errorStr) ||
|
|
117
|
+
/error.*insufficient_quota/i.test(errorStr)
|
|
118
|
+
) {
|
|
119
|
+
return "quota_exhausted";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Check for context/sequence length
|
|
123
|
+
if (
|
|
124
|
+
/error.*context.*length/i.test(errorStr) ||
|
|
125
|
+
/error.*too many tokens/i.test(errorStr) ||
|
|
126
|
+
/error.*maximum context/i.test(errorStr)
|
|
127
|
+
) {
|
|
128
|
+
return "context_full";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Check for output token limit
|
|
132
|
+
if (
|
|
133
|
+
this.detectOutputLimit(error, response) ||
|
|
134
|
+
response?.finishReason === "length"
|
|
135
|
+
) {
|
|
136
|
+
return "output_limit";
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return "unknown";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Handle output limit - save partial and prepare continuation
|
|
144
|
+
*/
|
|
145
|
+
async handleOutputLimit(
|
|
146
|
+
partialContent: string,
|
|
147
|
+
finishReason: string = "length",
|
|
148
|
+
): Promise<OutputLimitEvent> {
|
|
149
|
+
this.attempts++;
|
|
150
|
+
const timestamp = new Date().toISOString();
|
|
151
|
+
|
|
152
|
+
// Save partial response
|
|
153
|
+
const partialPath = this.savePartial(partialContent, this.attempts);
|
|
154
|
+
this.partials.push(partialPath);
|
|
155
|
+
|
|
156
|
+
// Create event
|
|
157
|
+
const event: OutputLimitEvent = {
|
|
158
|
+
timestamp,
|
|
159
|
+
jobId: this.jobId,
|
|
160
|
+
taskId: this.taskId,
|
|
161
|
+
reason: "output_token_limit",
|
|
162
|
+
partialContent,
|
|
163
|
+
finishReason: finishReason as OutputLimitEvent["finishReason"],
|
|
164
|
+
attempts: this.attempts,
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
// Save event
|
|
168
|
+
this.saveEvent(event);
|
|
169
|
+
|
|
170
|
+
// Wait with backoff before continuing
|
|
171
|
+
await this.backoff();
|
|
172
|
+
|
|
173
|
+
return event;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Check if we should continue (respects max attempts)
|
|
178
|
+
*/
|
|
179
|
+
shouldContinue(): boolean {
|
|
180
|
+
return this.attempts < this.maxAttempts;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Get current attempt count
|
|
185
|
+
*/
|
|
186
|
+
getAttempts(): number {
|
|
187
|
+
return this.attempts;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Get all partial responses
|
|
192
|
+
*/
|
|
193
|
+
getPartials(): string[] {
|
|
194
|
+
return [...this.partials];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Merge partial responses
|
|
199
|
+
*/
|
|
200
|
+
mergePartials(): string {
|
|
201
|
+
const merged: string[] = [];
|
|
202
|
+
|
|
203
|
+
for (const partialPath of this.partials) {
|
|
204
|
+
if (existsSync(partialPath)) {
|
|
205
|
+
const content = readFileSync(partialPath, "utf-8");
|
|
206
|
+
merged.push(content);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// v0.1: simple concatenation as markdown sections
|
|
211
|
+
return merged
|
|
212
|
+
.map((p, i) => `## Partial ${i + 1}\n\n${p}`)
|
|
213
|
+
.join("\n\n---\n\n");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Build continue message for the next turn
|
|
218
|
+
*/
|
|
219
|
+
buildContinueMessage(additionalContext?: string): string {
|
|
220
|
+
const merged = this.mergePartials();
|
|
221
|
+
|
|
222
|
+
const lines = [
|
|
223
|
+
"# Continue From Partial Response",
|
|
224
|
+
"",
|
|
225
|
+
"The previous response was truncated due to output token limit.",
|
|
226
|
+
"",
|
|
227
|
+
"## Merged Partial Content",
|
|
228
|
+
"",
|
|
229
|
+
merged,
|
|
230
|
+
];
|
|
231
|
+
|
|
232
|
+
if (additionalContext) {
|
|
233
|
+
lines.push("", "## Additional Context");
|
|
234
|
+
lines.push("", additionalContext);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
lines.push("", "## Instructions");
|
|
238
|
+
lines.push("");
|
|
239
|
+
lines.push("1. Review the partial content above");
|
|
240
|
+
lines.push("2. Continue from where the response was truncated");
|
|
241
|
+
lines.push("3. Do not repeat content that already appears above");
|
|
242
|
+
lines.push(
|
|
243
|
+
`4. This is attempt ${this.attempts + 1} of ${this.maxAttempts}`,
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
return lines.join("\n");
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Validate expected output if configured
|
|
251
|
+
*/
|
|
252
|
+
validateOutput(content: string, expected: ExpectedOutput): boolean {
|
|
253
|
+
if (!this.requireValidation) {
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (expected.validate) {
|
|
258
|
+
return expected.validate(content);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Basic validation: content should be longer than partial
|
|
262
|
+
return content.length > this.getPartials()[0]?.length ?? 0;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Reset for a new task
|
|
267
|
+
*/
|
|
268
|
+
reset(): void {
|
|
269
|
+
this.attempts = 0;
|
|
270
|
+
this.partials = [];
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Check if escalation is needed (max attempts reached)
|
|
275
|
+
*/
|
|
276
|
+
shouldEscalate(): boolean {
|
|
277
|
+
return this.attempts >= this.maxAttempts;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ─── Private Methods ────────────────────────────────────────────────
|
|
281
|
+
|
|
282
|
+
private ensureDir(): void {
|
|
283
|
+
if (!existsSync(this.rootDir)) {
|
|
284
|
+
mkdirSync(this.rootDir, { recursive: true });
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
private savePartial(content: string, attempt: number): string {
|
|
289
|
+
const filename = `partial_${String(attempt).padStart(3, "0")}.md`;
|
|
290
|
+
const path = join(this.rootDir, filename);
|
|
291
|
+
writeFileSync(path, content, "utf-8");
|
|
292
|
+
return path;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private saveEvent(event: OutputLimitEvent): void {
|
|
296
|
+
const eventsPath = join(this.rootDir, "events.jsonl");
|
|
297
|
+
writeFileSync(eventsPath, JSON.stringify(event) + "\n", "utf-8");
|
|
298
|
+
|
|
299
|
+
// Also save recovery status
|
|
300
|
+
const statusPath = join(this.rootDir, "recovery_status.json");
|
|
301
|
+
const status = {
|
|
302
|
+
taskId: this.taskId,
|
|
303
|
+
status: this.shouldContinue() ? "continuing" : "escalated",
|
|
304
|
+
partials: this.partials.map((p) => p.split("/").pop()),
|
|
305
|
+
mergedOutput: "merged.md",
|
|
306
|
+
attempts: this.attempts,
|
|
307
|
+
lastError: event.reason,
|
|
308
|
+
};
|
|
309
|
+
writeFileSync(statusPath, JSON.stringify(status, null, 2) + "\n", "utf-8");
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private async backoff(): Promise<void> {
|
|
313
|
+
const delay = this.backoffMs * 2 ** (this.attempts - 1);
|
|
314
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
315
|
+
}
|
|
316
|
+
}
|