ennodia 0.1.1 → 0.2.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/CONTRIBUTING.md +60 -36
- package/README.md +74 -58
- package/bench/README.md +7 -5
- package/bench/bug-recall/run.ts +12 -4
- package/bin/ennodia +23 -2
- package/docs/assets/agents/mcp.svg +1 -1
- package/docs/assets/orchestration-pipeline.svg +4 -3
- package/docs/comparisons/agent-frameworks.md +15 -10
- package/docs/comparisons/autogen.md +20 -15
- package/docs/comparisons/chathub.md +15 -14
- package/docs/comparisons/index.md +30 -22
- package/docs/comparisons/langgraph.md +19 -12
- package/docs/comparisons/mixture-of-agents.md +20 -17
- package/docs/comparisons/model-merging.md +7 -5
- package/docs/comparisons/openrouter.md +30 -22
- package/docs/concepts/compositional-audits.md +16 -13
- package/docs/concepts/data-governance.md +31 -15
- package/docs/concepts/how-ennodia-works.md +48 -22
- package/docs/concepts/interfaces-and-core.md +19 -6
- package/docs/concepts/second-opinions.md +20 -20
- package/docs/getting-started.md +55 -27
- package/docs/guides/agent-skills.md +16 -13
- package/docs/guides/budgets-and-limits.md +30 -20
- package/docs/guides/running-better-audits.md +50 -44
- package/docs/index.md +60 -37
- package/docs/install.md +41 -32
- package/docs/reference/benchmarks.md +6 -4
- package/docs/reference/controlled-english.md +130 -0
- package/docs/reference/ennodia-io.md +28 -22
- package/docs/reference/mcp-tools.md +244 -87
- package/docs/reference/supported-harnesses.md +9 -9
- package/package.json +7 -5
- package/src/advisor.ts +685 -0
- package/src/budget.ts +25 -6
- package/src/cli.ts +1 -1
- package/src/compare.ts +248 -52
- package/src/compositional.ts +18 -0
- package/src/core.ts +547 -16
- package/src/harnesses.ts +33 -2
- package/src/index.ts +38 -0
- package/src/plan-advice.ts +601 -0
- package/src/runs.ts +74 -9
- package/src/server.ts +312 -46
- package/src/skills.ts +131 -11
- package/src/tasks.ts +192 -22
- package/src/version.ts +1 -1
package/src/core.ts
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type {
|
|
3
|
+
AdvisorExecutionPlan,
|
|
4
|
+
AdvisorInventorySnapshot,
|
|
5
|
+
} from "./advisor";
|
|
1
6
|
import {
|
|
2
7
|
assertBudgetWithinLimits,
|
|
3
8
|
checkBudgetLimits,
|
|
4
9
|
estimateCompareBudget,
|
|
5
10
|
estimateRunBudget,
|
|
11
|
+
estimateTaskBatchBudget,
|
|
6
12
|
type BudgetCheck,
|
|
7
13
|
type BudgetLimits,
|
|
8
14
|
} from "./budget";
|
|
@@ -62,6 +68,7 @@ import {
|
|
|
62
68
|
} from "./runs";
|
|
63
69
|
import {
|
|
64
70
|
assertSkillsSupportHarnesses,
|
|
71
|
+
discoverSkills,
|
|
65
72
|
discoverSkillsWithWarnings,
|
|
66
73
|
installBundledSkills,
|
|
67
74
|
loadRunnableSkillsByIds,
|
|
@@ -77,6 +84,12 @@ import {
|
|
|
77
84
|
type TaskView,
|
|
78
85
|
type TaskViewOptions,
|
|
79
86
|
} from "./tasks";
|
|
87
|
+
import {
|
|
88
|
+
PlanAdviceManager,
|
|
89
|
+
type PlanAdviceManagerOptions,
|
|
90
|
+
type PlanAdviceView,
|
|
91
|
+
type PlanAdviceViewOptions,
|
|
92
|
+
} from "./plan-advice";
|
|
80
93
|
|
|
81
94
|
export type EnnodiaCoreOptions = {
|
|
82
95
|
discoverHarnesses?: DiscoverHarnesses;
|
|
@@ -85,9 +98,11 @@ export type EnnodiaCoreOptions = {
|
|
|
85
98
|
taskManager?: TaskManager;
|
|
86
99
|
compareManager?: CompareManager;
|
|
87
100
|
runManager?: RunManager;
|
|
101
|
+
planAdviceManager?: PlanAdviceManager;
|
|
88
102
|
taskManagerOptions?: TaskManagerOptions;
|
|
89
103
|
compareManagerOptions?: CompareManagerOptions;
|
|
90
104
|
runManagerOptions?: RunManagerOptions;
|
|
105
|
+
planAdviceManagerOptions?: PlanAdviceManagerOptions;
|
|
91
106
|
historySink?: HistorySink;
|
|
92
107
|
};
|
|
93
108
|
|
|
@@ -116,6 +131,7 @@ export type TaskBatchStartInput = {
|
|
|
116
131
|
harnessId?: string;
|
|
117
132
|
mode?: "single" | "parallel";
|
|
118
133
|
cwd?: string;
|
|
134
|
+
isolateCwd?: boolean;
|
|
119
135
|
model?: string;
|
|
120
136
|
timeoutMs?: number;
|
|
121
137
|
refresh?: boolean;
|
|
@@ -127,12 +143,17 @@ export type TaskBatchStart = {
|
|
|
127
143
|
plan: RoutePlan;
|
|
128
144
|
tasks: TaskView[];
|
|
129
145
|
budget: BudgetCheck;
|
|
146
|
+
/** Skills discoverable in cwd's native skill directories but not part of
|
|
147
|
+
* skillIds. A harness may self-select these on its own initiative even
|
|
148
|
+
* though this run didn't request them - see skillIds isolation caveat. */
|
|
149
|
+
unrequestedSkillsPresent: string[];
|
|
130
150
|
};
|
|
131
151
|
|
|
132
152
|
export type CompositionalEstimateInput = {
|
|
133
153
|
prompt: string;
|
|
134
154
|
slices: CompositionalSliceInput[];
|
|
135
155
|
cwd?: string;
|
|
156
|
+
isolateCwd?: boolean;
|
|
136
157
|
refresh?: boolean;
|
|
137
158
|
skillIds?: string[];
|
|
138
159
|
includeCompareEstimate?: boolean;
|
|
@@ -162,6 +183,7 @@ export type CompositionalStart = {
|
|
|
162
183
|
tasks: CompositionalTaskStart[];
|
|
163
184
|
budget: BudgetCheck;
|
|
164
185
|
compareNext: CompositionalCompareNext;
|
|
186
|
+
unrequestedSkillsPresent: string[];
|
|
165
187
|
};
|
|
166
188
|
|
|
167
189
|
export type CompositionalStatusInput = {
|
|
@@ -184,10 +206,70 @@ export type RunStartWithSkillIdsInput = RunStartInput & {
|
|
|
184
206
|
skillIds?: string[];
|
|
185
207
|
};
|
|
186
208
|
|
|
209
|
+
export type PlanAdviceStartInput = {
|
|
210
|
+
prompt: string;
|
|
211
|
+
cwd?: string;
|
|
212
|
+
refresh?: boolean;
|
|
213
|
+
advisorHarnessId?: string;
|
|
214
|
+
advisorModel?: string;
|
|
215
|
+
/** Worker harnesses the Advisor may select. Defaults to every runnable
|
|
216
|
+
* harness with a public Ennodia adapter. */
|
|
217
|
+
allowedHarnessIds?: string[];
|
|
218
|
+
/** Exact caller-approved model IDs per worker harness. Omitted harnesses may
|
|
219
|
+
* still be selected, but only with their configured harness default. */
|
|
220
|
+
allowedModels?: Record<string, string[]>;
|
|
221
|
+
maxSlices?: number;
|
|
222
|
+
maxSkillsPerSlice?: number;
|
|
223
|
+
maxTotalSkillAssignments?: number;
|
|
224
|
+
timeoutMs?: number;
|
|
225
|
+
budget?: BudgetLimits;
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
export type AdvisedPlanStartInput = {
|
|
229
|
+
adviceId: string;
|
|
230
|
+
expectedPlanDigest: string;
|
|
231
|
+
cwd?: string;
|
|
232
|
+
isolateCwd?: boolean;
|
|
233
|
+
timeoutMs?: number;
|
|
234
|
+
budget?: BudgetLimits;
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
export type AdvisedPlanTaskStart = {
|
|
238
|
+
sliceId: string;
|
|
239
|
+
sliceTitle?: string;
|
|
240
|
+
harnessId: string;
|
|
241
|
+
model?: string;
|
|
242
|
+
skillIds: string[];
|
|
243
|
+
task: TaskView;
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
export type AdvisedPlanStart = {
|
|
247
|
+
adviceId: string;
|
|
248
|
+
planDigest: string;
|
|
249
|
+
inventorySnapshotId: string;
|
|
250
|
+
plan: AdvisorExecutionPlan;
|
|
251
|
+
tasks: AdvisedPlanTaskStart[];
|
|
252
|
+
budget: BudgetCheck;
|
|
253
|
+
execution: {
|
|
254
|
+
cwd?: string;
|
|
255
|
+
isolateCwd: boolean;
|
|
256
|
+
timeoutMs?: number;
|
|
257
|
+
};
|
|
258
|
+
unrequestedSkillsPresent: string[];
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
type AdvisorInventoryRuntime = {
|
|
262
|
+
inventory: AdvisorInventorySnapshot;
|
|
263
|
+
harnesses: ResolvedHarness[];
|
|
264
|
+
skills: Skill[];
|
|
265
|
+
projectNativeSkillIds: string[];
|
|
266
|
+
};
|
|
267
|
+
|
|
187
268
|
export class EnnodiaCore {
|
|
188
269
|
readonly taskManager: TaskManager;
|
|
189
270
|
readonly compareManager: CompareManager;
|
|
190
271
|
readonly runManager: RunManager;
|
|
272
|
+
readonly planAdviceManager: PlanAdviceManager;
|
|
191
273
|
readonly discoverHarnesses: DiscoverHarnesses;
|
|
192
274
|
readonly findHarnessAdapter: FindHarnessAdapter;
|
|
193
275
|
readonly planRoute: PlanRoute;
|
|
@@ -204,6 +286,11 @@ export class EnnodiaCore {
|
|
|
204
286
|
noopHistorySink;
|
|
205
287
|
this.taskManager = options.taskManager ??
|
|
206
288
|
new TaskManager(options.taskManagerOptions);
|
|
289
|
+
this.planAdviceManager = options.planAdviceManager ??
|
|
290
|
+
new PlanAdviceManager(
|
|
291
|
+
this.taskManager,
|
|
292
|
+
options.planAdviceManagerOptions,
|
|
293
|
+
);
|
|
207
294
|
this.compareManager = options.compareManager ??
|
|
208
295
|
new CompareManager(
|
|
209
296
|
this.taskManager,
|
|
@@ -248,6 +335,198 @@ export class EnnodiaCore {
|
|
|
248
335
|
return this.planRoute(prompt, harnesses, { category: options.category });
|
|
249
336
|
}
|
|
250
337
|
|
|
338
|
+
async startPlanAdvice(
|
|
339
|
+
input: PlanAdviceStartInput,
|
|
340
|
+
): Promise<PlanAdviceView> {
|
|
341
|
+
const { inventory } = await this.buildAdvisorInventory({
|
|
342
|
+
cwd: input.cwd,
|
|
343
|
+
refresh: input.refresh,
|
|
344
|
+
allowedHarnessIds: input.allowedHarnessIds,
|
|
345
|
+
allowedModels: input.allowedModels,
|
|
346
|
+
maxSlices: Math.min(
|
|
347
|
+
input.maxSlices ?? 8,
|
|
348
|
+
input.budget?.maxChildTasks ?? Number.POSITIVE_INFINITY,
|
|
349
|
+
),
|
|
350
|
+
maxSkillsPerSlice: input.maxSkillsPerSlice ?? 8,
|
|
351
|
+
maxTotalSkillAssignments: input.maxTotalSkillAssignments ?? 32,
|
|
352
|
+
});
|
|
353
|
+
const advisor = await this.resolveRunnableHarness(input.advisorHarnessId);
|
|
354
|
+
|
|
355
|
+
return this.planAdviceManager.start({
|
|
356
|
+
prompt: input.prompt,
|
|
357
|
+
inventory,
|
|
358
|
+
inventoryCwd: input.cwd,
|
|
359
|
+
advisor,
|
|
360
|
+
advisorModel: input.advisorModel,
|
|
361
|
+
timeoutMs: input.timeoutMs,
|
|
362
|
+
budget: input.budget,
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
listPlanAdvice(
|
|
367
|
+
options: PlanAdviceViewOptions = {},
|
|
368
|
+
): PlanAdviceView[] {
|
|
369
|
+
return this.planAdviceManager.listViews(options);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
getPlanAdvice(
|
|
373
|
+
id: string,
|
|
374
|
+
options: PlanAdviceViewOptions = {},
|
|
375
|
+
): PlanAdviceView | undefined {
|
|
376
|
+
return this.planAdviceManager.get(id, options);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
waitForPlanAdvice(
|
|
380
|
+
id: string,
|
|
381
|
+
timeoutMs?: number,
|
|
382
|
+
options: PlanAdviceViewOptions = {},
|
|
383
|
+
): Promise<PlanAdviceView | undefined> {
|
|
384
|
+
return this.planAdviceManager.waitForTerminal(id, timeoutMs, options);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
cancelPlanAdvice(id: string): PlanAdviceView {
|
|
388
|
+
return this.planAdviceManager.cancel(id);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async startAdvisedPlan(
|
|
392
|
+
input: AdvisedPlanStartInput,
|
|
393
|
+
): Promise<AdvisedPlanStart> {
|
|
394
|
+
const context = this.planAdviceManager.getRevalidationContext(
|
|
395
|
+
input.adviceId,
|
|
396
|
+
);
|
|
397
|
+
const executionCwd = input.cwd ?? context.inventoryCwd;
|
|
398
|
+
const currentRuntime = await this.buildAdvisorInventory({
|
|
399
|
+
cwd: executionCwd,
|
|
400
|
+
refresh: true,
|
|
401
|
+
allowedHarnessIds: context.inventory.harnesses.map((harness) =>
|
|
402
|
+
harness.id
|
|
403
|
+
),
|
|
404
|
+
allowedModels: Object.fromEntries(
|
|
405
|
+
context.inventory.harnesses.map((harness) => [
|
|
406
|
+
harness.id,
|
|
407
|
+
harness.allowedModelIds,
|
|
408
|
+
]),
|
|
409
|
+
),
|
|
410
|
+
...context.inventory.limits,
|
|
411
|
+
});
|
|
412
|
+
let budget!: BudgetCheck;
|
|
413
|
+
let resolved!: Array<{
|
|
414
|
+
slice: AdvisorExecutionPlan["slices"][number];
|
|
415
|
+
harness: ResolvedHarness;
|
|
416
|
+
skills: Skill[];
|
|
417
|
+
}>;
|
|
418
|
+
let requestedSkillIds: string[] = [];
|
|
419
|
+
const plan = this.planAdviceManager.authorizeExecution(
|
|
420
|
+
input.adviceId,
|
|
421
|
+
input.expectedPlanDigest,
|
|
422
|
+
currentRuntime.inventory,
|
|
423
|
+
(authorizedPlan) => {
|
|
424
|
+
budget = checkBudgetLimits(
|
|
425
|
+
estimateTaskBatchBudget({
|
|
426
|
+
tasks: authorizedPlan.slices.map((slice) => ({
|
|
427
|
+
prompt: slice.prompt,
|
|
428
|
+
harnessId: slice.harnessId,
|
|
429
|
+
})),
|
|
430
|
+
comparePlanned: false,
|
|
431
|
+
}),
|
|
432
|
+
input.budget,
|
|
433
|
+
);
|
|
434
|
+
assertBudgetWithinLimits(budget);
|
|
435
|
+
|
|
436
|
+
// Resolve and validate every harness/skill before launching the first
|
|
437
|
+
// task. This preserves the zero-launch guarantee for deterministic
|
|
438
|
+
// validation, inventory drift, digest, and budget failures.
|
|
439
|
+
requestedSkillIds = [
|
|
440
|
+
...new Set(authorizedPlan.slices.flatMap((slice) => slice.skillIds)),
|
|
441
|
+
];
|
|
442
|
+
const runtimeSkillsById = new Map(
|
|
443
|
+
currentRuntime.skills.map((skill) => [skill.id, skill]),
|
|
444
|
+
);
|
|
445
|
+
const requestedSkills = requestedSkillIds.map((skillId) => {
|
|
446
|
+
const skill = runtimeSkillsById.get(skillId);
|
|
447
|
+
if (!skill) {
|
|
448
|
+
throw new Error(
|
|
449
|
+
`Skill not found in authorized inventory: ${skillId}`,
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
return skill;
|
|
453
|
+
});
|
|
454
|
+
const skillsById = new Map(
|
|
455
|
+
requestedSkills.map((skill) => [skill.id, skill]),
|
|
456
|
+
);
|
|
457
|
+
const harnessesById = new Map(
|
|
458
|
+
currentRuntime.harnesses.map((harness) => [
|
|
459
|
+
harness.discovery.id,
|
|
460
|
+
harness,
|
|
461
|
+
]),
|
|
462
|
+
);
|
|
463
|
+
resolved = authorizedPlan.slices.map((slice) => {
|
|
464
|
+
const harness = harnessesById.get(slice.harnessId);
|
|
465
|
+
if (!harness) {
|
|
466
|
+
throw new Error(
|
|
467
|
+
`Harness not found in authorized inventory: ${slice.harnessId}`,
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
const skills = slice.skillIds.map((skillId) => {
|
|
471
|
+
const skill = skillsById.get(skillId);
|
|
472
|
+
if (!skill) {
|
|
473
|
+
throw new Error(`Skill not found after loading: ${skillId}`);
|
|
474
|
+
}
|
|
475
|
+
return skill;
|
|
476
|
+
});
|
|
477
|
+
if (skills.length > 0) {
|
|
478
|
+
assertSkillsSupportHarnesses(skills, [slice.harnessId]);
|
|
479
|
+
}
|
|
480
|
+
return { slice, harness, skills };
|
|
481
|
+
});
|
|
482
|
+
},
|
|
483
|
+
);
|
|
484
|
+
|
|
485
|
+
const requestedIds = new Set(requestedSkillIds);
|
|
486
|
+
const unrequestedSkillsPresent = currentRuntime.projectNativeSkillIds
|
|
487
|
+
.filter((skillId) => !requestedIds.has(skillId));
|
|
488
|
+
|
|
489
|
+
const tasks: AdvisedPlanTaskStart[] = [];
|
|
490
|
+
try {
|
|
491
|
+
for (const { slice, harness, skills } of resolved) {
|
|
492
|
+
tasks.push({
|
|
493
|
+
sliceId: slice.id,
|
|
494
|
+
sliceTitle: slice.title,
|
|
495
|
+
harnessId: slice.harnessId,
|
|
496
|
+
model: slice.model,
|
|
497
|
+
skillIds: [...slice.skillIds],
|
|
498
|
+
task: this.taskManager.start(harness.adapter, harness.discovery, {
|
|
499
|
+
prompt: slice.prompt,
|
|
500
|
+
cwd: executionCwd,
|
|
501
|
+
isolateCwd: input.isolateCwd,
|
|
502
|
+
model: slice.model,
|
|
503
|
+
timeoutMs: input.timeoutMs,
|
|
504
|
+
skills,
|
|
505
|
+
}).task,
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
} catch (error) {
|
|
509
|
+
for (const started of tasks) {
|
|
510
|
+
this.taskManager.cancel(started.task.id);
|
|
511
|
+
}
|
|
512
|
+
throw error;
|
|
513
|
+
}
|
|
514
|
+
return {
|
|
515
|
+
adviceId: input.adviceId,
|
|
516
|
+
planDigest: input.expectedPlanDigest,
|
|
517
|
+
inventorySnapshotId: currentRuntime.inventory.snapshotId,
|
|
518
|
+
plan,
|
|
519
|
+
tasks,
|
|
520
|
+
budget,
|
|
521
|
+
execution: {
|
|
522
|
+
cwd: executionCwd,
|
|
523
|
+
isolateCwd: input.isolateCwd ?? false,
|
|
524
|
+
timeoutMs: input.timeoutMs,
|
|
525
|
+
},
|
|
526
|
+
unrequestedSkillsPresent,
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
|
|
251
530
|
async resolveRunnableHarness(harnessId?: string): Promise<ResolvedHarness> {
|
|
252
531
|
const harnesses = await this.discoverHarnesses();
|
|
253
532
|
const preferredIds = harnessId
|
|
@@ -342,13 +621,18 @@ export class EnnodiaCore {
|
|
|
342
621
|
return this.taskManager.start(adapter, discovery, {
|
|
343
622
|
prompt: input.prompt,
|
|
344
623
|
cwd: input.cwd,
|
|
624
|
+
isolateCwd: input.isolateCwd,
|
|
345
625
|
model: input.model,
|
|
346
626
|
timeoutMs: input.timeoutMs,
|
|
347
627
|
skills,
|
|
348
628
|
}).task;
|
|
349
629
|
});
|
|
630
|
+
const unrequestedSkillsPresent = await this.findUnrequestedSkills(
|
|
631
|
+
input.cwd,
|
|
632
|
+
skills,
|
|
633
|
+
);
|
|
350
634
|
|
|
351
|
-
return { plan, tasks, budget };
|
|
635
|
+
return { plan, tasks, budget, unrequestedSkillsPresent };
|
|
352
636
|
}
|
|
353
637
|
|
|
354
638
|
async estimateCompositional(
|
|
@@ -372,7 +656,7 @@ export class EnnodiaCore {
|
|
|
372
656
|
async startCompositional(
|
|
373
657
|
input: CompositionalStartInput,
|
|
374
658
|
): Promise<CompositionalStart> {
|
|
375
|
-
const { harnesses, resolvedSlices,
|
|
659
|
+
const { harnesses, resolvedSlices, sliceSkills, requestedSkills } = await this
|
|
376
660
|
.resolveCompositional(input);
|
|
377
661
|
const budget = estimateCompositionalBudget(
|
|
378
662
|
resolvedSlices,
|
|
@@ -382,7 +666,7 @@ export class EnnodiaCore {
|
|
|
382
666
|
);
|
|
383
667
|
assertBudgetWithinLimits(budget);
|
|
384
668
|
|
|
385
|
-
const tasks = resolvedSlices.map((slice) => {
|
|
669
|
+
const tasks = resolvedSlices.map((slice, index) => {
|
|
386
670
|
const { adapter, discovery } = this.requireRunnableHarness(
|
|
387
671
|
slice.harnessId,
|
|
388
672
|
harnesses,
|
|
@@ -396,12 +680,17 @@ export class EnnodiaCore {
|
|
|
396
680
|
task: this.taskManager.start(adapter, discovery, {
|
|
397
681
|
prompt: slice.prompt,
|
|
398
682
|
cwd: input.cwd,
|
|
683
|
+
isolateCwd: input.isolateCwd,
|
|
399
684
|
model: slice.model,
|
|
400
685
|
timeoutMs: input.timeoutMs,
|
|
401
|
-
skills,
|
|
686
|
+
skills: sliceSkills[index] ?? [],
|
|
402
687
|
}).task,
|
|
403
688
|
};
|
|
404
689
|
});
|
|
690
|
+
const unrequestedSkillsPresent = await this.findUnrequestedSkills(
|
|
691
|
+
input.cwd,
|
|
692
|
+
requestedSkills,
|
|
693
|
+
);
|
|
405
694
|
|
|
406
695
|
return {
|
|
407
696
|
tasks,
|
|
@@ -411,6 +700,7 @@ export class EnnodiaCore {
|
|
|
411
700
|
taskIds: tasks.map((item) => item.task.id),
|
|
412
701
|
maxOutputChars: input.maxOutputChars,
|
|
413
702
|
},
|
|
703
|
+
unrequestedSkillsPresent,
|
|
414
704
|
};
|
|
415
705
|
}
|
|
416
706
|
|
|
@@ -445,7 +735,13 @@ export class EnnodiaCore {
|
|
|
445
735
|
? await loadRunnableSkillsByIds(skillIds, runInput.cwd)
|
|
446
736
|
: undefined);
|
|
447
737
|
|
|
448
|
-
|
|
738
|
+
const view = await this.runManager.start({ ...runInput, skills });
|
|
739
|
+
const unrequestedSkillsPresent = await this.findUnrequestedSkills(
|
|
740
|
+
runInput.cwd,
|
|
741
|
+
skills ?? [],
|
|
742
|
+
);
|
|
743
|
+
|
|
744
|
+
return { ...view, unrequestedSkillsPresent };
|
|
449
745
|
}
|
|
450
746
|
|
|
451
747
|
listRuns(options: RunViewOptions = {}): RunView[] {
|
|
@@ -475,12 +771,31 @@ export class EnnodiaCore {
|
|
|
475
771
|
}
|
|
476
772
|
|
|
477
773
|
async startCompare(input: CompareStartWithBudgetInput): Promise<CompareStart> {
|
|
478
|
-
const {
|
|
774
|
+
const {
|
|
775
|
+
budget: budgetLimits,
|
|
776
|
+
advisorHarnessId: preferredAdvisorHarnessId,
|
|
777
|
+
advisorModel: preferredAdvisorModel,
|
|
778
|
+
synthesizerHarnessId,
|
|
779
|
+
synthesizerModel,
|
|
780
|
+
...compareInput
|
|
781
|
+
} = input;
|
|
782
|
+
const advisorHarnessId = resolvePreferredAlias(
|
|
783
|
+
"advisorHarnessId",
|
|
784
|
+
preferredAdvisorHarnessId,
|
|
785
|
+
"synthesizerHarnessId",
|
|
786
|
+
synthesizerHarnessId,
|
|
787
|
+
);
|
|
788
|
+
const advisorModel = resolvePreferredAlias(
|
|
789
|
+
"advisorModel",
|
|
790
|
+
preferredAdvisorModel,
|
|
791
|
+
"synthesizerModel",
|
|
792
|
+
synthesizerModel,
|
|
793
|
+
);
|
|
479
794
|
const judgeHarness = await this.resolveRunnableHarness(
|
|
480
795
|
compareInput.judgeHarnessId,
|
|
481
796
|
);
|
|
482
|
-
const
|
|
483
|
-
? await this.resolveRunnableHarness(
|
|
797
|
+
const advisorHarness = advisorHarnessId
|
|
798
|
+
? await this.resolveRunnableHarness(advisorHarnessId)
|
|
484
799
|
: judgeHarness;
|
|
485
800
|
const budget = checkBudgetLimits(
|
|
486
801
|
estimateCompareBudget({
|
|
@@ -491,7 +806,7 @@ export class EnnodiaCore {
|
|
|
491
806
|
0,
|
|
492
807
|
),
|
|
493
808
|
judgeHarnessId: judgeHarness.adapter.id,
|
|
494
|
-
|
|
809
|
+
advisorHarnessId: advisorHarness.adapter.id,
|
|
495
810
|
maxOutputChars: compareInput.maxOutputChars,
|
|
496
811
|
}),
|
|
497
812
|
budgetLimits,
|
|
@@ -501,7 +816,8 @@ export class EnnodiaCore {
|
|
|
501
816
|
const compare = await this.compareManager.start({
|
|
502
817
|
...compareInput,
|
|
503
818
|
judgeHarnessId: judgeHarness.adapter.id,
|
|
504
|
-
|
|
819
|
+
advisorHarnessId: advisorHarness.adapter.id,
|
|
820
|
+
advisorModel,
|
|
505
821
|
});
|
|
506
822
|
|
|
507
823
|
return { ...compare, budget };
|
|
@@ -534,14 +850,140 @@ export class EnnodiaCore {
|
|
|
534
850
|
async shutdown(options: EnnodiaCoreShutdownOptions = {}): Promise<void> {
|
|
535
851
|
await this.runManager.shutdown(options);
|
|
536
852
|
await this.compareManager.shutdown(options);
|
|
853
|
+
await this.planAdviceManager.shutdown(options);
|
|
537
854
|
await this.taskManager.shutdown(options);
|
|
538
855
|
}
|
|
539
856
|
|
|
857
|
+
private async buildAdvisorInventory(input: {
|
|
858
|
+
cwd?: string;
|
|
859
|
+
refresh?: boolean;
|
|
860
|
+
allowedHarnessIds?: string[];
|
|
861
|
+
allowedModels?: Record<string, string[]>;
|
|
862
|
+
maxSlices: number;
|
|
863
|
+
maxSkillsPerSlice: number;
|
|
864
|
+
maxTotalSkillAssignments: number;
|
|
865
|
+
}): Promise<AdvisorInventoryRuntime> {
|
|
866
|
+
const discoveredHarnesses = await this.discoverHarnesses({
|
|
867
|
+
refresh: input.refresh,
|
|
868
|
+
});
|
|
869
|
+
const requestedHarnessIds = input.allowedHarnessIds
|
|
870
|
+
? [...new Set(input.allowedHarnessIds)]
|
|
871
|
+
: discoveredHarnesses
|
|
872
|
+
.filter((harness) =>
|
|
873
|
+
harness.runnable && Boolean(this.findHarnessAdapter(harness.id)?.buildCommand)
|
|
874
|
+
)
|
|
875
|
+
.map((harness) => harness.id);
|
|
876
|
+
|
|
877
|
+
if (requestedHarnessIds.length === 0) {
|
|
878
|
+
throw new Error("Plan Advisor needs at least one runnable worker harness.");
|
|
879
|
+
}
|
|
880
|
+
this.assertHarnessesRunnable(requestedHarnessIds, discoveredHarnesses);
|
|
881
|
+
|
|
882
|
+
const outsideInventory = Object.keys(input.allowedModels ?? {}).filter(
|
|
883
|
+
(harnessId) => !requestedHarnessIds.includes(harnessId),
|
|
884
|
+
);
|
|
885
|
+
if (outsideInventory.length > 0) {
|
|
886
|
+
throw new Error(
|
|
887
|
+
`Model allowlist references a harness outside allowedHarnessIds: ${
|
|
888
|
+
outsideInventory.join(", ")
|
|
889
|
+
}`,
|
|
890
|
+
);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
const selectedHarnesses = requestedHarnessIds
|
|
894
|
+
.map((harnessId) =>
|
|
895
|
+
this.requireRunnableHarness(harnessId, discoveredHarnesses)
|
|
896
|
+
)
|
|
897
|
+
.sort((left, right) =>
|
|
898
|
+
left.discovery.id.localeCompare(right.discovery.id)
|
|
899
|
+
);
|
|
900
|
+
const inventoryHarnesses = selectedHarnesses.map(({ discovery }) => ({
|
|
901
|
+
id: discovery.id,
|
|
902
|
+
name: discovery.name,
|
|
903
|
+
runnable: true,
|
|
904
|
+
capabilities: [...new Set(discovery.capabilities)].sort(),
|
|
905
|
+
allowedModelIds: [
|
|
906
|
+
...new Set(input.allowedModels?.[discovery.id] ?? []),
|
|
907
|
+
].sort(),
|
|
908
|
+
}));
|
|
909
|
+
const harnessIds = new Set(inventoryHarnesses.map((harness) => harness.id));
|
|
910
|
+
const discoveredSkills = await discoverSkills(input.cwd);
|
|
911
|
+
const inventorySkills = discoveredSkills
|
|
912
|
+
.filter((skill) => skill.native)
|
|
913
|
+
.map((skill) => {
|
|
914
|
+
// A same-content legacy installation can merge into a native skill.
|
|
915
|
+
// It must not turn that skill into a wildcard: only native
|
|
916
|
+
// installations can make a skill available to a harness here.
|
|
917
|
+
const supportedHarnessIds = skill.installations
|
|
918
|
+
.filter((installation) => installation.native)
|
|
919
|
+
.flatMap((installation) => installation.harnessIds)
|
|
920
|
+
.filter((harnessId) =>
|
|
921
|
+
harnessIds.has(harnessId) && skill.harnessIds.includes(harnessId)
|
|
922
|
+
);
|
|
923
|
+
return {
|
|
924
|
+
skill,
|
|
925
|
+
supportedHarnessIds: [...new Set(supportedHarnessIds)].sort(),
|
|
926
|
+
};
|
|
927
|
+
})
|
|
928
|
+
.filter((entry) => entry.supportedHarnessIds.length > 0)
|
|
929
|
+
.sort((left, right) => left.skill.id.localeCompare(right.skill.id));
|
|
930
|
+
const limits = {
|
|
931
|
+
maxSlices: input.maxSlices,
|
|
932
|
+
maxSkillsPerSlice: input.maxSkillsPerSlice,
|
|
933
|
+
maxTotalSkillAssignments: input.maxTotalSkillAssignments,
|
|
934
|
+
};
|
|
935
|
+
const snapshotBody = {
|
|
936
|
+
schemaVersion: 1 as const,
|
|
937
|
+
harnesses: inventoryHarnesses,
|
|
938
|
+
skills: inventorySkills.map(({ skill, supportedHarnessIds }) => ({
|
|
939
|
+
id: skill.id,
|
|
940
|
+
name: skill.name,
|
|
941
|
+
description: skill.description,
|
|
942
|
+
harnessIds: supportedHarnessIds,
|
|
943
|
+
})),
|
|
944
|
+
limits,
|
|
945
|
+
};
|
|
946
|
+
const fingerprintMaterial = {
|
|
947
|
+
...snapshotBody,
|
|
948
|
+
harnessRuntime: selectedHarnesses.map(({ discovery }) => ({
|
|
949
|
+
id: discovery.id,
|
|
950
|
+
commandPath: discovery.commandPath,
|
|
951
|
+
version: discovery.version,
|
|
952
|
+
})),
|
|
953
|
+
skillRuntime: inventorySkills.map(({ skill, supportedHarnessIds }) => ({
|
|
954
|
+
id: skill.id,
|
|
955
|
+
version: skill.version,
|
|
956
|
+
hash: skill.hash,
|
|
957
|
+
native: skill.native,
|
|
958
|
+
harnessIds: supportedHarnessIds,
|
|
959
|
+
})),
|
|
960
|
+
};
|
|
961
|
+
|
|
962
|
+
return {
|
|
963
|
+
inventory: {
|
|
964
|
+
...snapshotBody,
|
|
965
|
+
snapshotId: `sha256:${
|
|
966
|
+
createHash("sha256").update(stableJson(fingerprintMaterial)).digest("hex")
|
|
967
|
+
}`,
|
|
968
|
+
},
|
|
969
|
+
harnesses: selectedHarnesses,
|
|
970
|
+
skills: inventorySkills.map(({ skill }) => skill),
|
|
971
|
+
projectNativeSkillIds: discoveredSkills
|
|
972
|
+
.filter((skill) =>
|
|
973
|
+
skill.native && skill.installations.some((installation) =>
|
|
974
|
+
installation.scope === "project" && installation.native
|
|
975
|
+
)
|
|
976
|
+
)
|
|
977
|
+
.map((skill) => skill.id),
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
|
|
540
981
|
private async resolveCompositional(input: CompositionalEstimateInput): Promise<{
|
|
541
982
|
harnesses: HarnessDiscovery[];
|
|
542
983
|
resolvedSlices: ResolvedCompositionalSlice[];
|
|
543
984
|
selectedHarnessIds: string[];
|
|
544
|
-
|
|
985
|
+
sliceSkills: Skill[][];
|
|
986
|
+
requestedSkills: Skill[];
|
|
545
987
|
}> {
|
|
546
988
|
assertUniqueSliceIds(input.slices);
|
|
547
989
|
const harnesses = await this.discoverHarnesses({ refresh: input.refresh });
|
|
@@ -550,16 +992,42 @@ export class EnnodiaCore {
|
|
|
550
992
|
input.slices,
|
|
551
993
|
harnesses,
|
|
552
994
|
this.planRoute,
|
|
995
|
+
input.skillIds,
|
|
553
996
|
);
|
|
554
997
|
const selectedHarnessIds = resolvedSlices.map((slice) => slice.harnessId);
|
|
555
998
|
this.assertHarnessesRunnable(selectedHarnessIds, harnesses);
|
|
556
|
-
const
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
999
|
+
const requestedSkillIds = [
|
|
1000
|
+
...new Set(resolvedSlices.flatMap((slice) => slice.skillIds)),
|
|
1001
|
+
];
|
|
1002
|
+
const requestedSkills = requestedSkillIds.length
|
|
1003
|
+
? await loadRunnableSkillsByIds(requestedSkillIds, input.cwd)
|
|
1004
|
+
: [];
|
|
1005
|
+
const skillsById = new Map(
|
|
1006
|
+
requestedSkills.map((skill) => [skill.id, skill]),
|
|
560
1007
|
);
|
|
1008
|
+
const sliceSkills = resolvedSlices.map((slice) => {
|
|
1009
|
+
const skills = slice.skillIds.map((skillId) => {
|
|
1010
|
+
const skill = skillsById.get(skillId);
|
|
1011
|
+
if (!skill) {
|
|
1012
|
+
throw new Error(`Skill not found after loading: ${skillId}`);
|
|
1013
|
+
}
|
|
1014
|
+
return skill;
|
|
1015
|
+
});
|
|
1016
|
+
|
|
1017
|
+
if (skills.length > 0) {
|
|
1018
|
+
assertSkillsSupportHarnesses(skills, [slice.harnessId]);
|
|
1019
|
+
}
|
|
561
1020
|
|
|
562
|
-
|
|
1021
|
+
return skills;
|
|
1022
|
+
});
|
|
1023
|
+
|
|
1024
|
+
return {
|
|
1025
|
+
harnesses,
|
|
1026
|
+
resolvedSlices,
|
|
1027
|
+
selectedHarnessIds,
|
|
1028
|
+
sliceSkills,
|
|
1029
|
+
requestedSkills,
|
|
1030
|
+
};
|
|
563
1031
|
}
|
|
564
1032
|
|
|
565
1033
|
private async loadSkillsFor(
|
|
@@ -578,6 +1046,35 @@ export class EnnodiaCore {
|
|
|
578
1046
|
return skills;
|
|
579
1047
|
}
|
|
580
1048
|
|
|
1049
|
+
/**
|
|
1050
|
+
* Skills discoverable in cwd's native skill directories that were not part
|
|
1051
|
+
* of this run's requested skills. A harness's own skill mechanism can pick
|
|
1052
|
+
* these up on its own initiative (observed: Codex reading and applying an
|
|
1053
|
+
* unrequested skill file purely because it existed on disk), independent
|
|
1054
|
+
* of what Ennodia told it to use via skillIds. This does not prevent that;
|
|
1055
|
+
* it only makes the exposure visible to the caller.
|
|
1056
|
+
*/
|
|
1057
|
+
private async findUnrequestedSkills(
|
|
1058
|
+
cwd: string | undefined,
|
|
1059
|
+
requestedSkills: Skill[],
|
|
1060
|
+
): Promise<string[]> {
|
|
1061
|
+
if (!cwd) {
|
|
1062
|
+
return [];
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
const requestedIds = new Set(requestedSkills.map((skill) => skill.id));
|
|
1066
|
+
const discovery = await discoverSkillsWithWarnings(cwd);
|
|
1067
|
+
|
|
1068
|
+
return discovery.skills
|
|
1069
|
+
.filter((skill) =>
|
|
1070
|
+
skill.installations.some((installation) =>
|
|
1071
|
+
installation.scope === "project" && installation.native
|
|
1072
|
+
)
|
|
1073
|
+
)
|
|
1074
|
+
.map((skill) => skill.id)
|
|
1075
|
+
.filter((id) => !requestedIds.has(id));
|
|
1076
|
+
}
|
|
1077
|
+
|
|
581
1078
|
private requireRunnableHarness(
|
|
582
1079
|
harnessId: string,
|
|
583
1080
|
harnesses: HarnessDiscovery[],
|
|
@@ -616,3 +1113,37 @@ export function createDefaultEnnodiaCore(
|
|
|
616
1113
|
createDefaultHistorySink(),
|
|
617
1114
|
});
|
|
618
1115
|
}
|
|
1116
|
+
|
|
1117
|
+
function resolvePreferredAlias(
|
|
1118
|
+
preferredName: string,
|
|
1119
|
+
preferredValue: string | undefined,
|
|
1120
|
+
legacyName: string,
|
|
1121
|
+
legacyValue: string | undefined,
|
|
1122
|
+
): string | undefined {
|
|
1123
|
+
if (
|
|
1124
|
+
preferredValue !== undefined &&
|
|
1125
|
+
legacyValue !== undefined &&
|
|
1126
|
+
preferredValue !== legacyValue
|
|
1127
|
+
) {
|
|
1128
|
+
throw new Error(
|
|
1129
|
+
`Conflicting Compare fields: ${preferredName} and deprecated ${legacyName}.`,
|
|
1130
|
+
);
|
|
1131
|
+
}
|
|
1132
|
+
return preferredValue ?? legacyValue;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
function stableJson(value: unknown): string {
|
|
1136
|
+
if (Array.isArray(value)) {
|
|
1137
|
+
return `[${value.map(stableJson).join(",")}]`;
|
|
1138
|
+
}
|
|
1139
|
+
if (value && typeof value === "object") {
|
|
1140
|
+
return `{${
|
|
1141
|
+
Object.entries(value as Record<string, unknown>)
|
|
1142
|
+
.filter(([, entry]) => entry !== undefined)
|
|
1143
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
1144
|
+
.map(([key, entry]) => `${JSON.stringify(key)}:${stableJson(entry)}`)
|
|
1145
|
+
.join(",")
|
|
1146
|
+
}}`;
|
|
1147
|
+
}
|
|
1148
|
+
return JSON.stringify(value);
|
|
1149
|
+
}
|