plugin-agent-orchestrator 1.0.20 → 1.0.21
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/dist/client/hooks/useRunEventStream.d.ts +22 -0
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.js +1 -1
- package/dist/externalVersion.js +6 -6
- package/dist/server/collections/agent-execution-spans.js +24 -0
- package/dist/server/collections/agent-loop-runs.js +36 -0
- package/dist/server/collections/orchestrator-config.js +14 -0
- package/dist/server/migrations/20260601000000-add-token-fields.d.ts +7 -0
- package/dist/server/migrations/20260601000000-add-token-fields.js +101 -0
- package/dist/server/plugin.js +47 -0
- package/dist/server/resources/agent-loop.js +33 -25
- package/dist/server/resources/tracing.js +5 -8
- package/dist/server/services/AgentHarness.d.ts +2 -0
- package/dist/server/services/AgentHarness.js +56 -90
- package/dist/server/services/AgentLoopController.d.ts +33 -20
- package/dist/server/services/AgentLoopController.js +164 -125
- package/dist/server/services/AgentLoopRepository.js +16 -34
- package/dist/server/services/AgentLoopService.d.ts +28 -18
- package/dist/server/services/AgentLoopService.js +7 -1
- package/dist/server/services/AgentPlannerService.js +5 -25
- package/dist/server/services/AgentRegistryService.d.ts +8 -0
- package/dist/server/services/AgentRegistryService.js +34 -24
- package/dist/server/services/CircuitBreaker.d.ts +40 -0
- package/dist/server/services/CircuitBreaker.js +120 -0
- package/dist/server/services/ContextAggregator.d.ts +45 -0
- package/dist/server/services/ContextAggregator.js +201 -0
- package/dist/server/services/ExecutionSpanService.js +2 -5
- package/dist/server/services/RunEventBus.d.ts +9 -0
- package/dist/server/services/RunEventBus.js +73 -0
- package/dist/server/services/TokenTracker.d.ts +62 -0
- package/dist/server/services/TokenTracker.js +173 -0
- package/dist/server/tools/agent-loop.d.ts +8 -8
- package/dist/server/tools/agent-loop.js +30 -63
- package/dist/server/tools/delegate-task.js +14 -72
- package/dist/server/tools/orchestrator-plan.d.ts +6 -6
- package/dist/server/tools/orchestrator-plan.js +10 -47
- package/dist/server/types.d.ts +47 -0
- package/dist/server/types.js +24 -0
- package/dist/server/utils/ctx-utils.d.ts +30 -0
- package/dist/server/utils/ctx-utils.js +152 -0
- package/dist/server/utils/logging.d.ts +6 -0
- package/dist/server/utils/logging.js +86 -0
- package/package.json +44 -44
- package/src/client/AgentRunsTab.tsx +764 -764
- package/src/client/HarnessProfilesTab.tsx +247 -247
- package/src/client/OrchestratorSettings.tsx +106 -106
- package/src/client/RulesTab.tsx +716 -716
- package/src/client/hooks/useRunEventStream.ts +76 -0
- package/src/client/index.tsx +2 -1
- package/src/client/plugin.tsx +27 -27
- package/src/client/skill-hub/components/LoopSettings.tsx +331 -331
- package/src/client/skill-hub/index.tsx +51 -51
- package/src/client/skill-hub/tools/InteractionSchemasProvider.tsx +99 -99
- package/src/client/skill-hub/tools/SkillHubCard.tsx +109 -109
- package/src/client/skill-hub/tools/loopTemplates.ts +52 -52
- package/src/client/skill-hub/tools/registerSkillLoopCards.ts +58 -58
- package/src/client/tools/PlanApprovalCard.tsx +175 -175
- package/src/client/tools/registerOrchestratorCards.ts +7 -7
- package/src/server/__tests__/agent-loop-controller.test.ts +375 -0
- package/src/server/__tests__/circuit-breaker.test.ts +169 -0
- package/src/server/__tests__/context-aggregator.test.ts +222 -0
- package/src/server/__tests__/parallel-execution.test.ts +318 -0
- package/src/server/__tests__/smoke.test.ts +120 -0
- package/src/server/collections/agent-execution-spans.ts +24 -0
- package/src/server/collections/agent-harness-profiles.ts +59 -59
- package/src/server/collections/agent-loop-events.ts +71 -71
- package/src/server/collections/agent-loop-runs.ts +38 -1
- package/src/server/collections/agent-loop-steps.ts +144 -144
- package/src/server/collections/orchestrator-config.ts +14 -0
- package/src/server/collections/skill-executions.ts +106 -106
- package/src/server/collections/skill-loop-configs.ts +65 -65
- package/src/server/migrations/20260524000000-add-agent-loop-fields-to-skill-executions.ts +30 -30
- package/src/server/migrations/20260524001000-add-plan-approval-and-harness-profiles.ts +142 -142
- package/src/server/migrations/20260601000000-add-token-fields.ts +89 -0
- package/src/server/plugin.ts +53 -0
- package/src/server/resources/agent-loop.ts +21 -12
- package/src/server/resources/tracing.ts +3 -7
- package/src/server/services/AgentHarness.ts +78 -116
- package/src/server/services/AgentLoopController.ts +197 -122
- package/src/server/services/AgentLoopRepository.ts +9 -25
- package/src/server/services/AgentLoopService.ts +13 -1
- package/src/server/services/AgentPlanValidator.ts +73 -73
- package/src/server/services/AgentPlannerService.ts +2 -25
- package/src/server/services/AgentRegistryService.ts +40 -31
- package/src/server/services/CircuitBreaker.ts +116 -0
- package/src/server/services/ContextAggregator.ts +239 -0
- package/src/server/services/ExecutionSpanService.ts +2 -4
- package/src/server/services/RunEventBus.ts +45 -0
- package/src/server/services/TokenTracker.ts +209 -0
- package/src/server/skill-hub/plugin.ts +898 -898
- package/src/server/skill-hub/tasks/SkillExecutionTask.ts +460 -460
- package/src/server/tools/agent-loop.ts +18 -57
- package/src/server/tools/delegate-task.ts +11 -93
- package/src/server/tools/orchestrator-plan.ts +26 -50
- package/src/server/tools/skill-execute.ts +160 -160
- package/src/server/types.ts +55 -0
- package/src/server/utils/ctx-utils.ts +118 -0
- package/src/server/utils/logging.ts +63 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { ContextAggregator } from '../services/ContextAggregator';
|
|
3
|
+
|
|
4
|
+
function createMockPlugin() {
|
|
5
|
+
const stepIdCounter = 1;
|
|
6
|
+
const stepsStore: any[] = [];
|
|
7
|
+
const runsStore: any[] = [];
|
|
8
|
+
|
|
9
|
+
const mockRepo = {
|
|
10
|
+
find: vi.fn(async ({ filter }: any) => {
|
|
11
|
+
if (filter?.runId) {
|
|
12
|
+
return stepsStore.filter((s) => s.runId === filter.runId);
|
|
13
|
+
}
|
|
14
|
+
return stepsStore;
|
|
15
|
+
}),
|
|
16
|
+
findOne: vi.fn(async ({ filter }: any) => {
|
|
17
|
+
if (filter?.id) {
|
|
18
|
+
return runsStore.find((r) => r.id === filter.id) || null;
|
|
19
|
+
}
|
|
20
|
+
return runsStore[0] || null;
|
|
21
|
+
}),
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
db: {
|
|
26
|
+
getRepository: vi.fn((name: string) => {
|
|
27
|
+
if (name === 'agentLoopSteps') return mockRepo;
|
|
28
|
+
if (name === 'agentLoopRuns') return mockRepo;
|
|
29
|
+
return null;
|
|
30
|
+
}),
|
|
31
|
+
},
|
|
32
|
+
app: { log: { warn: vi.fn() } },
|
|
33
|
+
_stepsStore: stepsStore,
|
|
34
|
+
_runsStore: runsStore,
|
|
35
|
+
_repo: mockRepo,
|
|
36
|
+
_stepIdCounter: stepIdCounter,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function addStep(plugin: any, overrides: any = {}) {
|
|
41
|
+
const id = plugin._stepIdCounter++;
|
|
42
|
+
const step = {
|
|
43
|
+
id,
|
|
44
|
+
runId: overrides.runId || 1,
|
|
45
|
+
planKey: overrides.planKey || `step_${id}`,
|
|
46
|
+
index: overrides.index ?? id - 1,
|
|
47
|
+
title: overrides.title || `Step ${id}`,
|
|
48
|
+
description: overrides.description || '',
|
|
49
|
+
type: overrides.type || 'reasoning',
|
|
50
|
+
target: overrides.target || '',
|
|
51
|
+
status: overrides.status || 'succeeded',
|
|
52
|
+
output: overrides.output || {},
|
|
53
|
+
error: overrides.error || '',
|
|
54
|
+
metadata: overrides.metadata || {},
|
|
55
|
+
...overrides,
|
|
56
|
+
};
|
|
57
|
+
plugin._stepsStore.push(step);
|
|
58
|
+
return step;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function addRun(plugin: any, overrides: any = {}) {
|
|
62
|
+
const run = {
|
|
63
|
+
id: overrides.id || 1,
|
|
64
|
+
policy: overrides.policy || {},
|
|
65
|
+
...overrides,
|
|
66
|
+
};
|
|
67
|
+
plugin._runsStore.push(run);
|
|
68
|
+
return run;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe('ContextAggregator', () => {
|
|
72
|
+
describe('buildStepContext', () => {
|
|
73
|
+
it('returns empty string when there are no steps', async () => {
|
|
74
|
+
const plugin = createMockPlugin();
|
|
75
|
+
const aggregator = new ContextAggregator(plugin);
|
|
76
|
+
const result = await aggregator.buildStepContext(1);
|
|
77
|
+
expect(result).toBe('');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('returns empty string when all steps are pending', async () => {
|
|
81
|
+
const plugin = createMockPlugin();
|
|
82
|
+
addStep(plugin, { status: 'pending' });
|
|
83
|
+
addStep(plugin, { status: 'running' });
|
|
84
|
+
const aggregator = new ContextAggregator(plugin);
|
|
85
|
+
const result = await aggregator.buildStepContext(1);
|
|
86
|
+
expect(result).toBe('');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('builds XML for completed steps', async () => {
|
|
90
|
+
const plugin = createMockPlugin();
|
|
91
|
+
addStep(plugin, {
|
|
92
|
+
planKey: 'step_1',
|
|
93
|
+
title: 'Research topic',
|
|
94
|
+
type: 'sub_agent',
|
|
95
|
+
status: 'succeeded',
|
|
96
|
+
output: { summary: 'Found data' },
|
|
97
|
+
});
|
|
98
|
+
const aggregator = new ContextAggregator(plugin);
|
|
99
|
+
const result = await aggregator.buildStepContext(1);
|
|
100
|
+
|
|
101
|
+
expect(result).toContain('<previous_steps>');
|
|
102
|
+
expect(result).toContain('<step key="step_1"');
|
|
103
|
+
expect(result).toContain('<title>Research topic</title>');
|
|
104
|
+
expect(result).toContain('<output>');
|
|
105
|
+
expect(result).toContain('Found data');
|
|
106
|
+
expect(result).toContain('</previous_steps>');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('includes error information for failed steps', async () => {
|
|
110
|
+
const plugin = createMockPlugin();
|
|
111
|
+
addStep(plugin, { planKey: 'step_1', status: 'failed', error: 'Something went wrong' });
|
|
112
|
+
const aggregator = new ContextAggregator(plugin);
|
|
113
|
+
const result = await aggregator.buildStepContext(1);
|
|
114
|
+
|
|
115
|
+
expect(result).toContain('<error>');
|
|
116
|
+
expect(result).toContain('Something went wrong');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('respects last_n strategy', async () => {
|
|
120
|
+
const plugin = createMockPlugin();
|
|
121
|
+
for (let i = 1; i <= 15; i++) {
|
|
122
|
+
addStep(plugin, { planKey: `step_${i}`, title: `Step ${i}`, status: 'succeeded' });
|
|
123
|
+
}
|
|
124
|
+
const aggregator = new ContextAggregator(plugin);
|
|
125
|
+
const result = await aggregator.buildStepContext(1, 4000, { strategy: 'last_n' });
|
|
126
|
+
|
|
127
|
+
// Should only include last 10
|
|
128
|
+
expect(result).toContain('step_6');
|
|
129
|
+
expect(result).not.toContain('planKey="step_1"');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('omits output when includeStepOutputs is false', async () => {
|
|
133
|
+
const plugin = createMockPlugin();
|
|
134
|
+
addStep(plugin, { planKey: 'step_1', status: 'succeeded', output: { secret: 'data' } });
|
|
135
|
+
const aggregator = new ContextAggregator(plugin);
|
|
136
|
+
const result = await aggregator.buildStepContext(1, 4000, { includeStepOutputs: false });
|
|
137
|
+
|
|
138
|
+
expect(result).not.toContain('<output>');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('includes tool_results when includeToolResults is true', async () => {
|
|
142
|
+
const plugin = createMockPlugin();
|
|
143
|
+
addStep(plugin, {
|
|
144
|
+
planKey: 'step_1',
|
|
145
|
+
status: 'succeeded',
|
|
146
|
+
metadata: { toolResults: [{ tool: 'search', result: 'found' }] },
|
|
147
|
+
});
|
|
148
|
+
const aggregator = new ContextAggregator(plugin);
|
|
149
|
+
const result = await aggregator.buildStepContext(1, 4000, { includeToolResults: true });
|
|
150
|
+
|
|
151
|
+
expect(result).toContain('<tool_results>');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('escapes XML special characters', async () => {
|
|
155
|
+
const plugin = createMockPlugin();
|
|
156
|
+
addStep(plugin, { planKey: 'step_1', status: 'succeeded', title: 'Test & "Hello" <World>' });
|
|
157
|
+
const aggregator = new ContextAggregator(plugin);
|
|
158
|
+
const result = await aggregator.buildStepContext(1);
|
|
159
|
+
|
|
160
|
+
expect(result).toContain('&');
|
|
161
|
+
expect(result).toContain('<');
|
|
162
|
+
expect(result).toContain('"');
|
|
163
|
+
expect(result).not.toContain('<World>');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('truncates text exceeding maxTokens', async () => {
|
|
167
|
+
const plugin = createMockPlugin();
|
|
168
|
+
// Add many steps to force truncation
|
|
169
|
+
for (let i = 1; i <= 20; i++) {
|
|
170
|
+
addStep(plugin, { planKey: `step_${i}`, status: 'succeeded', description: 'A'.repeat(500) });
|
|
171
|
+
}
|
|
172
|
+
const aggregator = new ContextAggregator(plugin);
|
|
173
|
+
const result = await aggregator.buildStepContext(1, 500); // very low token limit
|
|
174
|
+
|
|
175
|
+
expect(result).toContain('intermediate step(s) omitted');
|
|
176
|
+
expect(result.length).toBeLessThan(3000);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('handles repository errors gracefully', async () => {
|
|
180
|
+
const plugin = createMockPlugin();
|
|
181
|
+
plugin.db.getRepository = vi.fn(() => null);
|
|
182
|
+
const aggregator = new ContextAggregator(plugin);
|
|
183
|
+
const result = await aggregator.buildStepContext(1);
|
|
184
|
+
expect(result).toBe('');
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe('enrichSystemPrompt', () => {
|
|
189
|
+
it('returns base prompt when no run exists', async () => {
|
|
190
|
+
const plugin = createMockPlugin();
|
|
191
|
+
const aggregator = new ContextAggregator(plugin);
|
|
192
|
+
const result = await aggregator.enrichSystemPrompt('You are a helpful assistant.', 999);
|
|
193
|
+
expect(result).toBe('You are a helpful assistant.');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('enriches prompt with step context', async () => {
|
|
197
|
+
const plugin = createMockPlugin();
|
|
198
|
+
addRun(plugin, { id: 1, policy: { maxContextTokens: 4000 } });
|
|
199
|
+
addStep(plugin, { planKey: 'step_1', title: 'Research', status: 'succeeded', output: { data: 'results' } });
|
|
200
|
+
const aggregator = new ContextAggregator(plugin);
|
|
201
|
+
const result = await aggregator.enrichSystemPrompt('You are a helpful assistant.', 1);
|
|
202
|
+
|
|
203
|
+
expect(result).toContain('You are a helpful assistant.');
|
|
204
|
+
expect(result).toContain('<previous_steps_context>');
|
|
205
|
+
expect(result).toContain('Research');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('reads policy settings from run', async () => {
|
|
209
|
+
const plugin = createMockPlugin();
|
|
210
|
+
addRun(plugin, {
|
|
211
|
+
id: 1,
|
|
212
|
+
policy: { maxContextTokens: 100, contextSummaryStrategy: 'last_n', includeStepOutputs: false },
|
|
213
|
+
});
|
|
214
|
+
addStep(plugin, { planKey: 'step_1', status: 'succeeded', output: { data: 'results' } });
|
|
215
|
+
addStep(plugin, { planKey: 'step_2', status: 'succeeded', output: { data: 'more' } });
|
|
216
|
+
const aggregator = new ContextAggregator(plugin);
|
|
217
|
+
const result = await aggregator.enrichSystemPrompt('Base prompt.', 1);
|
|
218
|
+
|
|
219
|
+
expect(result).not.toContain('<output>');
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
});
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { AgentLoopController } from '../services/AgentLoopController';
|
|
3
|
+
|
|
4
|
+
// ── Mocks ──
|
|
5
|
+
function createMockRepository() {
|
|
6
|
+
const runs = new Map<number, any>();
|
|
7
|
+
const steps = new Map<number, any>();
|
|
8
|
+
const events: any[] = [];
|
|
9
|
+
let runIdCounter = 1;
|
|
10
|
+
let stepIdCounter = 1;
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
_runs: runs,
|
|
14
|
+
_steps: steps,
|
|
15
|
+
_events: events,
|
|
16
|
+
|
|
17
|
+
requireRun: vi.fn(async (id: number) => {
|
|
18
|
+
const run = runs.get(Number(id));
|
|
19
|
+
if (!run) throw new Error(`Run ${id} not found`);
|
|
20
|
+
return { ...run };
|
|
21
|
+
}),
|
|
22
|
+
getRun: vi.fn(async (id: number) => {
|
|
23
|
+
const run = runs.get(Number(id));
|
|
24
|
+
return run ? { ...run } : null;
|
|
25
|
+
}),
|
|
26
|
+
createRun: vi.fn(async (values: any) => {
|
|
27
|
+
const id = runIdCounter++;
|
|
28
|
+
const run = { id, ...values, createdAt: new Date(), updatedAt: new Date() };
|
|
29
|
+
runs.set(id, run);
|
|
30
|
+
return { ...run };
|
|
31
|
+
}),
|
|
32
|
+
updateRun: vi.fn(async (id: number, values: any) => {
|
|
33
|
+
const existing = runs.get(Number(id));
|
|
34
|
+
if (existing) {
|
|
35
|
+
runs.set(Number(id), { ...existing, ...values, updatedAt: new Date() });
|
|
36
|
+
}
|
|
37
|
+
}),
|
|
38
|
+
requireStep: vi.fn(async (id: number) => {
|
|
39
|
+
const step = steps.get(Number(id));
|
|
40
|
+
if (!step) throw new Error(`Step ${id} not found`);
|
|
41
|
+
return { ...step };
|
|
42
|
+
}),
|
|
43
|
+
getStep: vi.fn(async (id: number) => {
|
|
44
|
+
const step = steps.get(Number(id));
|
|
45
|
+
return step ? { ...step } : null;
|
|
46
|
+
}),
|
|
47
|
+
createStep: vi.fn(async (values: any) => {
|
|
48
|
+
const id = stepIdCounter++;
|
|
49
|
+
const step = { id, ...values, createdAt: new Date(), updatedAt: new Date() };
|
|
50
|
+
steps.set(id, step);
|
|
51
|
+
return { ...step };
|
|
52
|
+
}),
|
|
53
|
+
updateStep: vi.fn(async (id: number, values: any) => {
|
|
54
|
+
const existing = steps.get(Number(id));
|
|
55
|
+
if (existing) {
|
|
56
|
+
steps.set(Number(id), { ...existing, ...values, updatedAt: new Date() });
|
|
57
|
+
}
|
|
58
|
+
}),
|
|
59
|
+
getSteps: vi.fn(async (runId: number) => {
|
|
60
|
+
return Array.from(steps.values())
|
|
61
|
+
.filter((s) => s.runId === runId)
|
|
62
|
+
.map((s) => ({ ...s }))
|
|
63
|
+
.sort((a, b) => (a.index || 0) - (b.index || 0));
|
|
64
|
+
}),
|
|
65
|
+
createEvent: vi.fn(async (values: any) => {
|
|
66
|
+
const event = { id: events.length + 1, ...values, createdAt: new Date() };
|
|
67
|
+
events.push(event);
|
|
68
|
+
return event;
|
|
69
|
+
}),
|
|
70
|
+
getEvents: vi.fn(async () => []),
|
|
71
|
+
getLinkedSpans: vi.fn(async () => []),
|
|
72
|
+
getLinkedSkillExecutions: vi.fn(async () => []),
|
|
73
|
+
lockRun: vi.fn(async () => true),
|
|
74
|
+
unlockRun: vi.fn(async () => {}),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function createController(harnessExecute?: any) {
|
|
79
|
+
const repository = createMockRepository();
|
|
80
|
+
const harness = {
|
|
81
|
+
executeStep:
|
|
82
|
+
harnessExecute ||
|
|
83
|
+
vi.fn(async (_run: any, step: any) => ({
|
|
84
|
+
summary: `Executed: ${step.title}`,
|
|
85
|
+
})),
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const controller = new AgentLoopController(
|
|
89
|
+
{ getHarnessProfile: vi.fn(async () => ({ settings: {} })) } as any,
|
|
90
|
+
{ buildPlan: vi.fn() } as any,
|
|
91
|
+
{ validate: vi.fn() } as any,
|
|
92
|
+
repository as any,
|
|
93
|
+
harness as any,
|
|
94
|
+
{ checkBudget: vi.fn(async () => ({ allowed: true })) } as any,
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
return { controller, repository, harness };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function seedRunWithSteps(
|
|
101
|
+
repository: any,
|
|
102
|
+
stepDefs: { planKey: string; dependsOn?: string[]; title?: string; type?: string; target?: string }[],
|
|
103
|
+
) {
|
|
104
|
+
const run = await repository.createRun({
|
|
105
|
+
goal: 'parallel test',
|
|
106
|
+
rootRunId: 'parallel-test-root',
|
|
107
|
+
status: 'approved',
|
|
108
|
+
policy: {
|
|
109
|
+
maxIterations: 20,
|
|
110
|
+
maxStepAttempts: 2,
|
|
111
|
+
allowReplan: false,
|
|
112
|
+
requireVerification: false,
|
|
113
|
+
stopOnApprovalRequired: false,
|
|
114
|
+
maxConcurrency: 5,
|
|
115
|
+
},
|
|
116
|
+
metadata: {
|
|
117
|
+
harnessSettings: {},
|
|
118
|
+
approvalMode: 'plan_first',
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
for (let i = 0; i < stepDefs.length; i++) {
|
|
123
|
+
const def = stepDefs[i];
|
|
124
|
+
await repository.createStep({
|
|
125
|
+
runId: run.id,
|
|
126
|
+
planKey: def.planKey,
|
|
127
|
+
index: i,
|
|
128
|
+
title: def.title || `Step ${i + 1}`,
|
|
129
|
+
type: def.type || 'reasoning',
|
|
130
|
+
target: def.target || '',
|
|
131
|
+
status: 'pending',
|
|
132
|
+
attempt: 0,
|
|
133
|
+
maxAttempts: 2,
|
|
134
|
+
dependsOn: def.dependsOn || [],
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return run.id;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
describe('Parallel execution', () => {
|
|
142
|
+
it('executes independent steps concurrently', async () => {
|
|
143
|
+
const executeOrder: number[] = [];
|
|
144
|
+
const { controller, repository } = createController(
|
|
145
|
+
vi.fn(async (_run: any, step: any) => {
|
|
146
|
+
executeOrder.push(step.id);
|
|
147
|
+
return { summary: `Done: ${step.title}` };
|
|
148
|
+
}),
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
// 3 independent steps + 1 dependent on the last
|
|
152
|
+
const runId = await seedRunWithSteps(repository, [
|
|
153
|
+
{ planKey: 'step_a', title: 'Research A' },
|
|
154
|
+
{ planKey: 'step_b', title: 'Research B' },
|
|
155
|
+
{ planKey: 'step_c', title: 'Research C' },
|
|
156
|
+
{ planKey: 'step_d', title: 'Combine', dependsOn: ['step_a', 'step_b', 'step_c'] },
|
|
157
|
+
]);
|
|
158
|
+
|
|
159
|
+
const snapshot = await controller.executeApprovedPlan(runId);
|
|
160
|
+
expect(snapshot.run.status).toBe('succeeded');
|
|
161
|
+
expect(executeOrder.length).toBe(4);
|
|
162
|
+
|
|
163
|
+
// Steps A, B, C should have been executed before D
|
|
164
|
+
const aIdx = executeOrder.indexOf(1);
|
|
165
|
+
const bIdx = executeOrder.indexOf(2);
|
|
166
|
+
const cIdx = executeOrder.indexOf(3);
|
|
167
|
+
const dIdx = executeOrder.indexOf(4);
|
|
168
|
+
expect(dIdx).toBeGreaterThan(aIdx);
|
|
169
|
+
expect(dIdx).toBeGreaterThan(bIdx);
|
|
170
|
+
expect(dIdx).toBeGreaterThan(cIdx);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('respects maxConcurrency limit', async () => {
|
|
174
|
+
let concurrentMax = 0;
|
|
175
|
+
let currentConcurrent = 0;
|
|
176
|
+
|
|
177
|
+
const { controller, repository } = createController(
|
|
178
|
+
vi.fn(async (_run: any, _step: any) => {
|
|
179
|
+
currentConcurrent++;
|
|
180
|
+
concurrentMax = Math.max(concurrentMax, currentConcurrent);
|
|
181
|
+
// Simulate async work
|
|
182
|
+
await new Promise<void>((resolve) => queueMicrotask(resolve));
|
|
183
|
+
currentConcurrent--;
|
|
184
|
+
return { summary: 'done' };
|
|
185
|
+
}),
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
const runId = await seedRunWithSteps(repository, [
|
|
189
|
+
{ planKey: 'step_1', title: 'Task 1' },
|
|
190
|
+
{ planKey: 'step_2', title: 'Task 2' },
|
|
191
|
+
{ planKey: 'step_3', title: 'Task 3' },
|
|
192
|
+
{ planKey: 'step_4', title: 'Task 4' },
|
|
193
|
+
{ planKey: 'step_5', title: 'Task 5' },
|
|
194
|
+
{ planKey: 'step_6', title: 'Task 6' },
|
|
195
|
+
]);
|
|
196
|
+
|
|
197
|
+
// The repo policy has maxConcurrency=5
|
|
198
|
+
await controller.executeApprovedPlan(runId);
|
|
199
|
+
// With 6 independent steps and concurrency 5, at most 5 should run simultaneously
|
|
200
|
+
expect(concurrentMax).toBeLessThanOrEqual(5);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('handles partial failure in a batch', async () => {
|
|
204
|
+
const { controller, repository } = createController(
|
|
205
|
+
vi.fn(async (_run: any, step: any) => {
|
|
206
|
+
if (step.planKey === 'step_b') {
|
|
207
|
+
throw new Error('Step B failed');
|
|
208
|
+
}
|
|
209
|
+
return { summary: `Done: ${step.title}` };
|
|
210
|
+
}),
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
const runId = await seedRunWithSteps(repository, [
|
|
214
|
+
{ planKey: 'step_a', title: 'Works fine' },
|
|
215
|
+
{ planKey: 'step_b', title: 'Fails', type: 'skill', target: 'bad_tool' },
|
|
216
|
+
{ planKey: 'step_c', title: 'Also fine' },
|
|
217
|
+
]);
|
|
218
|
+
|
|
219
|
+
const snapshot = await controller.executeApprovedPlan(runId);
|
|
220
|
+
// Step B should be marked failed
|
|
221
|
+
const failedStep = snapshot.steps.find((s: any) => s.planKey === 'step_b');
|
|
222
|
+
expect(failedStep.status).toBe('failed');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('executes chain: step1 → step2 → step3 sequentially', async () => {
|
|
226
|
+
const executeOrder: number[] = [];
|
|
227
|
+
const { controller, repository } = createController(
|
|
228
|
+
vi.fn(async (_run: any, step: any) => {
|
|
229
|
+
executeOrder.push(step.id);
|
|
230
|
+
return { summary: `Done: ${step.title}` };
|
|
231
|
+
}),
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
const runId = await seedRunWithSteps(repository, [
|
|
235
|
+
{ planKey: 'step_1', title: 'First' },
|
|
236
|
+
{ planKey: 'step_2', title: 'Second', dependsOn: ['step_1'] },
|
|
237
|
+
{ planKey: 'step_3', title: 'Third', dependsOn: ['step_2'] },
|
|
238
|
+
]);
|
|
239
|
+
|
|
240
|
+
const snapshot = await controller.executeApprovedPlan(runId);
|
|
241
|
+
expect(snapshot.run.status).toBe('succeeded');
|
|
242
|
+
expect(executeOrder).toEqual([1, 2, 3]);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('executes diamond dependencies: A → (B, C) → D', async () => {
|
|
246
|
+
const executeOrder: number[] = [];
|
|
247
|
+
const { controller, repository } = createController(
|
|
248
|
+
vi.fn(async (_run: any, step: any) => {
|
|
249
|
+
executeOrder.push(step.id);
|
|
250
|
+
return { summary: `Done: ${step.title}` };
|
|
251
|
+
}),
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
const runId = await seedRunWithSteps(repository, [
|
|
255
|
+
{ planKey: 'step_a', title: 'Root' },
|
|
256
|
+
{ planKey: 'step_b', title: 'Branch 1', dependsOn: ['step_a'] },
|
|
257
|
+
{ planKey: 'step_c', title: 'Branch 2', dependsOn: ['step_a'] },
|
|
258
|
+
{ planKey: 'step_d', title: 'Merge', dependsOn: ['step_b', 'step_c'] },
|
|
259
|
+
]);
|
|
260
|
+
|
|
261
|
+
const snapshot = await controller.executeApprovedPlan(runId);
|
|
262
|
+
expect(snapshot.run.status).toBe('succeeded');
|
|
263
|
+
|
|
264
|
+
// A must be first, D must be last
|
|
265
|
+
const aIdx = executeOrder.indexOf(1);
|
|
266
|
+
const bIdx = executeOrder.indexOf(2);
|
|
267
|
+
const cIdx = executeOrder.indexOf(3);
|
|
268
|
+
const dIdx = executeOrder.indexOf(4);
|
|
269
|
+
expect(aIdx).toBe(0);
|
|
270
|
+
expect(dIdx).toBe(3);
|
|
271
|
+
// B and C can be in any order but both after A and before D
|
|
272
|
+
expect(bIdx).toBeGreaterThan(aIdx);
|
|
273
|
+
expect(cIdx).toBeGreaterThan(aIdx);
|
|
274
|
+
expect(bIdx).toBeLessThan(dIdx);
|
|
275
|
+
expect(cIdx).toBeLessThan(dIdx);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('stops execution when budget is exceeded', async () => {
|
|
279
|
+
const { controller, repository } = createController();
|
|
280
|
+
|
|
281
|
+
// Override tokenTracker
|
|
282
|
+
const tokenTracker = {
|
|
283
|
+
checkBudget: vi.fn(async () => ({ allowed: false, reason: 'Budget exceeded' })),
|
|
284
|
+
};
|
|
285
|
+
const harness = {
|
|
286
|
+
executeStep: vi.fn(async () => ({ summary: 'done' })),
|
|
287
|
+
};
|
|
288
|
+
const repo2 = createMockRepository();
|
|
289
|
+
const controller2 = new AgentLoopController(
|
|
290
|
+
{ getHarnessProfile: vi.fn(async () => ({ settings: {} })) } as any,
|
|
291
|
+
{ buildPlan: vi.fn() } as any,
|
|
292
|
+
{ validate: vi.fn() } as any,
|
|
293
|
+
repo2 as any,
|
|
294
|
+
harness as any,
|
|
295
|
+
tokenTracker as any,
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
const runId = await seedRunWithSteps(repo2, [{ planKey: 'step_1', title: 'Expensive step' }]);
|
|
299
|
+
|
|
300
|
+
const snapshot = await controller2.executeApprovedPlan(runId);
|
|
301
|
+
expect(snapshot.run.status).toBe('failed');
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('pauses for approval and does not continue', async () => {
|
|
305
|
+
const { controller, repository } = createController(
|
|
306
|
+
vi.fn(async (_run: any, _step: any) => {
|
|
307
|
+
throw new Error('requires_approval');
|
|
308
|
+
}),
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
const runId = await seedRunWithSteps(repository, [
|
|
312
|
+
{ planKey: 'step_1', title: 'Needs approval', type: 'tool', target: 'restricted_tool' },
|
|
313
|
+
]);
|
|
314
|
+
|
|
315
|
+
const snapshot = await controller.executeApprovedPlan(runId);
|
|
316
|
+
expect(snapshot.run.status).toBe('waiting_user');
|
|
317
|
+
});
|
|
318
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { createMockServer } from '@nocobase/test';
|
|
2
|
+
|
|
3
|
+
describe('Agent Orchestrator plugin smoke', () => {
|
|
4
|
+
let app;
|
|
5
|
+
|
|
6
|
+
afterEach(async () => {
|
|
7
|
+
await app?.destroy();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('loads without starting the full app', async () => {
|
|
11
|
+
app = await createMockServer({
|
|
12
|
+
plugins: ['nocobase', 'plugin-agent-orchestrator'],
|
|
13
|
+
});
|
|
14
|
+
expect(app).toBeTruthy();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('registers collection definitions', async () => {
|
|
18
|
+
app = await createMockServer({
|
|
19
|
+
plugins: ['nocobase', 'plugin-agent-orchestrator'],
|
|
20
|
+
});
|
|
21
|
+
const collections = [
|
|
22
|
+
'agentLoopRuns',
|
|
23
|
+
'agentLoopSteps',
|
|
24
|
+
'agentLoopEvents',
|
|
25
|
+
'orchestratorConfig',
|
|
26
|
+
'orchestratorLogs',
|
|
27
|
+
'agentExecutionSpans',
|
|
28
|
+
'agentHarnessProfiles',
|
|
29
|
+
'skillDefinitions',
|
|
30
|
+
'skillExecutions',
|
|
31
|
+
'skillLoopConfigs',
|
|
32
|
+
'skillWorkerConfigs',
|
|
33
|
+
];
|
|
34
|
+
for (const name of collections) {
|
|
35
|
+
const collection = app.db.getCollection(name);
|
|
36
|
+
expect(collection).toBeTruthy();
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('has agentLoopRuns schema with required fields', async () => {
|
|
41
|
+
app = await createMockServer({
|
|
42
|
+
plugins: ['nocobase', 'plugin-agent-orchestrator'],
|
|
43
|
+
});
|
|
44
|
+
const collection = app.db.getCollection('agentLoopRuns');
|
|
45
|
+
expect(collection).toBeTruthy();
|
|
46
|
+
|
|
47
|
+
const fields = {
|
|
48
|
+
goal: 'text',
|
|
49
|
+
status: 'string',
|
|
50
|
+
planVersion: 'integer',
|
|
51
|
+
iterationCount: 'integer',
|
|
52
|
+
totalTokens: 'integer',
|
|
53
|
+
totalCost: 'float',
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
for (const [name, type] of Object.entries(fields)) {
|
|
57
|
+
const field = collection.getField(name);
|
|
58
|
+
expect(field).toBeTruthy();
|
|
59
|
+
expect(field.type).toBe(type);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('can create an agentLoopRun record', async () => {
|
|
64
|
+
app = await createMockServer({
|
|
65
|
+
plugins: ['nocobase', 'plugin-agent-orchestrator'],
|
|
66
|
+
});
|
|
67
|
+
const repo = app.db.getRepository('agentLoopRuns');
|
|
68
|
+
const run = await repo.create({
|
|
69
|
+
values: {
|
|
70
|
+
rootRunId: 'test-root',
|
|
71
|
+
goal: 'Test goal',
|
|
72
|
+
status: 'planning',
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
expect(run.id).toBeTruthy();
|
|
76
|
+
expect(run.goal).toBe('Test goal');
|
|
77
|
+
expect(run.status).toBe('planning');
|
|
78
|
+
expect(run.rootRunId).toBe('test-root');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('can create agentLoopSteps with parent-child relationship', async () => {
|
|
82
|
+
app = await createMockServer({
|
|
83
|
+
plugins: ['nocobase', 'plugin-agent-orchestrator'],
|
|
84
|
+
});
|
|
85
|
+
const runRepo = app.db.getRepository('agentLoopRuns');
|
|
86
|
+
const stepRepo = app.db.getRepository('agentLoopSteps');
|
|
87
|
+
|
|
88
|
+
const run = await runRepo.create({
|
|
89
|
+
values: {
|
|
90
|
+
rootRunId: 'test-root-2',
|
|
91
|
+
goal: 'Multi-step test',
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const parentStep = await stepRepo.create({
|
|
96
|
+
values: {
|
|
97
|
+
runId: run.id,
|
|
98
|
+
planKey: 'step_1',
|
|
99
|
+
title: 'Parent step',
|
|
100
|
+
type: 'reasoning',
|
|
101
|
+
status: 'succeeded',
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const childStep = await stepRepo.create({
|
|
106
|
+
values: {
|
|
107
|
+
runId: run.id,
|
|
108
|
+
planKey: 'step_2',
|
|
109
|
+
title: 'Child step',
|
|
110
|
+
type: 'skill',
|
|
111
|
+
status: 'pending',
|
|
112
|
+
dependsOn: ['step_1'],
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
expect(parentStep.id).toBeTruthy();
|
|
117
|
+
expect(childStep.id).toBeTruthy();
|
|
118
|
+
expect(childStep.dependsOn).toEqual(['step_1']);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -78,6 +78,30 @@ export default defineCollection({
|
|
|
78
78
|
name: 'durationMs',
|
|
79
79
|
type: 'integer',
|
|
80
80
|
},
|
|
81
|
+
{
|
|
82
|
+
name: 'inputTokens',
|
|
83
|
+
type: 'integer',
|
|
84
|
+
defaultValue: 0,
|
|
85
|
+
comment: 'Number of input/prompt tokens consumed by this span',
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
name: 'outputTokens',
|
|
89
|
+
type: 'integer',
|
|
90
|
+
defaultValue: 0,
|
|
91
|
+
comment: 'Number of output/completion tokens generated by this span',
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
name: 'totalTokens',
|
|
95
|
+
type: 'integer',
|
|
96
|
+
defaultValue: 0,
|
|
97
|
+
comment: 'Total tokens consumed by this span (input + output)',
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
name: 'cost',
|
|
101
|
+
type: 'float',
|
|
102
|
+
defaultValue: 0,
|
|
103
|
+
comment: 'Estimated cost in USD for this span',
|
|
104
|
+
},
|
|
81
105
|
{
|
|
82
106
|
name: 'startedAt',
|
|
83
107
|
type: 'date',
|