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
@@ -0,0 +1,63 @@
1
+ import { afterEach, describe, expect, it } from 'vitest';
2
+ import { CodeValidator, CodeValidationError } from '../services/CodeValidator';
3
+
4
+ const validator = new CodeValidator();
5
+
6
+ describe('CodeValidator.validate — dangerous patterns', () => {
7
+ it('blocks node child_process and dynamic require', () => {
8
+ expect(() => validator.validate(`require('child_process')`, 'node')).toThrow(CodeValidationError);
9
+ expect(() => validator.validate(`const m = 'fs'; require(m);`, 'node')).toThrow(CodeValidationError);
10
+ });
11
+
12
+ it('blocks node indirect eval / Function constructor / dynamic import', () => {
13
+ expect(() => validator.validate(`eval('1+1')`, 'node')).toThrow(CodeValidationError);
14
+ expect(() => validator.validate(`new Function('return 1')()`, 'node')).toThrow(CodeValidationError);
15
+ expect(() => validator.validate(`import('fs')`, 'node')).toThrow(CodeValidationError);
16
+ expect(() => validator.validate(`process.binding('spawn_sync')`, 'node')).toThrow(CodeValidationError);
17
+ });
18
+
19
+ it('blocks python subprocess / socket / importlib / getattr bypass', () => {
20
+ expect(() => validator.validate(`import subprocess`, 'python')).toThrow(CodeValidationError);
21
+ expect(() => validator.validate(`import socket`, 'python')).toThrow(CodeValidationError);
22
+ expect(() => validator.validate(`import importlib`, 'python')).toThrow(CodeValidationError);
23
+ expect(() => validator.validate(`getattr(os, 'sys' + 'tem')('id')`, 'python')).toThrow(CodeValidationError);
24
+ expect(() => validator.validate(`__import__('os')`, 'python')).toThrow(CodeValidationError);
25
+ });
26
+
27
+ it('allows benign code', () => {
28
+ expect(() => validator.validate(`const fs = require('fs'); fs.writeFileSync('x', '1');`, 'node')).not.toThrow();
29
+ expect(() => validator.validate(`import json\nprint(json.dumps({'a': 1}))`, 'python')).not.toThrow();
30
+ });
31
+ });
32
+
33
+ describe('CodeValidator.validateImports — allowlist', () => {
34
+ afterEach(() => {
35
+ delete process.env.SKILL_HUB_ALLOW_ANY_IMPORT;
36
+ });
37
+
38
+ it('blocks stdlib network modules even when whitelist is empty (exfiltration hole)', () => {
39
+ // urllib / ftplib are stdlib (no install needed) and must NOT pass on empty whitelist.
40
+ expect(() => validator.validateImports(`import urllib.request`, 'python', [])).toThrow(CodeValidationError);
41
+ expect(() => validator.validateImports(`import ftplib`, 'python', [])).toThrow(CodeValidationError);
42
+ });
43
+
44
+ it('allows builtins on empty whitelist', () => {
45
+ expect(() => validator.validateImports(`import json`, 'python', [])).not.toThrow();
46
+ });
47
+
48
+ it('allows whitelisted package and maps PyPI→import name', () => {
49
+ expect(() => validator.validateImports(`import requests`, 'python', ['requests'])).not.toThrow();
50
+ expect(() => validator.validateImports(`from docx import Document`, 'python', ['python-docx'])).not.toThrow();
51
+ });
52
+
53
+ it('blocks non-whitelisted node package but allows builtins', () => {
54
+ expect(() => validator.validateImports(`require('lodash')`, 'node', [])).toThrow(CodeValidationError);
55
+ expect(() => validator.validateImports(`require('lodash')`, 'node', ['lodash'])).not.toThrow();
56
+ expect(() => validator.validateImports(`require('path')`, 'node', [])).not.toThrow();
57
+ });
58
+
59
+ it('honours SKILL_HUB_ALLOW_ANY_IMPORT escape hatch on empty whitelist', () => {
60
+ process.env.SKILL_HUB_ALLOW_ANY_IMPORT = 'true';
61
+ expect(() => validator.validateImports(`import urllib.request`, 'python', [])).not.toThrow();
62
+ });
63
+ });
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { createSkillExecuteTool } from '../tools/skill-execute';
3
+
4
+ describe('createSkillExecuteTool', () => {
5
+ it('bridges ToolsRuntime onto ctx.runtime while executing a skill', async () => {
6
+ const runtime = { toolCallId: 'tool-1', writer: vi.fn() };
7
+ const ctx: { runtime?: typeof runtime } = {};
8
+ const skill = { get: vi.fn((name: string) => (name === 'name' ? 'demo' : undefined)) };
9
+ const executeSkill = vi.fn(async (_skill, _input, executeCtx) => {
10
+ expect(executeCtx.runtime).toBe(runtime);
11
+ return {
12
+ status: 'succeeded',
13
+ files: [],
14
+ };
15
+ });
16
+ const plugin = {
17
+ app: { logger: { info: vi.fn() } },
18
+ db: {
19
+ getRepository: vi.fn(() => ({
20
+ findOne: vi.fn(async () => skill),
21
+ })),
22
+ },
23
+ executeSkill,
24
+ };
25
+
26
+ const tool = createSkillExecuteTool(plugin);
27
+ const result = await tool.invoke(ctx, { action: 'execute', skillName: 'demo', input: { ok: true } }, runtime);
28
+
29
+ expect(result.status).toBe('success');
30
+ expect(executeSkill).toHaveBeenCalledOnce();
31
+ expect(ctx.runtime).toBeUndefined();
32
+ });
33
+ });
@@ -0,0 +1,63 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { normalizeAIEmployeeSkillSettings } from '../utils/skill-settings';
3
+
4
+ describe('normalizeAIEmployeeSkillSettings', () => {
5
+ it('moves legacy object tool bindings from skills to tools', () => {
6
+ const result = normalizeAIEmployeeSkillSettings({
7
+ skills: [{ name: 'orchestrator_plan_goal', autoCall: false }, 'crm-research'],
8
+ tools: [],
9
+ });
10
+
11
+ expect(result.changed).toBe(true);
12
+ expect(result.skillSettings.skills).toEqual(['crm-research']);
13
+ expect(result.skillSettings.tools).toEqual([{ name: 'orchestrator_plan_goal', autoCall: false }]);
14
+ });
15
+
16
+ it('moves orchestrator tool names stored as skill strings to tools', () => {
17
+ const result = normalizeAIEmployeeSkillSettings({
18
+ skills: ['delegate_lead_to_researcher', 'crm-research'],
19
+ });
20
+
21
+ expect(result.changed).toBe(true);
22
+ expect(result.skillSettings.skills).toEqual(['crm-research']);
23
+ expect(result.skillSettings.tools).toEqual([{ name: 'delegate_lead_to_researcher', autoCall: false }]);
24
+ });
25
+
26
+ it('keeps current tool bindings and avoids duplicate migrated tools', () => {
27
+ const result = normalizeAIEmployeeSkillSettings({
28
+ skills: [{ name: 'dispatch_subagents_lead', autoCall: true }],
29
+ tools: [{ name: 'dispatch_subagents_lead', autoCall: false }],
30
+ });
31
+
32
+ expect(result.changed).toBe(true);
33
+ expect(result.skillSettings.skills).toEqual([]);
34
+ expect(result.skillSettings.tools).toEqual([{ name: 'dispatch_subagents_lead', autoCall: false }]);
35
+ });
36
+
37
+ it('moves browser and drawio tools stored as skill strings to tools', () => {
38
+ const result = normalizeAIEmployeeSkillSettings({
39
+ skills: ['browser_open_url', 'drawio-edit-diagram', 'crm-research'],
40
+ });
41
+
42
+ expect(result.changed).toBe(true);
43
+ expect(result.skillSettings.skills).toEqual(['crm-research']);
44
+ expect(result.skillSettings.tools).toEqual([
45
+ { name: 'browser_open_url', autoCall: false },
46
+ { name: 'drawio-edit-diagram', autoCall: false },
47
+ ]);
48
+ });
49
+
50
+ it('moves Skill Hub tool bindings from skills to tools', () => {
51
+ const result = normalizeAIEmployeeSkillSettings({
52
+ skills: ['skill_hub_execute', { name: 'skill_hub_generate_report', autoCall: true }],
53
+ tools: [],
54
+ });
55
+
56
+ expect(result.changed).toBe(true);
57
+ expect(result.skillSettings.skills).toEqual([]);
58
+ expect(result.skillSettings.tools).toEqual([
59
+ { name: 'skill_hub_execute', autoCall: false },
60
+ { name: 'skill_hub_generate_report', autoCall: true },
61
+ ]);
62
+ });
63
+ });
@@ -0,0 +1,39 @@
1
+ import { Migration } from '@nocobase/server';
2
+ import { normalizeAIEmployeeSkillSettings } from '../utils/skill-settings';
3
+
4
+ export default class NormalizeAIEmployeeToolBindings extends Migration {
5
+ on = 'afterLoad';
6
+ appVersion = '>=0.1.0';
7
+
8
+ async up() {
9
+ const repo = (this as unknown as { db: { getRepository: (name: string) => any }; app?: any }).db.getRepository(
10
+ 'aiEmployees',
11
+ );
12
+ if (!repo) return;
13
+
14
+ const rows = await repo.find({});
15
+ let updated = 0;
16
+
17
+ for (const row of rows) {
18
+ const skillSettings = row.get?.('skillSettings') ?? row.skillSettings;
19
+ const normalized = normalizeAIEmployeeSkillSettings(skillSettings);
20
+ if (!normalized.changed) continue;
21
+
22
+ await row.update({
23
+ skillSettings: normalized.skillSettings,
24
+ });
25
+ updated += 1;
26
+ }
27
+
28
+ if (updated > 0) {
29
+ (this as unknown as { app?: { logger?: { info?: (message: string) => void } } }).app?.logger?.info?.(
30
+ `[AgentOrchestrator] Normalized AI employee tool bindings (${updated}).`,
31
+ );
32
+ }
33
+ }
34
+
35
+ async down() {
36
+ // No rollback: this only moves tool-shaped entries from skillSettings.skills
37
+ // into the current NocoBase skillSettings.tools shape.
38
+ }
39
+ }
@@ -8,6 +8,8 @@ import { registerAgentLoopResource } from './resources/agent-loop';
8
8
  import { getRunEventBus } from './services/RunEventBus';
9
9
  import SkillHubSubFeature from './skill-hub/plugin';
10
10
  import { AgentLoopService } from './services/AgentLoopService';
11
+ import { isAdminUser, currentUserId } from './utils/ctx-utils';
12
+ import { getAIToolsManager } from './utils/ai-manager';
11
13
 
12
14
  export class PluginAgentOrchestratorServer extends Plugin {
13
15
  skillHub: SkillHubSubFeature;
@@ -20,10 +22,10 @@ export class PluginAgentOrchestratorServer extends Plugin {
20
22
 
21
23
  async beforeLoad() {
22
24
  // Import collection definitions
23
- (this as any).db.import({ directory: path.resolve(__dirname, 'collections') });
25
+ this.db.import({ directory: path.resolve(__dirname, 'collections') });
24
26
 
25
- (this as any).db.addMigrations({
26
- namespace: (this as any).name,
27
+ this.db.addMigrations({
28
+ namespace: this.name,
27
29
  directory: path.resolve(__dirname, 'migrations'),
28
30
  context: { plugin: this },
29
31
  });
@@ -33,8 +35,8 @@ export class PluginAgentOrchestratorServer extends Plugin {
33
35
  await this.skillHub.load();
34
36
 
35
37
  // --- ACL ---
36
- (this as any).app.acl.registerSnippet({
37
- name: `pm.${(this as any).name}`,
38
+ this.app.acl.registerSnippet({
39
+ name: `pm.${this.name}`,
38
40
  actions: [
39
41
  'orchestratorConfig:*',
40
42
  'orchestratorTracing:*',
@@ -58,21 +60,41 @@ export class PluginAgentOrchestratorServer extends Plugin {
58
60
  // so that non-admin users with AI roles can use skills without
59
61
  // requiring manual snippet assignment per role.
60
62
  // Create/update/destroy remain restricted to admin roles via the snippet above.
61
- (this as any).app.acl.allow('skillDefinitions', 'list', 'loggedIn');
62
- (this as any).app.acl.allow('skillDefinitions', 'get', 'loggedIn');
63
- (this as any).app.acl.allow('skillLoopConfigs', 'list', 'loggedIn');
64
- (this as any).app.acl.allow('skillLoopConfigs', 'get', 'loggedIn');
65
- (this as any).app.acl.allow('skillExecutions', 'list', 'loggedIn');
66
- (this as any).app.acl.allow('skillExecutions', 'get', 'loggedIn');
67
- (this as any).app.acl.allow('skillHub', 'test', 'loggedIn');
68
- (this as any).app.acl.allow('skillHub', 'download', 'loggedIn');
69
- (this as any).app.acl.allow('skillHub', 'listTemplates', 'loggedIn');
63
+ this.app.acl.allow('skillDefinitions', 'list', 'loggedIn');
64
+ this.app.acl.allow('skillDefinitions', 'get', 'loggedIn');
65
+ this.app.acl.allow('skillLoopConfigs', 'list', 'loggedIn');
66
+ this.app.acl.allow('skillLoopConfigs', 'get', 'loggedIn');
67
+ this.app.acl.allow('skillExecutions', 'list', 'loggedIn');
68
+ this.app.acl.allow('skillExecutions', 'get', 'loggedIn');
69
+ this.app.acl.allow('skillHub', 'test', 'loggedIn');
70
+ this.app.acl.allow('skillHub', 'download', 'loggedIn');
71
+ this.app.acl.allow('skillHub', 'listTemplates', 'loggedIn');
72
+
73
+ // Data scoping for skillExecutions: a logged-in non-admin user may only
74
+ // read their own executions. Rows hold inputArgs / stdout / output files,
75
+ // so an unscoped list/get would leak one user's data to another. Admins
76
+ // (root/admin roles) keep full visibility. This mirrors the owner/admin
77
+ // check enforced by skillHub:download.
78
+ this.app.resourceManager.use(
79
+ async (ctx, next) => {
80
+ const { resourceName, actionName } = ctx.action || {};
81
+ if (resourceName === 'skillExecutions' && (actionName === 'list' || actionName === 'get')) {
82
+ if (!isAdminUser(ctx)) {
83
+ const userId = currentUserId(ctx);
84
+ const ownerFilter = userId ? { triggeredById: userId } : { triggeredById: null };
85
+ ctx.action.mergeParams({ filter: ownerFilter });
86
+ }
87
+ }
88
+ await next();
89
+ },
90
+ { tag: 'orchestrator-skill-executions-scope', after: 'acl' },
91
+ );
70
92
 
71
93
  // --- Register Dynamic Tools ---
72
94
  // Each configured sub-agent becomes a callable tool for its leader.
73
95
  // Uses createReactAgent (LangGraph public API) instead of private AIEmployee class.
74
96
  // Tools are registered via app.aiManager.toolsManager (public API from @nocobase/ai core).
75
- const toolsManager = (this as any).app.aiManager.toolsManager;
97
+ const toolsManager = getAIToolsManager(this.app);
76
98
  toolsManager.registerTools(createOrchestratorPlanTools(this, this.agentLoopService));
77
99
  toolsManager.registerTools(createExternalRagSearchTool(this));
78
100
  toolsManager.registerDynamicTools(createDelegateToolsProvider(this));
@@ -81,7 +103,7 @@ export class PluginAgentOrchestratorServer extends Plugin {
81
103
  registerAgentLoopResource(this, this.agentLoopService);
82
104
 
83
105
  // --- Register SSE Event Stream Resource (Phase 6) ---
84
- (this as any).app.resource({
106
+ this.app.resource({
85
107
  name: 'agentLoopEventsStream',
86
108
  actions: {
87
109
  async stream(ctx, next) {
@@ -91,6 +113,25 @@ export class PluginAgentOrchestratorServer extends Plugin {
91
113
  return;
92
114
  }
93
115
 
116
+ // Ownership check: a non-admin user may only stream events for a run
117
+ // they started. Run events can echo step inputs/outputs, so an
118
+ // unscoped stream would leak another user's run activity.
119
+ if (!isAdminUser(ctx)) {
120
+ const userId = currentUserId(ctx);
121
+ const run = await ctx.db.getRepository('agentLoopRuns').findOne({
122
+ filter: { id: runId },
123
+ });
124
+ if (!run) {
125
+ ctx.throw(404, 'Run not found.');
126
+ return;
127
+ }
128
+ const ownerId = run.get ? run.get('userId') : run.userId;
129
+ if (!userId || String(ownerId) !== String(userId)) {
130
+ ctx.throw(403, 'You cannot stream events for this run.');
131
+ return;
132
+ }
133
+ }
134
+
94
135
  ctx.type = 'text/event-stream';
95
136
  ctx.set('Cache-Control', 'no-cache');
96
137
  ctx.set('Connection', 'keep-alive');
@@ -138,15 +179,15 @@ export class PluginAgentOrchestratorServer extends Plugin {
138
179
  // --- Log Retention ---
139
180
  // Daily prune of orchestratorLogs / agentExecutionSpans to keep tables bounded.
140
181
  // Override window via env: ORCHESTRATOR_LOG_RETENTION_DAYS (default 30).
141
- (this as any).app.cronJobManager.addJob({
182
+ this.app.cronJobManager.addJob({
142
183
  cronTime: '0 30 2 * * *',
143
184
  onTick: async () => {
144
185
  try {
145
186
  const days = Number(process.env.ORCHESTRATOR_LOG_RETENTION_DAYS || 30);
146
187
  if (!Number.isFinite(days) || days <= 0) return;
147
188
  const cutoff = new Date(Date.now() - days * 86400000);
148
- const repo = (this as any).db.getRepository('orchestratorLogs');
149
- const spansRepo = (this as any).db.getRepository('agentExecutionSpans');
189
+ const repo = this.db.getRepository('orchestratorLogs');
190
+ const spansRepo = this.db.getRepository('agentExecutionSpans');
150
191
  const deletedLogs = repo
151
192
  ? await repo.destroy({
152
193
  filter: { createdAt: { $lt: cutoff.toISOString() } },
@@ -157,11 +198,11 @@ export class PluginAgentOrchestratorServer extends Plugin {
157
198
  filter: { createdAt: { $lt: cutoff.toISOString() } },
158
199
  })
159
200
  : 0;
160
- (this as any).app.log.info(
201
+ this.app.log.info(
161
202
  `[AgentOrchestrator] Pruned ${deletedLogs} orchestratorLogs and ${deletedSpans} agentExecutionSpans rows older than ${days} day(s).`,
162
203
  );
163
204
  } catch (e) {
164
- (this as any).app.log.error('[AgentOrchestrator] Log retention job failed', e);
205
+ this.app.log.error('[AgentOrchestrator] Log retention job failed', e);
165
206
  }
166
207
  },
167
208
  });
@@ -7,10 +7,13 @@ import { ExecutionSpanService, setOrchestratorTraceContext } from './ExecutionSp
7
7
  import { AgentRegistryService } from './AgentRegistryService';
8
8
  import { TokenTracker, extractTokenUsage } from './TokenTracker';
9
9
  import { ContextAggregator } from './ContextAggregator';
10
- import { getCircuitBreaker } from './CircuitBreaker';
10
+ import { getCircuitBreaker, subAgentCircuitKey } from './CircuitBreaker';
11
11
  import { toPlain, asObject, trimText, nowIso } from '../utils/ctx-utils';
12
+ import { normalizeAIEmployeeSkillSettings } from '../utils/skill-settings';
12
13
  import { logDelegation as sharedLogDelegation } from '../utils/logging';
14
+ import { getAIToolsManager, tryGetAIToolsManager } from '../utils/ai-manager';
13
15
  import { TraceEvent } from '../types';
16
+ import type { ToolsRuntime } from '@nocobase/ai';
14
17
 
15
18
  const ORCHESTRATOR_DEPTH_KEY = '__orchestratorDepth';
16
19
  const ORCHESTRATOR_PATH_KEY = '__orchestratorPath';
@@ -31,9 +34,10 @@ export class AgentHarness {
31
34
  constructor(
32
35
  private readonly plugin: any,
33
36
  private readonly registryService: AgentRegistryService,
37
+ tokenTracker?: TokenTracker,
34
38
  ) {
35
39
  this.spanService = new ExecutionSpanService(plugin);
36
- this.tokenTracker = new TokenTracker(plugin);
40
+ this.tokenTracker = tokenTracker || new TokenTracker(plugin);
37
41
  this.contextAggregator = new ContextAggregator(plugin);
38
42
  }
39
43
 
@@ -112,10 +116,11 @@ export class AgentHarness {
112
116
  );
113
117
 
114
118
  const circuitBreaker = getCircuitBreaker({ appLog: this.app });
119
+ const circuitKey = subAgentCircuitKey(run.leaderUsername, target);
115
120
 
116
121
  // ── Circuit breaker check before invoking sub-agent ──────────────
117
- if (!circuitBreaker.isAllowed(target)) {
118
- const state = circuitBreaker.getState(target);
122
+ if (!circuitBreaker.isAllowed(circuitKey)) {
123
+ const state = circuitBreaker.getState(circuitKey);
119
124
  throw new Error(
120
125
  `Sub-agent "${target}" circuit is open (${state?.failures || 0} failures). Retry after the recovery timeout.`,
121
126
  );
@@ -123,12 +128,12 @@ export class AgentHarness {
123
128
 
124
129
  try {
125
130
  const result = await this.invokeNamedTool(run, step, toolName, { task, context }, settings, options);
126
- circuitBreaker.recordSuccess(target);
131
+ circuitBreaker.recordSuccess(circuitKey);
127
132
  return result;
128
133
  } catch (e: any) {
129
134
  // Don't count approval pauses as circuit failures
130
135
  if (e?.message !== 'requires_approval') {
131
- circuitBreaker.recordFailure(target);
136
+ circuitBreaker.recordFailure(circuitKey);
132
137
  }
133
138
  throw e;
134
139
  }
@@ -157,7 +162,7 @@ export class AgentHarness {
157
162
  }
158
163
  }
159
164
 
160
- const toolsManager = this.app?.aiManager?.toolsManager;
165
+ const toolsManager = tryGetAIToolsManager(this.app);
161
166
  const tool = await toolsManager?.getTools?.(toolName);
162
167
  if (!tool?.invoke) {
163
168
  throw new Error(`Tool "${toolName}" was not found or is missing standard invoke handler.`);
@@ -184,8 +189,18 @@ export class AgentHarness {
184
189
  ctx.state = { ...(ctx.state || {}), currentAIEmployee: run.leaderUsername };
185
190
  }
186
191
 
192
+ const previousRuntime = ctx.runtime;
193
+ const toolCallId = `agent-loop-${run.id}-${step.id}`;
194
+ const runtime: ToolsRuntime = {
195
+ toolCallId,
196
+ writer: (chunk: any) => {
197
+ this.app.log.trace(`[AgentHarness] Tool output:`, chunk);
198
+ },
199
+ };
200
+ ctx.runtime = runtime;
201
+
187
202
  try {
188
- const result = await tool.invoke(ctx, args, `agent-loop-${run.id}-${step.id}`);
203
+ const result = await tool.invoke(ctx, args, runtime);
189
204
  if (result?.status === 'error') {
190
205
  throw new Error(result.content || `Tool "${toolName}" returned an error.`);
191
206
  }
@@ -195,6 +210,11 @@ export class AgentHarness {
195
210
  result,
196
211
  };
197
212
  } finally {
213
+ if (previousRuntime === undefined) {
214
+ delete ctx.runtime;
215
+ } else {
216
+ ctx.runtime = previousRuntime;
217
+ }
198
218
  if (previousEmployee === undefined) {
199
219
  delete ctx._currentAIEmployee;
200
220
  } else {
@@ -251,6 +271,7 @@ export class AgentHarness {
251
271
  agentLoopRunId,
252
272
  agentLoopStepId,
253
273
  } = options;
274
+ const effectiveRootRunId = rootRunId || `run_${Date.now()}`;
254
275
 
255
276
  const startTime = Date.now();
256
277
  const currentDepth = options.currentDepth ?? 0;
@@ -265,7 +286,7 @@ export class AgentHarness {
265
286
  ];
266
287
 
267
288
  const executionSpan = await this.spanService.create({
268
- rootRunId: rootRunId || `run_${Date.now()}`,
289
+ rootRunId: effectiveRootRunId,
269
290
  parentSpanId,
270
291
  type: 'sub_agent',
271
292
  status: 'running',
@@ -323,24 +344,21 @@ export class AgentHarness {
323
344
  });
324
345
  const chatModel = provider.createModel();
325
346
 
326
- const coreToolsManager = ctx.app.aiManager.toolsManager;
347
+ const coreToolsManager = getAIToolsManager(ctx.app);
327
348
  const allTools = await coreToolsManager.listTools();
328
349
 
329
- const employeeSkills = (subAgentEmployee.skillSettings?.skills ?? [])
330
- .map((s: any) =>
331
- typeof s === 'string' ? { name: s, autoCall: false } : { name: s?.name, autoCall: s?.autoCall === true },
332
- )
333
- .filter((s: any) => Boolean(s.name));
334
- const employeeSkillMap = new Map<string, any>(employeeSkills.map((s: any) => [s.name, s]));
350
+ const normalizedSkillSettings = normalizeAIEmployeeSkillSettings(subAgentEmployee.skillSettings).skillSettings;
351
+ const employeeTools = normalizedSkillSettings.tools.filter((tool) => Boolean(tool.name));
352
+ const employeeToolMap = new Map(employeeTools.map((tool) => [tool.name, tool]));
335
353
 
336
354
  const langchainTools: DynamicStructuredTool[] = [];
337
355
 
338
356
  for (const toolEntry of allTools) {
339
357
  const entryName = toolEntry.definition.name;
340
358
  if (!entryName) continue;
341
- const employeeSkill = employeeSkillMap.get(entryName);
359
+ const employeeTool = employeeToolMap.get(entryName);
342
360
 
343
- if (!employeeSkill || employeeSkill.autoCall !== true || toolEntry.defaultPermission !== 'ALLOW') {
361
+ if (!employeeTool || employeeTool.autoCall !== true || toolEntry.execution === 'frontend') {
344
362
  continue;
345
363
  }
346
364
 
@@ -362,7 +380,7 @@ export class AgentHarness {
362
380
  const toolStartedAt = Date.now();
363
381
  const isSkillHubTool = entryName === 'skill_hub_execute' || entryName.startsWith('skill_hub_');
364
382
  const toolSpan = await this.spanService.create({
365
- rootRunId: rootRunId || `run_${Date.now()}`,
383
+ rootRunId: effectiveRootRunId,
366
384
  parentSpanId: executionSpanId,
367
385
  type: isSkillHubTool ? 'skill' : 'tool',
368
386
  status: 'running',
@@ -381,7 +399,7 @@ export class AgentHarness {
381
399
  });
382
400
  const toolSpanId = toolSpan?.id ? String(toolSpan.id) : undefined;
383
401
  setOrchestratorTraceContext(invokeCtx, {
384
- rootRunId,
402
+ rootRunId: effectiveRootRunId,
385
403
  spanId: toolSpanId,
386
404
  parentSpanId: executionSpanId,
387
405
  toolCallId: `orch-${toolCallId}`,
@@ -400,8 +418,17 @@ export class AgentHarness {
400
418
  args: toolArgs,
401
419
  });
402
420
 
421
+ const subToolCallId = `orch-${toolCallId}`;
422
+ const runtime: ToolsRuntime = {
423
+ toolCallId: subToolCallId,
424
+ writer: (chunk: any) => {
425
+ this.app.log.trace(`[AgentHarness] Tool output:`, chunk);
426
+ },
427
+ };
428
+ invokeCtx.runtime = runtime;
429
+
403
430
  try {
404
- const res = await toolEntry.invoke(invokeCtx, toolArgs, `orch-${toolCallId}`);
431
+ const res = await toolEntry.invoke(invokeCtx, toolArgs, runtime);
405
432
  const output = trimText(res?.content ?? res?.result ?? res, 50000);
406
433
  trace.push({
407
434
  type: 'tool_result',
@@ -461,7 +488,7 @@ export class AgentHarness {
461
488
  const sessionId =
462
489
  ctx.action?.params?.values?.sessionId || ctx.action?.params?.sessionId || ctx.state?.sessionId;
463
490
  const contextSummary = await kbPlugin.sessionContext.buildSummary(
464
- { rootRunId, ...(sessionId ? { sessionId } : {}) },
491
+ { rootRunId: effectiveRootRunId, ...(sessionId ? { sessionId } : {}) },
465
492
  6000,
466
493
  );
467
494
  if (contextSummary) {
@@ -5,7 +5,7 @@ import { AgentLoopRepository } from './AgentLoopRepository';
5
5
  import { AgentHarness } from './AgentHarness';
6
6
  import { AgentLoopPolicy, AgentLoopPlanStepInput, AgentLoopRunStatus, AgentLoopStepStatus } from './AgentLoopService';
7
7
  import { TokenTracker } from './TokenTracker';
8
- import { getCircuitBreaker } from './CircuitBreaker';
8
+ import { getCircuitBreaker, subAgentCircuitKey } from './CircuitBreaker';
9
9
  import { createHash } from 'crypto';
10
10
  import { asObject, asArray, trimText, normalizeStepType, normalizePlanKey } from '../utils/ctx-utils';
11
11
 
@@ -816,7 +816,7 @@ export class AgentLoopController {
816
816
  const target = step.target || '';
817
817
  if (target) {
818
818
  const circuitBreaker = getCircuitBreaker();
819
- const state = circuitBreaker.getState(target);
819
+ const state = circuitBreaker.getState(subAgentCircuitKey(run.leaderUsername, target));
820
820
  const attempt = Number(step.attempt || 0);
821
821
 
822
822
  // Route away if: circuit is open, or 2+ failures and at least 1 retry already attempted
@@ -1044,7 +1044,13 @@ export class AgentLoopController {
1044
1044
  runningSteps.map((step: any) => this.harness.executeStep(snapshot.run, step, options)),
1045
1045
  );
1046
1046
 
1047
- // Process results failures need individual backoff before retry
1047
+ // Process results. Failed-but-retryable steps need a backoff before the
1048
+ // next iteration picks them up again. We compute the LARGEST backoff in
1049
+ // the batch and sleep once after the loop — never sum per-step sleeps.
1050
+ // Summing held the run-lock for up to maxConcurrency × 60s (= the whole
1051
+ // 5-minute lock when a full batch fails), starving other steps.
1052
+ let batchBackoffMs = 0;
1053
+ let approvalBreak = false;
1048
1054
  for (let i = 0; i < results.length; i++) {
1049
1055
  const runningStep = runningSteps[i];
1050
1056
  const result = results[i];
@@ -1063,6 +1069,7 @@ export class AgentLoopController {
1063
1069
  { userId: options.userId, reason: 'Dynamic tool approval required by policy.' },
1064
1070
  );
1065
1071
  // Stop processing more results; remaining steps are left pending
1072
+ approvalBreak = true;
1066
1073
  break;
1067
1074
  }
1068
1075
 
@@ -1075,17 +1082,21 @@ export class AgentLoopController {
1075
1082
  failedStep &&
1076
1083
  Number(failedStep.attempt || 0) < Number(failedStep.maxAttempts || policy.maxStepAttempts)
1077
1084
  ) {
1078
- // Exponential backoff before retry
1085
+ // Exponential backoff track the max; the step stays pending for retry.
1079
1086
  const attempt = Number(failedStep.attempt || 1);
1080
1087
  const baseDelay = 1000;
1081
1088
  const maxDelay = 60000;
1082
1089
  const delay = Math.min(baseDelay * Math.pow(2, attempt - 1), maxDelay);
1083
- await new Promise((resolve) => setTimeout(resolve, delay));
1084
- // Step remains pending for retry (failStep resets status back)
1090
+ batchBackoffMs = Math.max(batchBackoffMs, delay);
1085
1091
  }
1086
1092
  }
1087
1093
  }
1088
1094
 
1095
+ // Single consolidated backoff for the whole batch (bounded at maxDelay).
1096
+ if (!approvalBreak && batchBackoffMs > 0) {
1097
+ await new Promise((resolve) => setTimeout(resolve, batchBackoffMs));
1098
+ }
1099
+
1089
1100
  // Re-evaluate the plan — newly unblocked steps become eligible
1090
1101
  snapshot = await this.getRunSnapshot(runId);
1091
1102
 
@@ -63,8 +63,8 @@ export class AgentLoopService {
63
63
  this.plannerService = new AgentPlannerService();
64
64
  this.validator = new AgentPlanValidator();
65
65
  this.repository = new AgentLoopRepository(plugin);
66
- this.harness = new AgentHarness(plugin, this.registryService);
67
66
  const tokenTracker = new TokenTracker(plugin);
67
+ this.harness = new AgentHarness(plugin, this.registryService, tokenTracker);
68
68
  this.controller = new AgentLoopController(
69
69
  this.registryService,
70
70
  this.plannerService,
@@ -2,6 +2,16 @@ import { AgentLoopPlanStepInput } from './AgentLoopService';
2
2
 
3
3
  import { normalizeStepType, normalizePlanKey, asArray, asObject } from '../utils/ctx-utils';
4
4
 
5
+ /**
6
+ * Deterministic, template-based plan builder. This service does NOT call an LLM:
7
+ * it either passes through a caller-provided plan or emits a fixed
8
+ * prepare → (delegate|execute) → verify skeleton.
9
+ *
10
+ * The run's `plannerModel` field is metadata only (records which model an
11
+ * upstream caller used to author a provided plan); it does not drive any
12
+ * generation here. If LLM-authored planning is added later, it belongs in a
13
+ * separate path — keep this builder deterministic so it stays test-stable.
14
+ */
5
15
  export class AgentPlannerService {
6
16
  buildPlan(
7
17
  goal: string,