@loom-node/amoeba 0.1.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 (42) hide show
  1. package/LICENSE +21 -0
  2. package/dist/__tests__/cluster.test.d.ts +1 -0
  3. package/dist/__tests__/cluster.test.js +54 -0
  4. package/dist/__tests__/helpers.d.ts +4 -0
  5. package/dist/__tests__/helpers.js +13 -0
  6. package/dist/__tests__/lifecycle.test.d.ts +1 -0
  7. package/dist/__tests__/lifecycle.test.js +59 -0
  8. package/dist/__tests__/reward-bus.test.d.ts +1 -0
  9. package/dist/__tests__/reward-bus.test.js +40 -0
  10. package/dist/amoeba-loop.d.ts +41 -0
  11. package/dist/amoeba-loop.js +256 -0
  12. package/dist/cluster-provider.d.ts +9 -0
  13. package/dist/cluster-provider.js +39 -0
  14. package/dist/cluster.d.ts +18 -0
  15. package/dist/cluster.js +68 -0
  16. package/dist/errors.d.ts +12 -0
  17. package/dist/errors.js +23 -0
  18. package/dist/index.d.ts +22 -0
  19. package/dist/index.js +15 -0
  20. package/dist/lifecycle.d.ts +42 -0
  21. package/dist/lifecycle.js +171 -0
  22. package/dist/mitosis-provider.d.ts +10 -0
  23. package/dist/mitosis-provider.js +61 -0
  24. package/dist/mitosis-scratchpad.d.ts +8 -0
  25. package/dist/mitosis-scratchpad.js +23 -0
  26. package/dist/orchestrate-strategy.d.ts +21 -0
  27. package/dist/orchestrate-strategy.js +78 -0
  28. package/dist/plan-strategy.d.ts +9 -0
  29. package/dist/plan-strategy.js +39 -0
  30. package/dist/planner.d.ts +14 -0
  31. package/dist/planner.js +64 -0
  32. package/dist/reward-bus.d.ts +28 -0
  33. package/dist/reward-bus.js +68 -0
  34. package/dist/runtime.d.ts +64 -0
  35. package/dist/runtime.js +139 -0
  36. package/dist/skill-evolver.d.ts +7 -0
  37. package/dist/skill-evolver.js +89 -0
  38. package/dist/skill-node.d.ts +11 -0
  39. package/dist/skill-node.js +41 -0
  40. package/dist/skill-registry.d.ts +18 -0
  41. package/dist/skill-registry.js +71 -0
  42. package/package.json +27 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Loom Node Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,54 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { ClusterManager } from '../cluster.js';
3
+ import { mockNode } from './helpers.js';
4
+ describe('ClusterManager', () => {
5
+ it('adds and removes nodes', () => {
6
+ const cm = new ClusterManager();
7
+ const n = mockNode({ id: 'n1' });
8
+ cm.addNode(n);
9
+ expect(cm.nodes.size).toBe(1);
10
+ cm.removeNode('n1');
11
+ expect(cm.nodes.size).toBe(0);
12
+ });
13
+ it('computes bid score', () => {
14
+ const cm = new ClusterManager();
15
+ const n = mockNode({ id: 'n1' });
16
+ cm.addNode(n);
17
+ const bid = cm.computeBid(n, {
18
+ taskId: 't1', domain: 'general', description: 'test',
19
+ estimatedComplexity: 0.5, priority: 'normal',
20
+ });
21
+ expect(bid.score).toBeGreaterThan(0);
22
+ expect(bid.agentId).toBe('n1');
23
+ });
24
+ it('selectWinner returns best node', () => {
25
+ const cm = new ClusterManager();
26
+ cm.addNode(mockNode({ id: 'a' }));
27
+ const b = mockNode({ id: 'b' });
28
+ b.capabilities.scores.set('general', 0.9);
29
+ cm.addNode(b);
30
+ const winner = cm.selectWinner({
31
+ taskId: 't1', domain: 'general', description: 'x',
32
+ estimatedComplexity: 0.3, priority: 'normal',
33
+ });
34
+ expect(winner?.id).toBe('b');
35
+ });
36
+ it('returns undefined when no bids meet minimum', () => {
37
+ const cm = new ClusterManager({ minBids: 5 });
38
+ cm.addNode(mockNode({ id: 'a' }));
39
+ const winner = cm.selectWinner({
40
+ taskId: 't1', domain: 'general', description: 'x',
41
+ estimatedComplexity: 0.3, priority: 'normal',
42
+ });
43
+ expect(winner).toBeUndefined();
44
+ });
45
+ it('updateLoad clamps between 0 and 1', () => {
46
+ const cm = new ClusterManager();
47
+ const n = mockNode({ id: 'n1' });
48
+ cm.addNode(n);
49
+ cm.updateLoad('n1', 2);
50
+ expect(n.load).toBe(1);
51
+ cm.updateLoad('n1', -1);
52
+ expect(n.load).toBe(0);
53
+ });
54
+ });
@@ -0,0 +1,4 @@
1
+ import type { AgentNode } from '@loom-node/core';
2
+ export declare function mockNode(overrides: Partial<AgentNode> & {
3
+ id: string;
4
+ }): AgentNode;
@@ -0,0 +1,13 @@
1
+ export function mockNode(overrides) {
2
+ return {
3
+ depth: 0,
4
+ capabilities: { scores: new Map([['general', 0.5]]), tools: [], totalTasks: 0, successRate: 0 },
5
+ rewardHistory: [],
6
+ status: 'idle',
7
+ load: 0,
8
+ agent: { run: async () => ({ content: 'ok', usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 } }) },
9
+ lastActiveAt: Date.now(),
10
+ consecutiveLosses: 0,
11
+ ...overrides,
12
+ };
13
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,59 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { LifecycleManager } from '../lifecycle.js';
3
+ import { ClusterManager } from '../cluster.js';
4
+ import { ApoptosisRejectedError } from '../errors.js';
5
+ import { mockNode } from './helpers.js';
6
+ describe('LifecycleManager', () => {
7
+ it('shouldMitosis returns true for complex tasks', () => {
8
+ const lm = new LifecycleManager();
9
+ const node = mockNode({ id: 'n1' });
10
+ const task = {
11
+ taskId: 't1', domain: 'general', description: 'complex',
12
+ estimatedComplexity: 0.9, priority: 'normal',
13
+ };
14
+ expect(lm.shouldMitosis(node, task)).toBe(true);
15
+ });
16
+ it('shouldMitosis returns false at max depth', () => {
17
+ const lm = new LifecycleManager();
18
+ const node = mockNode({ id: 'n1', depth: 3 });
19
+ const task = {
20
+ taskId: 't1', domain: 'general', description: 'complex',
21
+ estimatedComplexity: 0.9, priority: 'normal',
22
+ };
23
+ expect(lm.shouldMitosis(node, task)).toBe(false);
24
+ });
25
+ it('checkHealth returns healthy for good node', () => {
26
+ const lm = new LifecycleManager();
27
+ const node = mockNode({ id: 'n1' });
28
+ const report = lm.checkHealth(node);
29
+ expect(report.status).toBe('healthy');
30
+ expect(report.recommendation).toBe('keep');
31
+ });
32
+ it('checkHealth returns dying for idle node', () => {
33
+ const lm = new LifecycleManager();
34
+ const node = mockNode({ id: 'n1', lastActiveAt: Date.now() - 600_000 });
35
+ const report = lm.checkHealth(node);
36
+ expect(report.status).toBe('dying');
37
+ expect(report.recommendation).toBe('recycle');
38
+ });
39
+ it('apoptosis throws when at min nodes', () => {
40
+ const lm = new LifecycleManager();
41
+ const cm = new ClusterManager();
42
+ cm.addNode(mockNode({ id: 'a' }));
43
+ cm.addNode(mockNode({ id: 'b' }));
44
+ const dying = cm.nodes.get('a');
45
+ expect(() => lm.apoptosis(dying, cm)).toThrow(ApoptosisRejectedError);
46
+ });
47
+ it('apoptosis removes node and merges capabilities', () => {
48
+ const lm = new LifecycleManager();
49
+ const cm = new ClusterManager();
50
+ cm.addNode(mockNode({ id: 'a' }));
51
+ cm.addNode(mockNode({ id: 'b' }));
52
+ cm.addNode(mockNode({ id: 'c' }));
53
+ const dying = cm.nodes.get('a');
54
+ dying.capabilities.scores.set('coding', 0.8);
55
+ lm.apoptosis(dying, cm);
56
+ expect(cm.nodes.size).toBe(2);
57
+ expect(cm.nodes.has('a')).toBe(false);
58
+ });
59
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,40 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { RewardBus } from '../reward-bus.js';
3
+ import { mockNode } from './helpers.js';
4
+ describe('RewardBus', () => {
5
+ const task = {
6
+ taskId: 't1', domain: 'general', description: 'test',
7
+ estimatedComplexity: 0.5, priority: 'normal',
8
+ };
9
+ const result = {
10
+ taskId: 't1', agentId: 'n1', content: 'done',
11
+ success: true, tokenCost: 100, errorCount: 0, durationMs: 500,
12
+ };
13
+ it('evaluates and updates node capability', async () => {
14
+ const bus = new RewardBus();
15
+ const node = mockNode({ id: 'n1' });
16
+ const reward = await bus.evaluate(node, task, result);
17
+ expect(reward).toBeGreaterThan(0);
18
+ expect(node.rewardHistory).toHaveLength(1);
19
+ expect(node.capabilities.totalTasks).toBe(1);
20
+ });
21
+ it('failed task yields lower reward', async () => {
22
+ const bus = new RewardBus();
23
+ const n1 = mockNode({ id: 'n1' });
24
+ const n2 = mockNode({ id: 'n2' });
25
+ const good = await bus.evaluate(n1, task, result);
26
+ const bad = await bus.evaluate(n2, task, { ...result, success: false, errorCount: 2 });
27
+ expect(good).toBeGreaterThan(bad);
28
+ });
29
+ it('decayInactive reduces scores over time', () => {
30
+ const bus = new RewardBus();
31
+ const node = mockNode({ id: 'n1' });
32
+ node.capabilities.scores.set('general', 0.8);
33
+ node.rewardHistory.push({
34
+ taskId: 't', reward: 0.8, domain: 'general',
35
+ tokenCost: 100, timestamp: Date.now() - 3 * 86_400_000,
36
+ });
37
+ bus.decayInactive(node);
38
+ expect(node.capabilities.scores.get('general')).toBeLessThan(0.8);
39
+ });
40
+ });
@@ -0,0 +1,41 @@
1
+ import type { AgentEvent, LoopStrategy, LoopContext, Skill, LLMProvider } from '@loom-node/core';
2
+ import type { ClusterManager } from './cluster.js';
3
+ import type { RewardBus } from './reward-bus.js';
4
+ import type { LifecycleManager, AgentFactory } from './lifecycle.js';
5
+ import type { TaskPlanner } from './planner.js';
6
+ import type { SkillNodeRegistry } from './skill-registry.js';
7
+ import type { AmoebaRuntime } from './runtime.js';
8
+ export interface AmoebaLoopConfig {
9
+ cluster: ClusterManager;
10
+ rewardBus: RewardBus;
11
+ lifecycle: LifecycleManager;
12
+ planner: TaskPlanner;
13
+ skillRegistry: SkillNodeRegistry;
14
+ llm: LLMProvider;
15
+ agentFactory?: AgentFactory;
16
+ runtime?: AmoebaRuntime;
17
+ skillEvolver?: Skill;
18
+ complexityLlmThreshold?: number;
19
+ evolutionRewardThreshold?: number;
20
+ evolutionWindow?: number;
21
+ }
22
+ export declare class AmoebaLoop implements LoopStrategy {
23
+ private readonly config;
24
+ readonly name = "amoeba";
25
+ private calibrationMap;
26
+ constructor(config: AmoebaLoopConfig);
27
+ execute(ctx: LoopContext): AsyncGenerator<AgentEvent>;
28
+ private sense;
29
+ private heuristicComplexity;
30
+ private llmComplexity;
31
+ private detectDomains;
32
+ private match;
33
+ private scaleAndExecute;
34
+ private buildEnrichedPrompt;
35
+ private evaluateAndAdapt;
36
+ private deriveActualComplexity;
37
+ private recordCalibration;
38
+ private calibrate;
39
+ private shouldEvolveSkill;
40
+ private triggerSkillEvolution;
41
+ }
@@ -0,0 +1,256 @@
1
+ import { skillToNode } from './skill-node.js';
2
+ import { AuctionNoWinnerError } from './errors.js';
3
+ export class AmoebaLoop {
4
+ config;
5
+ name = 'amoeba';
6
+ calibrationMap = new Map();
7
+ constructor(config) {
8
+ this.config = config;
9
+ }
10
+ async *execute(ctx) {
11
+ const startTime = Date.now();
12
+ const totalUsage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
13
+ const userMsg = [...ctx.messages].reverse().find((m) => m.role === 'user');
14
+ const raw = userMsg?.content ?? '';
15
+ const input = typeof raw === 'string' ? raw : '';
16
+ // Phase 1: SENSE
17
+ const spec = await this.sense(input);
18
+ yield { type: 'text-delta', content: `[Sense] complexity=${spec.task.estimatedComplexity.toFixed(2)} domains=[${spec.domainHints}]\n` };
19
+ // Phase 2: MATCH
20
+ const { winner, tier } = await this.match(spec, input);
21
+ if (!winner) {
22
+ yield { type: 'error', error: new AuctionNoWinnerError(spec.task.taskId), recoverable: false };
23
+ yield { type: 'done', content: '', usage: totalUsage, steps: 1, durationMs: Date.now() - startTime };
24
+ return;
25
+ }
26
+ yield { type: 'text-delta', content: `[Match] winner=${winner.id} tier=${tier}\n` };
27
+ // Phase 3+4: SCALE + EXECUTE
28
+ const result = await this.scaleAndExecute(winner, spec, input);
29
+ totalUsage.totalTokens += result.tokenCost;
30
+ yield { type: 'text-delta', content: result.content };
31
+ // Phase 5+6: EVALUATE + ADAPT
32
+ const adaptInfo = await this.evaluateAndAdapt(winner, spec, result);
33
+ yield { type: 'text-delta', content: `\n[Adapt] reward=${adaptInfo.reward.toFixed(2)} evolved=${adaptInfo.evolved} recycled=${adaptInfo.recycled}\n` };
34
+ yield { type: 'done', content: result.content, usage: totalUsage, steps: 1, durationMs: Date.now() - startTime };
35
+ }
36
+ // ── Phase 1: SENSE ──
37
+ async sense(input) {
38
+ const threshold = this.config.complexityLlmThreshold ?? 200;
39
+ const estimate = input.length < threshold
40
+ ? this.heuristicComplexity(input)
41
+ : await this.llmComplexity(input);
42
+ const calibrated = this.calibrate(estimate);
43
+ const task = {
44
+ taskId: `amoeba-${Date.now()}`,
45
+ domain: calibrated.domains[0] ?? 'general',
46
+ description: input,
47
+ estimatedComplexity: calibrated.score,
48
+ priority: 'normal',
49
+ tokenBudget: calibrated.score < 0.4 ? 2048 : calibrated.score < 0.7 ? 4096 : 8192,
50
+ };
51
+ return { task, objective: input, domainHints: calibrated.domains };
52
+ }
53
+ heuristicComplexity(input) {
54
+ const words = input.split(/\s+/).length;
55
+ const sentences = (input.match(/[.!?。!?]/g) ?? []).length;
56
+ const hasList = /\d+[.)]|[-*•]/.test(input);
57
+ const domains = this.detectDomains(input);
58
+ let score = Math.min(words / 200, 0.5);
59
+ if (sentences > 2)
60
+ score += 0.15;
61
+ if (hasList)
62
+ score += 0.1;
63
+ if (domains.length > 2)
64
+ score += 0.15;
65
+ return { score: Math.min(score, 1), domains, method: 'heuristic' };
66
+ }
67
+ async llmComplexity(input) {
68
+ try {
69
+ const result = await this.config.llm.complete({
70
+ messages: [{
71
+ role: 'user',
72
+ content: `Assess this task. Reply ONLY with JSON: {"score":0.0-1.0,"domains":["..."],"reasoning":"..."}\n\nTask: ${input}`,
73
+ }],
74
+ temperature: 0,
75
+ maxTokens: 128,
76
+ });
77
+ const match = result.content.match(/\{[\s\S]*\}/);
78
+ if (match) {
79
+ const obj = JSON.parse(match[0]);
80
+ return {
81
+ score: Math.max(0, Math.min(1, obj.score ?? 0.5)),
82
+ domains: obj.domains ?? ['general'],
83
+ reasoning: obj.reasoning,
84
+ method: 'llm',
85
+ };
86
+ }
87
+ }
88
+ catch { /* fall through */ }
89
+ return this.heuristicComplexity(input);
90
+ }
91
+ detectDomains(input) {
92
+ const lower = input.toLowerCase();
93
+ const domainKeywords = {
94
+ code: ['code', 'function', 'bug', 'api', 'implement', 'refactor'],
95
+ data: ['data', 'database', 'query', 'sql', 'schema'],
96
+ writing: ['write', 'draft', 'essay', 'article', 'document'],
97
+ math: ['calculate', 'equation', 'formula', 'math', 'statistics'],
98
+ research: ['research', 'analyze', 'compare', 'evaluate', 'study'],
99
+ };
100
+ const found = [];
101
+ for (const [domain, kws] of Object.entries(domainKeywords)) {
102
+ if (kws.some(k => lower.includes(k)))
103
+ found.push(domain);
104
+ }
105
+ return found.length > 0 ? found : ['general'];
106
+ }
107
+ // ── Phase 2: MATCH ──
108
+ async match(spec, input) {
109
+ // Tier 1: auction across loaded nodes
110
+ const auctionWinner = this.config.cluster.selectWinner(spec.task);
111
+ if (auctionWinner)
112
+ return { winner: auctionWinner, tier: 1 };
113
+ // Tier 2: scan unloaded skill catalog
114
+ const skillMatch = await this.config.skillRegistry.findMatch(input);
115
+ if (skillMatch) {
116
+ const node = this.config.runtime
117
+ ? this.config.runtime.loadSkill(skillMatch.skill)
118
+ : (() => { const n = skillToNode(skillMatch.skill, this.config.llm); this.config.cluster.addNode(n); this.config.skillRegistry.markLoaded(skillMatch.skill.name); return n; })();
119
+ return { winner: node, tier: 2 };
120
+ }
121
+ // Tier 3: skill evolution via LLM (opt-in)
122
+ if (this.config.skillEvolver?.tools?.[0]) {
123
+ try {
124
+ const res = await this.config.skillEvolver.tools[0].execute({ task: input }, {});
125
+ const r = res;
126
+ if (r.success && r.nodeId) {
127
+ const node = this.config.cluster.nodes.get(r.nodeId);
128
+ if (node)
129
+ return { winner: node, tier: 3 };
130
+ }
131
+ }
132
+ catch { /* tier 3 failed, fall through */ }
133
+ }
134
+ return { winner: undefined, tier: 1 };
135
+ }
136
+ // ── Phase 3+4: SCALE + EXECUTE ──
137
+ async scaleAndExecute(winner, spec, input) {
138
+ const complexity = spec.task.estimatedComplexity;
139
+ winner.status = 'busy';
140
+ winner.lastActiveAt = Date.now();
141
+ this.config.cluster.updateLoad(winner.id, 0.8);
142
+ try {
143
+ if (complexity > 0.7 && this.config.lifecycle.shouldMitosis(winner, spec.task)) {
144
+ return await this.config.lifecycle.mitosis(winner, spec.task, this.config.planner, this.config.cluster, this.config.rewardBus, this.config.agentFactory, spec, this.config.runtime);
145
+ }
146
+ const prompt = complexity >= 0.4 ? this.buildEnrichedPrompt(spec) : input;
147
+ const start = Date.now();
148
+ const done = await winner.agent.run(prompt);
149
+ return {
150
+ taskId: spec.task.taskId, agentId: winner.id,
151
+ content: done.content, success: true,
152
+ tokenCost: done.usage.totalTokens, errorCount: 0,
153
+ durationMs: Date.now() - start,
154
+ };
155
+ }
156
+ catch {
157
+ return {
158
+ taskId: spec.task.taskId, agentId: winner.id,
159
+ content: '', success: false, tokenCost: 0, errorCount: 1, durationMs: 0,
160
+ };
161
+ }
162
+ finally {
163
+ winner.status = 'idle';
164
+ this.config.cluster.updateLoad(winner.id, 0);
165
+ }
166
+ }
167
+ buildEnrichedPrompt(spec) {
168
+ let prompt = `Objective: ${spec.objective}`;
169
+ if (spec.outputFormat)
170
+ prompt += `\nOutput format: ${spec.outputFormat}`;
171
+ if (spec.boundaries)
172
+ prompt += `\nConstraints: ${spec.boundaries}`;
173
+ if (spec.toolGuidance)
174
+ prompt += `\nTool guidance: ${spec.toolGuidance}`;
175
+ return prompt;
176
+ }
177
+ // ── Phase 5+6: EVALUATE + ADAPT ──
178
+ async evaluateAndAdapt(winner, spec, result) {
179
+ // EVALUATE
180
+ const reward = await this.config.rewardBus.evaluate(winner, spec.task, result);
181
+ // ADAPT: consecutiveLosses
182
+ if (reward < 0.5) {
183
+ winner.consecutiveLosses++;
184
+ }
185
+ else {
186
+ winner.consecutiveLosses = 0;
187
+ }
188
+ // ADAPT: health check → apoptosis
189
+ let recycled = false;
190
+ const health = this.config.lifecycle.checkHealth(winner);
191
+ if (health.recommendation === 'recycle') {
192
+ try {
193
+ this.config.lifecycle.apoptosis(winner, this.config.cluster);
194
+ recycled = true;
195
+ }
196
+ catch { /* rejected, skip */ }
197
+ }
198
+ // ADAPT: complexity calibration
199
+ const actual = this.deriveActualComplexity(result);
200
+ this.recordCalibration(spec.task.domain, spec.task.estimatedComplexity, actual);
201
+ // ADAPT: skill evolution bridge
202
+ let evolved = false;
203
+ if (this.shouldEvolveSkill(winner)) {
204
+ evolved = await this.triggerSkillEvolution(winner, spec);
205
+ }
206
+ // ADAPT: decay inactive
207
+ this.config.rewardBus.decayInactive(winner);
208
+ return { reward, evolved, recycled };
209
+ }
210
+ // ── Helpers: calibration ──
211
+ deriveActualComplexity(result) {
212
+ const tokenRatio = Math.min(result.tokenCost / 8192, 1);
213
+ const durationRatio = Math.min(result.durationMs / 30000, 1);
214
+ return tokenRatio * 0.6 + durationRatio * 0.4;
215
+ }
216
+ recordCalibration(domain, estimated, actual) {
217
+ const entry = this.calibrationMap.get(domain) ?? { bias: 0, count: 0 };
218
+ entry.bias = 0.3 * (actual - estimated) + 0.7 * entry.bias;
219
+ entry.count++;
220
+ this.calibrationMap.set(domain, entry);
221
+ }
222
+ calibrate(estimate) {
223
+ const domain = estimate.domains[0] ?? 'general';
224
+ const entry = this.calibrationMap.get(domain);
225
+ if (!entry || entry.count < 3)
226
+ return estimate;
227
+ return { ...estimate, score: Math.max(0, Math.min(1, estimate.score + entry.bias)) };
228
+ }
229
+ // ── Helpers: skill evolution bridge ──
230
+ shouldEvolveSkill(node) {
231
+ if (!this.config.skillEvolver)
232
+ return false;
233
+ if (!node.id.startsWith('skill:'))
234
+ return false;
235
+ const window = this.config.evolutionWindow ?? 5;
236
+ const threshold = this.config.evolutionRewardThreshold ?? 0.35;
237
+ const recent = node.rewardHistory.slice(-window);
238
+ if (recent.length < window)
239
+ return false;
240
+ const avg = recent.reduce((s, r) => s + r.reward, 0) / recent.length;
241
+ return avg < threshold;
242
+ }
243
+ async triggerSkillEvolution(node, spec) {
244
+ const tool = this.config.skillEvolver?.tools?.[0];
245
+ if (!tool)
246
+ return false;
247
+ try {
248
+ const skillName = node.id.replace('skill:', '');
249
+ await tool.execute({ task: spec.objective, existingSkill: skillName, feedback: 'Low reward — needs improvement' }, {});
250
+ return true;
251
+ }
252
+ catch {
253
+ return false;
254
+ }
255
+ }
256
+ }
@@ -0,0 +1,9 @@
1
+ import type { ContextProvider, ContextFragment } from '@loom-node/core';
2
+ import type { ClusterManager } from './cluster.js';
3
+ export declare class ClusterProvider implements ContextProvider {
4
+ private cluster;
5
+ private agentId;
6
+ readonly source: "cluster";
7
+ constructor(cluster: ClusterManager, agentId: string);
8
+ provide(query: string, budget: number): Promise<ContextFragment[]>;
9
+ }
@@ -0,0 +1,39 @@
1
+ export class ClusterProvider {
2
+ cluster;
3
+ agentId;
4
+ source = 'cluster';
5
+ constructor(cluster, agentId) {
6
+ this.cluster = cluster;
7
+ this.agentId = agentId;
8
+ }
9
+ async provide(query, budget) {
10
+ const fragments = [];
11
+ const self = this.cluster.nodes.get(this.agentId);
12
+ if (!self)
13
+ return fragments;
14
+ const capLines = [...self.capabilities.scores.entries()]
15
+ .sort((a, b) => b[1] - a[1])
16
+ .map(([d, s]) => `${d}: ${s.toFixed(2)}`)
17
+ .join(', ');
18
+ const capText = `My capabilities: ${capLines}. Success rate: ${(self.capabilities.successRate * 100).toFixed(0)}%`;
19
+ fragments.push({ source: 'cluster', content: capText, tokens: estimateTokens(capText), relevance: 0.8, metadata: { type: 'self-capability' } });
20
+ const peers = [...this.cluster.nodes.values()].filter(n => n.id !== this.agentId);
21
+ if (peers.length > 0) {
22
+ const peerText = `Cluster: ${peers.length} peers. ` + peers.slice(0, 5)
23
+ .map(p => `${p.id}(${p.status}, load:${p.load.toFixed(1)})`)
24
+ .join(', ');
25
+ fragments.push({ source: 'cluster', content: peerText, tokens: estimateTokens(peerText), relevance: 0.5, metadata: { type: 'cluster-state' } });
26
+ }
27
+ const recent = self.rewardHistory.slice(-5);
28
+ if (recent.length > 0) {
29
+ const avg = recent.reduce((s, r) => s + r.reward, 0) / recent.length;
30
+ const rewardText = `Recent reward avg: ${avg.toFixed(2)} over ${recent.length} tasks`;
31
+ fragments.push({ source: 'cluster', content: rewardText, tokens: estimateTokens(rewardText), relevance: 0.6, metadata: { type: 'reward-history' } });
32
+ }
33
+ let total = 0;
34
+ return fragments.filter(f => { total += f.tokens; return total <= budget; });
35
+ }
36
+ }
37
+ function estimateTokens(text) {
38
+ return Math.ceil(text.length / 4);
39
+ }
@@ -0,0 +1,18 @@
1
+ import type { AgentNode, TaskAd, Bid } from '@loom-node/core';
2
+ export interface AuctionConfig {
3
+ bidTimeoutMs: number;
4
+ minBids: number;
5
+ fallbackStrategy: 'random' | 'reject';
6
+ }
7
+ export declare class ClusterManager {
8
+ readonly nodes: Map<string, AgentNode>;
9
+ private auctionConfig;
10
+ constructor(config?: Partial<AuctionConfig>);
11
+ addNode(node: AgentNode): void;
12
+ removeNode(id: string): void;
13
+ computeBid(node: AgentNode, task: TaskAd): Bid;
14
+ collectBids(task: TaskAd): Bid[];
15
+ selectWinner(task: TaskAd): AgentNode | undefined;
16
+ findIdle(): AgentNode | undefined;
17
+ updateLoad(nodeId: string, load: number): void;
18
+ }
@@ -0,0 +1,68 @@
1
+ const DEFAULT_AUCTION = {
2
+ bidTimeoutMs: 2000,
3
+ minBids: 1,
4
+ fallbackStrategy: 'reject',
5
+ };
6
+ export class ClusterManager {
7
+ nodes = new Map();
8
+ auctionConfig;
9
+ constructor(config) {
10
+ this.auctionConfig = { ...DEFAULT_AUCTION, ...config };
11
+ }
12
+ addNode(node) {
13
+ this.nodes.set(node.id, node);
14
+ }
15
+ removeNode(id) {
16
+ this.nodes.delete(id);
17
+ }
18
+ computeBid(node, task) {
19
+ const capabilityMatch = node.capabilities.scores.get(task.domain) ?? 0;
20
+ const availability = node.status === 'idle' ? 1 - node.load : 0;
21
+ const historySuccess = node.capabilities.successRate;
22
+ const tools = task.requiredTools ?? [];
23
+ const toolCoverage = tools.length === 0
24
+ ? 1
25
+ : tools.filter(t => node.capabilities.tools.includes(t)).length / tools.length;
26
+ const score = capabilityMatch * 0.4 + availability * 0.25 + historySuccess * 0.2 + toolCoverage * 0.15;
27
+ return {
28
+ agentId: node.id,
29
+ taskId: task.taskId,
30
+ score,
31
+ breakdown: { capabilityMatch, availability, historySuccess, toolCoverage },
32
+ estimatedTokens: task.tokenBudget ?? 4096,
33
+ };
34
+ }
35
+ collectBids(task) {
36
+ const bids = [];
37
+ for (const node of this.nodes.values()) {
38
+ if (node.status !== 'idle' && node.status !== 'busy')
39
+ continue;
40
+ const bid = this.computeBid(node, task);
41
+ if (bid.score > 0)
42
+ bids.push(bid);
43
+ }
44
+ return bids.sort((a, b) => b.score - a.score);
45
+ }
46
+ selectWinner(task) {
47
+ const bids = this.collectBids(task);
48
+ if (bids.length < this.auctionConfig.minBids) {
49
+ if (this.auctionConfig.fallbackStrategy === 'random') {
50
+ return this.findIdle();
51
+ }
52
+ return undefined;
53
+ }
54
+ return this.nodes.get(bids[0].agentId);
55
+ }
56
+ findIdle() {
57
+ for (const node of this.nodes.values()) {
58
+ if (node.status === 'idle')
59
+ return node;
60
+ }
61
+ return undefined;
62
+ }
63
+ updateLoad(nodeId, load) {
64
+ const node = this.nodes.get(nodeId);
65
+ if (node)
66
+ node.load = Math.max(0, Math.min(1, load));
67
+ }
68
+ }
@@ -0,0 +1,12 @@
1
+ import { LoomError } from '@loom-node/core';
2
+ export declare class AuctionNoWinnerError extends LoomError {
3
+ constructor(taskId: string);
4
+ }
5
+ export declare class MitosisError extends LoomError {
6
+ readonly parentId: string;
7
+ constructor(parentId: string, message: string, cause?: Error);
8
+ }
9
+ export declare class ApoptosisRejectedError extends LoomError {
10
+ readonly nodeId: string;
11
+ constructor(nodeId: string, reason: string);
12
+ }