@dotdrelle/wiki-manager 0.11.9 → 0.12.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.
Files changed (81) hide show
  1. package/README.md +32 -7
  2. package/agents.docker-compose.yml +3 -0
  3. package/docker-compose.yml +1 -0
  4. package/package.json +3 -2
  5. package/src/activity/activityAggregator.js +109 -0
  6. package/src/activity/activityAggregator.test.js +60 -0
  7. package/src/activity/activityDeduplicator.js +50 -0
  8. package/src/activity/progressCalculator.js +61 -0
  9. package/src/activity/runSynthesis.js +15 -0
  10. package/src/agent/graph.js +65 -30
  11. package/src/agent/graph.test.js +28 -5
  12. package/src/cli/wiki-manager.js +22 -0
  13. package/src/contracts/schemas.js +249 -13
  14. package/src/contracts/schemas.test.js +145 -0
  15. package/src/core/activity.js +8 -5
  16. package/src/core/agentEvents.js +260 -23
  17. package/src/core/agentEvents.test.js +53 -0
  18. package/src/core/compose.js +6 -1
  19. package/src/core/dockerCompose.test.js +12 -0
  20. package/src/core/documentIntake.js +33 -38
  21. package/src/core/documentIntake.test.js +25 -0
  22. package/src/core/env.js +11 -1
  23. package/src/core/jobQueue.js +26 -2
  24. package/src/core/mcp.js +1 -1
  25. package/src/core/plan.js +46 -4
  26. package/src/core/plan.test.js +16 -1
  27. package/src/core/planPatch.js +64 -3
  28. package/src/core/planPatch.test.js +110 -1
  29. package/src/core/queueStore.test.js +21 -0
  30. package/src/core/runtimeLog.js +119 -0
  31. package/src/core/runtimeLog.test.js +84 -0
  32. package/src/core/wikirc.js +22 -0
  33. package/src/core/wikirc.test.js +49 -1
  34. package/src/core/workflow.js +14 -3
  35. package/src/graph/graphAggregator.js +5 -0
  36. package/src/graph/graphPatch.js +8 -0
  37. package/src/graph/graphSnapshot.js +14 -0
  38. package/src/graph/graphVisibilityPolicy.js +40 -0
  39. package/src/graph/runGraphProjector.js +87 -0
  40. package/src/graph/runGraphProjector.test.js +111 -0
  41. package/src/orchestrator/agentRegistry.js +172 -0
  42. package/src/orchestrator/agentRegistry.test.js +114 -0
  43. package/src/orchestrator/approvalPolicy.js +126 -0
  44. package/src/orchestrator/approvalPolicy.test.js +78 -0
  45. package/src/orchestrator/assignmentManager.js +80 -0
  46. package/src/orchestrator/attemptManager.js +168 -0
  47. package/src/orchestrator/attemptManager.test.js +160 -0
  48. package/src/orchestrator/budgetManager.js +127 -0
  49. package/src/orchestrator/capabilityRegistry.js +64 -0
  50. package/src/orchestrator/capabilityRegistry.test.js +59 -0
  51. package/src/orchestrator/capabilityResolver.js +151 -0
  52. package/src/orchestrator/capabilityResolver.test.js +127 -0
  53. package/src/orchestrator/dependencyResolver.js +112 -0
  54. package/src/orchestrator/dispatcher.js +302 -0
  55. package/src/orchestrator/lockManager.js +51 -0
  56. package/src/orchestrator/planIntegrator.js +268 -0
  57. package/src/orchestrator/planIntegrator.test.js +213 -0
  58. package/src/orchestrator/planValidator.js +534 -0
  59. package/src/orchestrator/planValidator.test.js +262 -0
  60. package/src/orchestrator/resultAggregator.js +248 -0
  61. package/src/orchestrator/resultAggregator.test.js +211 -0
  62. package/src/orchestrator/scheduler.js +103 -0
  63. package/src/orchestrator/scheduler.test.js +138 -0
  64. package/src/runtime/approvals.js +130 -1
  65. package/src/runtime/donna-contract.test.js +293 -30
  66. package/src/runtime/recoveryManager.js +176 -0
  67. package/src/runtime/recoveryManager.test.js +162 -0
  68. package/src/runtime/runner.e2e.test.js +98 -12
  69. package/src/runtime/runner.js +261 -227
  70. package/src/runtime/runner.test.js +249 -452
  71. package/src/runtime/server.js +137 -20
  72. package/src/runtime/server.test.js +137 -7
  73. package/src/runtime/store.js +837 -2
  74. package/src/runtime/store.test.js +245 -4
  75. package/src/runtime/supervisor.js +33 -1
  76. package/src/runtime/supervisor.test.js +47 -1
  77. package/src/shell/RightPane.tsx +8 -5
  78. package/src/shell/repl.js +4 -9
  79. package/src/shell/repl.test.js +25 -4
  80. package/src/shell/tui.tsx +1 -0
  81. package/src/shell/useSession.ts +38 -10
@@ -0,0 +1,168 @@
1
+ import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
2
+ import { createLockManager, locksForTask } from './lockManager.js';
3
+
4
+ export function createAttemptManager({ locks = new Set() } = {}) {
5
+ let nextAttempt = 0;
6
+ const lockManager = createLockManager({ locks });
7
+ return {
8
+ reserve(task, requestedLocks = locksForTask(task)) {
9
+ const taskId = planTaskId(task);
10
+ const reservation = lockManager.acquire(requestedLocks);
11
+ if (!reservation) return null;
12
+ nextAttempt += 1;
13
+ return {
14
+ taskId,
15
+ attemptId: `${taskId}:attempt-${nextAttempt}`,
16
+ locks: reservation.locks,
17
+ release: reservation.release,
18
+ };
19
+ },
20
+ clear() {
21
+ lockManager.clear();
22
+ },
23
+ snapshot() {
24
+ return lockManager.snapshot();
25
+ },
26
+ canAcquire(task) {
27
+ return lockManager.canAcquire(task);
28
+ },
29
+ scheduleRetry(task, failure, options = {}) {
30
+ return scheduleRetry(task, failure, options);
31
+ },
32
+ };
33
+ }
34
+
35
+ export function scheduleRetry(task, failure, {
36
+ assignment = null,
37
+ registry = null,
38
+ session = null,
39
+ runId = null,
40
+ store = null,
41
+ } = {}) {
42
+ const retryPolicy = task?.retryPolicy;
43
+ if (!retryPolicy || !retryableFailure(failure, retryPolicy)) return { scheduled: false, reason: 'not_retryable' };
44
+ const attempts = currentAttempts(task);
45
+ if (attempts >= Number(retryPolicy.maxAttempts)) {
46
+ return { scheduled: false, reason: 'max_attempts_reached', attempts };
47
+ }
48
+
49
+ const previousAgentInstanceId = assignment?.agentInstanceId ?? failure?.result?.agentInstanceId ?? failure?.agentInstanceId ?? null;
50
+ let nextAgentInstanceId = previousAgentInstanceId;
51
+ if (retryPolicy.allowAgentFallback === true) {
52
+ const fallback = fallbackProvider(task, {
53
+ registry,
54
+ previousAgentInstanceId,
55
+ });
56
+ if (!fallback) return { scheduled: false, reason: 'no_compatible_fallback', attempts };
57
+ nextAgentInstanceId = fallback.agentInstanceId;
58
+ }
59
+
60
+ const nextAttempts = attempts + 1;
61
+ const retryAssignment = nextAgentInstanceId ? {
62
+ agentInstanceId: nextAgentInstanceId,
63
+ previousAgentInstanceId,
64
+ contractVersion: contractVersionFor(registry, task.requiredCapability, nextAgentInstanceId),
65
+ } : null;
66
+ const payload = {
67
+ runId,
68
+ taskId: planTaskId(task),
69
+ previousAgentInstanceId,
70
+ newAgentInstanceId: nextAgentInstanceId,
71
+ attempts: nextAttempts,
72
+ maxAttempts: retryPolicy.maxAttempts,
73
+ retryAssignment,
74
+ error: failure?.result?.error ?? failure?.error ?? null,
75
+ };
76
+ if (session) {
77
+ persistDispatch(store, dispatchAgentEvent(session, createAgentEvent('task.retry_scheduled', {
78
+ origin: 'attempt_manager',
79
+ runId,
80
+ taskId: planTaskId(task),
81
+ payload,
82
+ })));
83
+ persistDispatch(store, dispatchAgentEvent(session, createAgentEvent('plan_step_updated', {
84
+ origin: 'attempt_manager',
85
+ runId,
86
+ taskId: planTaskId(task),
87
+ payload: {
88
+ taskId: planTaskId(task),
89
+ status: 'pending',
90
+ retryState: {
91
+ attempts: nextAttempts,
92
+ previousAgentInstanceId,
93
+ agentInstanceId: nextAgentInstanceId,
94
+ },
95
+ retryAssignment,
96
+ },
97
+ })));
98
+ }
99
+ return {
100
+ scheduled: true,
101
+ attempts: nextAttempts,
102
+ previousAgentInstanceId,
103
+ newAgentInstanceId: nextAgentInstanceId,
104
+ retryAssignment,
105
+ };
106
+ }
107
+
108
+ function retryableFailure(failure, retryPolicy) {
109
+ const error = failure?.result?.error ?? failure?.error ?? {};
110
+ if (error?.retryable !== true) return false;
111
+ const retryableErrors = Array.isArray(retryPolicy.retryableErrors) ? retryPolicy.retryableErrors.map(String) : [];
112
+ if (retryableErrors.length === 0 || retryableErrors.includes('*')) return true;
113
+ const code = String(error.code ?? '');
114
+ return code ? retryableErrors.includes(code) : true;
115
+ }
116
+
117
+ function currentAttempts(task) {
118
+ const value = Number(task?.retryState?.attempts ?? task?.attempts ?? 1);
119
+ return Number.isFinite(value) && value > 0 ? Math.floor(value) : 1;
120
+ }
121
+
122
+ function fallbackProvider(task, { registry, previousAgentInstanceId }) {
123
+ if (!registry || typeof registry.providersFor !== 'function') return null;
124
+ const previous = providerFor(registry, task.requiredCapability, previousAgentInstanceId);
125
+ const previousContractVersion = previous ? contractVersion(previous) : null;
126
+ const previousCapabilityVersion = previous?.capability?.version ?? null;
127
+ return (registry.providersFor(task.requiredCapability) ?? [])
128
+ .filter((provider) => provider.agentInstanceId !== previousAgentInstanceId)
129
+ .filter((provider) => compatibleProvider(provider, registry, { previousContractVersion, previousCapabilityVersion, operation: task.operation }))
130
+ .sort((a, b) => String(a.agentInstanceId).localeCompare(String(b.agentInstanceId)))[0] ?? null;
131
+ }
132
+
133
+ function compatibleProvider(provider, registry, { previousContractVersion, previousCapabilityVersion, operation }) {
134
+ const providerContractVersion = contractVersion(provider);
135
+ if (previousContractVersion && providerContractVersion !== previousContractVersion) return false;
136
+ if (previousCapabilityVersion && provider?.capability?.version !== previousCapabilityVersion) return false;
137
+ if (typeof registry.isCompatible === 'function' && !registry.isCompatible(providerContractVersion)) return false;
138
+ const health = String(provider?.health ?? provider?.description?.health?.status ?? '');
139
+ if (!['available', 'degraded'].includes(health)) return false;
140
+ if (provider?.available === false || provider?.availability === 'unavailable') return false;
141
+ const operations = provider?.capability?.supportedOperations ?? [];
142
+ return !operation || operations.length === 0 || operations.includes(operation);
143
+ }
144
+
145
+ function providerFor(registry, capability, agentInstanceId) {
146
+ if (!agentInstanceId || typeof registry?.providersFor !== 'function') return null;
147
+ return (registry.providersFor(capability) ?? [])
148
+ .find((provider) => provider.agentInstanceId === agentInstanceId) ?? null;
149
+ }
150
+
151
+ function contractVersionFor(registry, capability, agentInstanceId) {
152
+ const provider = providerFor(registry, capability, agentInstanceId);
153
+ return provider ? contractVersion(provider) : null;
154
+ }
155
+
156
+ function contractVersion(provider) {
157
+ return String(provider?.description?.contractVersion ?? provider?.contractVersion ?? '');
158
+ }
159
+
160
+ function persistDispatch(store, event) {
161
+ store?.persistEvent?.(event);
162
+ }
163
+
164
+ function planTaskId(task) {
165
+ return String(task?.id ?? task?.step);
166
+ }
167
+
168
+ export { locksForTask };
@@ -0,0 +1,160 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+
4
+ import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
5
+ import { assign } from './assignmentManager.js';
6
+ import { scheduleRetry } from './attemptManager.js';
7
+
8
+ test('assignment selects Commercial when CME is unavailable for the capability', async () => {
9
+ const assignment = await assign(retryTask(), {
10
+ session: agentSession(),
11
+ registry: retryRegistry({ cmeHealth: 'unavailable' }),
12
+ });
13
+
14
+ assert.equal(assignment.agentInstanceId, 'commercial-main');
15
+ assert.equal(assignment.serverName, 'commercial');
16
+ });
17
+
18
+ test('attemptManager schedules retry on Commercial after retryable CME failure', async () => {
19
+ const session = agentSession();
20
+ session.headlessPlan = [retryTask({ status: 'failed' })];
21
+ dispatchAgentEvent(session, createAgentEvent('plan_set', {
22
+ origin: 'test',
23
+ runId: 'run-retry',
24
+ payload: { steps: session.headlessPlan, planRevision: 0 },
25
+ }));
26
+ session.agentEvents = [];
27
+
28
+ const retry = scheduleRetry(session.headlessPlan[0], retryableFailure(), {
29
+ assignment: { agentInstanceId: 'cme-main', serverName: 'cme' },
30
+ registry: retryRegistry(),
31
+ session,
32
+ runId: 'run-retry',
33
+ });
34
+ const assignment = await assign(session.headlessPlan[0], {
35
+ session,
36
+ registry: retryRegistry(),
37
+ });
38
+
39
+ assert.equal(retry.scheduled, true);
40
+ assert.equal(retry.previousAgentInstanceId, 'cme-main');
41
+ assert.equal(retry.newAgentInstanceId, 'commercial-main');
42
+ assert.equal(session.headlessPlan[0].status, 'pending');
43
+ assert.equal(session.headlessPlan[0].retryState.attempts, 2);
44
+ assert.equal(assignment.agentInstanceId, 'commercial-main');
45
+ assert.ok(session.agentEvents.some((event) => event.type === 'task.retry_scheduled'
46
+ && event.payload.previousAgentInstanceId === 'cme-main'
47
+ && event.payload.newAgentInstanceId === 'commercial-main'));
48
+ });
49
+
50
+ test('attemptManager does not fallback to an incompatible contract provider', () => {
51
+ const session = agentSession();
52
+ session.headlessPlan = [retryTask({ status: 'failed' })];
53
+
54
+ const retry = scheduleRetry(session.headlessPlan[0], retryableFailure(), {
55
+ assignment: { agentInstanceId: 'cme-main', serverName: 'cme' },
56
+ registry: retryRegistry({ commercialContractVersion: '2' }),
57
+ session,
58
+ runId: 'run-retry',
59
+ });
60
+
61
+ assert.equal(retry.scheduled, false);
62
+ assert.equal(retry.reason, 'no_compatible_fallback');
63
+ assert.equal(session.headlessPlan[0].status, 'failed');
64
+ assert.equal(session.agentEvents?.some((event) => event.type === 'task.retry_scheduled'), false);
65
+ });
66
+
67
+ function retryTask(overrides = {}) {
68
+ return {
69
+ id: 'run-retry:diagnose',
70
+ step: 1,
71
+ label: 'Diagnose',
72
+ description: 'Diagnose',
73
+ status: 'pending',
74
+ dependsOn: [],
75
+ requiredCapability: 'workspace.diagnose',
76
+ operation: 'doctor',
77
+ arguments: {},
78
+ inputRefs: [],
79
+ expectedOutputRefs: [{ type: 'file', ref: 'diagnostics.json' }],
80
+ locks: [],
81
+ requiresApproval: false,
82
+ idempotencyKey: null,
83
+ progressWeight: 1,
84
+ retryPolicy: {
85
+ maxAttempts: 2,
86
+ retryableErrors: ['temporarily_unavailable'],
87
+ allowAgentFallback: true,
88
+ },
89
+ ...overrides,
90
+ };
91
+ }
92
+
93
+ function retryableFailure() {
94
+ return {
95
+ ok: false,
96
+ taskId: 'run-retry:diagnose',
97
+ result: {
98
+ ok: false,
99
+ taskId: 'run-retry:diagnose',
100
+ agentInstanceId: 'cme-main',
101
+ status: 'failed',
102
+ error: {
103
+ code: 'temporarily_unavailable',
104
+ message: 'CME temporarily unavailable',
105
+ retryable: true,
106
+ },
107
+ },
108
+ };
109
+ }
110
+
111
+ function agentSession() {
112
+ return {
113
+ mcp: {},
114
+ agentEvents: [],
115
+ activities: {},
116
+ agentRegistrySnapshot: [
117
+ { agentInstanceId: 'cme-main', serverName: 'cme' },
118
+ { agentInstanceId: 'commercial-main', serverName: 'commercial' },
119
+ ],
120
+ };
121
+ }
122
+
123
+ function retryRegistry({
124
+ cmeHealth = 'available',
125
+ commercialHealth = 'available',
126
+ commercialContractVersion = '1',
127
+ } = {}) {
128
+ const providers = [
129
+ provider('cme-main', 'cme', cmeHealth, '1'),
130
+ provider('commercial-main', 'commercial', commercialHealth, commercialContractVersion),
131
+ ];
132
+ return {
133
+ providersFor(capability) {
134
+ return capability === 'workspace.diagnose' ? providers : [];
135
+ },
136
+ isCompatible(contractVersion) {
137
+ return String(contractVersion) === '1';
138
+ },
139
+ };
140
+ }
141
+
142
+ function provider(agentInstanceId, serverName, health, contractVersion) {
143
+ return {
144
+ agentInstanceId,
145
+ serverName,
146
+ health,
147
+ capability: {
148
+ id: 'workspace.diagnose',
149
+ version: '1',
150
+ description: 'Diagnose',
151
+ inputSchema: { type: 'object', additionalProperties: true },
152
+ outputSchema: {},
153
+ supportedOperations: ['doctor'],
154
+ },
155
+ description: {
156
+ contractVersion,
157
+ agentInstanceId,
158
+ },
159
+ };
160
+ }
@@ -0,0 +1,127 @@
1
+ export class BudgetExceededError extends Error {
2
+ constructor(reason, details = {}) {
3
+ super(`Run budget exceeded: ${reason}`);
4
+ this.name = 'BudgetExceededError';
5
+ this.reason = reason;
6
+ this.details = details;
7
+ }
8
+ }
9
+
10
+ export function createBudgetManager({ budgets = {}, runId = null } = {}) {
11
+ const limits = normalizeBudgets(budgets);
12
+ const counters = {
13
+ runId,
14
+ tasksStarted: 0,
15
+ tasksCompleted: 0,
16
+ attempts: 0,
17
+ durationMs: 0,
18
+ tokens: 0,
19
+ maxDepthSeen: 0,
20
+ };
21
+ let exceeded = null;
22
+
23
+ return {
24
+ canStartTask(task) {
25
+ const check = checkStartBudget(task, counters, limits);
26
+ if (!check.ok) exceeded = check;
27
+ return check.ok;
28
+ },
29
+ assertCanStartTask(task) {
30
+ const check = checkStartBudget(task, counters, limits);
31
+ if (!check.ok) {
32
+ exceeded = check;
33
+ throw new BudgetExceededError(check.reason, check.details);
34
+ }
35
+ return true;
36
+ },
37
+ recordTaskStart(task) {
38
+ this.assertCanStartTask(task);
39
+ counters.tasksStarted += 1;
40
+ counters.attempts += 1;
41
+ counters.maxDepthSeen = Math.max(counters.maxDepthSeen, taskDepth(task));
42
+ return this.snapshot();
43
+ },
44
+ recordTaskResult(result = {}) {
45
+ counters.tasksCompleted += 1;
46
+ const metrics = result.metrics && typeof result.metrics === 'object' ? result.metrics : {};
47
+ counters.durationMs += nonNegativeNumber(metrics.durationMs);
48
+ counters.tokens += tokenCount(metrics);
49
+ const check = checkConsumedBudget(counters, limits);
50
+ if (!check.ok) {
51
+ exceeded = check;
52
+ throw new BudgetExceededError(check.reason, check.details);
53
+ }
54
+ return this.snapshot();
55
+ },
56
+ markExceeded(reason, details = {}) {
57
+ exceeded = { ok: false, reason, details };
58
+ return exceeded;
59
+ },
60
+ exceeded() {
61
+ return exceeded;
62
+ },
63
+ snapshot() {
64
+ return { ...counters, exceeded };
65
+ },
66
+ };
67
+ }
68
+
69
+ function checkStartBudget(task, counters, limits) {
70
+ if (limits.maxTasks != null && counters.tasksStarted + 1 > limits.maxTasks) {
71
+ return exceeded('max_tasks_exceeded', { maxTasks: limits.maxTasks, nextTasksStarted: counters.tasksStarted + 1 });
72
+ }
73
+ if (limits.maxAttempts != null && counters.attempts + 1 > limits.maxAttempts) {
74
+ return exceeded('max_attempts_exceeded', { maxAttempts: limits.maxAttempts, nextAttempts: counters.attempts + 1 });
75
+ }
76
+ const depth = taskDepth(task);
77
+ if (limits.maxDepth != null && depth > limits.maxDepth) {
78
+ return exceeded('max_depth_exceeded', { maxDepth: limits.maxDepth, taskDepth: depth, taskId: task?.id ?? task?.step ?? null });
79
+ }
80
+ return { ok: true };
81
+ }
82
+
83
+ function checkConsumedBudget(counters, limits) {
84
+ if (limits.maxDurationMs != null && counters.durationMs > limits.maxDurationMs) {
85
+ return exceeded('max_duration_exceeded', { maxDurationMs: limits.maxDurationMs, durationMs: counters.durationMs });
86
+ }
87
+ if (limits.maxTokens != null && counters.tokens > limits.maxTokens) {
88
+ return exceeded('max_tokens_exceeded', { maxTokens: limits.maxTokens, tokens: counters.tokens });
89
+ }
90
+ return { ok: true };
91
+ }
92
+
93
+ function normalizeBudgets(budgets) {
94
+ return {
95
+ maxTasks: positiveInteger(budgets.maxTasks),
96
+ maxAttempts: positiveInteger(budgets.maxAttempts ?? budgets.maxTaskAttempts),
97
+ maxDepth: positiveInteger(budgets.maxDepth),
98
+ maxDurationMs: positiveInteger(budgets.maxDurationMs ?? budgets.maxRunDurationMs),
99
+ maxTokens: positiveInteger(budgets.maxTokens),
100
+ };
101
+ }
102
+
103
+ function positiveInteger(value) {
104
+ const number = Number(value);
105
+ return Number.isFinite(number) && number >= 0 ? Math.floor(number) : null;
106
+ }
107
+
108
+ function nonNegativeNumber(value) {
109
+ const number = Number(value);
110
+ return Number.isFinite(number) && number > 0 ? number : 0;
111
+ }
112
+
113
+ function tokenCount(metrics) {
114
+ if (metrics.totalTokens != null) return nonNegativeNumber(metrics.totalTokens);
115
+ if (metrics.tokens != null) return nonNegativeNumber(metrics.tokens);
116
+ return nonNegativeNumber(metrics.inputTokens) + nonNegativeNumber(metrics.outputTokens);
117
+ }
118
+
119
+ function taskDepth(task) {
120
+ const value = task?.depth ?? task?.dagDepth ?? task?.level ?? 1;
121
+ const number = Number(value);
122
+ return Number.isFinite(number) && number > 0 ? Math.floor(number) : 1;
123
+ }
124
+
125
+ function exceeded(reason, details) {
126
+ return { ok: false, reason, details };
127
+ }
@@ -0,0 +1,64 @@
1
+ const SUPPORTED_CONTRACT_VERSIONS = new Set(['1']);
2
+
3
+ export function createCapabilityRegistry({ agents = [], compatibleContractVersions = SUPPORTED_CONTRACT_VERSIONS } = {}) {
4
+ const compatible = compatibleContractVersions instanceof Set
5
+ ? compatibleContractVersions
6
+ : new Set(compatibleContractVersions);
7
+ const providers = new Map();
8
+
9
+ for (const agent of agents ?? []) {
10
+ if (!isProviderAgent(agent, compatible)) continue;
11
+ for (const capability of agent.description?.capabilities ?? []) {
12
+ const key = capabilityKey(capability.id, capability.version);
13
+ const entry = {
14
+ agentInstanceId: agent.agentInstanceId,
15
+ serverName: agent.serverName ?? null,
16
+ agentType: agent.description.agentType,
17
+ displayName: agent.description.displayName,
18
+ health: agent.health,
19
+ capability,
20
+ description: agent.description,
21
+ lastSeenAt: agent.lastSeenAt ?? null,
22
+ };
23
+ const list = providers.get(key) ?? [];
24
+ list.push(entry);
25
+ providers.set(key, list);
26
+ }
27
+ }
28
+
29
+ return {
30
+ providersFor(capability) {
31
+ const { id, version } = parseCapability(capability);
32
+ if (version) return [...(providers.get(capabilityKey(id, version)) ?? [])];
33
+ return [...providers.entries()]
34
+ .filter(([key]) => key.startsWith(`${id}@`))
35
+ .flatMap(([, list]) => list);
36
+ },
37
+ isCompatible(contractVersion) {
38
+ return compatible.has(String(contractVersion ?? ''));
39
+ },
40
+ snapshot() {
41
+ return Object.fromEntries(
42
+ [...providers.entries()].map(([key, list]) => [key, list.map((entry) => ({ ...entry }))]),
43
+ );
44
+ },
45
+ };
46
+ }
47
+
48
+ function isProviderAgent(agent, compatible) {
49
+ if (!agent || agent.legacy || agent.orchestrable === false) return false;
50
+ if (!compatible.has(String(agent.description?.contractVersion ?? ''))) return false;
51
+ if (!['available', 'degraded'].includes(String(agent.health ?? agent.description?.health?.status ?? ''))) return false;
52
+ return Array.isArray(agent.description?.capabilities);
53
+ }
54
+
55
+ function parseCapability(value) {
56
+ const raw = String(value ?? '');
57
+ const index = raw.lastIndexOf('@');
58
+ if (index <= 0 || index === raw.length - 1) return { id: raw, version: null };
59
+ return { id: raw.slice(0, index), version: raw.slice(index + 1) };
60
+ }
61
+
62
+ function capabilityKey(id, version) {
63
+ return `${id}@${version}`;
64
+ }
@@ -0,0 +1,59 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { createCapabilityRegistry } from './capabilityRegistry.js';
4
+
5
+ function agent(agentInstanceId, capabilityId, { contractVersion = '1', health = 'available', version = '1' } = {}) {
6
+ return {
7
+ agentInstanceId,
8
+ health,
9
+ description: {
10
+ contractVersion,
11
+ agentType: agentInstanceId.split('-')[0],
12
+ agentInstanceId,
13
+ displayName: agentInstanceId,
14
+ capabilities: [{
15
+ id: capabilityId,
16
+ version,
17
+ description: capabilityId,
18
+ inputSchema: {},
19
+ outputSchema: {},
20
+ supportedOperations: ['run'],
21
+ }],
22
+ },
23
+ };
24
+ }
25
+
26
+ test('capabilityRegistry indexes two agents for the same capability', () => {
27
+ const registry = createCapabilityRegistry({
28
+ agents: [
29
+ agent('production-main', 'knowledge.update'),
30
+ agent('production-secondary', 'knowledge.update'),
31
+ ],
32
+ });
33
+
34
+ assert.deepEqual(
35
+ registry.providersFor('knowledge.update').map((provider) => provider.agentInstanceId),
36
+ ['production-main', 'production-secondary'],
37
+ );
38
+ assert.deepEqual(
39
+ registry.providersFor('knowledge.update@1').map((provider) => provider.agentInstanceId),
40
+ ['production-main', 'production-secondary'],
41
+ );
42
+ });
43
+
44
+ test('capabilityRegistry excludes incompatible contract versions and unavailable agents', () => {
45
+ const registry = createCapabilityRegistry({
46
+ agents: [
47
+ agent('compatible', 'document.build'),
48
+ agent('future', 'document.build', { contractVersion: '99' }),
49
+ agent('down', 'document.build', { health: 'unavailable' }),
50
+ ],
51
+ });
52
+
53
+ assert.equal(registry.isCompatible('1'), true);
54
+ assert.equal(registry.isCompatible('99'), false);
55
+ assert.deepEqual(
56
+ registry.providersFor('document.build').map((provider) => provider.agentInstanceId),
57
+ ['compatible'],
58
+ );
59
+ });