@ifi/oh-pi-ant-colony 0.2.9 → 0.2.11
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/extensions/ant-colony/budget-planner.ts +55 -0
- package/extensions/ant-colony/index.ts +32 -0
- package/extensions/ant-colony/multimodal-routing.ts +68 -0
- package/extensions/ant-colony/nest.ts +26 -1
- package/extensions/ant-colony/queen.ts +86 -8
- package/extensions/ant-colony/types.ts +32 -2
- package/extensions/ant-colony/worktree.ts +55 -2
- package/package.json +7 -1
|
@@ -68,6 +68,18 @@ export interface CasteBudget {
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
/** Full budget plan for a colony run. */
|
|
71
|
+
export interface RoutingTelemetrySnapshot {
|
|
72
|
+
totalRoutes: number;
|
|
73
|
+
avgLatencyMs: number;
|
|
74
|
+
outcomeCounts: {
|
|
75
|
+
claimed: number;
|
|
76
|
+
completed: number;
|
|
77
|
+
failed: number;
|
|
78
|
+
escalated: number;
|
|
79
|
+
};
|
|
80
|
+
escalationReasonCounts: Record<string, number>;
|
|
81
|
+
}
|
|
82
|
+
|
|
71
83
|
export interface BudgetPlan {
|
|
72
84
|
/** Per-caste allocations. */
|
|
73
85
|
castes: Record<AntCaste, CasteBudget>;
|
|
@@ -79,6 +91,8 @@ export interface BudgetPlan {
|
|
|
79
91
|
lowestRateLimitPct: number;
|
|
80
92
|
/** Human-readable summary for prompt injection. */
|
|
81
93
|
summary: string;
|
|
94
|
+
/** Aggregated routing telemetry used for reporting and debugging. */
|
|
95
|
+
routingTelemetry: RoutingTelemetrySnapshot;
|
|
82
96
|
}
|
|
83
97
|
|
|
84
98
|
// ═══ Constants ═══
|
|
@@ -210,6 +224,7 @@ export function buildBudgetSummary(
|
|
|
210
224
|
maxCost: number | null,
|
|
211
225
|
tasksDone: number,
|
|
212
226
|
tasksTotal: number,
|
|
227
|
+
routingTelemetry?: RoutingTelemetrySnapshot,
|
|
213
228
|
): string {
|
|
214
229
|
const parts: string[] = [];
|
|
215
230
|
|
|
@@ -233,6 +248,16 @@ export function buildBudgetSummary(
|
|
|
233
248
|
parts.push(`Progress: ${tasksDone}/${tasksTotal} tasks completed.`);
|
|
234
249
|
}
|
|
235
250
|
|
|
251
|
+
if (routingTelemetry && routingTelemetry.totalRoutes > 0) {
|
|
252
|
+
parts.push(
|
|
253
|
+
`Routing: ${routingTelemetry.totalRoutes} outcomes, avg latency ${routingTelemetry.avgLatencyMs}ms, escalations ${routingTelemetry.outcomeCounts.escalated}.`,
|
|
254
|
+
);
|
|
255
|
+
const topEscalation = Object.entries(routingTelemetry.escalationReasonCounts).sort((a, b) => b[1] - a[1])[0];
|
|
256
|
+
if (topEscalation) {
|
|
257
|
+
parts.push(`Top escalation reason: ${topEscalation[0]} (${topEscalation[1]}).`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
236
261
|
// Severity-specific guidance
|
|
237
262
|
switch (severity) {
|
|
238
263
|
case "critical":
|
|
@@ -265,6 +290,33 @@ export function buildBudgetSummary(
|
|
|
265
290
|
* @param concurrency - Current concurrency config for max bounds.
|
|
266
291
|
* @returns A complete budget plan with per-caste allocations.
|
|
267
292
|
*/
|
|
293
|
+
export function buildRoutingTelemetrySnapshot(metrics: ColonyMetrics): RoutingTelemetrySnapshot {
|
|
294
|
+
const entries = metrics.routingTelemetry ?? [];
|
|
295
|
+
const outcomeCounts = {
|
|
296
|
+
claimed: 0,
|
|
297
|
+
completed: 0,
|
|
298
|
+
failed: 0,
|
|
299
|
+
escalated: 0,
|
|
300
|
+
};
|
|
301
|
+
const escalationReasonCounts: Record<string, number> = {};
|
|
302
|
+
let latencyTotal = 0;
|
|
303
|
+
|
|
304
|
+
for (const entry of entries) {
|
|
305
|
+
outcomeCounts[entry.outcome] += 1;
|
|
306
|
+
latencyTotal += entry.latencyMs;
|
|
307
|
+
for (const reason of entry.escalationReasons) {
|
|
308
|
+
escalationReasonCounts[reason] = (escalationReasonCounts[reason] ?? 0) + 1;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return {
|
|
313
|
+
totalRoutes: entries.length,
|
|
314
|
+
avgLatencyMs: entries.length > 0 ? Math.round(latencyTotal / entries.length) : 0,
|
|
315
|
+
outcomeCounts,
|
|
316
|
+
escalationReasonCounts,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
268
320
|
export function planBudget(
|
|
269
321
|
usageLimits: UsageLimitsEvent | null,
|
|
270
322
|
metrics: ColonyMetrics,
|
|
@@ -311,6 +363,7 @@ export function planBudget(
|
|
|
311
363
|
};
|
|
312
364
|
}
|
|
313
365
|
|
|
366
|
+
const routingTelemetry = buildRoutingTelemetrySnapshot(metrics);
|
|
314
367
|
const summary = buildBudgetSummary(
|
|
315
368
|
severity,
|
|
316
369
|
lowestRateLimitPct,
|
|
@@ -318,6 +371,7 @@ export function planBudget(
|
|
|
318
371
|
maxCost,
|
|
319
372
|
metrics.tasksDone,
|
|
320
373
|
metrics.tasksTotal,
|
|
374
|
+
routingTelemetry,
|
|
321
375
|
);
|
|
322
376
|
|
|
323
377
|
return {
|
|
@@ -326,6 +380,7 @@ export function planBudget(
|
|
|
326
380
|
severity,
|
|
327
381
|
lowestRateLimitPct,
|
|
328
382
|
summary,
|
|
383
|
+
routingTelemetry,
|
|
329
384
|
};
|
|
330
385
|
}
|
|
331
386
|
|
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
statusLabel,
|
|
36
36
|
} from "./ui.js";
|
|
37
37
|
import {
|
|
38
|
+
cleanupIsolatedWorktree,
|
|
38
39
|
formatWorkspaceReport,
|
|
39
40
|
formatWorkspaceSummary,
|
|
40
41
|
prepareColonyWorkspace,
|
|
@@ -549,6 +550,17 @@ export default function antColonyExtension(pi: ExtensionAPI) {
|
|
|
549
550
|
lastBgStatusSnapshotAt = 0;
|
|
550
551
|
throttledRender();
|
|
551
552
|
|
|
553
|
+
const cleanupWorkspace = (reason: "completion" | "crash") => {
|
|
554
|
+
const cleanupResult = cleanupIsolatedWorktree(workspace);
|
|
555
|
+
if (!cleanupResult) {
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
pushLog(colony, {
|
|
559
|
+
level: /failed|skipped/i.test(cleanupResult) ? "warning" : "info",
|
|
560
|
+
text: `WORKTREE ${reason.toUpperCase()} CLEANUP · ${cleanupResult}`,
|
|
561
|
+
});
|
|
562
|
+
};
|
|
563
|
+
|
|
552
564
|
// Wait for completion in background, inject results
|
|
553
565
|
colony.promise
|
|
554
566
|
.then((state) => {
|
|
@@ -565,6 +577,7 @@ export default function antColonyExtension(pi: ExtensionAPI) {
|
|
|
565
577
|
level: ok ? "info" : "error",
|
|
566
578
|
text: `${statusLabel(phase)} · ${m.tasksDone}/${m.tasksTotal} · ${formatCost(m.totalCost)}`,
|
|
567
579
|
});
|
|
580
|
+
cleanupWorkspace("completion");
|
|
568
581
|
|
|
569
582
|
colonies.delete(colonyId);
|
|
570
583
|
if (colonies.size === 0) {
|
|
@@ -588,6 +601,7 @@ export default function antColonyExtension(pi: ExtensionAPI) {
|
|
|
588
601
|
})
|
|
589
602
|
.catch((e) => {
|
|
590
603
|
pushLog(colony, { level: "error", text: `CRASHED · ${String(e).slice(0, 120)}` });
|
|
604
|
+
cleanupWorkspace("crash");
|
|
591
605
|
colonies.delete(colonyId);
|
|
592
606
|
if (colonies.size === 0) {
|
|
593
607
|
pi.events.emit("ant-colony:clear-ui");
|
|
@@ -987,6 +1001,12 @@ export default function antColonyExtension(pi: ExtensionAPI) {
|
|
|
987
1001
|
soldierModel: Type.Optional(
|
|
988
1002
|
Type.String({ description: "Model for soldier ants (default: current session model)" }),
|
|
989
1003
|
),
|
|
1004
|
+
designWorkerModel: Type.Optional(Type.String({ description: "Model override for design worker class" })),
|
|
1005
|
+
multimodalWorkerModel: Type.Optional(
|
|
1006
|
+
Type.String({ description: "Model override for multimodal worker class (cheap-first default route)" }),
|
|
1007
|
+
),
|
|
1008
|
+
backendWorkerModel: Type.Optional(Type.String({ description: "Model override for backend worker class" })),
|
|
1009
|
+
reviewWorkerModel: Type.Optional(Type.String({ description: "Model override for review worker class" })),
|
|
990
1010
|
}),
|
|
991
1011
|
|
|
992
1012
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
@@ -1008,6 +1028,18 @@ export default function antColonyExtension(pi: ExtensionAPI) {
|
|
|
1008
1028
|
if (params.soldierModel) {
|
|
1009
1029
|
modelOverrides.soldier = params.soldierModel;
|
|
1010
1030
|
}
|
|
1031
|
+
if (params.designWorkerModel) {
|
|
1032
|
+
modelOverrides.design = params.designWorkerModel;
|
|
1033
|
+
}
|
|
1034
|
+
if (params.multimodalWorkerModel) {
|
|
1035
|
+
modelOverrides.multimodal = params.multimodalWorkerModel;
|
|
1036
|
+
}
|
|
1037
|
+
if (params.backendWorkerModel) {
|
|
1038
|
+
modelOverrides.backend = params.backendWorkerModel;
|
|
1039
|
+
}
|
|
1040
|
+
if (params.reviewWorkerModel) {
|
|
1041
|
+
modelOverrides.review = params.reviewWorkerModel;
|
|
1042
|
+
}
|
|
1011
1043
|
|
|
1012
1044
|
const colonyParams = {
|
|
1013
1045
|
goal: params.goal,
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { EscalationReason, Task } from "./types.js";
|
|
2
|
+
|
|
3
|
+
const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".svg", ".heic"]);
|
|
4
|
+
const VIDEO_EXTENSIONS = new Set([".mp4", ".mov", ".m4v", ".avi", ".mkv", ".webm", ".mpeg", ".mpg"]);
|
|
5
|
+
|
|
6
|
+
export interface IngestionArtifact {
|
|
7
|
+
path: string;
|
|
8
|
+
kind: "image" | "video";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface MultimodalIngestionReport {
|
|
12
|
+
hasMultimodalInput: boolean;
|
|
13
|
+
artifacts: IngestionArtifact[];
|
|
14
|
+
summary: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function preprocessMultimodalTask(task: Task): MultimodalIngestionReport {
|
|
18
|
+
const artifacts: IngestionArtifact[] = [];
|
|
19
|
+
for (const file of task.files) {
|
|
20
|
+
const lower = file.toLowerCase();
|
|
21
|
+
const imageExt = [...IMAGE_EXTENSIONS].find((ext) => lower.endsWith(ext));
|
|
22
|
+
if (imageExt) {
|
|
23
|
+
artifacts.push({ path: file, kind: "image" });
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
const videoExt = [...VIDEO_EXTENSIONS].find((ext) => lower.endsWith(ext));
|
|
27
|
+
if (videoExt) {
|
|
28
|
+
artifacts.push({ path: file, kind: "video" });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (artifacts.length === 0) {
|
|
33
|
+
return {
|
|
34
|
+
hasMultimodalInput: false,
|
|
35
|
+
artifacts,
|
|
36
|
+
summary: "No image/video artifacts detected.",
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const imageCount = artifacts.filter((a) => a.kind === "image").length;
|
|
41
|
+
const videoCount = artifacts.filter((a) => a.kind === "video").length;
|
|
42
|
+
return {
|
|
43
|
+
hasMultimodalInput: true,
|
|
44
|
+
artifacts,
|
|
45
|
+
summary: `Detected multimodal artifacts: ${imageCount} image(s), ${videoCount} video(s).`,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function shouldEscalateMultimodalRoute(task: Task, report: MultimodalIngestionReport): EscalationReason[] {
|
|
50
|
+
const reasons: EscalationReason[] = [];
|
|
51
|
+
if (!report.hasMultimodalInput) {
|
|
52
|
+
return reasons;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (report.artifacts.some((artifact) => artifact.kind === "video")) {
|
|
56
|
+
reasons.push("risk_flag");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if ((task.description ?? "").toLowerCase().includes("policy")) {
|
|
60
|
+
reasons.push("policy_violation");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if ((task.context ?? "").length > 5000) {
|
|
64
|
+
reasons.push("low_confidence");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return reasons;
|
|
68
|
+
}
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
import * as fs from "node:fs";
|
|
21
21
|
import * as path from "node:path";
|
|
22
|
-
import type { Ant, ColonyState, ConcurrencySample, Pheromone, Task, TaskStatus } from "./types.js";
|
|
22
|
+
import type { Ant, ColonyState, ConcurrencySample, EscalationReason, Pheromone, Task, TaskStatus } from "./types.js";
|
|
23
23
|
|
|
24
24
|
/** Minimum pheromone strength to keep (below this, entries are garbage-collected). */
|
|
25
25
|
const PHEROMONE_MIN_STRENGTH = 0.05;
|
|
@@ -249,6 +249,31 @@ export class Nest {
|
|
|
249
249
|
this.writeTask(task);
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
+
recordRoutingOutcome(
|
|
253
|
+
taskId: string,
|
|
254
|
+
caste: "scout" | "worker" | "soldier" | "drone",
|
|
255
|
+
outcome: "claimed" | "completed" | "failed" | "escalated",
|
|
256
|
+
latencyMs: number,
|
|
257
|
+
escalationReasons: EscalationReason[] = [],
|
|
258
|
+
): void {
|
|
259
|
+
this.withStateLock(() => {
|
|
260
|
+
if (!this.stateCache) {
|
|
261
|
+
this.stateCache = this.readJson<ColonyState>(this.stateFile);
|
|
262
|
+
}
|
|
263
|
+
const routingTelemetry = [...(this.stateCache.metrics.routingTelemetry ?? [])];
|
|
264
|
+
routingTelemetry.push({
|
|
265
|
+
taskId,
|
|
266
|
+
caste,
|
|
267
|
+
outcome,
|
|
268
|
+
latencyMs: Math.max(0, Math.floor(latencyMs)),
|
|
269
|
+
escalationReasons,
|
|
270
|
+
timestamp: Date.now(),
|
|
271
|
+
});
|
|
272
|
+
this.stateCache.metrics.routingTelemetry = routingTelemetry.slice(-500);
|
|
273
|
+
this.writeJson(this.stateFile, this.stateCache);
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
252
277
|
/** Register a child task and link it to its parent's `spawnedTasks` list. */
|
|
253
278
|
addSubTask(parentId: string, child: Task): void {
|
|
254
279
|
this.writeTask(child);
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
} from "./budget-planner.js";
|
|
25
25
|
import { adapt, defaultConcurrency, sampleSystem } from "./concurrency.js";
|
|
26
26
|
import { buildImportGraph, type ImportGraph, taskDependsOn } from "./deps.js";
|
|
27
|
+
import { preprocessMultimodalTask, shouldEscalateMultimodalRoute } from "./multimodal-routing.js";
|
|
27
28
|
import { Nest } from "./nest.js";
|
|
28
29
|
import { makePheromoneId, makeTaskId, resetAntCounter, runDrone, spawnAnt } from "./spawner.js";
|
|
29
30
|
import type {
|
|
@@ -36,8 +37,11 @@ import type {
|
|
|
36
37
|
ColonyState,
|
|
37
38
|
ColonyWorkspace,
|
|
38
39
|
ModelOverrides,
|
|
40
|
+
PromoteFinalizeGateDecision,
|
|
41
|
+
PromoteFinalizeGateInput,
|
|
39
42
|
Task,
|
|
40
43
|
TaskPriority,
|
|
44
|
+
WorkerClass,
|
|
41
45
|
} from "./types.js";
|
|
42
46
|
import { DEFAULT_ANT_CONFIGS } from "./types.js";
|
|
43
47
|
|
|
@@ -158,6 +162,20 @@ function makeInitialScoutTask(goal: string): Task {
|
|
|
158
162
|
};
|
|
159
163
|
}
|
|
160
164
|
|
|
165
|
+
function classifyWorkerClass(title: string, description: string, files: string[]): WorkerClass {
|
|
166
|
+
const haystack = `${title}\n${description}\n${files.join("\n")}`.toLowerCase();
|
|
167
|
+
if (/(ui|ux|design|layout|style|css|figma|theme|color|typography|component)/.test(haystack)) {
|
|
168
|
+
return "design";
|
|
169
|
+
}
|
|
170
|
+
if (/(image|video|audio|vision|ocr|multimodal|caption|embedding)/.test(haystack)) {
|
|
171
|
+
return "multimodal";
|
|
172
|
+
}
|
|
173
|
+
if (/(review|qa|validate|verify|audit|test|lint|check)/.test(haystack)) {
|
|
174
|
+
return "review";
|
|
175
|
+
}
|
|
176
|
+
return "backend";
|
|
177
|
+
}
|
|
178
|
+
|
|
161
179
|
function childTaskFromParsed(
|
|
162
180
|
parentId: string,
|
|
163
181
|
parsed: {
|
|
@@ -179,6 +197,8 @@ function childTaskFromParsed(
|
|
|
179
197
|
priority: parsed.priority,
|
|
180
198
|
files: parsed.files,
|
|
181
199
|
context: parsed.context || undefined,
|
|
200
|
+
workerClass:
|
|
201
|
+
parsed.caste === "worker" ? classifyWorkerClass(parsed.title, parsed.description, parsed.files) : undefined,
|
|
182
202
|
claimedBy: null,
|
|
183
203
|
result: null,
|
|
184
204
|
error: null,
|
|
@@ -242,6 +262,38 @@ export function shouldUseScoutQuorum(goal: string): boolean {
|
|
|
242
262
|
return /(\n\s*\d+[.)]|[;;]| and |以及|并且|同时|步骤|phase|then|之后)/i.test(goal);
|
|
243
263
|
}
|
|
244
264
|
|
|
265
|
+
export function decidePromoteOrFinalize(input: PromoteFinalizeGateInput): PromoteFinalizeGateDecision {
|
|
266
|
+
const escalationReasons: PromoteFinalizeGateDecision["escalationReasons"] = [];
|
|
267
|
+
if (input.confidenceScore < 0.78) {
|
|
268
|
+
escalationReasons.push("low_confidence");
|
|
269
|
+
}
|
|
270
|
+
if (input.coverageScore < 0.85) {
|
|
271
|
+
escalationReasons.push("low_coverage");
|
|
272
|
+
}
|
|
273
|
+
if (input.riskFlags.length > 0) {
|
|
274
|
+
escalationReasons.push("risk_flag");
|
|
275
|
+
}
|
|
276
|
+
if (input.policyViolations.length > 0) {
|
|
277
|
+
escalationReasons.push("policy_violation");
|
|
278
|
+
}
|
|
279
|
+
if (input.sloBreached) {
|
|
280
|
+
escalationReasons.push("slo_breach");
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (escalationReasons.length > 0) {
|
|
284
|
+
return {
|
|
285
|
+
action: "promote",
|
|
286
|
+
escalationReasons,
|
|
287
|
+
cheapPassSummary: input.cheapPassSummary,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
action: "finalize",
|
|
293
|
+
escalationReasons,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
245
297
|
export function validateExecutionPlan(tasks: Task[]): PlanValidation {
|
|
246
298
|
const issues: string[] = [];
|
|
247
299
|
const warnings: string[] = [];
|
|
@@ -378,7 +430,7 @@ interface WaveOptions {
|
|
|
378
430
|
modelOverrides?: ModelOverrides;
|
|
379
431
|
signal?: AbortSignal;
|
|
380
432
|
callbacks: QueenCallbacks;
|
|
381
|
-
emitSignal: (phase: ColonyState["status"], message: string) => void;
|
|
433
|
+
emitSignal: (phase: ColonyState["status"], message: string, extras?: Partial<ColonySignal>) => void;
|
|
382
434
|
authStorage?: AuthStorage;
|
|
383
435
|
modelRegistry?: ModelRegistry;
|
|
384
436
|
importGraph?: ImportGraph;
|
|
@@ -462,14 +514,30 @@ async function runAntWave(opts: WaveOptions): Promise<"ok" | "budget"> {
|
|
|
462
514
|
if (!task) {
|
|
463
515
|
return "empty";
|
|
464
516
|
}
|
|
517
|
+
nest.recordRoutingOutcome(task.id, caste, "claimed", 0);
|
|
518
|
+
|
|
519
|
+
const multimodalReport = caste === "worker" ? preprocessMultimodalTask(task) : null;
|
|
520
|
+
const multimodalEscalationReasons =
|
|
521
|
+
caste === "worker" && multimodalReport ? shouldEscalateMultimodalRoute(task, multimodalReport) : [];
|
|
522
|
+
if (multimodalEscalationReasons.length > 0) {
|
|
523
|
+
nest.recordRoutingOutcome(task.id, caste, "escalated", 0, multimodalEscalationReasons);
|
|
524
|
+
}
|
|
465
525
|
|
|
526
|
+
const shouldUseCheapMultimodalFirst = caste === "worker" && task.workerClass === "multimodal";
|
|
527
|
+
let selectedModel =
|
|
528
|
+
caste === "worker"
|
|
529
|
+
? (task.workerClass ? opts.modelOverrides?.[task.workerClass] : undefined) || casteModel
|
|
530
|
+
: casteModel;
|
|
531
|
+
if (shouldUseCheapMultimodalFirst && opts.modelOverrides?.multimodal) {
|
|
532
|
+
selectedModel = opts.modelOverrides.multimodal;
|
|
533
|
+
}
|
|
466
534
|
const ant: Ant = {
|
|
467
535
|
id: "",
|
|
468
536
|
caste,
|
|
469
537
|
status: "idle",
|
|
470
538
|
taskId: task.id,
|
|
471
539
|
pid: null,
|
|
472
|
-
model:
|
|
540
|
+
model: selectedModel,
|
|
473
541
|
usage: { input: 0, output: 0, cost: 0, turns: 0 },
|
|
474
542
|
startedAt: Date.now(),
|
|
475
543
|
finishedAt: null,
|
|
@@ -483,7 +551,7 @@ async function runAntWave(opts: WaveOptions): Promise<"ok" | "budget"> {
|
|
|
483
551
|
const antSignal = antAbort.signal;
|
|
484
552
|
// Bio 7: Age polymorphism — conservative early, convergent late
|
|
485
553
|
const progress = state.metrics.tasksTotal > 0 ? state.metrics.tasksDone / state.metrics.tasksTotal : 0;
|
|
486
|
-
const config = { ...baseConfig };
|
|
554
|
+
const config = { ...baseConfig, model: selectedModel };
|
|
487
555
|
if (progress < 0.3) {
|
|
488
556
|
config.maxTurns = Math.max(baseConfig.maxTurns - 3, 5); // Conservative early phase
|
|
489
557
|
} else if (progress > 0.7) {
|
|
@@ -491,6 +559,11 @@ async function runAntWave(opts: WaveOptions): Promise<"ok" | "budget"> {
|
|
|
491
559
|
}
|
|
492
560
|
// Build budget-awareness prompt section for non-drone ants
|
|
493
561
|
const budgetSection = opts.budgetPlan ? buildBudgetPromptSection(opts.budgetPlan) : undefined;
|
|
562
|
+
const ingestionSection = multimodalReport?.hasMultimodalInput
|
|
563
|
+
? `## Multimodal Ingestion\n${multimodalReport.summary}\nArtifacts:\n${multimodalReport.artifacts
|
|
564
|
+
.map((artifact) => `- ${artifact.kind}: ${artifact.path}`)
|
|
565
|
+
.join("\n")}`
|
|
566
|
+
: undefined;
|
|
494
567
|
const antPromise =
|
|
495
568
|
caste === "drone"
|
|
496
569
|
? runDrone(cwd, nest, task)
|
|
@@ -504,7 +577,7 @@ async function runAntWave(opts: WaveOptions): Promise<"ok" | "budget"> {
|
|
|
504
577
|
callbacks.onAntUsage,
|
|
505
578
|
opts.authStorage,
|
|
506
579
|
opts.modelRegistry,
|
|
507
|
-
budgetSection,
|
|
580
|
+
[budgetSection, ingestionSection].filter(Boolean).join("\n\n") || undefined,
|
|
508
581
|
);
|
|
509
582
|
let timeoutId: ReturnType<typeof setTimeout>;
|
|
510
583
|
const result = await Promise.race([
|
|
@@ -519,9 +592,12 @@ async function runAntWave(opts: WaveOptions): Promise<"ok" | "budget"> {
|
|
|
519
592
|
callbacks.onAntDone?.(result.ant, task, result.output);
|
|
520
593
|
|
|
521
594
|
if (result.rateLimited) {
|
|
595
|
+
nest.recordRoutingOutcome(task.id, caste, "escalated", Date.now() - ant.startedAt, ["slo_breach"]);
|
|
522
596
|
return "rate_limited";
|
|
523
597
|
}
|
|
524
598
|
|
|
599
|
+
nest.recordRoutingOutcome(task.id, caste, "completed", Date.now() - ant.startedAt);
|
|
600
|
+
|
|
525
601
|
// Cost warning: signal when >80% of budget is spent
|
|
526
602
|
const curState = nest.getStateLight();
|
|
527
603
|
if (curState.maxCost != null) {
|
|
@@ -589,8 +665,10 @@ async function runAntWave(opts: WaveOptions): Promise<"ok" | "budget"> {
|
|
|
589
665
|
const count = retryCount.get(task.id) ?? 0;
|
|
590
666
|
if (isRetryable && count < MAX_RETRIES) {
|
|
591
667
|
retryCount.set(task.id, count + 1);
|
|
668
|
+
nest.recordRoutingOutcome(task.id, caste, "escalated", Date.now() - ant.startedAt, ["slo_breach"]);
|
|
592
669
|
nest.updateTaskStatus(task.id, "pending");
|
|
593
670
|
} else {
|
|
671
|
+
nest.recordRoutingOutcome(task.id, caste, "failed", Date.now() - ant.startedAt);
|
|
594
672
|
// Negative pheromone: failed task releases warning proportional to task scope
|
|
595
673
|
if (task.files.length > 0) {
|
|
596
674
|
const warnStrength = Math.min(1.0, 0.5 + task.files.length * 0.1);
|
|
@@ -800,12 +878,12 @@ export async function runColony(opts: QueenOptions): Promise<ColonyState> {
|
|
|
800
878
|
}
|
|
801
879
|
};
|
|
802
880
|
|
|
803
|
-
const emitSignal = (phase: ColonyState["status"], message: string) => {
|
|
881
|
+
const emitSignal = (phase: ColonyState["status"], message: string, extras?: Partial<ColonySignal>) => {
|
|
804
882
|
const state = nest.getStateLight();
|
|
805
883
|
const m = state.metrics;
|
|
806
884
|
const active = state.ants.filter((a) => a.status === "working").length;
|
|
807
885
|
const progress = m.tasksTotal > 0 ? m.tasksDone / m.tasksTotal : 0;
|
|
808
|
-
callbacks.onSignal?.({ phase, progress, active, cost: m.totalCost, message, colonyId: state.id });
|
|
886
|
+
callbacks.onSignal?.({ phase, progress, active, cost: m.totalCost, message, colonyId: state.id, ...extras });
|
|
809
887
|
};
|
|
810
888
|
|
|
811
889
|
const waveBase: Omit<WaveOptions, "caste"> & { importGraph?: ImportGraph } = {
|
|
@@ -1070,12 +1148,12 @@ export async function resumeColony(opts: QueenOptions): Promise<ColonyState> {
|
|
|
1070
1148
|
|
|
1071
1149
|
const { signal, callbacks } = opts;
|
|
1072
1150
|
|
|
1073
|
-
const emitSignal = (phase: ColonyState["status"], message: string) => {
|
|
1151
|
+
const emitSignal = (phase: ColonyState["status"], message: string, extras?: Partial<ColonySignal>) => {
|
|
1074
1152
|
const state = nest.getStateLight();
|
|
1075
1153
|
const m = state.metrics;
|
|
1076
1154
|
const active = state.ants.filter((a) => a.status === "working").length;
|
|
1077
1155
|
const progress = m.tasksTotal > 0 ? m.tasksDone / m.tasksTotal : 0;
|
|
1078
|
-
callbacks.onSignal?.({ phase, progress, active, cost: m.totalCost, message, colonyId: state.id });
|
|
1156
|
+
callbacks.onSignal?.({ phase, progress, active, cost: m.totalCost, message, colonyId: state.id, ...extras });
|
|
1079
1157
|
};
|
|
1080
1158
|
|
|
1081
1159
|
const waveBase: Omit<WaveOptions, "caste"> & { budgetPlan?: BudgetPlan | null } = {
|
|
@@ -27,8 +27,10 @@ export const DEFAULT_ANT_CONFIGS: Record<AntCaste, Omit<AntConfig, "systemPrompt
|
|
|
27
27
|
drone: { caste: "drone", model: "", tools: ["bash"], maxTurns: 1 },
|
|
28
28
|
};
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
export type WorkerClass = "design" | "multimodal" | "backend" | "review";
|
|
31
|
+
|
|
32
|
+
/** Per-caste / per-worker-class model overrides from user config */
|
|
33
|
+
export type ModelOverrides = Partial<Record<AntCaste | WorkerClass, string>>;
|
|
32
34
|
|
|
33
35
|
// ═══ Tasks (Food Sources) ═══
|
|
34
36
|
export type TaskStatus = "pending" | "claimed" | "active" | "done" | "failed" | "blocked";
|
|
@@ -44,6 +46,7 @@ export interface Task {
|
|
|
44
46
|
priority: TaskPriority;
|
|
45
47
|
files: string[]; // Files locked by this task
|
|
46
48
|
context?: string; // Code snippets pre-loaded by scout
|
|
49
|
+
workerClass?: WorkerClass; // worker specialization for model routing
|
|
47
50
|
claimedBy: string | null; // ant id
|
|
48
51
|
result: string | null;
|
|
49
52
|
error: string | null;
|
|
@@ -162,6 +165,32 @@ export interface ConcurrencySample {
|
|
|
162
165
|
throughput: number; // tasks completed per minute
|
|
163
166
|
}
|
|
164
167
|
|
|
168
|
+
export type EscalationReason = "low_confidence" | "low_coverage" | "risk_flag" | "policy_violation" | "slo_breach";
|
|
169
|
+
|
|
170
|
+
export interface PromoteFinalizeGateInput {
|
|
171
|
+
confidenceScore: number;
|
|
172
|
+
coverageScore: number;
|
|
173
|
+
riskFlags: string[];
|
|
174
|
+
policyViolations: string[];
|
|
175
|
+
sloBreached: boolean;
|
|
176
|
+
cheapPassSummary: string;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export interface PromoteFinalizeGateDecision {
|
|
180
|
+
action: "promote" | "finalize";
|
|
181
|
+
escalationReasons: EscalationReason[];
|
|
182
|
+
cheapPassSummary?: string;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export interface RoutingTelemetry {
|
|
186
|
+
taskId: string;
|
|
187
|
+
caste: AntCaste;
|
|
188
|
+
outcome: "claimed" | "completed" | "failed" | "escalated";
|
|
189
|
+
latencyMs: number;
|
|
190
|
+
escalationReasons: EscalationReason[];
|
|
191
|
+
timestamp: number;
|
|
192
|
+
}
|
|
193
|
+
|
|
165
194
|
export interface ColonyMetrics {
|
|
166
195
|
tasksTotal: number;
|
|
167
196
|
tasksDone: number;
|
|
@@ -171,6 +200,7 @@ export interface ColonyMetrics {
|
|
|
171
200
|
totalTokens: number;
|
|
172
201
|
startTime: number;
|
|
173
202
|
throughputHistory: number[]; // Sliding window of tasks/min throughput
|
|
203
|
+
routingTelemetry?: RoutingTelemetry[];
|
|
174
204
|
}
|
|
175
205
|
|
|
176
206
|
/** Colony signal — the single abstraction observers need to handle. */
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
-
import { existsSync, mkdirSync } from "node:fs";
|
|
3
|
-
import { join, relative, resolve } from "node:path";
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
|
|
3
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
4
4
|
import type { ColonyWorkspace } from "./types.js";
|
|
5
5
|
|
|
6
6
|
const WORKTREE_ENV_FLAG = "PI_ANT_COLONY_WORKTREE";
|
|
@@ -58,6 +58,59 @@ function resolveExecutionCwd(worktreeRoot: string, repoRoot: string, originCwd:
|
|
|
58
58
|
return existsSync(candidate) ? candidate : worktreeRoot;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
export function cleanupIsolatedWorktree(workspace: ColonyWorkspace): string | null {
|
|
62
|
+
if (workspace.mode !== "worktree" || !workspace.repoRoot || !workspace.worktreeRoot || !workspace.branch) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const notes: string[] = [];
|
|
67
|
+
try {
|
|
68
|
+
if (existsSync(workspace.worktreeRoot)) {
|
|
69
|
+
git(workspace.repoRoot, ["worktree", "remove", "--force", workspace.worktreeRoot]);
|
|
70
|
+
notes.push("removed isolated worktree");
|
|
71
|
+
}
|
|
72
|
+
} catch (error) {
|
|
73
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
74
|
+
notes.push(`worktree remove failed (${reason})`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
if (workspace.branch) {
|
|
79
|
+
git(workspace.repoRoot, ["branch", "-D", workspace.branch]);
|
|
80
|
+
notes.push("deleted temporary branch");
|
|
81
|
+
}
|
|
82
|
+
} catch (error) {
|
|
83
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
84
|
+
notes.push(`branch cleanup skipped (${reason})`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
git(workspace.repoRoot, ["worktree", "prune"]);
|
|
89
|
+
} catch {
|
|
90
|
+
// ignore prune failures; this is best-effort hygiene.
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const parent = dirname(workspace.worktreeRoot);
|
|
95
|
+
rmSync(workspace.worktreeRoot, { recursive: true, force: true });
|
|
96
|
+
if (existsSync(parent) && isEmptyDir(parent)) {
|
|
97
|
+
rmSync(parent, { recursive: true, force: true });
|
|
98
|
+
}
|
|
99
|
+
} catch {
|
|
100
|
+
// ignore filesystem cleanup failures.
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return notes.length > 0 ? `Cleanup: ${notes.join("; ")}.` : "Cleanup: no stale isolated worktree artifacts found.";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function isEmptyDir(path: string): boolean {
|
|
107
|
+
try {
|
|
108
|
+
return readdirSync(path).length === 0;
|
|
109
|
+
} catch {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
61
114
|
export function worktreeEnabledByDefault(): boolean {
|
|
62
115
|
const raw = process.env[WORKTREE_ENV_FLAG];
|
|
63
116
|
if (typeof raw !== "string") {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ifi/oh-pi-ant-colony",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.11",
|
|
4
4
|
"description": "Autonomous multi-agent swarm extension for pi — adaptive concurrency, pheromone communication.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package"
|
|
@@ -22,5 +22,11 @@
|
|
|
22
22
|
"@mariozechner/pi-coding-agent": "*",
|
|
23
23
|
"@mariozechner/pi-tui": "*",
|
|
24
24
|
"@sinclair/typebox": "*"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "pnpm run typecheck:worktree && pnpm run test:worktree",
|
|
28
|
+
"typecheck": "pnpm run typecheck:worktree",
|
|
29
|
+
"typecheck:worktree": "tsgo --project ./tsconfig.json --noEmit",
|
|
30
|
+
"test:worktree": "vitest run --config ./vitest.worktree.config.ts"
|
|
25
31
|
}
|
|
26
32
|
}
|