pi-harness-runtime 0.2.0 → 0.3.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/README.md +117 -158
- package/harness/agent-handoff.ts +189 -0
- package/harness/blackboard.ts +291 -0
- package/harness/context-window-manager.ts +210 -0
- package/harness/e2e/playwright-runner.ts +252 -0
- package/harness/e2e/test-engine.ts +402 -0
- package/harness/job-state-machine.ts +363 -0
- package/harness/loop-runtime.ts +337 -0
- package/harness/master-planner.ts +327 -0
- package/harness/project-detector/detector.ts +328 -0
- package/harness/repair-engine.ts +340 -0
- package/harness/task-graph.ts +336 -0
- package/index.ts +294 -2
- package/package.json +5 -2
- package/packages/checkpoint/README.md +3 -0
- package/packages/checkpoint/src/checkpoint-manager.ts +38 -0
- package/packages/provider-router/src/provider-router.ts +42 -0
- package/packages/providers/README.md +3 -0
- package/packages/providers/adapters.ts +261 -0
- package/packages/quota-manager/README.md +3 -0
- package/packages/quota-manager/quota-manager.ts +328 -0
- package/packages/runtime/README.md +3 -0
- package/packages/scheduler/README.md +3 -0
- package/packages/scheduler/src/scheduler.ts +50 -0
- package/packages/shared-context/README.md +3 -0
- package/packages/shared-context/src/shared-context.ts +42 -0
- package/packages/tui/README.md +3 -0
- package/packages/types/src/runtime-types.ts +439 -0
- package/packages/worktree/README.md +3 -0
- package/packages/worktree/worktree.ts +293 -0
- package/skills/harness-runtime/SKILL.md +186 -72
package/index.ts
CHANGED
|
@@ -19,16 +19,60 @@ import type {
|
|
|
19
19
|
ExtensionCommandContext,
|
|
20
20
|
} from "@earendil-works/pi-coding-agent";
|
|
21
21
|
import { UsageTracker } from "./tracker.ts";
|
|
22
|
-
import { MirrorStore
|
|
22
|
+
import { MirrorStore } from "./mirror.ts";
|
|
23
23
|
import { aggregateWindows } from "./windows.ts";
|
|
24
24
|
import { renderStatus } from "./renderer.ts";
|
|
25
25
|
import { buildMirrorRecord, parseSyncValues } from "./sync-form.ts";
|
|
26
|
+
import {
|
|
27
|
+
JobStateMachine,
|
|
28
|
+
type CheckpointManager,
|
|
29
|
+
} from "./harness/job-state-machine.ts";
|
|
30
|
+
import { TaskGraphManager } from "./harness/task-graph.ts";
|
|
31
|
+
import { MasterPlanner } from "./harness/master-planner.ts";
|
|
32
|
+
import { RepairEngine } from "./harness/repair-engine.ts";
|
|
33
|
+
import {
|
|
34
|
+
type SharedBlackboard,
|
|
35
|
+
createBlackboard,
|
|
36
|
+
} from "./harness/blackboard.ts";
|
|
37
|
+
import { homedir } from "node:os";
|
|
38
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
39
|
+
import { join } from "node:path";
|
|
26
40
|
|
|
27
41
|
const PROVIDER_DEFAULT = "minimax"; // can be changed via /usage sync form
|
|
28
42
|
|
|
43
|
+
// ─── Harness Runtime State ────────────────────────────────────────────
|
|
44
|
+
const HARNESS_ROOT_DIR = join(homedir(), ".pi", "harness");
|
|
45
|
+
|
|
46
|
+
interface HarnessSession {
|
|
47
|
+
jobId: string;
|
|
48
|
+
machine: JobStateMachine;
|
|
49
|
+
graph: TaskGraphManager;
|
|
50
|
+
blackboard: SharedBlackboard;
|
|
51
|
+
repairEngine: RepairEngine;
|
|
52
|
+
createdAt: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let currentSession: HarnessSession | null = null;
|
|
56
|
+
|
|
57
|
+
function ensureHarnessDir() {
|
|
58
|
+
if (!existsSync(HARNESS_ROOT_DIR)) {
|
|
59
|
+
mkdirSync(HARNESS_ROOT_DIR, { recursive: true });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function getCheckpointManager(): Promise<CheckpointManager> {
|
|
64
|
+
const { JsonCheckpointManager } = await import(
|
|
65
|
+
"./packages/checkpoint/src/checkpoint-manager.ts"
|
|
66
|
+
);
|
|
67
|
+
return new JsonCheckpointManager(
|
|
68
|
+
HARNESS_ROOT_DIR,
|
|
69
|
+
) as unknown as CheckpointManager;
|
|
70
|
+
}
|
|
71
|
+
|
|
29
72
|
export default function (pi: ExtensionAPI) {
|
|
30
73
|
const tracker = new UsageTracker();
|
|
31
74
|
const mirrorStore = new MirrorStore();
|
|
75
|
+
ensureHarnessDir();
|
|
32
76
|
|
|
33
77
|
// ─── Auto-track every assistant message ──────────────────────────────
|
|
34
78
|
pi.on("message_end", async (event, ctx) => {
|
|
@@ -165,6 +209,255 @@ export default function (pi: ExtensionAPI) {
|
|
|
165
209
|
},
|
|
166
210
|
});
|
|
167
211
|
|
|
212
|
+
// ─── /harness start — Start a new harness job ──────────────────────
|
|
213
|
+
pi.registerCommand("harness-start", {
|
|
214
|
+
description: "Start a new harness job: /harness start <requirement>",
|
|
215
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
216
|
+
if (!args.trim()) {
|
|
217
|
+
ctx.ui.notify("Usage: /harness start <requirement>", "error");
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const jobId = `job-${Date.now()}`;
|
|
222
|
+
const requirement = args.trim();
|
|
223
|
+
|
|
224
|
+
ctx.ui.notify(`Starting harness job ${jobId}...`, "info");
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
const cm = await getCheckpointManager();
|
|
228
|
+
const machine = new JobStateMachine({ checkpointManager: cm });
|
|
229
|
+
const result = await machine.createJob(jobId, requirement);
|
|
230
|
+
|
|
231
|
+
if (!result.success) {
|
|
232
|
+
ctx.ui.notify(`Failed to create job: ${result.error}`, "error");
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Create task graph using heuristic planner
|
|
237
|
+
const planner = new MasterPlanner();
|
|
238
|
+
const planResult = await planner.createPlan(
|
|
239
|
+
requirement,
|
|
240
|
+
jobId,
|
|
241
|
+
HARNESS_ROOT_DIR,
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
if (!planResult.success) {
|
|
245
|
+
ctx.ui.notify(`Failed to create plan: ${planResult.error}`, "error");
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Create blackboard
|
|
250
|
+
const blackboard = createBlackboard(
|
|
251
|
+
jobId,
|
|
252
|
+
HARNESS_ROOT_DIR,
|
|
253
|
+
planResult.graph!,
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
// Create repair engine
|
|
257
|
+
const repairEngine = new RepairEngine(HARNESS_ROOT_DIR);
|
|
258
|
+
|
|
259
|
+
// Store session
|
|
260
|
+
currentSession = {
|
|
261
|
+
jobId,
|
|
262
|
+
machine,
|
|
263
|
+
graph: new TaskGraphManager({ jobId }),
|
|
264
|
+
blackboard,
|
|
265
|
+
repairEngine,
|
|
266
|
+
createdAt: new Date().toISOString(),
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const taskCount = planResult.graph?.nodes
|
|
270
|
+
? Object.keys(planResult.graph.nodes).length
|
|
271
|
+
: 0;
|
|
272
|
+
ctx.ui.notify(
|
|
273
|
+
`Job ${jobId} created with ${taskCount} tasks.\n` +
|
|
274
|
+
`Requirement: ${requirement}\n\n` +
|
|
275
|
+
`Run /harness status to see tasks, or /harness tasks to list them.`,
|
|
276
|
+
"info",
|
|
277
|
+
);
|
|
278
|
+
} catch (e) {
|
|
279
|
+
ctx.ui.notify(`Error starting harness: ${e}`, "error");
|
|
280
|
+
}
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// ─── /harness status — Show harness job status ─────────────────────
|
|
285
|
+
pi.registerCommand("harness-status", {
|
|
286
|
+
description: "Show current harness job status",
|
|
287
|
+
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
|
288
|
+
if (!currentSession) {
|
|
289
|
+
ctx.ui.notify(
|
|
290
|
+
"No active harness job. Run /harness start <requirement> to begin.",
|
|
291
|
+
"info",
|
|
292
|
+
);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const summary = currentSession.machine.getStatusSummary();
|
|
297
|
+
if (!summary) {
|
|
298
|
+
ctx.ui.notify("Failed to get job status.", "error");
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const progress = currentSession.graph.getProgressSummary();
|
|
303
|
+
const lines = [
|
|
304
|
+
`Harness Job Status`,
|
|
305
|
+
`${"─".repeat(40)}`,
|
|
306
|
+
`Job ID: ${currentSession.jobId}`,
|
|
307
|
+
`Status: ${summary.status}`,
|
|
308
|
+
`Terminal: ${summary.isTerminal ? "Yes" : "No"}`,
|
|
309
|
+
`Can Resume: ${summary.canResume ? "Yes" : "No"}`,
|
|
310
|
+
`${"─".repeat(40)}`,
|
|
311
|
+
`Tasks: ${progress.done}/${progress.total} done, ${progress.running} running, ${progress.failed} failed`,
|
|
312
|
+
`Created: ${currentSession.createdAt}`,
|
|
313
|
+
`${"─".repeat(40)}`,
|
|
314
|
+
`Run /harness tasks for task list`,
|
|
315
|
+
];
|
|
316
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
317
|
+
},
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// ─── /harness tasks — List all tasks ───────────────────────────────
|
|
321
|
+
pi.registerCommand("harness-tasks", {
|
|
322
|
+
description: "List all tasks in the current harness job",
|
|
323
|
+
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
|
324
|
+
if (!currentSession) {
|
|
325
|
+
ctx.ui.notify(
|
|
326
|
+
"No active harness job. Run /harness start <requirement> to begin.",
|
|
327
|
+
"info",
|
|
328
|
+
);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const tasks = currentSession.graph.getAllTasks();
|
|
333
|
+
if (tasks.length === 0) {
|
|
334
|
+
ctx.ui.notify(
|
|
335
|
+
"No tasks found. The job may not have been planned yet.",
|
|
336
|
+
"info",
|
|
337
|
+
);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const lines = [
|
|
342
|
+
`Tasks for Job ${currentSession.jobId}`,
|
|
343
|
+
`${"─".repeat(50)}`,
|
|
344
|
+
];
|
|
345
|
+
|
|
346
|
+
for (const task of tasks) {
|
|
347
|
+
const status = task.status.padEnd(10);
|
|
348
|
+
const retry = task.retryCount ? ` (${task.retryCount} retries)` : "";
|
|
349
|
+
lines.push(`[${task.id}] ${status} ${task.title}${retry}`);
|
|
350
|
+
if (task.dependencies.length > 0) {
|
|
351
|
+
lines.push(` deps: ${task.dependencies.join(", ")}`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
lines.push(`${"─".repeat(50)}`);
|
|
356
|
+
const ready = currentSession.graph.getReadyTasks();
|
|
357
|
+
lines.push(`${ready.length} tasks ready to execute.`);
|
|
358
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
359
|
+
},
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
// ─── /harness pause — Pause the harness job ───────────────────────
|
|
363
|
+
pi.registerCommand("harness-pause", {
|
|
364
|
+
description: "Pause the current harness job",
|
|
365
|
+
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
|
366
|
+
if (!currentSession) {
|
|
367
|
+
ctx.ui.notify("No active harness job to pause.", "info");
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const checkpoint = currentSession.machine.getCheckpoint();
|
|
372
|
+
if (!checkpoint) {
|
|
373
|
+
ctx.ui.notify("Failed to get checkpoint.", "error");
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const result = await currentSession.machine.transition("paused_quota");
|
|
378
|
+
if (!result.success) {
|
|
379
|
+
ctx.ui.notify(`Failed to pause: ${result.error}`, "error");
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
ctx.ui.notify(
|
|
384
|
+
`Job ${currentSession.jobId} paused.\n` +
|
|
385
|
+
`Current status: paused_quota\n` +
|
|
386
|
+
`Run /harness resume to continue.`,
|
|
387
|
+
"info",
|
|
388
|
+
);
|
|
389
|
+
},
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
// ─── /harness resume — Resume the harness job ───────────────────────
|
|
393
|
+
pi.registerCommand("harness-resume", {
|
|
394
|
+
description: "Resume a paused harness job",
|
|
395
|
+
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
|
396
|
+
if (!currentSession) {
|
|
397
|
+
ctx.ui.notify("No active harness job to resume.", "info");
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const checkpoint = currentSession.machine.getCheckpoint();
|
|
402
|
+
if (!checkpoint || checkpoint.status !== "paused_quota") {
|
|
403
|
+
ctx.ui.notify(
|
|
404
|
+
"Job is not paused. Run /harness start to begin a new job.",
|
|
405
|
+
"info",
|
|
406
|
+
);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const result = await currentSession.machine.transition("running");
|
|
411
|
+
if (!result.success) {
|
|
412
|
+
ctx.ui.notify(`Failed to resume: ${result.error}`, "error");
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
ctx.ui.notify(
|
|
417
|
+
`Job ${currentSession.jobId} resumed.\n` +
|
|
418
|
+
`Current status: running\n` +
|
|
419
|
+
`Run /harness status to monitor progress.`,
|
|
420
|
+
"info",
|
|
421
|
+
);
|
|
422
|
+
},
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
// ─── /harness cancel — Cancel the harness job ──────────────────────
|
|
426
|
+
pi.registerCommand("harness-cancel", {
|
|
427
|
+
description: "Cancel the current harness job",
|
|
428
|
+
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
|
429
|
+
if (!currentSession) {
|
|
430
|
+
ctx.ui.notify("No active harness job to cancel.", "info");
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const ok = await ctx.ui.confirm(
|
|
435
|
+
`Cancel job ${currentSession.jobId}?`,
|
|
436
|
+
"This will mark the job as cancelled. Task state is preserved but work stops.",
|
|
437
|
+
);
|
|
438
|
+
|
|
439
|
+
if (!ok) {
|
|
440
|
+
ctx.ui.notify("Cancelled", "info");
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const result = await currentSession.machine.transition("cancelled");
|
|
445
|
+
if (!result.success) {
|
|
446
|
+
ctx.ui.notify(`Failed to cancel: ${result.error}`, "error");
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const sessionJobId = currentSession.jobId;
|
|
451
|
+
currentSession = null;
|
|
452
|
+
|
|
453
|
+
ctx.ui.notify(
|
|
454
|
+
`Job ${sessionJobId} cancelled.\n` +
|
|
455
|
+
`Run /harness start to begin a new job.`,
|
|
456
|
+
"info",
|
|
457
|
+
);
|
|
458
|
+
},
|
|
459
|
+
});
|
|
460
|
+
|
|
168
461
|
// ─── Footer status (persistent badge) ────────────────────────────────
|
|
169
462
|
pi.on("session_start", async (_event, ctx) => {
|
|
170
463
|
await refreshFooterStatus(ctx, mirrorStore, tracker);
|
|
@@ -185,7 +478,6 @@ async function refreshFooterStatus(
|
|
|
185
478
|
) {
|
|
186
479
|
const local = aggregateWindows(tracker.all());
|
|
187
480
|
const mirror = mirrorStore.read();
|
|
188
|
-
const now = Date.now();
|
|
189
481
|
|
|
190
482
|
const todayStr = `${(local.today.tokens / 1000).toFixed(1)}k tok · $${local.today.cost.toFixed(3)}`;
|
|
191
483
|
let summary = `today: ${todayStr}`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-harness-runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Codex-style /usage status for pi: local token tracking + provider mirror",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
"renderer.ts",
|
|
20
20
|
"sync-form.ts",
|
|
21
21
|
"cli.ts",
|
|
22
|
+
"harness",
|
|
23
|
+
"packages",
|
|
22
24
|
"skills",
|
|
23
25
|
"package.json",
|
|
24
26
|
"README.md",
|
|
@@ -60,6 +62,7 @@
|
|
|
60
62
|
"typebox": "*"
|
|
61
63
|
},
|
|
62
64
|
"devDependencies": {
|
|
65
|
+
"@types/node": "^20.14.0",
|
|
63
66
|
"standard-version": "^9.5.0"
|
|
64
67
|
}
|
|
65
|
-
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile, appendFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import type { RuntimeCheckpoint, RuntimeEvent } from "../../types/src/runtime-types";
|
|
4
|
+
|
|
5
|
+
export class JsonCheckpointManager {
|
|
6
|
+
constructor(private readonly rootDir: string) {}
|
|
7
|
+
|
|
8
|
+
private jobDir(jobId: string): string {
|
|
9
|
+
return join(this.rootDir, "jobs", jobId);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
private checkpointPath(jobId: string): string {
|
|
13
|
+
return join(this.jobDir(jobId), "checkpoint.json");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async save(checkpoint: RuntimeCheckpoint): Promise<void> {
|
|
17
|
+
const path = this.checkpointPath(checkpoint.jobId);
|
|
18
|
+
await mkdir(dirname(path), { recursive: true });
|
|
19
|
+
const tmp = `${path}.tmp`;
|
|
20
|
+
await writeFile(tmp, JSON.stringify(checkpoint, null, 2) + "\n", "utf-8");
|
|
21
|
+
await rename(tmp, path);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async load(jobId: string): Promise<RuntimeCheckpoint | null> {
|
|
25
|
+
try {
|
|
26
|
+
const text = await readFile(this.checkpointPath(jobId), "utf-8");
|
|
27
|
+
return JSON.parse(text) as RuntimeCheckpoint;
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async appendEvent(jobId: string, event: RuntimeEvent): Promise<void> {
|
|
34
|
+
const path = join(this.jobDir(jobId), "events.jsonl");
|
|
35
|
+
await mkdir(dirname(path), { recursive: true });
|
|
36
|
+
await appendFile(path, JSON.stringify(event) + "\n", "utf-8");
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { ProviderSelection, RuntimeContext, RuntimeTask } from "../../types/src/runtime-types";
|
|
2
|
+
|
|
3
|
+
export interface RoutingPolicy {
|
|
4
|
+
plannerProvider: string;
|
|
5
|
+
codeProviders: string[];
|
|
6
|
+
reviewProvider: string;
|
|
7
|
+
fallbackProviders: string[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class SimpleProviderRouter {
|
|
11
|
+
constructor(private readonly policy: RoutingPolicy) {}
|
|
12
|
+
|
|
13
|
+
async selectProvider(task: RuntimeTask, context: RuntimeContext): Promise<ProviderSelection> {
|
|
14
|
+
const candidates = this.candidatesForTask(task);
|
|
15
|
+
|
|
16
|
+
for (const providerId of candidates) {
|
|
17
|
+
const state = context.providerStates[providerId] ?? "unknown";
|
|
18
|
+
if (state === "available" || state === "unknown") {
|
|
19
|
+
return {
|
|
20
|
+
providerId,
|
|
21
|
+
reason: `selected ${providerId} for task ${task.id}; state=${state}`,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
throw new Error(`No available provider for task ${task.id}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
private candidatesForTask(task: RuntimeTask): string[] {
|
|
30
|
+
const title = task.title.toLowerCase();
|
|
31
|
+
|
|
32
|
+
if (title.includes("plan") || title.includes("architecture")) {
|
|
33
|
+
return [this.policy.plannerProvider, ...this.policy.fallbackProviders];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (title.includes("review") || title.includes("diff")) {
|
|
37
|
+
return [this.policy.reviewProvider, ...this.policy.fallbackProviders];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return [...this.policy.codeProviders, ...this.policy.fallbackProviders];
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider Adapter — RFC-0002
|
|
3
|
+
*
|
|
4
|
+
* Normalizes provider-specific behavior for:
|
|
5
|
+
* - model invocation
|
|
6
|
+
* - error detection
|
|
7
|
+
* - quota detection
|
|
8
|
+
* - reset time discovery
|
|
9
|
+
* - retry policy
|
|
10
|
+
* - model capability metadata
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type {
|
|
14
|
+
ProviderConfig,
|
|
15
|
+
ProviderCapability,
|
|
16
|
+
ProviderRequest,
|
|
17
|
+
ProviderResponse,
|
|
18
|
+
} from "../../packages/types/src/runtime-types.ts";
|
|
19
|
+
|
|
20
|
+
export interface AdapterConfig {
|
|
21
|
+
provider: ProviderConfig;
|
|
22
|
+
quotaSignalExtractor?: (error: unknown) => QuotaSignal | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface QuotaSignal {
|
|
26
|
+
exhausted: boolean;
|
|
27
|
+
resetsAt?: string;
|
|
28
|
+
retryAfterMs?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface AdapterResult {
|
|
32
|
+
response: ProviderResponse;
|
|
33
|
+
quotaSignal?: QuotaSignal;
|
|
34
|
+
retryable: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ProviderAdapter {
|
|
38
|
+
readonly id: string;
|
|
39
|
+
readonly name: string;
|
|
40
|
+
|
|
41
|
+
invoke(request: ProviderRequest): Promise<AdapterResult>;
|
|
42
|
+
|
|
43
|
+
parseError(error: unknown): {
|
|
44
|
+
quotaExceeded: boolean;
|
|
45
|
+
rateLimited: boolean;
|
|
46
|
+
timeout: boolean;
|
|
47
|
+
serverError: boolean;
|
|
48
|
+
clientError: boolean;
|
|
49
|
+
quotaSignal?: QuotaSignal;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
getCapabilities(): ProviderCapability[];
|
|
53
|
+
|
|
54
|
+
supportsModel(model: string): boolean;
|
|
55
|
+
|
|
56
|
+
getDefaultModel(): string;
|
|
57
|
+
|
|
58
|
+
getMaxTokens(model?: string): number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Base adapter with common functionality
|
|
63
|
+
*/
|
|
64
|
+
export abstract class BaseProviderAdapter implements ProviderAdapter {
|
|
65
|
+
abstract readonly id: string;
|
|
66
|
+
abstract readonly name: string;
|
|
67
|
+
|
|
68
|
+
constructor(protected config: ProviderConfig) {}
|
|
69
|
+
|
|
70
|
+
abstract invoke(request: ProviderRequest): Promise<AdapterResult>;
|
|
71
|
+
|
|
72
|
+
abstract parseError(error: unknown): {
|
|
73
|
+
quotaExceeded: boolean;
|
|
74
|
+
rateLimited: boolean;
|
|
75
|
+
timeout: boolean;
|
|
76
|
+
serverError: boolean;
|
|
77
|
+
clientError: boolean;
|
|
78
|
+
quotaSignal?: QuotaSignal;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
getCapabilities(): ProviderCapability[] {
|
|
82
|
+
return this.config.capabilities;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
supportsModel(model: string): boolean {
|
|
86
|
+
return this.config.models.includes(model);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
getDefaultModel(): string {
|
|
90
|
+
return this.config.models[0] ?? "default";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
getMaxTokens(model?: string): number {
|
|
94
|
+
// Default token limits per model family
|
|
95
|
+
const limits: Record<string, number> = {
|
|
96
|
+
"minimax/MiniMax-M3": 32768,
|
|
97
|
+
"minimax/MiniMax-Text-01": 1000000,
|
|
98
|
+
"anthropic/claude-3-5-sonnet": 200000,
|
|
99
|
+
"openai/gpt-4o": 128000,
|
|
100
|
+
"openai/gpt-4-turbo": 128000,
|
|
101
|
+
};
|
|
102
|
+
return limits[model ?? this.getDefaultModel()] ?? 4096;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Parse usage from response
|
|
107
|
+
*/
|
|
108
|
+
protected parseUsage(response: unknown): ProviderResponse["usage"] {
|
|
109
|
+
const r = response as Record<string, unknown>;
|
|
110
|
+
return {
|
|
111
|
+
input: (r.input_tokens as number) ?? (r.prompt_tokens as number) ?? 0,
|
|
112
|
+
output:
|
|
113
|
+
(r.output_tokens as number) ?? (r.completion_tokens as number) ?? 0,
|
|
114
|
+
cacheRead: (r.cache_read_tokens as number) ?? 0,
|
|
115
|
+
cacheWrite: (r.cache_write_tokens as number) ?? 0,
|
|
116
|
+
cost: (r.cost as number) ?? 0,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* MiniMax adapter
|
|
123
|
+
*/
|
|
124
|
+
export class MiniMaxAdapter extends BaseProviderAdapter {
|
|
125
|
+
readonly id = "minimax";
|
|
126
|
+
readonly name = "MiniMax";
|
|
127
|
+
|
|
128
|
+
async invoke(request: ProviderRequest): Promise<AdapterResult> {
|
|
129
|
+
// In practice, this would call the MiniMax API
|
|
130
|
+
// For now, return a mock response
|
|
131
|
+
return {
|
|
132
|
+
response: {
|
|
133
|
+
content: "Mock response",
|
|
134
|
+
usage: { input: 100, output: 200, cost: 0.001 },
|
|
135
|
+
model: request.model,
|
|
136
|
+
finishReason: "stop",
|
|
137
|
+
},
|
|
138
|
+
retryable: false,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
parseError(error: unknown): {
|
|
143
|
+
quotaExceeded: boolean;
|
|
144
|
+
rateLimited: boolean;
|
|
145
|
+
timeout: boolean;
|
|
146
|
+
serverError: boolean;
|
|
147
|
+
clientError: boolean;
|
|
148
|
+
quotaSignal?: QuotaSignal;
|
|
149
|
+
} {
|
|
150
|
+
const e = error as Record<string, unknown>;
|
|
151
|
+
const msg = String(e.message ?? e.error ?? "").toLowerCase();
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
quotaExceeded: msg.includes("2056") || msg.includes("quota"),
|
|
155
|
+
rateLimited: msg.includes("rate limit") || msg.includes("429"),
|
|
156
|
+
timeout: msg.includes("timeout") || msg.includes("timed out"),
|
|
157
|
+
serverError:
|
|
158
|
+
msg.includes("500") || msg.includes("502") || msg.includes("503"),
|
|
159
|
+
clientError:
|
|
160
|
+
msg.includes("400") || msg.includes("401") || msg.includes("403"),
|
|
161
|
+
quotaSignal:
|
|
162
|
+
msg.includes("quota") || msg.includes("2056")
|
|
163
|
+
? { exhausted: true, resetsAt: undefined }
|
|
164
|
+
: undefined,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* OpenAI adapter
|
|
171
|
+
*/
|
|
172
|
+
export class OpenAIAdapter extends BaseProviderAdapter {
|
|
173
|
+
readonly id = "openai";
|
|
174
|
+
readonly name = "OpenAI";
|
|
175
|
+
|
|
176
|
+
async invoke(request: ProviderRequest): Promise<AdapterResult> {
|
|
177
|
+
return {
|
|
178
|
+
response: {
|
|
179
|
+
content: "Mock response",
|
|
180
|
+
usage: { input: 100, output: 200, cost: 0.002 },
|
|
181
|
+
model: request.model,
|
|
182
|
+
finishReason: "stop",
|
|
183
|
+
},
|
|
184
|
+
retryable: false,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
parseError(error: unknown): {
|
|
189
|
+
quotaExceeded: boolean;
|
|
190
|
+
rateLimited: boolean;
|
|
191
|
+
timeout: boolean;
|
|
192
|
+
serverError: boolean;
|
|
193
|
+
clientError: boolean;
|
|
194
|
+
quotaSignal?: QuotaSignal;
|
|
195
|
+
} {
|
|
196
|
+
const e = error as Record<string, unknown>;
|
|
197
|
+
const msg = String(e.message ?? e.error ?? "").toLowerCase();
|
|
198
|
+
const code = String(e.code ?? "");
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
quotaExceeded:
|
|
202
|
+
code === "insufficient_quota" || code === "context_length_exceeded",
|
|
203
|
+
rateLimited: code === "rate_limit_exceeded" || msg.includes("429"),
|
|
204
|
+
timeout: msg.includes("timeout"),
|
|
205
|
+
serverError: code.startsWith("5"),
|
|
206
|
+
clientError: code.startsWith("4"),
|
|
207
|
+
quotaSignal:
|
|
208
|
+
code === "insufficient_quota"
|
|
209
|
+
? { exhausted: true, resetsAt: undefined }
|
|
210
|
+
: undefined,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Adapter registry
|
|
217
|
+
*/
|
|
218
|
+
export class AdapterRegistry {
|
|
219
|
+
private adapters: Map<string, ProviderAdapter> = new Map();
|
|
220
|
+
|
|
221
|
+
register(adapter: ProviderAdapter): void {
|
|
222
|
+
this.adapters.set(adapter.id, adapter);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
get(id: string): ProviderAdapter | undefined {
|
|
226
|
+
return this.adapters.get(id);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
list(): ProviderAdapter[] {
|
|
230
|
+
return Array.from(this.adapters.values());
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Create default registry with standard adapters
|
|
235
|
+
*/
|
|
236
|
+
static createDefault(): AdapterRegistry {
|
|
237
|
+
const registry = new AdapterRegistry();
|
|
238
|
+
|
|
239
|
+
registry.register(
|
|
240
|
+
new MiniMaxAdapter({
|
|
241
|
+
id: "minimax",
|
|
242
|
+
name: "MiniMax",
|
|
243
|
+
models: ["minimax/MiniMax-M3", "minimax/MiniMax-Text-01"],
|
|
244
|
+
capabilities: ["code", "review", "plan", "test"],
|
|
245
|
+
rateLimits: {},
|
|
246
|
+
}),
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
registry.register(
|
|
250
|
+
new OpenAIAdapter({
|
|
251
|
+
id: "openai",
|
|
252
|
+
name: "OpenAI",
|
|
253
|
+
models: ["openai/gpt-4o", "openai/gpt-4-turbo"],
|
|
254
|
+
capabilities: ["code", "review", "plan", "test", "refactor"],
|
|
255
|
+
rateLimits: {},
|
|
256
|
+
}),
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
return registry;
|
|
260
|
+
}
|
|
261
|
+
}
|