plugin-agent-orchestrator 1.0.14 → 1.0.16

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 (34) hide show
  1. package/README.md +96 -0
  2. package/dist/externalVersion.js +6 -6
  3. package/dist/server/plugin.js +11 -1
  4. package/dist/server/services/CodeValidator.js +1 -0
  5. package/dist/server/services/SkillManager.js +0 -39
  6. package/dist/server/skill-hub/plugin.js +3 -3
  7. package/dist/server/skill-hub/tasks/SkillExecutionTask.d.ts +2 -0
  8. package/dist/server/skill-hub/tasks/SkillExecutionTask.js +122 -0
  9. package/dist/server/tools/delegate-task.js +22 -2
  10. package/dist/server/tools/external-rag-search.d.ts +42 -0
  11. package/dist/server/tools/external-rag-search.js +140 -0
  12. package/dist/server/tools/skill-execute.d.ts +1 -1
  13. package/dist/server/tools/skill-execute.js +1 -2
  14. package/package.json +1 -1
  15. package/src/client/index.tsx +1 -1
  16. package/src/client/plugin.tsx +54 -54
  17. package/src/client/skill-hub/index.tsx +75 -75
  18. package/src/server/migrations/20260423000000-add-progress-fields.ts +5 -5
  19. package/src/server/migrations/20260425000000-add-interaction-schema.ts +5 -5
  20. package/src/server/migrations/20260427000000-add-tracing-detail-fields.ts +5 -5
  21. package/src/server/migrations/20260427000000-change-packages-to-text.ts +7 -7
  22. package/src/server/migrations/20260427000001-change-other-json-to-text.ts +10 -10
  23. package/src/server/migrations/20260429000000-add-llm-fields.ts +2 -2
  24. package/src/server/migrations/20260429000000-fix-inputargs-json-to-text.ts +2 -2
  25. package/src/server/migrations/20260503000000-add-orchestrator-trace-fields.ts +2 -2
  26. package/src/server/plugin.ts +23 -13
  27. package/src/server/services/CodeValidator.ts +5 -5
  28. package/src/server/services/SkillManager.ts +12 -52
  29. package/src/server/services/WorkerEnvManager.ts +5 -5
  30. package/src/server/skill-hub/plugin.ts +61 -61
  31. package/src/server/skill-hub/tasks/SkillExecutionTask.ts +162 -16
  32. package/src/server/tools/delegate-task.ts +25 -1
  33. package/src/server/tools/external-rag-search.ts +128 -0
  34. package/src/server/tools/skill-execute.ts +1 -2
@@ -1,8 +1,10 @@
1
1
  import Application from '@nocobase/server';
2
- import { resolve } from 'path';
2
+ import { cpSync, existsSync, readFileSync, rmSync } from 'fs';
3
+ import { resolve, sep } from 'path';
3
4
  import { SandboxRunner } from '../../services/SandboxRunner';
4
5
  import { FileManager } from '../../services/FileManager';
5
6
  import { SkillRepositoryService } from '../../services/SkillRepositoryService';
7
+ import { CodeValidator } from '../../services/CodeValidator';
6
8
  import { parseJsonText, stringifyJsonText } from '../utils/json-fields';
7
9
 
8
10
  /**
@@ -62,13 +64,13 @@ export class SkillExecutionTask {
62
64
  const abortController = new TaskAbortController();
63
65
  const abortChannel = `skill-hub.abort.${execId}`;
64
66
  const abortCallback = async () => {
65
- this.app.logger.info(`[skill-hub] Task ${execId}: received abort signal`);
67
+ (this as any).app.logger.info(`[skill-hub] Task ${execId}: received abort signal`);
66
68
  abortController.abort();
67
69
  };
68
70
 
69
71
  try {
70
72
  // Subscribe to abort channel before starting execution
71
- await this.app.pubSubManager.subscribe(abortChannel, abortCallback);
73
+ await (this as any).app.pubSubManager.subscribe(abortChannel, abortCallback);
72
74
 
73
75
  // Render code template with input args
74
76
  const inputArgs = parseJsonText(this.execution.get('inputArgs'), {});
@@ -84,7 +86,7 @@ export class SkillExecutionTask {
84
86
 
85
87
  if (storageType === 'plugin') {
86
88
  const pluginSkillName = (skill.get ? skill.get('pluginSource') : skill.pluginSource) || skillName;
87
- const orchestratorPlugin = this.app.pm.get('plugin-agent-orchestrator') as any;
89
+ const orchestratorPlugin = (this as any).app.pm.get('plugin-agent-orchestrator') as any;
88
90
  const skillHub = orchestratorPlugin?.skillHub;
89
91
  let pluginTemplate = typeof skillHub?.resolveSkillTemplate === 'function'
90
92
  ? skillHub.resolveSkillTemplate(pluginSkillName)
@@ -92,7 +94,7 @@ export class SkillExecutionTask {
92
94
 
93
95
  // Fallback: discover dynamically if not cached (e.g. executed in worker before UI was loaded)
94
96
  if (!pluginTemplate && skillHub) {
95
- const allPlugins = this.app.pm.getPlugins();
97
+ const allPlugins = (this as any).app.pm.getPlugins();
96
98
  for (const [, pInstance] of allPlugins) {
97
99
  if (typeof (pInstance as any).getSkillTemplates === 'function') {
98
100
  const pluginSkills = (pInstance as any).getSkillTemplates();
@@ -148,7 +150,7 @@ export class SkillExecutionTask {
148
150
  // Load package whitelist for import validation
149
151
  let packageWhitelist: string[] = [];
150
152
  try {
151
- const workerConfig = await this.app.db.getRepository('skillWorkerConfigs').findOne();
153
+ const workerConfig = await (this as any).app.db.getRepository('skillWorkerConfigs').findOne();
152
154
  if (workerConfig) {
153
155
  const wl = parseJsonText(
154
156
  workerConfig.get ? workerConfig.get('packageWhitelist') : workerConfig.packageWhitelist,
@@ -167,8 +169,8 @@ export class SkillExecutionTask {
167
169
 
168
170
  // In multi-node setups, local cache might be missing on this specific worker node. Re-download from S3 if needed.
169
171
  if (!require('fs').existsSync(this.skillRepoService.getSkillPath(skillName)) && fileId) {
170
- const fmPlugin = this.app.pm.get('@nocobase/plugin-file-manager') as any;
171
- const attachment = await this.app.db.getRepository('attachments').findOne({ filter: { id: fileId } });
172
+ const fmPlugin = (this as any).app.pm.get('@nocobase/plugin-file-manager') as any;
173
+ const attachment = await (this as any).app.db.getRepository('attachments').findOne({ filter: { id: fileId } });
172
174
  if (fmPlugin && attachment) {
173
175
  try {
174
176
  const streamData = await fmPlugin.getFileStream(attachment);
@@ -183,12 +185,12 @@ export class SkillExecutionTask {
183
185
  });
184
186
  await this.skillRepoService.extractSkillPackage(skillName, tempZipPath);
185
187
  require('fs').unlinkSync(tempZipPath);
186
- this.app.logger.info(
188
+ (this as any).app.logger.info(
187
189
  `[skill-hub] Task ${execId}: Auto-restored skill package ${skillName} from S3/Storage`,
188
190
  );
189
191
  }
190
192
  } catch (fetchErr) {
191
- this.app.logger.warn(
193
+ (this as any).app.logger.warn(
192
194
  `[skill-hub] Task ${execId}: Failed to fetch skill package ${skillName} from storage`,
193
195
  { error: fetchErr },
194
196
  );
@@ -212,10 +214,17 @@ export class SkillExecutionTask {
212
214
  packageWhitelist,
213
215
  onProgress: (progress) => {
214
216
  // Worker → PubSub → Main Server → runtime.writer → SSE → Client
215
- this.app.pubSubManager.publish(`skill-hub.progress.${execId}`, progress);
217
+ (this as any).app.pubSubManager.publish(`skill-hub.progress.${execId}`, progress);
216
218
  },
217
219
  });
218
220
 
221
+ if (result.success) {
222
+ const installMessage = await this.installGeneratedSkillIfRequested(execId);
223
+ if (installMessage) {
224
+ result.stdout = [result.stdout, installMessage].filter(Boolean).join('\n');
225
+ }
226
+ }
227
+
219
228
  // Determine final status
220
229
  let status: string;
221
230
  if (result.canceled) {
@@ -235,7 +244,7 @@ export class SkillExecutionTask {
235
244
  });
236
245
 
237
246
  // Notify main server: task completed
238
- await this.app.pubSubManager.publish(`skill-hub.done.${execId}`, {
247
+ await (this as any).app.pubSubManager.publish(`skill-hub.done.${execId}`, {
239
248
  status,
240
249
  stdout: result.stdout?.slice(0, 3000),
241
250
  stderr: result.stderr?.slice(0, 1000),
@@ -244,7 +253,7 @@ export class SkillExecutionTask {
244
253
  });
245
254
 
246
255
  // Log execution metrics
247
- this.app.logger.info(
256
+ (this as any).app.logger.info(
248
257
  `[skill-hub] Execution ${execId} ${status}: ` +
249
258
  `skill=${skill.get ? skill.get('name') : skill.name}, ` +
250
259
  `language=${language}, ` +
@@ -260,18 +269,18 @@ export class SkillExecutionTask {
260
269
  stderr: errorMessage,
261
270
  });
262
271
 
263
- await this.app.pubSubManager.publish(`skill-hub.done.${execId}`, {
272
+ await (this as any).app.pubSubManager.publish(`skill-hub.done.${execId}`, {
264
273
  status: 'failed',
265
274
  stderr: errorMessage,
266
275
  files: [],
267
276
  durationMs: 0,
268
277
  });
269
278
 
270
- this.app.logger.error(`[skill-hub] Execution ${execId} error: ${errorMessage}`);
279
+ (this as any).app.logger.error(`[skill-hub] Execution ${execId} error: ${errorMessage}`);
271
280
  } finally {
272
281
  // Always cleanup abort subscription
273
282
  try {
274
- await this.app.pubSubManager.unsubscribe(abortChannel, abortCallback);
283
+ await (this as any).app.pubSubManager.unsubscribe(abortChannel, abortCallback);
275
284
  } catch {
276
285
  // ignore cleanup errors
277
286
  }
@@ -294,4 +303,141 @@ export class SkillExecutionTask {
294
303
  code = code.replaceAll('{{skillDir}}', (skillDir || '').replace(/\\/g, '/'));
295
304
  return code;
296
305
  }
306
+
307
+ private async installGeneratedSkillIfRequested(execId: string): Promise<string | null> {
308
+ const manifestPath = this.fileManager.getOutputFilePath(execId, 'skill-hub-install.json');
309
+ if (!manifestPath) return null;
310
+
311
+ let manifest: any;
312
+ try {
313
+ manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
314
+ } catch (error) {
315
+ throw new Error(`Generated skill install manifest is invalid JSON: ${error instanceof Error ? error.message : String(error)}`);
316
+ }
317
+
318
+ if (!manifest?.autoInstall) return null;
319
+
320
+ const skill = manifest.skill || {};
321
+ const name = String(skill.name || '').trim();
322
+ if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(name)) {
323
+ throw new Error(`Generated skill name "${name}" is invalid. Use lowercase letters, numbers, and hyphens.`);
324
+ }
325
+
326
+ const language = skill.language;
327
+ if (language !== 'python' && language !== 'node') {
328
+ throw new Error(`Generated skill "${name}" has unsupported language "${language}".`);
329
+ }
330
+
331
+ if (!skill.codeTemplate) {
332
+ throw new Error(`Generated skill "${name}" is missing codeTemplate.`);
333
+ }
334
+
335
+ const validator = new CodeValidator();
336
+ validator.validate(skill.codeTemplate, language);
337
+ await this.validateGeneratedSkillPackages(name, language, skill.packages);
338
+
339
+ const outputDir = this.fileManager.getOutputDir(execId);
340
+ const outputRoot = resolve(outputDir);
341
+ const packageDirName = String(manifest.packageDir || name);
342
+ const packageDir = resolve(outputRoot, packageDirName);
343
+ if ((packageDir !== outputRoot && !packageDir.startsWith(outputRoot + sep)) || !existsSync(packageDir)) {
344
+ throw new Error(`Generated skill package directory "${packageDirName}" was not found in output.`);
345
+ }
346
+
347
+ if (manifest.testInput && Object.keys(manifest.testInput).length > 0) {
348
+ const verifyExecId = `${execId}-verify-${name}`;
349
+ const verifyCode = this.renderTemplate(skill.codeTemplate, manifest.testInput, verifyExecId, packageDir);
350
+ const verifyResult = await this.sandboxRunner.execute({
351
+ language,
352
+ code: verifyCode,
353
+ execId: verifyExecId,
354
+ timeoutSeconds: Math.min(Number(skill.timeoutSeconds || 60), 30),
355
+ maxOutputSizeMb: Math.min(Number(skill.maxOutputSizeMb || 50), 10),
356
+ skillDir: packageDir,
357
+ });
358
+
359
+ if (!verifyResult.success) {
360
+ throw new Error(
361
+ `Generated skill "${name}" failed smoke verification: ${verifyResult.stderr || verifyResult.stdout || 'unknown error'}`,
362
+ );
363
+ }
364
+ }
365
+
366
+ const skillRepoDir = this.skillRepoService.getSkillPath(name);
367
+ if (existsSync(skillRepoDir)) {
368
+ rmSync(skillRepoDir, { recursive: true, force: true });
369
+ }
370
+ cpSync(packageDir, skillRepoDir, {
371
+ recursive: true,
372
+ force: true,
373
+ filter: (src) => {
374
+ const leaf = src.split(/[\\/]/).pop();
375
+ return !['node_modules', '.git', '__pycache__'].includes(leaf || '') && !src.endsWith('.pyc');
376
+ },
377
+ });
378
+
379
+ const values: any = {
380
+ name,
381
+ title: skill.title || name,
382
+ description: skill.description || '',
383
+ instructions: skill.instructions || '',
384
+ language,
385
+ codeTemplate: skill.codeTemplate,
386
+ inputSchema: stringifyJsonText(skill.inputSchema || { type: 'object', properties: {} }),
387
+ packages: stringifyJsonText(skill.packages || [], []),
388
+ timeoutSeconds: skill.timeoutSeconds || 60,
389
+ maxOutputSizeMb: skill.maxOutputSizeMb || 50,
390
+ enabled: skill.enabled !== false,
391
+ toolScope: skill.toolScope || 'CUSTOM',
392
+ autoCall: !!skill.autoCall,
393
+ storageType: 'local',
394
+ storageUrl: `local://generated/${name}`,
395
+ };
396
+
397
+ if (skill.interactionSchema) {
398
+ values.interactionSchema = stringifyJsonText(skill.interactionSchema);
399
+ }
400
+
401
+ const repo = (this as any).app.db.getRepository('skillDefinitions');
402
+ const existing = await repo.findOne({ filter: { name } });
403
+ if (existing) {
404
+ if (manifest.overwrite === false) {
405
+ throw new Error(`Skill "${name}" already exists and overwrite=false.`);
406
+ }
407
+ await repo.update({ filter: { name }, values });
408
+ return `[skill-hub] Updated generated skill "${name}" in Skill Hub.`;
409
+ }
410
+
411
+ await repo.create({ values });
412
+ return `[skill-hub] Installed generated skill "${name}" in Skill Hub.`;
413
+ }
414
+
415
+ private async validateGeneratedSkillPackages(name: string, language: 'python' | 'node', packages: any) {
416
+ if (!Array.isArray(packages) || packages.length === 0) return;
417
+ if (!packages.every((pkg) => typeof pkg === 'string' && pkg.trim())) {
418
+ throw new Error(`Generated skill "${name}" has invalid packages. Use an array of package names.`);
419
+ }
420
+
421
+ const workerConfig = await (this as any).app.db.getRepository('skillWorkerConfigs').findOne();
422
+ if (!workerConfig) return;
423
+
424
+ const whitelist = parseJsonText(
425
+ workerConfig.get ? workerConfig.get('packageWhitelist') : workerConfig.packageWhitelist,
426
+ null,
427
+ );
428
+ const allowed = whitelist?.[language];
429
+ if (!Array.isArray(allowed) || allowed.length === 0) return;
430
+
431
+ const allowedSet = new Set(allowed.map((pkg: string) => pkg.toLowerCase()));
432
+ const missing = packages
433
+ .map((pkg: string) => pkg.trim())
434
+ .filter((pkg: string) => !allowedSet.has(pkg.toLowerCase()));
435
+
436
+ if (missing.length > 0) {
437
+ throw new Error(
438
+ `Generated skill "${name}" requires ${language} package(s) not available in the Skill Hub worker environment: ` +
439
+ `${missing.join(', ')}. Add them to the worker environment and refresh/init Skill Hub before installing this skill.`,
440
+ );
441
+ }
442
+ }
297
443
  }
@@ -1046,13 +1046,37 @@ async function invokeDelegateTask(
1046
1046
  });
1047
1047
 
1048
1048
  // --- Step 4: Construct messages ---
1049
- const systemPrompt =
1049
+ let systemPrompt =
1050
1050
  subAgentEmployee.chatSettings?.systemPrompt ||
1051
1051
  subAgentEmployee.bio ||
1052
1052
  `You are an AI assistant named "${subAgentEmployee.nickname || subAgentUsername}". ${
1053
1053
  subAgentEmployee.about || ''
1054
1054
  }`;
1055
1055
 
1056
+ // --- Step 4b: Inject shared context from Knowledge Base (soft dependency) ---
1057
+ // If plugin-knowledge-base is installed, inject the session context summary
1058
+ // so the sub-agent is aware of findings from previous agents in this run.
1059
+ try {
1060
+ const kbPlugin = ctx.app.pm.get('plugin-knowledge-base') as any;
1061
+ if (kbPlugin?.sessionContext) {
1062
+ const sessionId =
1063
+ ctx.action?.params?.values?.sessionId ||
1064
+ ctx.action?.params?.sessionId ||
1065
+ ctx.state?.sessionId;
1066
+
1067
+ const contextSummary = await kbPlugin.sessionContext.buildSummary(
1068
+ { rootRunId, ...(sessionId ? { sessionId } : {}) },
1069
+ 6000,
1070
+ );
1071
+ if (contextSummary) {
1072
+ systemPrompt += `\n\n<shared_context>\nThe following context was shared by other agents in this workflow. Use it to avoid redundant work:\n${contextSummary}\n</shared_context>`;
1073
+ }
1074
+ }
1075
+ } catch (e: any) {
1076
+ // Graceful fallback — never block delegation due to context injection failure.
1077
+ ctx.app.log?.debug?.(`[AgentOrchestrator] Shared context injection skipped: ${e.message}`);
1078
+ }
1079
+
1056
1080
  const combinedTask = context ? `Task: ${task}\n\nContext Provided:\n${context}` : `Task: ${task}`;
1057
1081
 
1058
1082
  // --- Step 5: Execute with timeout + abort ---
@@ -0,0 +1,128 @@
1
+ import { z } from 'zod';
2
+
3
+ const TOOL_NAME = 'external_rag_search';
4
+ const MAX_CONTENT_LENGTH = 4000;
5
+
6
+ function truncate(value: unknown, max = MAX_CONTENT_LENGTH) {
7
+ const text = typeof value === 'string' ? value : value == null ? '' : String(value);
8
+ return text.length > max ? `${text.slice(0, max)}...` : text;
9
+ }
10
+
11
+ function firstString(...values: unknown[]) {
12
+ for (const value of values) {
13
+ if (typeof value === 'string' && value.trim()) {
14
+ return value.trim();
15
+ }
16
+ if (typeof value === 'number' && Number.isFinite(value)) {
17
+ return String(value);
18
+ }
19
+ }
20
+ return undefined;
21
+ }
22
+
23
+ function normalizeResult(result: any) {
24
+ const metadata = result?.metadata ?? {};
25
+ const sourceId = firstString(
26
+ result?.id,
27
+ metadata.id,
28
+ metadata.sourceId,
29
+ metadata.documentId,
30
+ metadata.docId,
31
+ metadata.fileId,
32
+ metadata.recordId,
33
+ );
34
+ const filename = firstString(metadata.filename, metadata.fileName, metadata.name, metadata.title, metadata.source);
35
+
36
+ return {
37
+ content: truncate(result?.content),
38
+ score: Number(result?.rerankScore ?? result?.score ?? result?.vectorScore ?? 0),
39
+ knowledgeBaseId: result?.knowledgeBaseId,
40
+ knowledgeBaseName: result?.knowledgeBaseName,
41
+ source: {
42
+ id: sourceId,
43
+ filename,
44
+ url: firstString(metadata.url, metadata.fileUrl, metadata.sourceUrl),
45
+ collection: firstString(metadata.collection, metadata.collectionName),
46
+ recordId: firstString(metadata.recordId, metadata.rowId),
47
+ },
48
+ metadata,
49
+ };
50
+ }
51
+
52
+ export function createExternalRagSearchTool(plugin: any) {
53
+ return {
54
+ scope: 'CUSTOM' as const,
55
+ execution: 'backend' as const,
56
+ defaultPermission: 'ALLOW' as const,
57
+
58
+ introduction: {
59
+ title: 'External RAG Search',
60
+ about:
61
+ 'Search NocoBase knowledge bases through plugin-knowledge-base, including EXTERNAL_RAG services that own chunking, embedding, and retrieval.',
62
+ },
63
+
64
+ definition: {
65
+ name: TOOL_NAME,
66
+ description: `Search configured knowledge bases for relevant context. Use this before answering questions that require documents, files, or datasource-backed knowledge.
67
+
68
+ The search may be served by an external RAG service. Results include content plus source identifiers such as id, filename, collection, and recordId when the external service provides them.`,
69
+ schema: z.object({
70
+ query: z.string().min(1).describe('Natural-language search query.'),
71
+ knowledgeBaseIds: z
72
+ .array(z.string().min(1))
73
+ .optional()
74
+ .describe(
75
+ 'Optional list of NocoBase knowledge base IDs to search. If omitted, all accessible KBs are searched.',
76
+ ),
77
+ topK: z.number().int().min(1).max(20).optional().describe('Maximum results to return. Default 5, max 20.'),
78
+ scoreThreshold: z
79
+ .number()
80
+ .min(0)
81
+ .max(1)
82
+ .optional()
83
+ .describe('Minimum relevance score. Default is controlled by plugin-knowledge-base.'),
84
+ }),
85
+ },
86
+
87
+ invoke: async (
88
+ ctx: any,
89
+ args: { query?: string; knowledgeBaseIds?: string[]; topK?: number; scoreThreshold?: number },
90
+ ) => {
91
+ const query = typeof args?.query === 'string' ? args.query.trim() : '';
92
+ if (!query) {
93
+ return { status: 'error' as const, content: 'Missing required field: query.' };
94
+ }
95
+
96
+ const kbPlugin = ctx?.app?.pm?.get?.('plugin-knowledge-base');
97
+ if (!kbPlugin?.searchKnowledgeBases) {
98
+ return {
99
+ status: 'error' as const,
100
+ content: 'plugin-knowledge-base is not installed or does not expose searchKnowledgeBases().',
101
+ };
102
+ }
103
+
104
+ try {
105
+ const results = await kbPlugin.searchKnowledgeBases(ctx, query, {
106
+ knowledgeBaseIds: Array.isArray(args.knowledgeBaseIds) ? args.knowledgeBaseIds.map(String) : undefined,
107
+ topK: args.topK,
108
+ scoreThreshold: args.scoreThreshold,
109
+ rerank: true,
110
+ });
111
+
112
+ return {
113
+ status: 'success' as const,
114
+ content: JSON.stringify({
115
+ query,
116
+ results: (results ?? []).map(normalizeResult),
117
+ }),
118
+ };
119
+ } catch (error: any) {
120
+ plugin.app.log?.error?.('[AgentOrchestrator] external_rag_search failed', error);
121
+ return {
122
+ status: 'error' as const,
123
+ content: `External RAG search failed: ${error?.message || String(error)}`,
124
+ };
125
+ }
126
+ },
127
+ };
128
+ }
@@ -42,8 +42,7 @@ IMPORTANT: If the skill returns file download URLs, you MUST format them as clic
42
42
  },
43
43
  },
44
44
 
45
- async invoke(args: Record<string, any>, options?: any) {
46
- const ctx = options?.context;
45
+ async invoke(ctx: any, args: Record<string, any>, _id?: string) {
47
46
  plugin.app.logger.info(`[skill-execute] Tool invoked with action: ${args.action}, skillName: ${args.skillName}`);
48
47
 
49
48
  // Action: list available skills