plugin-agent-orchestrator 1.0.22 → 1.0.25

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 (103) hide show
  1. package/client-v2.d.ts +2 -0
  2. package/client-v2.js +1 -0
  3. package/dist/client/index.js +1 -1
  4. package/dist/client-v2/214.723affb37c13bf7a.js +10 -0
  5. package/dist/client-v2/264.0533912e6c5ea2d7.js +10 -0
  6. package/dist/client-v2/41.1805b2edfaa4afe2.js +10 -0
  7. package/dist/client-v2/418.5ae055abf141820e.js +10 -0
  8. package/dist/client-v2/619.d99d3c9e61c99064.js +10 -0
  9. package/dist/client-v2/70.a15d7fcec7c41768.js +10 -0
  10. package/dist/client-v2/892.72db4161511c8a16.js +10 -0
  11. package/dist/client-v2/926.87f660b670d85bcc.js +10 -0
  12. package/dist/client-v2/index.js +10 -0
  13. package/dist/externalVersion.js +8 -6
  14. package/dist/locale/en-US.json +7 -0
  15. package/dist/locale/vi-VN.json +7 -0
  16. package/dist/locale/zh-CN.json +27 -0
  17. package/dist/server/migrations/20260615000000-normalize-ai-employee-tool-bindings.js +63 -0
  18. package/dist/server/plugin.js +32 -1
  19. package/dist/server/services/AgentHarness.js +52 -27
  20. package/dist/server/services/AgentLoopController.js +8 -2
  21. package/dist/server/services/AgentLoopService.js +1 -1
  22. package/dist/server/services/AgentRegistryService.js +53 -42
  23. package/dist/server/services/CircuitBreaker.js +7 -2
  24. package/dist/server/services/CodeValidator.js +48 -14
  25. package/dist/server/services/SandboxRunner.js +18 -14
  26. package/dist/server/skill-hub/plugin.js +44 -17
  27. package/dist/server/tools/delegate-task.js +7 -2
  28. package/dist/server/tools/skill-execute.js +33 -2
  29. package/dist/server/utils/ai-manager.js +51 -0
  30. package/dist/server/utils/ctx-utils.js +11 -0
  31. package/dist/server/utils/skill-settings.js +122 -0
  32. package/package.json +49 -45
  33. package/src/client/AIEmployeesContext.tsx +60 -19
  34. package/src/client/AgentRunsTab.tsx +769 -764
  35. package/src/client/HarnessProfilesTab.tsx +257 -247
  36. package/src/client/RulesTab.tsx +787 -716
  37. package/src/client/TracingTab.tsx +9 -6
  38. package/src/client/plugin.tsx +34 -27
  39. package/src/client/skill-hub/components/ExecutionHistory.tsx +9 -8
  40. package/src/client/skill-hub/components/GitSkillImport.tsx +12 -5
  41. package/src/client/skill-hub/components/LoopSettings.tsx +2 -2
  42. package/src/client/skill-hub/components/SkillEditor.tsx +2 -2
  43. package/src/client/skill-hub/components/SkillManager.tsx +2 -2
  44. package/src/client/skill-hub/components/SkillMetrics.tsx +157 -124
  45. package/src/client/skill-hub/components/SkillTestPanel.tsx +14 -13
  46. package/src/client/skill-hub/index.tsx +58 -51
  47. package/src/client/skill-hub/locale.ts +1 -1
  48. package/src/client/skill-hub/tools/InteractionSchemasProvider.tsx +132 -99
  49. package/src/client/skill-hub/tools/registerSkillLoopCards.ts +71 -58
  50. package/src/client/tools/PlanApprovalCard.tsx +3 -2
  51. package/src/client/tools/registerOrchestratorCards.ts +17 -7
  52. package/src/client-v2/components/AIEmployeeSelect.tsx +47 -0
  53. package/src/client-v2/components/AIEmployeesContext.tsx +110 -0
  54. package/src/client-v2/components/AgentRunsTab.tsx +767 -0
  55. package/src/client-v2/components/HarnessProfilesTab.tsx +254 -0
  56. package/src/client-v2/components/RulesTab.tsx +782 -0
  57. package/src/client-v2/components/TracingTab.tsx +432 -0
  58. package/src/client-v2/hooks/useApiRequest.ts +114 -0
  59. package/src/client-v2/index.tsx +1 -0
  60. package/src/client-v2/pages/AgentRunsPage.tsx +13 -0
  61. package/src/client-v2/pages/ExecutionHistoryPage.tsx +10 -0
  62. package/src/client-v2/pages/HarnessProfilesPage.tsx +10 -0
  63. package/src/client-v2/pages/LoopSettingsPage.tsx +10 -0
  64. package/src/client-v2/pages/RulesPage.tsx +13 -0
  65. package/src/client-v2/pages/SkillDefinitionsPage.tsx +10 -0
  66. package/src/client-v2/pages/SkillMetricsPage.tsx +10 -0
  67. package/src/client-v2/pages/TracingPage.tsx +13 -0
  68. package/src/client-v2/plugin.tsx +70 -0
  69. package/src/client-v2/skill-hub/components/ExecutionHistory.tsx +196 -0
  70. package/src/client-v2/skill-hub/components/FileLinkList.tsx +37 -0
  71. package/src/client-v2/skill-hub/components/GitSkillImport.tsx +539 -0
  72. package/src/client-v2/skill-hub/components/LoopSettings.tsx +331 -0
  73. package/src/client-v2/skill-hub/components/SkillEditor.tsx +453 -0
  74. package/src/client-v2/skill-hub/components/SkillManager.tsx +174 -0
  75. package/src/client-v2/skill-hub/components/SkillMetrics.tsx +157 -0
  76. package/src/client-v2/skill-hub/components/SkillTestPanel.tsx +135 -0
  77. package/src/client-v2/skill-hub/locale.ts +13 -0
  78. package/src/client-v2/skill-hub/tools/loopTemplates.ts +52 -0
  79. package/src/client-v2/skill-hub/utils/jsonFields.ts +41 -0
  80. package/src/client-v2/utils/jsonFields.ts +41 -0
  81. package/src/locale/en-US.json +7 -0
  82. package/src/locale/vi-VN.json +7 -0
  83. package/src/locale/zh-CN.json +27 -0
  84. package/src/server/__tests__/agent-registry-service.test.ts +147 -0
  85. package/src/server/__tests__/code-validator.test.ts +63 -0
  86. package/src/server/__tests__/skill-execute.test.ts +33 -0
  87. package/src/server/__tests__/skill-settings.test.ts +63 -0
  88. package/src/server/migrations/20260615000000-normalize-ai-employee-tool-bindings.ts +39 -0
  89. package/src/server/plugin.ts +62 -21
  90. package/src/server/services/AgentHarness.ts +49 -22
  91. package/src/server/services/AgentLoopController.ts +17 -6
  92. package/src/server/services/AgentLoopService.ts +1 -1
  93. package/src/server/services/AgentPlannerService.ts +10 -0
  94. package/src/server/services/AgentRegistryService.ts +89 -47
  95. package/src/server/services/CircuitBreaker.ts +10 -0
  96. package/src/server/services/CodeValidator.ts +237 -159
  97. package/src/server/services/SandboxRunner.ts +203 -189
  98. package/src/server/skill-hub/plugin.ts +933 -898
  99. package/src/server/tools/delegate-task.ts +12 -9
  100. package/src/server/tools/skill-execute.ts +194 -160
  101. package/src/server/utils/ai-manager.ts +24 -0
  102. package/src/server/utils/ctx-utils.ts +14 -0
  103. package/src/server/utils/skill-settings.ts +116 -0
@@ -1,716 +1,787 @@
1
- import React, { useState } from 'react';
2
- import {
3
- Table,
4
- Button,
5
- Drawer,
6
- Form,
7
- InputNumber,
8
- Switch,
9
- Space,
10
- Popconfirm,
11
- Card,
12
- message,
13
- Tag,
14
- Typography,
15
- Alert,
16
- Collapse,
17
- Empty,
18
- Select,
19
- } from 'antd';
20
- import {
21
- PlusOutlined,
22
- EditOutlined,
23
- DeleteOutlined,
24
- SwapRightOutlined,
25
- WarningOutlined,
26
- ThunderboltOutlined,
27
- } from '@ant-design/icons';
28
- import { useAPIClient, useRequest } from '@nocobase/client';
29
- import { AIEmployeeSelect } from './AIEmployeeSelect';
30
- import { useAIEmployees } from './AIEmployeesContext';
31
-
32
- const { Text } = Typography;
33
-
34
- /**
35
- * Mirrors server-side `sanitizeToolPart` in delegate-task.ts so we can compute
36
- * the expected delegation tool names here and detect when the leader hasn't
37
- * added them to its skillSettings.
38
- */
39
- const sanitizeToolPart = (value: string) => (value || '').replace(/[^a-zA-Z0-9_-]/g, '_');
40
- const expectedDelegateToolName = (leader: string, sub: string) =>
41
- `delegate_${sanitizeToolPart(leader)}_to_${sanitizeToolPart(sub)}`;
42
- const expectedDispatchToolName = (leader: string) => `dispatch_subagents_${sanitizeToolPart(leader)}`;
43
- const controllerToolNames = [
44
- 'orchestrator_plan_goal',
45
- 'orchestrator_execute_plan',
46
- 'orchestrator_status',
47
- 'orchestrator_cancel',
48
- ];
49
-
50
- export const RulesTab: React.FC = () => {
51
- const api = useAPIClient();
52
- const [visible, setVisible] = useState(false);
53
- const [editingRecord, setEditingRecord] = useState<any>(null);
54
- const [form] = Form.useForm();
55
-
56
- const { data, loading, refresh } = useRequest({
57
- url: 'orchestratorConfig:list',
58
- params: {
59
- sort: ['-createdAt'],
60
- },
61
- });
62
-
63
- const { data: llmServicesData, loading: llmLoading } = useRequest({
64
- url: 'ai:listAllEnabledModels',
65
- });
66
-
67
- const { data: harnessProfilesData, loading: harnessLoading } = useRequest({
68
- url: 'agentHarnessProfiles:list',
69
- params: {
70
- filter: { enabled: true },
71
- sort: ['tag'],
72
- pageSize: 100,
73
- },
74
- });
75
-
76
- const llmServices = React.useMemo(() => {
77
- const raw = (llmServicesData as any)?.data ?? llmServicesData;
78
- if (Array.isArray(raw)) return raw;
79
- return Array.isArray(raw?.data) ? raw.data : [];
80
- }, [llmServicesData]);
81
-
82
- const harnessProfiles = React.useMemo(() => {
83
- const raw = (harnessProfilesData as any)?.data ?? harnessProfilesData;
84
- if (Array.isArray(raw)) return raw;
85
- return Array.isArray(raw?.data) ? raw.data : [];
86
- }, [harnessProfilesData]);
87
-
88
- // P3 FIX: Use shared context instead of duplicate API call
89
- const { employeeMap, skillsMap, refresh: refreshEmployees } = useAIEmployees();
90
- const rules = React.useMemo(() => {
91
- const rows = (data as any)?.data;
92
- return Array.isArray(rows) ? rows : [];
93
- }, [data]);
94
-
95
- const handleAddSkillsToEmployee = async (employeeUsername: string, toolNames: string[]) => {
96
- try {
97
- // Re-fetch the leader to merge its current skills (skillsMap may be stale).
98
- const leaderResp = await api.request({
99
- url: 'aiEmployees:get',
100
- params: { filterByTk: employeeUsername },
101
- });
102
- const leader = (leaderResp as any)?.data?.data;
103
- if (!leader) {
104
- message.error('Could not load AI employee.');
105
- return;
106
- }
107
- const existing = Array.isArray(leader.skillSettings?.skills) ? leader.skillSettings.skills : [];
108
- const existingNames = new Set(existing.map((s: any) => (typeof s === 'string' ? s : s?.name)));
109
- const missing = toolNames.filter((toolName) => !existingNames.has(toolName));
110
- if (!missing.length) {
111
- message.info('Skills already present.');
112
- await refreshEmployees();
113
- return;
114
- }
115
- const nextSkills = [...existing, ...missing.map((name) => ({ name, autoCall: false }))];
116
- await api.request({
117
- url: 'aiEmployees:update',
118
- method: 'put',
119
- params: { filterByTk: employeeUsername },
120
- data: { skillSettings: { ...(leader.skillSettings || {}), skills: nextSkills } },
121
- });
122
- message.success(`Added ${missing.length} skill${missing.length > 1 ? 's' : ''} to ${employeeUsername}.`);
123
- await refreshEmployees();
124
- } catch (e: any) {
125
- message.error(`Auto-assign failed: ${e?.message || 'unknown error'}`);
126
- }
127
- };
128
-
129
- const handleAddSkillToEmployee = async (employeeUsername: string, toolName: string) => {
130
- await handleAddSkillsToEmployee(employeeUsername, [toolName]);
131
- };
132
-
133
- const handleAutoAssignSkill = async (record: any) => {
134
- await handleAddSkillToEmployee(
135
- record.leaderUsername,
136
- expectedDelegateToolName(record.leaderUsername, record.subAgentUsername),
137
- );
138
- };
139
-
140
- const handleAutoAssignDispatchSkill = async (leaderUsername: string) => {
141
- await handleAddSkillToEmployee(leaderUsername, expectedDispatchToolName(leaderUsername));
142
- };
143
-
144
- const subAgentLeaderCount = React.useMemo(() => {
145
- const counts = new Map<string, Set<string>>();
146
- for (const rule of rules) {
147
- const set = counts.get(rule.subAgentUsername) || new Set<string>();
148
- set.add(rule.leaderUsername);
149
- counts.set(rule.subAgentUsername, set);
150
- }
151
- return counts;
152
- }, [rules]);
153
-
154
- const aliasConflicts = React.useMemo(() => {
155
- return Array.from(subAgentLeaderCount.entries())
156
- .filter(([, leaders]) => leaders.size > 1)
157
- .map(([sub, leaders]) => ({ sub, leaders: Array.from(leaders) }));
158
- }, [subAgentLeaderCount]);
159
-
160
- const groupedRules = React.useMemo(() => {
161
- const groups = new Map<string, any[]>();
162
- for (const rule of rules) {
163
- const key = rule.leaderUsername || 'unknown';
164
- let items = groups.get(key);
165
- if (!items) {
166
- items = [];
167
- groups.set(key, items);
168
- }
169
- items.push(rule);
170
- }
171
-
172
- return Array.from(groups.entries()).map(([leaderUsername, items]) => ({
173
- leaderUsername,
174
- items,
175
- }));
176
- }, [rules]);
177
-
178
- const handleOpen = (record?: any) => {
179
- setEditingRecord(record);
180
- if (record) {
181
- form.setFieldsValue(record);
182
- } else {
183
- form.resetFields();
184
- form.setFieldsValue({ enabled: true, maxDepth: 1, timeout: 120000, recursionLimit: 50, harnessTag: 'default' });
185
- }
186
- setVisible(true);
187
- };
188
-
189
- const handleClose = () => {
190
- setVisible(false);
191
- setEditingRecord(null);
192
- };
193
-
194
- const handleSave = async (values: any) => {
195
- // Validate: leader !== subAgent
196
- if (values.leaderUsername === values.subAgentUsername) {
197
- message.error('Leader and Sub-Agent cannot be the same employee.');
198
- return;
199
- }
200
-
201
- try {
202
- if (editingRecord) {
203
- await api.request({
204
- url: 'orchestratorConfig:update',
205
- method: 'put',
206
- params: { filterByTk: editingRecord.id },
207
- data: values,
208
- });
209
- message.success('Rule updated');
210
- } else {
211
- await api.request({
212
- url: 'orchestratorConfig:create',
213
- method: 'post',
214
- data: values,
215
- });
216
- message.success('Rule created');
217
- }
218
- handleClose();
219
- refresh();
220
- } catch (e: any) {
221
- const msg = e?.response?.data?.errors?.[0]?.message || e.message;
222
- message.error(`Save failed: ${msg}`);
223
- }
224
- };
225
-
226
- const handleDelete = async (id: string) => {
227
- try {
228
- await api.request({
229
- url: 'orchestratorConfig:destroy',
230
- method: 'delete',
231
- params: { filterByTk: id },
232
- });
233
- message.success('Rule deleted');
234
- refresh();
235
- } catch (e: any) {
236
- message.error(`Delete failed: ${e.message}`);
237
- }
238
- };
239
-
240
- const columns = [
241
- {
242
- title: 'Leader (Orchestrator)',
243
- dataIndex: 'leaderUsername',
244
- key: 'leaderUsername',
245
- render: (username: string) => <Tag color="blue">{employeeMap.get(username) || username}</Tag>,
246
- },
247
- {
248
- title: '',
249
- key: 'arrow',
250
- width: 50,
251
- render: () => <SwapRightOutlined style={{ color: '#999', fontSize: 18 }} />,
252
- },
253
- {
254
- title: 'Sub-Agent',
255
- dataIndex: 'subAgentUsername',
256
- key: 'subAgentUsername',
257
- render: (username: string) => <Tag color="green">{employeeMap.get(username) || username}</Tag>,
258
- },
259
- {
260
- title: 'Harness',
261
- dataIndex: 'harnessTag',
262
- key: 'harnessTag',
263
- width: 120,
264
- render: (tag: string) => <Tag color="purple">{tag || 'default'}</Tag>,
265
- },
266
- {
267
- title: 'Max Depth',
268
- dataIndex: 'maxDepth',
269
- key: 'maxDepth',
270
- width: 100,
271
- render: (v: number) => v ?? 1,
272
- },
273
- {
274
- title: 'Timeout',
275
- dataIndex: 'timeout',
276
- key: 'timeout',
277
- width: 100,
278
- render: (v: number) => `${((v ?? 120000) / 1000).toFixed(0)}s`,
279
- },
280
- {
281
- title: 'LLM Override',
282
- key: 'llmOverride',
283
- width: 140,
284
- render: (_: any, record: any) => {
285
- if (record.llmService && record.model) {
286
- const svc = llmServices.find((s: any) => s.llmService === record.llmService);
287
- const svcName = svc ? svc.llmServiceTitle : record.llmService;
288
- return (
289
- <Space direction="vertical" size={0}>
290
- <Text style={{ fontSize: 12 }}>{svcName}</Text>
291
- <Text type="secondary" style={{ fontSize: 12 }}>
292
- {record.model}
293
- </Text>
294
- </Space>
295
- );
296
- }
297
- return (
298
- <Text type="secondary" style={{ fontSize: 12 }}>
299
- Inherited
300
- </Text>
301
- );
302
- },
303
- },
304
- {
305
- title: 'Enabled',
306
- dataIndex: 'enabled',
307
- key: 'enabled',
308
- width: 80,
309
- render: (enabled: boolean, record: any) => (
310
- <Switch
311
- checked={enabled}
312
- size="small"
313
- onChange={async (checked) => {
314
- await api.request({
315
- url: 'orchestratorConfig:update',
316
- method: 'put',
317
- params: { filterByTk: record.id },
318
- data: { enabled: checked },
319
- });
320
- refresh();
321
- }}
322
- />
323
- ),
324
- },
325
- {
326
- title: 'Skill',
327
- key: 'skill',
328
- width: 150,
329
- render: (_: any, record: any) => {
330
- const expected = expectedDelegateToolName(record.leaderUsername, record.subAgentUsername);
331
- const leaderSkills = skillsMap.get(record.leaderUsername);
332
- if (!leaderSkills) {
333
- return (
334
- <Text type="secondary" style={{ fontSize: 12 }}>
335
-
336
- </Text>
337
- );
338
- }
339
- const present = leaderSkills.has(expected);
340
- if (present) {
341
- return <Tag color="success">Assigned</Tag>;
342
- }
343
- return (
344
- <Space size={4}>
345
- <Tag icon={<WarningOutlined />} color="warning">
346
- Missing
347
- </Tag>
348
- <Button
349
- type="link"
350
- size="small"
351
- icon={<ThunderboltOutlined />}
352
- onClick={() => handleAutoAssignSkill(record)}
353
- >
354
- Auto-add
355
- </Button>
356
- </Space>
357
- );
358
- },
359
- },
360
- {
361
- title: 'Actions',
362
- key: 'actions',
363
- width: 160,
364
- render: (_: any, record: any) => (
365
- <Space>
366
- <Button type="link" size="small" icon={<EditOutlined />} onClick={() => handleOpen(record)}>
367
- Edit
368
- </Button>
369
- <Popconfirm title="Delete this rule?" onConfirm={() => handleDelete(record.id)}>
370
- <Button type="link" size="small" danger icon={<DeleteOutlined />}>
371
- Delete
372
- </Button>
373
- </Popconfirm>
374
- </Space>
375
- ),
376
- },
377
- ];
378
-
379
- const leaderUsername = Form.useWatch('leaderUsername', form);
380
-
381
- const missingSkillCount = React.useMemo(() => {
382
- return rules.reduce((acc: number, r: any) => {
383
- const leaderSkills = skillsMap.get(r.leaderUsername);
384
- if (!leaderSkills) return acc;
385
- const expected = expectedDelegateToolName(r.leaderUsername, r.subAgentUsername);
386
- return leaderSkills.has(expected) ? acc : acc + 1;
387
- }, 0);
388
- }, [rules, skillsMap]);
389
-
390
- const missingDispatchSkills = React.useMemo(() => {
391
- return groupedRules
392
- .map((group) => {
393
- const leaderSkills = skillsMap.get(group.leaderUsername);
394
- if (!leaderSkills) return null;
395
- const toolName = expectedDispatchToolName(group.leaderUsername);
396
- return leaderSkills.has(toolName)
397
- ? null
398
- : { leaderUsername: group.leaderUsername, toolName, count: group.items.length };
399
- })
400
- .filter(Boolean) as Array<{ leaderUsername: string; toolName: string; count: number }>;
401
- }, [groupedRules, skillsMap]);
402
-
403
- const missingControllerSkills = React.useMemo(() => {
404
- return groupedRules
405
- .map((group) => {
406
- const leaderSkills = skillsMap.get(group.leaderUsername);
407
- if (!leaderSkills) return null;
408
- const missing = controllerToolNames.filter((toolName) => !leaderSkills.has(toolName));
409
- return missing.length ? { leaderUsername: group.leaderUsername, missing } : null;
410
- })
411
- .filter(Boolean) as Array<{ leaderUsername: string; missing: string[] }>;
412
- }, [groupedRules, skillsMap]);
413
-
414
- return (
415
- <div>
416
- <Alert
417
- type="info"
418
- showIcon
419
- style={{ marginBottom: 16 }}
420
- message="Orchestration Rules"
421
- description={
422
- <Text type="secondary">
423
- Configure which AI Employees can act as Leaders (Orchestrators) and which ones they can delegate tasks to.
424
- Each rule creates a callable tool for the Leader to invoke the Sub-Agent.
425
- </Text>
426
- }
427
- />
428
-
429
- {missingSkillCount > 0 && (
430
- <Alert
431
- type="warning"
432
- showIcon
433
- style={{ marginBottom: 16 }}
434
- message={`${missingSkillCount} rule${missingSkillCount > 1 ? 's' : ''} missing required skill assignment`}
435
- description={
436
- <Text type="secondary">
437
- The Leader employee hasn&apos;t added the corresponding{' '}
438
- <Text code>delegate_&lt;leader&gt;_to_&lt;sub&gt;</Text> tool to its skillSettings, so the LLM cannot
439
- actually call these sub-agents. Use the <b>Auto-add</b> button in the Skill column to fix.
440
- </Text>
441
- }
442
- />
443
- )}
444
-
445
- {missingControllerSkills.length > 0 && (
446
- <Alert
447
- type="warning"
448
- showIcon
449
- style={{ marginBottom: 16 }}
450
- message={`${missingControllerSkills.length} leader${
451
- missingControllerSkills.length > 1 ? 's' : ''
452
- } missing orchestrator controller tools`}
453
- description={
454
- <Space direction="vertical" size={6}>
455
- <Text type="secondary">
456
- Leaders need the orchestrator controller tools to create an approval-first plan and execute it after
457
- the user accepts the card.
458
- </Text>
459
- {missingControllerSkills.map(({ leaderUsername, missing }) => (
460
- <Space key={leaderUsername} size={8} wrap>
461
- <Tag color="blue">{employeeMap.get(leaderUsername) || leaderUsername}</Tag>
462
- <Text type="secondary">{missing.length} missing</Text>
463
- <Button
464
- type="link"
465
- size="small"
466
- icon={<ThunderboltOutlined />}
467
- onClick={() => handleAddSkillsToEmployee(leaderUsername, missing)}
468
- >
469
- Auto-add
470
- </Button>
471
- </Space>
472
- ))}
473
- </Space>
474
- }
475
- />
476
- )}
477
-
478
- {missingDispatchSkills.length > 0 && (
479
- <Alert
480
- type="warning"
481
- showIcon
482
- style={{ marginBottom: 16 }}
483
- message={`${missingDispatchSkills.length} leader${
484
- missingDispatchSkills.length > 1 ? 's' : ''
485
- } missing dispatch skill assignment`}
486
- description={
487
- <Space direction="vertical" size={6}>
488
- <Text type="secondary">
489
- The fan-out tool lets a Leader dispatch multiple independent sub-tasks in one call. Add it to the
490
- Leader&apos;s skills to enable the new multi-agent flow.
491
- </Text>
492
- {missingDispatchSkills.map(({ leaderUsername, toolName, count }) => (
493
- <Space key={leaderUsername} size={8} wrap>
494
- <Tag color="blue">{employeeMap.get(leaderUsername) || leaderUsername}</Tag>
495
- <Text type="secondary">
496
- {count} sub-agent{count > 1 ? 's' : ''}
497
- </Text>
498
- <Text code>{toolName}</Text>
499
- <Button
500
- type="link"
501
- size="small"
502
- icon={<ThunderboltOutlined />}
503
- onClick={() => handleAutoAssignDispatchSkill(leaderUsername)}
504
- >
505
- Auto-add
506
- </Button>
507
- </Space>
508
- ))}
509
- </Space>
510
- }
511
- />
512
- )}
513
-
514
- {aliasConflicts.length > 0 && (
515
- <Alert
516
- type="warning"
517
- showIcon
518
- style={{ marginBottom: 16 }}
519
- message="Legacy delegate_to_<sub> alias is no longer registered for these sub-agents"
520
- description={
521
- <Space direction="vertical" size={2}>
522
- {aliasConflicts.map(({ sub, leaders }) => (
523
- <Text key={sub} type="secondary">
524
- <Tag color="green">{employeeMap.get(sub) || sub}</Tag>
525
- has multiple leaders ({leaders.map((l) => employeeMap.get(l) || l).join(', ')}). The legacy alias is
526
- dropped to avoid ambiguity — leaders must use <Text code>delegate_&lt;leader&gt;_to_&lt;sub&gt;</Text>{' '}
527
- in their skills.
528
- </Text>
529
- ))}
530
- </Space>
531
- }
532
- />
533
- )}
534
-
535
- <Card bordered={false}>
536
- <div style={{ marginBottom: 16, display: 'flex', justifyContent: 'flex-end' }}>
537
- <Button type="primary" icon={<PlusOutlined />} onClick={() => handleOpen()}>
538
- New Rule
539
- </Button>
540
- </div>
541
- {groupedRules.length ? (
542
- <Collapse
543
- bordered={false}
544
- defaultActiveKey={groupedRules.map((group) => group.leaderUsername)}
545
- items={groupedRules.map((group) => ({
546
- key: group.leaderUsername,
547
- label: (
548
- <Space>
549
- <Tag color="blue">{employeeMap.get(group.leaderUsername) || group.leaderUsername}</Tag>
550
- <Text type="secondary">
551
- {group.items.length} sub-agent{group.items.length > 1 ? 's' : ''}
552
- </Text>
553
- {missingDispatchSkills.some((item) => item.leaderUsername === group.leaderUsername) && (
554
- <Tag color="warning">Dispatch missing</Tag>
555
- )}
556
- </Space>
557
- ),
558
- children: (
559
- <Table
560
- rowKey="id"
561
- loading={loading}
562
- dataSource={group.items}
563
- columns={columns}
564
- pagination={false}
565
- size="middle"
566
- />
567
- ),
568
- }))}
569
- />
570
- ) : (
571
- <Table
572
- rowKey="id"
573
- loading={loading}
574
- dataSource={[]}
575
- columns={columns}
576
- pagination={false}
577
- size="middle"
578
- locale={{ emptyText: <Empty description="No orchestration rules yet" /> }}
579
- />
580
- )}
581
- </Card>
582
-
583
- <Drawer
584
- title={editingRecord ? 'Edit Orchestration Rule' : 'New Orchestration Rule'}
585
- width={480}
586
- onClose={handleClose}
587
- open={visible}
588
- styles={{ body: { paddingBottom: 80 } }}
589
- extra={
590
- <Space>
591
- <Button onClick={handleClose}>Cancel</Button>
592
- <Button onClick={() => form.submit()} type="primary">
593
- Save
594
- </Button>
595
- </Space>
596
- }
597
- >
598
- <Form form={form} layout="vertical" onFinish={handleSave}>
599
- <Form.Item
600
- name="leaderUsername"
601
- label="Leader (Orchestrator)"
602
- rules={[{ required: true, message: 'Please select a Leader' }]}
603
- tooltip="The AI Employee that will be able to delegate tasks to the Sub-Agent"
604
- >
605
- <AIEmployeeSelect placeholder="Select Leader AI Employee..." />
606
- </Form.Item>
607
-
608
- <Form.Item
609
- name="subAgentUsername"
610
- label="Sub-Agent"
611
- rules={[{ required: true, message: 'Please select a Sub-Agent' }]}
612
- tooltip="The AI Employee that will receive delegated tasks"
613
- >
614
- <AIEmployeeSelect placeholder="Select Sub-Agent AI Employee..." exclude={leaderUsername} />
615
- </Form.Item>
616
-
617
- <Form.Item
618
- name="maxDepth"
619
- label="Max Delegation Depth"
620
- tooltip="How many layers of delegation are allowed (1 = leader calls sub-agent, sub-agent cannot delegate further)"
621
- >
622
- <InputNumber min={1} max={3} style={{ width: '100%' }} />
623
- </Form.Item>
624
-
625
- <Form.Item
626
- name="timeout"
627
- label="Timeout (ms)"
628
- tooltip="Maximum time in milliseconds for the sub-agent to complete its task"
629
- >
630
- <InputNumber min={10000} max={600000} step={10000} style={{ width: '100%' }} />
631
- </Form.Item>
632
-
633
- <Form.Item
634
- name="recursionLimit"
635
- label="Recursion Limit"
636
- tooltip="Max LangGraph reasoning steps per delegation. Higher = more complex multi-step tasks; lower = stricter cap on token usage. Default 50."
637
- >
638
- <InputNumber min={5} max={200} step={5} style={{ width: '100%' }} />
639
- </Form.Item>
640
-
641
- <Form.Item
642
- name="harnessTag"
643
- label="Harness Profile"
644
- tooltip="Profile tag used by plan approval, controller limits, and orchestration policy."
645
- >
646
- <Select
647
- loading={harnessLoading}
648
- options={[
649
- { label: 'default', value: 'default' },
650
- ...harnessProfiles
651
- .filter((profile: any) => profile.tag !== 'default')
652
- .map((profile: any) => ({
653
- label: profile.title ? `${profile.tag} - ${profile.title}` : profile.tag,
654
- value: profile.tag,
655
- })),
656
- ]}
657
- />
658
- </Form.Item>
659
-
660
- <Form.Item
661
- name="llmService"
662
- label="Override LLM Service"
663
- tooltip="Optional: Provider name. Leave empty to inherit from Leader."
664
- >
665
- <Select
666
- allowClear
667
- placeholder="Inherit from Leader"
668
- loading={llmLoading}
669
- options={llmServices.map((svc: any) => ({
670
- label: svc.llmServiceTitle || svc.llmService,
671
- value: svc.llmService,
672
- }))}
673
- onChange={() => form.setFieldValue('model', undefined)}
674
- />
675
- </Form.Item>
676
-
677
- <Form.Item
678
- noStyle
679
- shouldUpdate={(prevValues, currentValues) => prevValues.llmService !== currentValues.llmService}
680
- >
681
- {() => {
682
- const selectedServiceId = form.getFieldValue('llmService');
683
- const selectedService = llmServices.find((s: any) => s.llmService === selectedServiceId);
684
- const availableModels = Array.isArray(selectedService?.enabledModels)
685
- ? selectedService.enabledModels
686
- : [];
687
-
688
- return (
689
- <Form.Item
690
- name="model"
691
- label="Override Model"
692
- tooltip="Optional: Model name. Leave empty to inherit from Leader."
693
- rules={[{ required: !!selectedServiceId, message: 'Please select a model' }]}
694
- >
695
- <Select
696
- allowClear
697
- placeholder={selectedServiceId ? 'Select a model' : 'Inherit from Leader'}
698
- disabled={!selectedServiceId}
699
- options={availableModels.map((m: any) => ({
700
- label: m.label,
701
- value: m.value,
702
- }))}
703
- />
704
- </Form.Item>
705
- );
706
- }}
707
- </Form.Item>
708
-
709
- <Form.Item name="enabled" label="Enabled" valuePropName="checked">
710
- <Switch />
711
- </Form.Item>
712
- </Form>
713
- </Drawer>
714
- </div>
715
- );
716
- };
1
+ import React, { useState } from 'react';
2
+ import {
3
+ Table,
4
+ Button,
5
+ Drawer,
6
+ Form,
7
+ InputNumber,
8
+ Switch,
9
+ Space,
10
+ Popconfirm,
11
+ Card,
12
+ message,
13
+ Tag,
14
+ Typography,
15
+ Alert,
16
+ Collapse,
17
+ Empty,
18
+ Select,
19
+ } from 'antd';
20
+ import {
21
+ PlusOutlined,
22
+ EditOutlined,
23
+ DeleteOutlined,
24
+ SwapRightOutlined,
25
+ WarningOutlined,
26
+ ThunderboltOutlined,
27
+ } from '@ant-design/icons';
28
+ import { useRequest } from 'ahooks';
29
+ import { useApp } from '@nocobase/client-v2';
30
+ import { AIEmployeeSelect } from './AIEmployeeSelect';
31
+ import { useAIEmployees } from './AIEmployeesContext';
32
+
33
+ const { Text } = Typography;
34
+
35
+ /**
36
+ * Mirrors server-side `sanitizeToolPart` in delegate-task.ts so we can compute
37
+ * the expected delegation tool names here and detect when the leader hasn't
38
+ * added them to its skillSettings.tools.
39
+ */
40
+ const sanitizeToolPart = (value: string) => (value || '').replace(/[^a-zA-Z0-9_-]/g, '_');
41
+ const expectedDelegateToolName = (leader: string, sub: string) =>
42
+ `delegate_${sanitizeToolPart(leader)}_to_${sanitizeToolPart(sub)}`;
43
+ const expectedDispatchToolName = (leader: string) => `dispatch_subagents_${sanitizeToolPart(leader)}`;
44
+ const controllerToolNames = [
45
+ 'orchestrator_plan_goal',
46
+ 'orchestrator_execute_plan',
47
+ 'orchestrator_status',
48
+ 'orchestrator_cancel',
49
+ ];
50
+ const toolLikeNames = new Set([...controllerToolNames, 'external_rag_search', 'skill_hub_execute']);
51
+
52
+ const isToolLikeName = (name: string) =>
53
+ toolLikeNames.has(name) ||
54
+ name.startsWith('delegate_') ||
55
+ name.startsWith('dispatch_subagents_') ||
56
+ name.startsWith('skill_hub_') ||
57
+ name.startsWith('browser_') ||
58
+ name.startsWith('drawio-');
59
+
60
+ const normalizeToolBinding = (value: any) => {
61
+ const name = typeof value === 'string' ? value : value?.name;
62
+ if (typeof name !== 'string' || !name.trim()) return null;
63
+ return {
64
+ name: name.trim(),
65
+ autoCall: value?.autoCall === true,
66
+ };
67
+ };
68
+
69
+ const normalizeSkillSettingsForTools = (skillSettings: any) => {
70
+ const source = skillSettings && typeof skillSettings === 'object' ? skillSettings : {};
71
+ const toolsByName = new Map<string, { name: string; autoCall: boolean }>();
72
+ const addTool = (tool: { name: string; autoCall: boolean } | null) => {
73
+ if (!tool || toolsByName.has(tool.name)) return;
74
+ toolsByName.set(tool.name, tool);
75
+ };
76
+
77
+ if (Array.isArray(source.tools)) {
78
+ for (const item of source.tools) {
79
+ addTool(normalizeToolBinding(item));
80
+ }
81
+ }
82
+
83
+ const nextSkills: string[] = [];
84
+ if (Array.isArray(source.skills)) {
85
+ for (const item of source.skills) {
86
+ if (typeof item === 'string') {
87
+ const name = item.trim();
88
+ if (!name) continue;
89
+ if (isToolLikeName(name)) {
90
+ addTool({ name, autoCall: false });
91
+ } else {
92
+ nextSkills.push(name);
93
+ }
94
+ continue;
95
+ }
96
+ addTool(normalizeToolBinding(item));
97
+ }
98
+ }
99
+
100
+ return {
101
+ ...source,
102
+ skills: nextSkills,
103
+ tools: Array.from(toolsByName.values()),
104
+ };
105
+ };
106
+
107
+ export const RulesTab: React.FC = () => {
108
+ const api = useApp().apiClient;
109
+ const [visible, setVisible] = useState(false);
110
+ const [editingRecord, setEditingRecord] = useState<any>(null);
111
+ const [form] = Form.useForm();
112
+
113
+ const { data, loading, refresh } = useRequest(() =>
114
+ api.request({
115
+ url: 'orchestratorConfig:list',
116
+ params: {
117
+ sort: ['-createdAt'],
118
+ },
119
+ }),
120
+ );
121
+
122
+ const { data: llmServicesData, loading: llmLoading } = useRequest(() =>
123
+ api.request({
124
+ url: 'ai:listAllEnabledModels',
125
+ }),
126
+ );
127
+
128
+ const { data: harnessProfilesData, loading: harnessLoading } = useRequest(() =>
129
+ api.request({
130
+ url: 'agentHarnessProfiles:list',
131
+ params: {
132
+ filter: { enabled: true },
133
+ sort: ['tag'],
134
+ pageSize: 100,
135
+ },
136
+ }),
137
+ );
138
+
139
+ const llmServices = React.useMemo(() => {
140
+ const raw = (llmServicesData as any)?.data ?? llmServicesData;
141
+ if (Array.isArray(raw)) return raw;
142
+ return Array.isArray(raw?.data) ? raw.data : [];
143
+ }, [llmServicesData]);
144
+
145
+ const harnessProfiles = React.useMemo(() => {
146
+ const raw = (harnessProfilesData as any)?.data ?? harnessProfilesData;
147
+ if (Array.isArray(raw)) return raw;
148
+ return Array.isArray(raw?.data) ? raw.data : [];
149
+ }, [harnessProfilesData]);
150
+
151
+ // P3 FIX: Use shared context instead of duplicate API call
152
+ const { employeeMap, toolNamesMap, refresh: refreshEmployees } = useAIEmployees();
153
+ const rules = React.useMemo(() => {
154
+ const rows = (data as any)?.data;
155
+ return Array.isArray(rows) ? rows : [];
156
+ }, [data]);
157
+
158
+ const handleAddToolsToEmployee = async (employeeUsername: string, toolNames: string[]) => {
159
+ try {
160
+ // Re-fetch the leader to merge current tools and clean up legacy skillSettings.skills tool entries.
161
+ const leaderResp = await api.request({
162
+ url: 'aiEmployees:get',
163
+ params: { filterByTk: employeeUsername },
164
+ });
165
+ const leader = (leaderResp as any)?.data?.data;
166
+ if (!leader) {
167
+ message.error('Could not load AI employee.');
168
+ return;
169
+ }
170
+ const normalizedSkillSettings = normalizeSkillSettingsForTools(leader.skillSettings);
171
+ const existingNames = new Set(normalizedSkillSettings.tools.map((tool: { name: string }) => tool.name));
172
+ const missing = toolNames.filter((toolName) => !existingNames.has(toolName));
173
+ const hadLegacyToolBindings =
174
+ JSON.stringify(normalizedSkillSettings) !== JSON.stringify(leader.skillSettings || {});
175
+ if (!missing.length && !hadLegacyToolBindings) {
176
+ message.info('Tools already present.');
177
+ await refreshEmployees();
178
+ return;
179
+ }
180
+ const nextTools = [...normalizedSkillSettings.tools, ...missing.map((name) => ({ name, autoCall: false }))];
181
+ await api.request({
182
+ url: 'aiEmployees:update',
183
+ method: 'put',
184
+ params: { filterByTk: employeeUsername },
185
+ data: { skillSettings: { ...normalizedSkillSettings, tools: nextTools } },
186
+ });
187
+ message.success(
188
+ missing.length
189
+ ? `Added ${missing.length} tool${missing.length > 1 ? 's' : ''} to ${employeeUsername}.`
190
+ : `Normalized tool bindings for ${employeeUsername}.`,
191
+ );
192
+ await refreshEmployees();
193
+ } catch (e: any) {
194
+ message.error(`Auto-assign failed: ${e?.message || 'unknown error'}`);
195
+ }
196
+ };
197
+
198
+ const handleAddToolToEmployee = async (employeeUsername: string, toolName: string) => {
199
+ await handleAddToolsToEmployee(employeeUsername, [toolName]);
200
+ };
201
+
202
+ const handleAutoAssignTool = async (record: any) => {
203
+ await handleAddToolToEmployee(
204
+ record.leaderUsername,
205
+ expectedDelegateToolName(record.leaderUsername, record.subAgentUsername),
206
+ );
207
+ };
208
+
209
+ const handleAutoAssignDispatchTool = async (leaderUsername: string) => {
210
+ await handleAddToolToEmployee(leaderUsername, expectedDispatchToolName(leaderUsername));
211
+ };
212
+
213
+ const subAgentLeaderCount = React.useMemo(() => {
214
+ const counts = new Map<string, Set<string>>();
215
+ for (const rule of rules) {
216
+ const set = counts.get(rule.subAgentUsername) || new Set<string>();
217
+ set.add(rule.leaderUsername);
218
+ counts.set(rule.subAgentUsername, set);
219
+ }
220
+ return counts;
221
+ }, [rules]);
222
+
223
+ const aliasConflicts = React.useMemo(() => {
224
+ return Array.from(subAgentLeaderCount.entries())
225
+ .filter(([, leaders]) => leaders.size > 1)
226
+ .map(([sub, leaders]) => ({ sub, leaders: Array.from(leaders) }));
227
+ }, [subAgentLeaderCount]);
228
+
229
+ const groupedRules = React.useMemo(() => {
230
+ const groups = new Map<string, any[]>();
231
+ for (const rule of rules) {
232
+ const key = rule.leaderUsername || 'unknown';
233
+ let items = groups.get(key);
234
+ if (!items) {
235
+ items = [];
236
+ groups.set(key, items);
237
+ }
238
+ items.push(rule);
239
+ }
240
+
241
+ return Array.from(groups.entries()).map(([leaderUsername, items]) => ({
242
+ leaderUsername,
243
+ items,
244
+ }));
245
+ }, [rules]);
246
+
247
+ const handleOpen = (record?: any) => {
248
+ setEditingRecord(record);
249
+ if (record) {
250
+ form.setFieldsValue(record);
251
+ } else {
252
+ form.resetFields();
253
+ form.setFieldsValue({ enabled: true, maxDepth: 1, timeout: 120000, recursionLimit: 50, harnessTag: 'default' });
254
+ }
255
+ setVisible(true);
256
+ };
257
+
258
+ const handleClose = () => {
259
+ setVisible(false);
260
+ setEditingRecord(null);
261
+ };
262
+
263
+ const handleSave = async (values: any) => {
264
+ // Validate: leader !== subAgent
265
+ if (values.leaderUsername === values.subAgentUsername) {
266
+ message.error('Leader and Sub-Agent cannot be the same employee.');
267
+ return;
268
+ }
269
+
270
+ try {
271
+ if (editingRecord) {
272
+ await api.request({
273
+ url: 'orchestratorConfig:update',
274
+ method: 'put',
275
+ params: { filterByTk: editingRecord.id },
276
+ data: values,
277
+ });
278
+ message.success('Rule updated');
279
+ } else {
280
+ await api.request({
281
+ url: 'orchestratorConfig:create',
282
+ method: 'post',
283
+ data: values,
284
+ });
285
+ message.success('Rule created');
286
+ }
287
+ handleClose();
288
+ refresh();
289
+ } catch (e: any) {
290
+ const msg = e?.response?.data?.errors?.[0]?.message || e.message;
291
+ message.error(`Save failed: ${msg}`);
292
+ }
293
+ };
294
+
295
+ const handleDelete = async (id: string) => {
296
+ try {
297
+ await api.request({
298
+ url: 'orchestratorConfig:destroy',
299
+ method: 'delete',
300
+ params: { filterByTk: id },
301
+ });
302
+ message.success('Rule deleted');
303
+ refresh();
304
+ } catch (e: any) {
305
+ message.error(`Delete failed: ${e.message}`);
306
+ }
307
+ };
308
+
309
+ const columns = [
310
+ {
311
+ title: 'Leader (Orchestrator)',
312
+ dataIndex: 'leaderUsername',
313
+ key: 'leaderUsername',
314
+ render: (username: string) => <Tag color="blue">{employeeMap.get(username) || username}</Tag>,
315
+ },
316
+ {
317
+ title: '',
318
+ key: 'arrow',
319
+ width: 50,
320
+ render: () => <SwapRightOutlined style={{ color: '#999', fontSize: 18 }} />,
321
+ },
322
+ {
323
+ title: 'Sub-Agent',
324
+ dataIndex: 'subAgentUsername',
325
+ key: 'subAgentUsername',
326
+ render: (username: string) => <Tag color="green">{employeeMap.get(username) || username}</Tag>,
327
+ },
328
+ {
329
+ title: 'Harness',
330
+ dataIndex: 'harnessTag',
331
+ key: 'harnessTag',
332
+ width: 120,
333
+ render: (tag: string) => <Tag color="purple">{tag || 'default'}</Tag>,
334
+ },
335
+ {
336
+ title: 'Max Depth',
337
+ dataIndex: 'maxDepth',
338
+ key: 'maxDepth',
339
+ width: 100,
340
+ render: (v: number) => v ?? 1,
341
+ },
342
+ {
343
+ title: 'Timeout',
344
+ dataIndex: 'timeout',
345
+ key: 'timeout',
346
+ width: 100,
347
+ render: (v: number) => `${((v ?? 120000) / 1000).toFixed(0)}s`,
348
+ },
349
+ {
350
+ title: 'LLM Override',
351
+ key: 'llmOverride',
352
+ width: 140,
353
+ render: (_: any, record: any) => {
354
+ if (record.llmService && record.model) {
355
+ const svc = llmServices.find((s: any) => s.llmService === record.llmService);
356
+ const svcName = svc ? svc.llmServiceTitle : record.llmService;
357
+ return (
358
+ <Space direction="vertical" size={0}>
359
+ <Text style={{ fontSize: 12 }}>{svcName}</Text>
360
+ <Text type="secondary" style={{ fontSize: 12 }}>
361
+ {record.model}
362
+ </Text>
363
+ </Space>
364
+ );
365
+ }
366
+ return (
367
+ <Text type="secondary" style={{ fontSize: 12 }}>
368
+ Inherited
369
+ </Text>
370
+ );
371
+ },
372
+ },
373
+ {
374
+ title: 'Enabled',
375
+ dataIndex: 'enabled',
376
+ key: 'enabled',
377
+ width: 80,
378
+ render: (enabled: boolean, record: any) => (
379
+ <Switch
380
+ checked={enabled}
381
+ size="small"
382
+ onChange={async (checked) => {
383
+ await api.request({
384
+ url: 'orchestratorConfig:update',
385
+ method: 'put',
386
+ params: { filterByTk: record.id },
387
+ data: { enabled: checked },
388
+ });
389
+ refresh();
390
+ }}
391
+ />
392
+ ),
393
+ },
394
+ {
395
+ title: 'Tool',
396
+ key: 'tool',
397
+ width: 150,
398
+ render: (_: any, record: any) => {
399
+ const expected = expectedDelegateToolName(record.leaderUsername, record.subAgentUsername);
400
+ const leaderTools = toolNamesMap.get(record.leaderUsername);
401
+ if (!leaderTools) {
402
+ return (
403
+ <Text type="secondary" style={{ fontSize: 12 }}>
404
+
405
+ </Text>
406
+ );
407
+ }
408
+ const present = leaderTools.has(expected);
409
+ if (present) {
410
+ return <Tag color="success">Assigned</Tag>;
411
+ }
412
+ return (
413
+ <Space size={4}>
414
+ <Tag icon={<WarningOutlined />} color="warning">
415
+ Missing
416
+ </Tag>
417
+ <Button
418
+ type="link"
419
+ size="small"
420
+ icon={<ThunderboltOutlined />}
421
+ onClick={() => handleAutoAssignTool(record)}
422
+ >
423
+ Auto-add
424
+ </Button>
425
+ </Space>
426
+ );
427
+ },
428
+ },
429
+ {
430
+ title: 'Actions',
431
+ key: 'actions',
432
+ width: 160,
433
+ render: (_: any, record: any) => (
434
+ <Space>
435
+ <Button type="link" size="small" icon={<EditOutlined />} onClick={() => handleOpen(record)}>
436
+ Edit
437
+ </Button>
438
+ <Popconfirm title="Delete this rule?" onConfirm={() => handleDelete(record.id)}>
439
+ <Button type="link" size="small" danger icon={<DeleteOutlined />}>
440
+ Delete
441
+ </Button>
442
+ </Popconfirm>
443
+ </Space>
444
+ ),
445
+ },
446
+ ];
447
+
448
+ const leaderUsername = Form.useWatch('leaderUsername', form);
449
+
450
+ const missingToolCount = React.useMemo(() => {
451
+ return rules.reduce((acc: number, r: any) => {
452
+ const leaderTools = toolNamesMap.get(r.leaderUsername);
453
+ if (!leaderTools) return acc;
454
+ const expected = expectedDelegateToolName(r.leaderUsername, r.subAgentUsername);
455
+ return leaderTools.has(expected) ? acc : acc + 1;
456
+ }, 0);
457
+ }, [rules, toolNamesMap]);
458
+
459
+ const missingDispatchTools = React.useMemo(() => {
460
+ return groupedRules
461
+ .map((group) => {
462
+ const leaderTools = toolNamesMap.get(group.leaderUsername);
463
+ if (!leaderTools) return null;
464
+ const toolName = expectedDispatchToolName(group.leaderUsername);
465
+ return leaderTools.has(toolName)
466
+ ? null
467
+ : { leaderUsername: group.leaderUsername, toolName, count: group.items.length };
468
+ })
469
+ .filter(Boolean) as Array<{ leaderUsername: string; toolName: string; count: number }>;
470
+ }, [groupedRules, toolNamesMap]);
471
+
472
+ const missingControllerTools = React.useMemo(() => {
473
+ return groupedRules
474
+ .map((group) => {
475
+ const leaderTools = toolNamesMap.get(group.leaderUsername);
476
+ if (!leaderTools) return null;
477
+ const missing = controllerToolNames.filter((toolName) => !leaderTools.has(toolName));
478
+ return missing.length ? { leaderUsername: group.leaderUsername, missing } : null;
479
+ })
480
+ .filter(Boolean) as Array<{ leaderUsername: string; missing: string[] }>;
481
+ }, [groupedRules, toolNamesMap]);
482
+
483
+ return (
484
+ <div>
485
+ <Alert
486
+ type="info"
487
+ showIcon
488
+ style={{ marginBottom: 16 }}
489
+ message="Orchestration Rules"
490
+ description={
491
+ <Text type="secondary">
492
+ Configure which AI Employees can act as Leaders (Orchestrators) and which ones they can delegate tasks to.
493
+ Each rule creates a callable tool for the Leader to invoke the Sub-Agent.
494
+ </Text>
495
+ }
496
+ />
497
+
498
+ {missingToolCount > 0 && (
499
+ <Alert
500
+ type="warning"
501
+ showIcon
502
+ style={{ marginBottom: 16 }}
503
+ message={`${missingToolCount} rule${missingToolCount > 1 ? 's' : ''} missing required tool assignment`}
504
+ description={
505
+ <Text type="secondary">
506
+ The Leader employee hasn&apos;t added the corresponding{' '}
507
+ <Text code>delegate_&lt;leader&gt;_to_&lt;sub&gt;</Text> tool to its skillSettings.tools, so the LLM
508
+ cannot actually call these sub-agents. Use the <b>Auto-add</b> button in the Tool column to fix.
509
+ </Text>
510
+ }
511
+ />
512
+ )}
513
+
514
+ {missingControllerTools.length > 0 && (
515
+ <Alert
516
+ type="warning"
517
+ showIcon
518
+ style={{ marginBottom: 16 }}
519
+ message={`${missingControllerTools.length} leader${
520
+ missingControllerTools.length > 1 ? 's' : ''
521
+ } missing orchestrator controller tools`}
522
+ description={
523
+ <Space direction="vertical" size={6}>
524
+ <Text type="secondary">
525
+ Leaders need the orchestrator controller tools to create an approval-first plan and execute it after the
526
+ user accepts the card.
527
+ </Text>
528
+ {missingControllerTools.map(({ leaderUsername, missing }) => (
529
+ <Space key={leaderUsername} size={8} wrap>
530
+ <Tag color="blue">{employeeMap.get(leaderUsername) || leaderUsername}</Tag>
531
+ <Text type="secondary">{missing.length} missing</Text>
532
+ <Button
533
+ type="link"
534
+ size="small"
535
+ icon={<ThunderboltOutlined />}
536
+ onClick={() => handleAddToolsToEmployee(leaderUsername, missing)}
537
+ >
538
+ Auto-add
539
+ </Button>
540
+ </Space>
541
+ ))}
542
+ </Space>
543
+ }
544
+ />
545
+ )}
546
+
547
+ {missingDispatchTools.length > 0 && (
548
+ <Alert
549
+ type="warning"
550
+ showIcon
551
+ style={{ marginBottom: 16 }}
552
+ message={`${missingDispatchTools.length} leader${
553
+ missingDispatchTools.length > 1 ? 's' : ''
554
+ } missing dispatch tool assignment`}
555
+ description={
556
+ <Space direction="vertical" size={6}>
557
+ <Text type="secondary">
558
+ The fan-out tool lets a Leader dispatch multiple independent sub-tasks in one call. Add it to the
559
+ Leader&apos;s tools to enable the new multi-agent flow.
560
+ </Text>
561
+ {missingDispatchTools.map(({ leaderUsername, toolName, count }) => (
562
+ <Space key={leaderUsername} size={8} wrap>
563
+ <Tag color="blue">{employeeMap.get(leaderUsername) || leaderUsername}</Tag>
564
+ <Text type="secondary">
565
+ {count} sub-agent{count > 1 ? 's' : ''}
566
+ </Text>
567
+ <Text code>{toolName}</Text>
568
+ <Button
569
+ type="link"
570
+ size="small"
571
+ icon={<ThunderboltOutlined />}
572
+ onClick={() => handleAutoAssignDispatchTool(leaderUsername)}
573
+ >
574
+ Auto-add
575
+ </Button>
576
+ </Space>
577
+ ))}
578
+ </Space>
579
+ }
580
+ />
581
+ )}
582
+
583
+ {aliasConflicts.length > 0 && (
584
+ <Alert
585
+ type="warning"
586
+ showIcon
587
+ style={{ marginBottom: 16 }}
588
+ message="Legacy delegate_to_<sub> alias is no longer registered for these sub-agents"
589
+ description={
590
+ <Space direction="vertical" size={2}>
591
+ {aliasConflicts.map(({ sub, leaders }) => (
592
+ <Text key={sub} type="secondary">
593
+ <Tag color="green">{employeeMap.get(sub) || sub}</Tag>
594
+ has multiple leaders ({leaders.map((l) => employeeMap.get(l) || l).join(', ')}). The legacy alias is
595
+ dropped to avoid ambiguity — leaders must use <Text code>delegate_&lt;leader&gt;_to_&lt;sub&gt;</Text>{' '}
596
+ in their tools.
597
+ </Text>
598
+ ))}
599
+ </Space>
600
+ }
601
+ />
602
+ )}
603
+
604
+ <Card bordered={false}>
605
+ <div style={{ marginBottom: 16, display: 'flex', justifyContent: 'flex-end' }}>
606
+ <Button type="primary" icon={<PlusOutlined />} onClick={() => handleOpen()}>
607
+ New Rule
608
+ </Button>
609
+ </div>
610
+ {groupedRules.length ? (
611
+ <Collapse
612
+ bordered={false}
613
+ defaultActiveKey={groupedRules.map((group) => group.leaderUsername)}
614
+ items={groupedRules.map((group) => ({
615
+ key: group.leaderUsername,
616
+ label: (
617
+ <Space>
618
+ <Tag color="blue">{employeeMap.get(group.leaderUsername) || group.leaderUsername}</Tag>
619
+ <Text type="secondary">
620
+ {group.items.length} sub-agent{group.items.length > 1 ? 's' : ''}
621
+ </Text>
622
+ {missingDispatchTools.some((item) => item.leaderUsername === group.leaderUsername) && (
623
+ <Tag color="warning">Dispatch missing</Tag>
624
+ )}
625
+ </Space>
626
+ ),
627
+ children: (
628
+ <Table
629
+ rowKey="id"
630
+ loading={loading}
631
+ dataSource={group.items}
632
+ columns={columns}
633
+ pagination={false}
634
+ size="middle"
635
+ scroll={{ x: 'max-content' }}
636
+ />
637
+ ),
638
+ }))}
639
+ />
640
+ ) : (
641
+ <Table
642
+ rowKey="id"
643
+ loading={loading}
644
+ dataSource={[]}
645
+ columns={columns}
646
+ pagination={false}
647
+ size="middle"
648
+ scroll={{ x: 'max-content' }}
649
+ locale={{ emptyText: <Empty description="No orchestration rules yet" /> }}
650
+ />
651
+ )}
652
+ </Card>
653
+
654
+ <Drawer
655
+ title={editingRecord ? 'Edit Orchestration Rule' : 'New Orchestration Rule'}
656
+ width={480}
657
+ onClose={handleClose}
658
+ open={visible}
659
+ styles={{ body: { paddingBottom: 80 } }}
660
+ extra={
661
+ <Space>
662
+ <Button onClick={handleClose}>Cancel</Button>
663
+ <Button onClick={() => form.submit()} type="primary">
664
+ Save
665
+ </Button>
666
+ </Space>
667
+ }
668
+ >
669
+ <Form form={form} layout="vertical" onFinish={handleSave}>
670
+ <Form.Item
671
+ name="leaderUsername"
672
+ label="Leader (Orchestrator)"
673
+ rules={[{ required: true, message: 'Please select a Leader' }]}
674
+ tooltip="The AI Employee that will be able to delegate tasks to the Sub-Agent"
675
+ >
676
+ <AIEmployeeSelect placeholder="Select Leader AI Employee..." />
677
+ </Form.Item>
678
+
679
+ <Form.Item
680
+ name="subAgentUsername"
681
+ label="Sub-Agent"
682
+ rules={[{ required: true, message: 'Please select a Sub-Agent' }]}
683
+ tooltip="The AI Employee that will receive delegated tasks"
684
+ >
685
+ <AIEmployeeSelect placeholder="Select Sub-Agent AI Employee..." exclude={leaderUsername} />
686
+ </Form.Item>
687
+
688
+ <Form.Item
689
+ name="maxDepth"
690
+ label="Max Delegation Depth"
691
+ tooltip="How many layers of delegation are allowed (1 = leader calls sub-agent, sub-agent cannot delegate further)"
692
+ >
693
+ <InputNumber min={1} max={3} style={{ width: '100%' }} />
694
+ </Form.Item>
695
+
696
+ <Form.Item
697
+ name="timeout"
698
+ label="Timeout (ms)"
699
+ tooltip="Maximum time in milliseconds for the sub-agent to complete its task"
700
+ >
701
+ <InputNumber min={10000} max={600000} step={10000} style={{ width: '100%' }} />
702
+ </Form.Item>
703
+
704
+ <Form.Item
705
+ name="recursionLimit"
706
+ label="Recursion Limit"
707
+ tooltip="Max LangGraph reasoning steps per delegation. Higher = more complex multi-step tasks; lower = stricter cap on token usage. Default 50."
708
+ >
709
+ <InputNumber min={5} max={200} step={5} style={{ width: '100%' }} />
710
+ </Form.Item>
711
+
712
+ <Form.Item
713
+ name="harnessTag"
714
+ label="Harness Profile"
715
+ tooltip="Profile tag used by plan approval, controller limits, and orchestration policy."
716
+ >
717
+ <Select
718
+ loading={harnessLoading}
719
+ options={[
720
+ { label: 'default', value: 'default' },
721
+ ...harnessProfiles
722
+ .filter((profile: any) => profile.tag !== 'default')
723
+ .map((profile: any) => ({
724
+ label: profile.title ? `${profile.tag} - ${profile.title}` : profile.tag,
725
+ value: profile.tag,
726
+ })),
727
+ ]}
728
+ />
729
+ </Form.Item>
730
+
731
+ <Form.Item
732
+ name="llmService"
733
+ label="Override LLM Service"
734
+ tooltip="Optional: Provider name. Leave empty to inherit from Leader."
735
+ >
736
+ <Select
737
+ allowClear
738
+ placeholder="Inherit from Leader"
739
+ loading={llmLoading}
740
+ options={llmServices.map((svc: any) => ({
741
+ label: svc.llmServiceTitle || svc.llmService,
742
+ value: svc.llmService,
743
+ }))}
744
+ onChange={() => form.setFieldValue('model', undefined)}
745
+ />
746
+ </Form.Item>
747
+
748
+ <Form.Item
749
+ noStyle
750
+ shouldUpdate={(prevValues, currentValues) => prevValues.llmService !== currentValues.llmService}
751
+ >
752
+ {() => {
753
+ const selectedServiceId = form.getFieldValue('llmService');
754
+ const selectedService = llmServices.find((s: any) => s.llmService === selectedServiceId);
755
+ const availableModels = Array.isArray(selectedService?.enabledModels)
756
+ ? selectedService.enabledModels
757
+ : [];
758
+
759
+ return (
760
+ <Form.Item
761
+ name="model"
762
+ label="Override Model"
763
+ tooltip="Optional: Model name. Leave empty to inherit from Leader."
764
+ rules={[{ required: !!selectedServiceId, message: 'Please select a model' }]}
765
+ >
766
+ <Select
767
+ allowClear
768
+ placeholder={selectedServiceId ? 'Select a model' : 'Inherit from Leader'}
769
+ disabled={!selectedServiceId}
770
+ options={availableModels.map((m: any) => ({
771
+ label: m.label,
772
+ value: m.value,
773
+ }))}
774
+ />
775
+ </Form.Item>
776
+ );
777
+ }}
778
+ </Form.Item>
779
+
780
+ <Form.Item name="enabled" label="Enabled" valuePropName="checked">
781
+ <Switch />
782
+ </Form.Item>
783
+ </Form>
784
+ </Drawer>
785
+ </div>
786
+ );
787
+ };