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.
- package/.versionrc.js +32 -0
- package/cli.js +112 -0
- package/footer-status.js +155 -0
- package/harness/agent-handoff.js +123 -0
- package/harness/auto-compact.js +243 -0
- package/harness/auto-quota-resume.js +104 -0
- package/harness/blackboard.js +258 -0
- package/harness/context-compact-orchestrator.js +365 -0
- package/harness/context-window-manager.js +330 -0
- package/harness/continue-prompt.js +164 -0
- package/harness/e2e/minimax-quota-parser.js +52 -0
- package/harness/e2e/minimax-quota-scraper.js +475 -0
- package/harness/e2e/openai-quota-scraper.js +332 -0
- package/harness/e2e/playwright-runner.js +165 -0
- package/harness/e2e/quota-status.js +140 -0
- package/harness/e2e/test-engine.js +290 -0
- package/harness/forked-summarizer.js +212 -0
- package/harness/index.js +54 -0
- package/harness/job-state-machine.js +277 -0
- package/harness/loop-runtime.js +531 -0
- package/harness/master-planner.js +229 -0
- package/harness/notification-events.js +233 -0
- package/harness/output-limit-handler.js +233 -0
- package/harness/partial-recovery.js +413 -0
- package/harness/project-detector/detector.js +283 -0
- package/harness/repair-engine.js +256 -0
- package/harness/session-memory.js +293 -0
- package/harness/task-graph.js +87 -0
- package/index.js +1121 -0
- package/mirror.js +205 -0
- package/package.json +6 -4
- package/proactive-compact.js +42 -0
- package/renderer.js +134 -0
- package/status-parsers.js +63 -0
- package/tracker.js +49 -0
- package/windows.js +90 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Master Planner — RFC-0017
|
|
3
|
+
*
|
|
4
|
+
* Converts a human requirement into an executable task graph.
|
|
5
|
+
* Uses a planning LLM to decompose the requirement into tasks with dependencies.
|
|
6
|
+
*/
|
|
7
|
+
const DEFAULT_SYSTEM_PROMPT = `You are a software project planner. Given a human requirement, decompose it into a clear task list.
|
|
8
|
+
|
|
9
|
+
Rules:
|
|
10
|
+
1. Each task should be atomic and independently testable
|
|
11
|
+
2. Tasks must be ordered with proper dependencies
|
|
12
|
+
3. Include acceptance criteria for each task
|
|
13
|
+
4. Consider: analysis, implementation, testing, review phases
|
|
14
|
+
5. Output ONLY valid JSON in the specified format
|
|
15
|
+
|
|
16
|
+
Output format:
|
|
17
|
+
{
|
|
18
|
+
"tasks": [
|
|
19
|
+
{
|
|
20
|
+
"id": "task-001",
|
|
21
|
+
"title": "Descriptive title",
|
|
22
|
+
"description": "What this task does",
|
|
23
|
+
"dependencies": [], // array of task IDs this depends on
|
|
24
|
+
"acceptanceCriteria": ["criterion 1", "criterion 2"]
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
}`;
|
|
28
|
+
export class MasterPlanner {
|
|
29
|
+
options;
|
|
30
|
+
constructor(options = {}) {
|
|
31
|
+
this.options = options;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Create a plan from a requirement
|
|
35
|
+
*/
|
|
36
|
+
async createPlan(requirement, jobId, rootDir) {
|
|
37
|
+
try {
|
|
38
|
+
// Build the planning prompt
|
|
39
|
+
const userPrompt = `Human requirement:\n${requirement}\n\nMax tasks: ${this.options.maxTasks ?? 20}`;
|
|
40
|
+
let taskList;
|
|
41
|
+
if (this.options.planningProvider) {
|
|
42
|
+
// Use LLM to generate task list
|
|
43
|
+
const response = await this.options.planningProvider.call(userPrompt, DEFAULT_SYSTEM_PROMPT);
|
|
44
|
+
const parsed = this.parsePlanningResponse(response);
|
|
45
|
+
if (!parsed) {
|
|
46
|
+
return { success: false, error: "Failed to parse planning response" };
|
|
47
|
+
}
|
|
48
|
+
taskList = parsed;
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
// Use heuristic planner for simple requirements
|
|
52
|
+
taskList = this.heuristicPlan(requirement);
|
|
53
|
+
}
|
|
54
|
+
// Validate the task list
|
|
55
|
+
if (taskList.length === 0) {
|
|
56
|
+
return { success: false, error: "No tasks generated from requirement" };
|
|
57
|
+
}
|
|
58
|
+
// Validate dependencies (no cycles, all deps exist)
|
|
59
|
+
const taskIds = new Set(taskList.map((t) => t.id));
|
|
60
|
+
for (const task of taskList) {
|
|
61
|
+
for (const dep of task.dependencies) {
|
|
62
|
+
if (!taskIds.has(dep)) {
|
|
63
|
+
return {
|
|
64
|
+
success: false,
|
|
65
|
+
error: `Task ${task.id} has invalid dependency: ${dep}`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// Create task graph
|
|
71
|
+
const graphManager = new TaskGraphManager({ jobId });
|
|
72
|
+
for (const task of taskList) {
|
|
73
|
+
graphManager.addTask(task.id, task.title, task.description, task.dependencies, task.acceptanceCriteria);
|
|
74
|
+
}
|
|
75
|
+
const graph = graphManager.getGraph();
|
|
76
|
+
// Save the graph
|
|
77
|
+
await graphManager.save(rootDir);
|
|
78
|
+
return { success: true, graph };
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
return { success: false, error: String(error) };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Parse planning response from LLM
|
|
86
|
+
*/
|
|
87
|
+
parsePlanningResponse(response) {
|
|
88
|
+
// Try to extract JSON from response
|
|
89
|
+
const jsonMatch = response.match(/```json\n([\s\S]*?)\n```/) ??
|
|
90
|
+
response.match(/\{[\s\S]*"tasks"[\s\S]*\}/);
|
|
91
|
+
if (!jsonMatch) {
|
|
92
|
+
console.error("Failed to extract JSON from planning response");
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
const jsonStr = jsonMatch[1] ?? jsonMatch[0];
|
|
96
|
+
try {
|
|
97
|
+
const parsed = JSON.parse(jsonStr);
|
|
98
|
+
return parsed.tasks ?? [];
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
console.error("Failed to parse JSON from planning response");
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Heuristic planner for simple requirements
|
|
107
|
+
* Used when no LLM provider is available
|
|
108
|
+
*/
|
|
109
|
+
heuristicPlan(requirement) {
|
|
110
|
+
const tasks = [];
|
|
111
|
+
const req = requirement.toLowerCase();
|
|
112
|
+
// Task 1: Analysis
|
|
113
|
+
tasks.push({
|
|
114
|
+
id: "task-001",
|
|
115
|
+
title: "Analyze requirements",
|
|
116
|
+
description: `Analyze and document the requirements: ${requirement}`,
|
|
117
|
+
dependencies: [],
|
|
118
|
+
acceptanceCriteria: [
|
|
119
|
+
"Requirements are clearly documented",
|
|
120
|
+
"All edge cases identified",
|
|
121
|
+
"Technical approach defined",
|
|
122
|
+
],
|
|
123
|
+
});
|
|
124
|
+
// Task 2: Implementation
|
|
125
|
+
if (req.includes("api") ||
|
|
126
|
+
req.includes("endpoint") ||
|
|
127
|
+
req.includes("backend")) {
|
|
128
|
+
tasks.push({
|
|
129
|
+
id: "task-002",
|
|
130
|
+
title: "Implement API endpoints",
|
|
131
|
+
description: "Create API endpoints based on requirements",
|
|
132
|
+
dependencies: ["task-001"],
|
|
133
|
+
acceptanceCriteria: [
|
|
134
|
+
"Endpoints return correct responses",
|
|
135
|
+
"Error handling implemented",
|
|
136
|
+
"Input validation in place",
|
|
137
|
+
],
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
if (req.includes("database") ||
|
|
141
|
+
req.includes("model") ||
|
|
142
|
+
req.includes("schema")) {
|
|
143
|
+
tasks.push({
|
|
144
|
+
id: "task-003",
|
|
145
|
+
title: "Implement database schema",
|
|
146
|
+
description: "Create database models and migrations",
|
|
147
|
+
dependencies: ["task-001"],
|
|
148
|
+
acceptanceCriteria: [
|
|
149
|
+
"Schema matches requirements",
|
|
150
|
+
"Migrations run successfully",
|
|
151
|
+
"Relationships defined correctly",
|
|
152
|
+
],
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
if (req.includes("ui") ||
|
|
156
|
+
req.includes("frontend") ||
|
|
157
|
+
req.includes("page") ||
|
|
158
|
+
req.includes("component")) {
|
|
159
|
+
tasks.push({
|
|
160
|
+
id: "task-004",
|
|
161
|
+
title: "Implement UI components",
|
|
162
|
+
description: "Create frontend UI components",
|
|
163
|
+
dependencies: ["task-001"],
|
|
164
|
+
acceptanceCriteria: [
|
|
165
|
+
"Components match design",
|
|
166
|
+
"Responsive on all devices",
|
|
167
|
+
"Accessible",
|
|
168
|
+
],
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
// Task 5: Tests
|
|
172
|
+
const implDeps = tasks
|
|
173
|
+
.filter((t) => t.id.startsWith("task-00"))
|
|
174
|
+
.map((t) => t.id);
|
|
175
|
+
tasks.push({
|
|
176
|
+
id: "task-010",
|
|
177
|
+
title: "Write unit tests",
|
|
178
|
+
description: "Write unit tests for all implemented code",
|
|
179
|
+
dependencies: implDeps.length > 0 ? implDeps : ["task-001"],
|
|
180
|
+
acceptanceCriteria: [
|
|
181
|
+
"All new code has >80% test coverage",
|
|
182
|
+
"All tests pass",
|
|
183
|
+
"Edge cases covered",
|
|
184
|
+
],
|
|
185
|
+
});
|
|
186
|
+
// Task 6: Integration
|
|
187
|
+
tasks.push({
|
|
188
|
+
id: "task-011",
|
|
189
|
+
title: "Integration testing",
|
|
190
|
+
description: "Run integration tests and verify end-to-end flow",
|
|
191
|
+
dependencies: ["task-010"],
|
|
192
|
+
acceptanceCriteria: [
|
|
193
|
+
"Integration tests pass",
|
|
194
|
+
"No regression in existing functionality",
|
|
195
|
+
],
|
|
196
|
+
});
|
|
197
|
+
// Task 7: Review
|
|
198
|
+
tasks.push({
|
|
199
|
+
id: "task-012",
|
|
200
|
+
title: "Code review",
|
|
201
|
+
description: "Review code for quality, security, and best practices",
|
|
202
|
+
dependencies: ["task-011"],
|
|
203
|
+
acceptanceCriteria: [
|
|
204
|
+
"Code follows project style guide",
|
|
205
|
+
"No security vulnerabilities",
|
|
206
|
+
"Documentation updated",
|
|
207
|
+
],
|
|
208
|
+
});
|
|
209
|
+
return tasks;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Generate a simple task ID
|
|
213
|
+
*/
|
|
214
|
+
static generateTaskId(index) {
|
|
215
|
+
return `task-${String(index).padStart(3, "0")}`;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Parse a requirement into a basic task list (synchronous, no LLM)
|
|
220
|
+
*/
|
|
221
|
+
export function parseRequirementIntoTasks(requirement, jobId) {
|
|
222
|
+
const planner = new MasterPlanner();
|
|
223
|
+
const taskList = planner.heuristicPlan(requirement);
|
|
224
|
+
const graphManager = new TaskGraphManager({ jobId });
|
|
225
|
+
for (const task of taskList) {
|
|
226
|
+
graphManager.addTask(task.id, task.title, task.description, task.dependencies, task.acceptanceCriteria);
|
|
227
|
+
}
|
|
228
|
+
return graphManager;
|
|
229
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
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
|
+
import { NotificationCenter } from "../packages/notification/notification-center.js";
|
|
13
|
+
export class HarnessNotificationEvents {
|
|
14
|
+
center;
|
|
15
|
+
config;
|
|
16
|
+
machine = null;
|
|
17
|
+
graph = null;
|
|
18
|
+
constructor(config) {
|
|
19
|
+
this.config = config;
|
|
20
|
+
this.center = new NotificationCenter(config.notificationConfig);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Initialize the notification center
|
|
24
|
+
*/
|
|
25
|
+
async initialize() {
|
|
26
|
+
await this.center.initialize();
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Attach to job state machine to receive events
|
|
30
|
+
*/
|
|
31
|
+
attachToMachine(machine) {
|
|
32
|
+
this.machine = machine;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Attach to task graph to get task info
|
|
36
|
+
*/
|
|
37
|
+
attachToGraph(graph) {
|
|
38
|
+
this.graph = graph;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Check if notifications are configured
|
|
42
|
+
*/
|
|
43
|
+
hasChannels() {
|
|
44
|
+
return this.center.hasChannels();
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* List configured channels
|
|
48
|
+
*/
|
|
49
|
+
listChannels() {
|
|
50
|
+
return this.center.listChannels();
|
|
51
|
+
}
|
|
52
|
+
// --- Event Emitters ------------------------------------------------
|
|
53
|
+
/**
|
|
54
|
+
* Emit JobStarted event
|
|
55
|
+
*/
|
|
56
|
+
async emitJobStarted() {
|
|
57
|
+
await this.emit("JobStarted");
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Emit TaskCompleted event
|
|
61
|
+
*/
|
|
62
|
+
async emitTaskCompleted(taskId, taskTitle) {
|
|
63
|
+
await this.emit("TaskCompleted", { taskId, taskTitle });
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Emit TaskFailed event
|
|
67
|
+
*/
|
|
68
|
+
async emitTaskFailed(taskId, taskTitle, error) {
|
|
69
|
+
await this.emit("TaskFailed", { taskId, taskTitle, error });
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Emit QuotaPaused event
|
|
73
|
+
*/
|
|
74
|
+
async emitQuotaPaused(resumeAt) {
|
|
75
|
+
await this.emit("QuotaPaused", {
|
|
76
|
+
error: resumeAt ? `Resumes at ${resumeAt}` : undefined,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Emit ResumeScheduled event
|
|
81
|
+
*/
|
|
82
|
+
async emitResumeScheduled(resumeAt) {
|
|
83
|
+
await this.emit("ResumeScheduled", { error: resumeAt });
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Emit ContextCompacted event
|
|
87
|
+
*/
|
|
88
|
+
async emitContextCompacted(tokensCompacted) {
|
|
89
|
+
await this.emit("ContextCompacted", {
|
|
90
|
+
error: tokensCompacted
|
|
91
|
+
? `Compacted ${tokensCompacted.toLocaleString()} tokens`
|
|
92
|
+
: undefined,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Emit OutputLimitContinued event
|
|
97
|
+
*/
|
|
98
|
+
async emitOutputLimitContinued(attempt) {
|
|
99
|
+
await this.emit("OutputLimitContinued", {
|
|
100
|
+
error: `Attempt ${attempt}`,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Emit E2EFailed event
|
|
105
|
+
*/
|
|
106
|
+
async emitE2EFailed(scenarioId) {
|
|
107
|
+
await this.emit("E2EFailed", {
|
|
108
|
+
error: scenarioId ? `Scenario: ${scenarioId}` : undefined,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Emit HumanReviewNeeded event
|
|
113
|
+
*/
|
|
114
|
+
async emitHumanReviewNeeded(taskId, reason) {
|
|
115
|
+
await this.emit("HumanReviewNeeded", { taskId, error: reason });
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Emit ReadyForClient event
|
|
119
|
+
*/
|
|
120
|
+
async emitReadyForClient() {
|
|
121
|
+
await this.emit("ReadyForClient");
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Emit JobCancelled event
|
|
125
|
+
*/
|
|
126
|
+
async emitJobCancelled(reason) {
|
|
127
|
+
await this.emit("JobCancelled", { error: reason });
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Emit Error event
|
|
131
|
+
*/
|
|
132
|
+
async emitError(error) {
|
|
133
|
+
await this.emit("Error", { error });
|
|
134
|
+
}
|
|
135
|
+
// --- State Machine Event Listeners ---------------------------------
|
|
136
|
+
/**
|
|
137
|
+
* Wire up with JobStateMachine to emit events on transitions
|
|
138
|
+
*/
|
|
139
|
+
wireWithStateMachine(machine) {
|
|
140
|
+
this.machine = machine;
|
|
141
|
+
// Listen to state machine events via polling or callback
|
|
142
|
+
// This would need the state machine to emit events
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Check current state and emit appropriate events
|
|
146
|
+
*/
|
|
147
|
+
async checkAndEmitStateChange(oldState, newState) {
|
|
148
|
+
// Map state machine states to notification events
|
|
149
|
+
const stateEventMap = {
|
|
150
|
+
paused_quota: "QuotaPaused",
|
|
151
|
+
waiting_human: "HumanReviewNeeded",
|
|
152
|
+
ready_for_client: "ReadyForClient",
|
|
153
|
+
cancelled: "JobCancelled",
|
|
154
|
+
planning: "JobStarted",
|
|
155
|
+
};
|
|
156
|
+
const event = stateEventMap[newState];
|
|
157
|
+
if (event) {
|
|
158
|
+
await this.emit(event);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// --- Private Methods -----------------------------------------------
|
|
162
|
+
async emit(event, extra) {
|
|
163
|
+
if (!this.center.hasChannels()) {
|
|
164
|
+
return; // No channels configured, skip
|
|
165
|
+
}
|
|
166
|
+
const context = {
|
|
167
|
+
jobId: this.config.jobId,
|
|
168
|
+
requirement: this.config.requirement,
|
|
169
|
+
...extra,
|
|
170
|
+
};
|
|
171
|
+
try {
|
|
172
|
+
const results = await this.center.notify(event, context);
|
|
173
|
+
// Log results (but don't fail if notification fails)
|
|
174
|
+
for (const result of results) {
|
|
175
|
+
if (!result.success) {
|
|
176
|
+
console.warn(`[NotificationEvents] Failed to send ${event} to ${result.channel}: ${result.error}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
// Never crash the runtime due to notification failure
|
|
182
|
+
console.error(`[NotificationEvents] Notification error: ${error}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Create notification config from environment variables
|
|
188
|
+
*/
|
|
189
|
+
export function createNotificationConfigFromEnv() {
|
|
190
|
+
const channels = [];
|
|
191
|
+
// Telegram
|
|
192
|
+
if (process.env.TELEGRAM_BOT_TOKEN && process.env.TELEGRAM_CHAT_ID) {
|
|
193
|
+
channels.push({
|
|
194
|
+
id: "telegram",
|
|
195
|
+
type: "telegram",
|
|
196
|
+
enabled: true,
|
|
197
|
+
config: {
|
|
198
|
+
botToken: process.env.TELEGRAM_BOT_TOKEN,
|
|
199
|
+
chatId: process.env.TELEGRAM_CHAT_ID,
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
// Ntfy
|
|
204
|
+
if (process.env.NTFY_TOPIC) {
|
|
205
|
+
channels.push({
|
|
206
|
+
id: "ntfy",
|
|
207
|
+
type: "ntfy",
|
|
208
|
+
enabled: true,
|
|
209
|
+
config: {
|
|
210
|
+
server: process.env.NTFY_SERVER ?? "https://ntfy.sh",
|
|
211
|
+
topic: process.env.NTFY_TOPIC,
|
|
212
|
+
authToken: process.env.NTFY_TOKEN,
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
// Webhook
|
|
217
|
+
if (process.env.NOTIFICATION_WEBHOOK_URL) {
|
|
218
|
+
channels.push({
|
|
219
|
+
id: "webhook",
|
|
220
|
+
type: "webhook",
|
|
221
|
+
enabled: true,
|
|
222
|
+
config: {
|
|
223
|
+
url: process.env.NOTIFICATION_WEBHOOK_URL,
|
|
224
|
+
method: process.env.NOTIFICATION_WEBHOOK_METHOD ?? "POST",
|
|
225
|
+
authToken: process.env.NOTIFICATION_WEBHOOK_TOKEN,
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
if (channels.length === 0) {
|
|
230
|
+
return undefined;
|
|
231
|
+
}
|
|
232
|
+
return { channels, enabled: true };
|
|
233
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
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
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
const OUTPUT_LIMIT_PATTERNS = [
|
|
19
|
+
/reached the maximum output token limit/i,
|
|
20
|
+
/output token limit/i,
|
|
21
|
+
/response may be incomplete/i,
|
|
22
|
+
/stop_reason.*length/i,
|
|
23
|
+
/finish_reason.*length/i,
|
|
24
|
+
/max_tokens.*exceeded/i,
|
|
25
|
+
/completion.*truncated/i,
|
|
26
|
+
/model.*stopped.*token/i,
|
|
27
|
+
];
|
|
28
|
+
export class OutputLimitHandler {
|
|
29
|
+
rootDir;
|
|
30
|
+
jobId;
|
|
31
|
+
taskId;
|
|
32
|
+
maxAttempts;
|
|
33
|
+
backoffMs;
|
|
34
|
+
requireValidation;
|
|
35
|
+
attempts = 0;
|
|
36
|
+
partials = [];
|
|
37
|
+
constructor(config) {
|
|
38
|
+
this.rootDir =
|
|
39
|
+
config.rootDir ??
|
|
40
|
+
join(homedir(), ".pi", "harness", config.jobId, "partial", config.taskId);
|
|
41
|
+
this.jobId = config.jobId;
|
|
42
|
+
this.taskId = config.taskId;
|
|
43
|
+
this.maxAttempts = config.maxContinueAttempts ?? 5;
|
|
44
|
+
this.backoffMs = config.continuationBackoffMs ?? 1000;
|
|
45
|
+
this.requireValidation = config.requireExpectedOutputValidation ?? true;
|
|
46
|
+
this.ensureDir();
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Detect if an error or response indicates output limit was reached
|
|
50
|
+
*/
|
|
51
|
+
detectOutputLimit(error, response) {
|
|
52
|
+
// Check error message
|
|
53
|
+
if (error) {
|
|
54
|
+
const errorStr = String(error).toLowerCase();
|
|
55
|
+
for (const pattern of OUTPUT_LIMIT_PATTERNS) {
|
|
56
|
+
if (pattern.test(errorStr)) {
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// Check finish reason
|
|
62
|
+
if (response?.finishReason === "length") {
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Classify the type of failure for proper handling
|
|
69
|
+
*/
|
|
70
|
+
classifyFailure(error, response) {
|
|
71
|
+
const errorStr = String(error ?? "").toLowerCase();
|
|
72
|
+
// Check for quota exhaustion
|
|
73
|
+
if (/error.*quota/i.test(errorStr) ||
|
|
74
|
+
/error.*2056/i.test(errorStr) ||
|
|
75
|
+
/error.*insufficient_quota/i.test(errorStr)) {
|
|
76
|
+
return "quota_exhausted";
|
|
77
|
+
}
|
|
78
|
+
// Check for context/sequence length
|
|
79
|
+
if (/error.*context.*length/i.test(errorStr) ||
|
|
80
|
+
/error.*too many tokens/i.test(errorStr) ||
|
|
81
|
+
/error.*maximum context/i.test(errorStr)) {
|
|
82
|
+
return "context_full";
|
|
83
|
+
}
|
|
84
|
+
// Check for output token limit
|
|
85
|
+
if (this.detectOutputLimit(error, response) ||
|
|
86
|
+
response?.finishReason === "length") {
|
|
87
|
+
return "output_limit";
|
|
88
|
+
}
|
|
89
|
+
return "unknown";
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Handle output limit - save partial and prepare continuation
|
|
93
|
+
*/
|
|
94
|
+
async handleOutputLimit(partialContent, finishReason = "length") {
|
|
95
|
+
this.attempts++;
|
|
96
|
+
const timestamp = new Date().toISOString();
|
|
97
|
+
// Save partial response
|
|
98
|
+
const partialPath = this.savePartial(partialContent, this.attempts);
|
|
99
|
+
this.partials.push(partialPath);
|
|
100
|
+
// Create event
|
|
101
|
+
const event = {
|
|
102
|
+
timestamp,
|
|
103
|
+
jobId: this.jobId,
|
|
104
|
+
taskId: this.taskId,
|
|
105
|
+
reason: "output_token_limit",
|
|
106
|
+
partialContent,
|
|
107
|
+
finishReason: finishReason,
|
|
108
|
+
attempts: this.attempts,
|
|
109
|
+
};
|
|
110
|
+
// Save event
|
|
111
|
+
this.saveEvent(event);
|
|
112
|
+
// Wait with backoff before continuing
|
|
113
|
+
await this.backoff();
|
|
114
|
+
return event;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Check if we should continue (respects max attempts)
|
|
118
|
+
*/
|
|
119
|
+
shouldContinue() {
|
|
120
|
+
return this.attempts < this.maxAttempts;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Get current attempt count
|
|
124
|
+
*/
|
|
125
|
+
getAttempts() {
|
|
126
|
+
return this.attempts;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Get all partial responses
|
|
130
|
+
*/
|
|
131
|
+
getPartials() {
|
|
132
|
+
return [...this.partials];
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Merge partial responses
|
|
136
|
+
*/
|
|
137
|
+
mergePartials() {
|
|
138
|
+
const merged = [];
|
|
139
|
+
for (const partialPath of this.partials) {
|
|
140
|
+
if (existsSync(partialPath)) {
|
|
141
|
+
const content = readFileSync(partialPath, "utf-8");
|
|
142
|
+
merged.push(content);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// v0.1: simple concatenation as markdown sections
|
|
146
|
+
return merged
|
|
147
|
+
.map((p, i) => `## Partial ${i + 1}\n\n${p}`)
|
|
148
|
+
.join("\n\n---\n\n");
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Build continue message for the next turn
|
|
152
|
+
*/
|
|
153
|
+
buildContinueMessage(additionalContext) {
|
|
154
|
+
const merged = this.mergePartials();
|
|
155
|
+
const lines = [
|
|
156
|
+
"# Continue From Partial Response",
|
|
157
|
+
"",
|
|
158
|
+
"The previous response was truncated due to output token limit.",
|
|
159
|
+
"",
|
|
160
|
+
"## Merged Partial Content",
|
|
161
|
+
"",
|
|
162
|
+
merged,
|
|
163
|
+
];
|
|
164
|
+
if (additionalContext) {
|
|
165
|
+
lines.push("", "## Additional Context");
|
|
166
|
+
lines.push("", additionalContext);
|
|
167
|
+
}
|
|
168
|
+
lines.push("", "## Instructions");
|
|
169
|
+
lines.push("");
|
|
170
|
+
lines.push("1. Review the partial content above");
|
|
171
|
+
lines.push("2. Continue from where the response was truncated");
|
|
172
|
+
lines.push("3. Do not repeat content that already appears above");
|
|
173
|
+
lines.push(`4. This is attempt ${this.attempts + 1} of ${this.maxAttempts}`);
|
|
174
|
+
return lines.join("\n");
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Validate expected output if configured
|
|
178
|
+
*/
|
|
179
|
+
validateOutput(content, expected) {
|
|
180
|
+
if (!this.requireValidation) {
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
if (expected.validate) {
|
|
184
|
+
return expected.validate(content);
|
|
185
|
+
}
|
|
186
|
+
// Basic validation: content should be longer than partial
|
|
187
|
+
return content.length > this.getPartials()[0]?.length ?? 0;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Reset for a new task
|
|
191
|
+
*/
|
|
192
|
+
reset() {
|
|
193
|
+
this.attempts = 0;
|
|
194
|
+
this.partials = [];
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Check if escalation is needed (max attempts reached)
|
|
198
|
+
*/
|
|
199
|
+
shouldEscalate() {
|
|
200
|
+
return this.attempts >= this.maxAttempts;
|
|
201
|
+
}
|
|
202
|
+
// --- Private Methods ------------------------------------------------
|
|
203
|
+
ensureDir() {
|
|
204
|
+
if (!existsSync(this.rootDir)) {
|
|
205
|
+
mkdirSync(this.rootDir, { recursive: true });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
savePartial(content, attempt) {
|
|
209
|
+
const filename = `partial_${String(attempt).padStart(3, "0")}.md`;
|
|
210
|
+
const path = join(this.rootDir, filename);
|
|
211
|
+
writeFileSync(path, content, "utf-8");
|
|
212
|
+
return path;
|
|
213
|
+
}
|
|
214
|
+
saveEvent(event) {
|
|
215
|
+
const eventsPath = join(this.rootDir, "events.jsonl");
|
|
216
|
+
writeFileSync(eventsPath, JSON.stringify(event) + "\n", "utf-8");
|
|
217
|
+
// Also save recovery status
|
|
218
|
+
const statusPath = join(this.rootDir, "recovery_status.json");
|
|
219
|
+
const status = {
|
|
220
|
+
taskId: this.taskId,
|
|
221
|
+
status: this.shouldContinue() ? "continuing" : "escalated",
|
|
222
|
+
partials: this.partials.map((p) => p.split("/").pop()),
|
|
223
|
+
mergedOutput: "merged.md",
|
|
224
|
+
attempts: this.attempts,
|
|
225
|
+
lastError: event.reason,
|
|
226
|
+
};
|
|
227
|
+
writeFileSync(statusPath, JSON.stringify(status, null, 2) + "\n", "utf-8");
|
|
228
|
+
}
|
|
229
|
+
async backoff() {
|
|
230
|
+
const delay = this.backoffMs * 2 ** (this.attempts - 1);
|
|
231
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
232
|
+
}
|
|
233
|
+
}
|