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.
Files changed (98) hide show
  1. package/dist/client/hooks/useRunEventStream.d.ts +22 -0
  2. package/dist/client/index.d.ts +1 -0
  3. package/dist/client/index.js +1 -1
  4. package/dist/externalVersion.js +6 -6
  5. package/dist/server/collections/agent-execution-spans.js +24 -0
  6. package/dist/server/collections/agent-loop-runs.js +36 -0
  7. package/dist/server/collections/orchestrator-config.js +14 -0
  8. package/dist/server/migrations/20260601000000-add-token-fields.d.ts +7 -0
  9. package/dist/server/migrations/20260601000000-add-token-fields.js +101 -0
  10. package/dist/server/plugin.js +47 -0
  11. package/dist/server/resources/agent-loop.js +33 -25
  12. package/dist/server/resources/tracing.js +5 -8
  13. package/dist/server/services/AgentHarness.d.ts +2 -0
  14. package/dist/server/services/AgentHarness.js +56 -90
  15. package/dist/server/services/AgentLoopController.d.ts +33 -20
  16. package/dist/server/services/AgentLoopController.js +164 -125
  17. package/dist/server/services/AgentLoopRepository.js +16 -34
  18. package/dist/server/services/AgentLoopService.d.ts +28 -18
  19. package/dist/server/services/AgentLoopService.js +7 -1
  20. package/dist/server/services/AgentPlannerService.js +5 -25
  21. package/dist/server/services/AgentRegistryService.d.ts +8 -0
  22. package/dist/server/services/AgentRegistryService.js +34 -24
  23. package/dist/server/services/CircuitBreaker.d.ts +40 -0
  24. package/dist/server/services/CircuitBreaker.js +120 -0
  25. package/dist/server/services/ContextAggregator.d.ts +45 -0
  26. package/dist/server/services/ContextAggregator.js +201 -0
  27. package/dist/server/services/ExecutionSpanService.js +2 -5
  28. package/dist/server/services/RunEventBus.d.ts +9 -0
  29. package/dist/server/services/RunEventBus.js +73 -0
  30. package/dist/server/services/TokenTracker.d.ts +62 -0
  31. package/dist/server/services/TokenTracker.js +173 -0
  32. package/dist/server/tools/agent-loop.d.ts +8 -8
  33. package/dist/server/tools/agent-loop.js +30 -63
  34. package/dist/server/tools/delegate-task.js +14 -72
  35. package/dist/server/tools/orchestrator-plan.d.ts +6 -6
  36. package/dist/server/tools/orchestrator-plan.js +10 -47
  37. package/dist/server/types.d.ts +47 -0
  38. package/dist/server/types.js +24 -0
  39. package/dist/server/utils/ctx-utils.d.ts +30 -0
  40. package/dist/server/utils/ctx-utils.js +152 -0
  41. package/dist/server/utils/logging.d.ts +6 -0
  42. package/dist/server/utils/logging.js +86 -0
  43. package/package.json +44 -44
  44. package/src/client/AgentRunsTab.tsx +764 -764
  45. package/src/client/HarnessProfilesTab.tsx +247 -247
  46. package/src/client/OrchestratorSettings.tsx +106 -106
  47. package/src/client/RulesTab.tsx +716 -716
  48. package/src/client/hooks/useRunEventStream.ts +76 -0
  49. package/src/client/index.tsx +2 -1
  50. package/src/client/plugin.tsx +27 -27
  51. package/src/client/skill-hub/components/LoopSettings.tsx +331 -331
  52. package/src/client/skill-hub/index.tsx +51 -51
  53. package/src/client/skill-hub/tools/InteractionSchemasProvider.tsx +99 -99
  54. package/src/client/skill-hub/tools/SkillHubCard.tsx +109 -109
  55. package/src/client/skill-hub/tools/loopTemplates.ts +52 -52
  56. package/src/client/skill-hub/tools/registerSkillLoopCards.ts +58 -58
  57. package/src/client/tools/PlanApprovalCard.tsx +175 -175
  58. package/src/client/tools/registerOrchestratorCards.ts +7 -7
  59. package/src/server/__tests__/agent-loop-controller.test.ts +375 -0
  60. package/src/server/__tests__/circuit-breaker.test.ts +169 -0
  61. package/src/server/__tests__/context-aggregator.test.ts +222 -0
  62. package/src/server/__tests__/parallel-execution.test.ts +318 -0
  63. package/src/server/__tests__/smoke.test.ts +120 -0
  64. package/src/server/collections/agent-execution-spans.ts +24 -0
  65. package/src/server/collections/agent-harness-profiles.ts +59 -59
  66. package/src/server/collections/agent-loop-events.ts +71 -71
  67. package/src/server/collections/agent-loop-runs.ts +38 -1
  68. package/src/server/collections/agent-loop-steps.ts +144 -144
  69. package/src/server/collections/orchestrator-config.ts +14 -0
  70. package/src/server/collections/skill-executions.ts +106 -106
  71. package/src/server/collections/skill-loop-configs.ts +65 -65
  72. package/src/server/migrations/20260524000000-add-agent-loop-fields-to-skill-executions.ts +30 -30
  73. package/src/server/migrations/20260524001000-add-plan-approval-and-harness-profiles.ts +142 -142
  74. package/src/server/migrations/20260601000000-add-token-fields.ts +89 -0
  75. package/src/server/plugin.ts +53 -0
  76. package/src/server/resources/agent-loop.ts +21 -12
  77. package/src/server/resources/tracing.ts +3 -7
  78. package/src/server/services/AgentHarness.ts +78 -116
  79. package/src/server/services/AgentLoopController.ts +197 -122
  80. package/src/server/services/AgentLoopRepository.ts +9 -25
  81. package/src/server/services/AgentLoopService.ts +13 -1
  82. package/src/server/services/AgentPlanValidator.ts +73 -73
  83. package/src/server/services/AgentPlannerService.ts +2 -25
  84. package/src/server/services/AgentRegistryService.ts +40 -31
  85. package/src/server/services/CircuitBreaker.ts +116 -0
  86. package/src/server/services/ContextAggregator.ts +239 -0
  87. package/src/server/services/ExecutionSpanService.ts +2 -4
  88. package/src/server/services/RunEventBus.ts +45 -0
  89. package/src/server/services/TokenTracker.ts +209 -0
  90. package/src/server/skill-hub/plugin.ts +898 -898
  91. package/src/server/skill-hub/tasks/SkillExecutionTask.ts +460 -460
  92. package/src/server/tools/agent-loop.ts +18 -57
  93. package/src/server/tools/delegate-task.ts +11 -93
  94. package/src/server/tools/orchestrator-plan.ts +26 -50
  95. package/src/server/tools/skill-execute.ts +160 -160
  96. package/src/server/types.ts +55 -0
  97. package/src/server/utils/ctx-utils.ts +118 -0
  98. 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('&amp;');
161
+ expect(result).toContain('&lt;');
162
+ expect(result).toContain('&quot;');
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',