@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.
- package/LICENSE +21 -0
- package/dist/__tests__/cluster.test.d.ts +1 -0
- package/dist/__tests__/cluster.test.js +54 -0
- package/dist/__tests__/helpers.d.ts +4 -0
- package/dist/__tests__/helpers.js +13 -0
- package/dist/__tests__/lifecycle.test.d.ts +1 -0
- package/dist/__tests__/lifecycle.test.js +59 -0
- package/dist/__tests__/reward-bus.test.d.ts +1 -0
- package/dist/__tests__/reward-bus.test.js +40 -0
- package/dist/amoeba-loop.d.ts +41 -0
- package/dist/amoeba-loop.js +256 -0
- package/dist/cluster-provider.d.ts +9 -0
- package/dist/cluster-provider.js +39 -0
- package/dist/cluster.d.ts +18 -0
- package/dist/cluster.js +68 -0
- package/dist/errors.d.ts +12 -0
- package/dist/errors.js +23 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +15 -0
- package/dist/lifecycle.d.ts +42 -0
- package/dist/lifecycle.js +171 -0
- package/dist/mitosis-provider.d.ts +10 -0
- package/dist/mitosis-provider.js +61 -0
- package/dist/mitosis-scratchpad.d.ts +8 -0
- package/dist/mitosis-scratchpad.js +23 -0
- package/dist/orchestrate-strategy.d.ts +21 -0
- package/dist/orchestrate-strategy.js +78 -0
- package/dist/plan-strategy.d.ts +9 -0
- package/dist/plan-strategy.js +39 -0
- package/dist/planner.d.ts +14 -0
- package/dist/planner.js +64 -0
- package/dist/reward-bus.d.ts +28 -0
- package/dist/reward-bus.js +68 -0
- package/dist/runtime.d.ts +64 -0
- package/dist/runtime.js +139 -0
- package/dist/skill-evolver.d.ts +7 -0
- package/dist/skill-evolver.js +89 -0
- package/dist/skill-node.d.ts +11 -0
- package/dist/skill-node.js +41 -0
- package/dist/skill-registry.d.ts +18 -0
- package/dist/skill-registry.js +71 -0
- 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,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
|
+
}
|
package/dist/cluster.js
ADDED
|
@@ -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
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -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
|
+
}
|