plugin-agent-orchestrator 1.0.13 → 1.0.14

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 (251) hide show
  1. package/README.md +16 -291
  2. package/dist/client/AIEmployeesContext.d.ts +7 -0
  3. package/dist/client/OrchestratorSettings.d.ts +2 -1
  4. package/dist/client/index.js +1 -1
  5. package/dist/client/plugin.d.ts +1 -0
  6. package/dist/client/skill-hub/components/ExecutionHistory.d.ts +2 -0
  7. package/dist/client/skill-hub/components/ExecutionProgress.d.ts +20 -0
  8. package/dist/client/skill-hub/components/GitSkillImport.d.ts +7 -0
  9. package/dist/client/skill-hub/components/SkillEditor.d.ts +7 -0
  10. package/dist/client/skill-hub/components/SkillManager.d.ts +2 -0
  11. package/dist/client/skill-hub/components/SkillMetrics.d.ts +2 -0
  12. package/dist/client/skill-hub/components/SkillTestPanel.d.ts +7 -0
  13. package/dist/client/skill-hub/index.d.ts +10 -0
  14. package/dist/client/skill-hub/locale.d.ts +3 -0
  15. package/dist/client/skill-hub/tools/InteractionSchemasProvider.d.ts +19 -0
  16. package/dist/client/skill-hub/tools/SkillHubCard.d.ts +3 -0
  17. package/dist/client/skill-hub/utils/jsonFields.d.ts +3 -0
  18. package/dist/externalVersion.js +6 -6
  19. package/dist/node_modules/adm-zip/LICENSE +21 -0
  20. package/dist/node_modules/adm-zip/adm-zip.js +1 -0
  21. package/dist/node_modules/adm-zip/headers/entryHeader.js +377 -0
  22. package/dist/node_modules/adm-zip/headers/index.js +2 -0
  23. package/dist/node_modules/adm-zip/headers/mainHeader.js +130 -0
  24. package/dist/node_modules/adm-zip/methods/deflater.js +33 -0
  25. package/dist/node_modules/adm-zip/methods/index.js +3 -0
  26. package/dist/node_modules/adm-zip/methods/inflater.js +34 -0
  27. package/dist/node_modules/adm-zip/methods/zipcrypto.js +175 -0
  28. package/dist/node_modules/adm-zip/package.json +1 -0
  29. package/dist/node_modules/adm-zip/util/constants.js +142 -0
  30. package/dist/node_modules/adm-zip/util/decoder.js +5 -0
  31. package/dist/node_modules/adm-zip/util/errors.js +63 -0
  32. package/dist/node_modules/adm-zip/util/fattr.js +76 -0
  33. package/dist/node_modules/adm-zip/util/index.js +5 -0
  34. package/dist/node_modules/adm-zip/util/utils.js +339 -0
  35. package/dist/node_modules/adm-zip/zipEntry.js +405 -0
  36. package/dist/node_modules/adm-zip/zipFile.js +446 -0
  37. package/dist/node_modules/simple-git/dist/cjs/index.js +7399 -0
  38. package/dist/node_modules/simple-git/dist/esm/index.js +4745 -0
  39. package/dist/node_modules/simple-git/dist/esm/package.json +3 -0
  40. package/dist/node_modules/simple-git/dist/src/lib/api.d.ts +13 -0
  41. package/dist/node_modules/simple-git/dist/src/lib/args/log-format.d.ts +9 -0
  42. package/dist/node_modules/simple-git/dist/src/lib/errors/git-construct-error.d.ts +15 -0
  43. package/dist/node_modules/simple-git/dist/src/lib/errors/git-error.d.ts +30 -0
  44. package/dist/node_modules/simple-git/dist/src/lib/errors/git-plugin-error.d.ts +7 -0
  45. package/dist/node_modules/simple-git/dist/src/lib/errors/git-response-error.d.ts +32 -0
  46. package/dist/node_modules/simple-git/dist/src/lib/errors/task-configuration-error.d.ts +12 -0
  47. package/dist/node_modules/simple-git/dist/src/lib/git-factory.d.ts +15 -0
  48. package/dist/node_modules/simple-git/dist/src/lib/git-logger.d.ts +21 -0
  49. package/dist/node_modules/simple-git/dist/src/lib/parsers/parse-branch-delete.d.ts +5 -0
  50. package/dist/node_modules/simple-git/dist/src/lib/parsers/parse-branch.d.ts +2 -0
  51. package/dist/node_modules/simple-git/dist/src/lib/parsers/parse-commit.d.ts +2 -0
  52. package/dist/node_modules/simple-git/dist/src/lib/parsers/parse-diff-summary.d.ts +3 -0
  53. package/dist/node_modules/simple-git/dist/src/lib/parsers/parse-fetch.d.ts +2 -0
  54. package/dist/node_modules/simple-git/dist/src/lib/parsers/parse-list-log-summary.d.ts +6 -0
  55. package/dist/node_modules/simple-git/dist/src/lib/parsers/parse-merge.d.ts +11 -0
  56. package/dist/node_modules/simple-git/dist/src/lib/parsers/parse-move.d.ts +2 -0
  57. package/dist/node_modules/simple-git/dist/src/lib/parsers/parse-pull.d.ts +6 -0
  58. package/dist/node_modules/simple-git/dist/src/lib/parsers/parse-push.d.ts +4 -0
  59. package/dist/node_modules/simple-git/dist/src/lib/parsers/parse-remote-messages.d.ts +5 -0
  60. package/dist/node_modules/simple-git/dist/src/lib/parsers/parse-remote-objects.d.ts +3 -0
  61. package/dist/node_modules/simple-git/dist/src/lib/plugins/abort-plugin.d.ts +3 -0
  62. package/dist/node_modules/simple-git/dist/src/lib/plugins/block-unsafe-operations-plugin.d.ts +3 -0
  63. package/dist/node_modules/simple-git/dist/src/lib/plugins/command-config-prefixing-plugin.d.ts +2 -0
  64. package/dist/node_modules/simple-git/dist/src/lib/plugins/completion-detection.plugin.d.ts +3 -0
  65. package/dist/node_modules/simple-git/dist/src/lib/plugins/custom-binary.plugin.d.ts +3 -0
  66. package/dist/node_modules/simple-git/dist/src/lib/plugins/error-detection.plugin.d.ts +7 -0
  67. package/dist/node_modules/simple-git/dist/src/lib/plugins/index.d.ts +11 -0
  68. package/dist/node_modules/simple-git/dist/src/lib/plugins/plugin-store.d.ts +11 -0
  69. package/dist/node_modules/simple-git/dist/src/lib/plugins/progress-monitor-plugin.d.ts +3 -0
  70. package/dist/node_modules/simple-git/dist/src/lib/plugins/simple-git-plugin.d.ts +48 -0
  71. package/dist/node_modules/simple-git/dist/src/lib/plugins/spawn-options-plugin.d.ts +3 -0
  72. package/dist/node_modules/simple-git/dist/src/lib/plugins/suffix-paths.plugin.d.ts +2 -0
  73. package/dist/node_modules/simple-git/dist/src/lib/plugins/timout-plugin.d.ts +3 -0
  74. package/dist/node_modules/simple-git/dist/src/lib/responses/BranchDeleteSummary.d.ts +12 -0
  75. package/dist/node_modules/simple-git/dist/src/lib/responses/BranchSummary.d.ts +14 -0
  76. package/dist/node_modules/simple-git/dist/src/lib/responses/CheckIgnore.d.ts +4 -0
  77. package/dist/node_modules/simple-git/dist/src/lib/responses/CleanSummary.d.ts +9 -0
  78. package/dist/node_modules/simple-git/dist/src/lib/responses/ConfigList.d.ts +13 -0
  79. package/dist/node_modules/simple-git/dist/src/lib/responses/DiffSummary.d.ts +10 -0
  80. package/dist/node_modules/simple-git/dist/src/lib/responses/FileStatusSummary.d.ts +9 -0
  81. package/dist/node_modules/simple-git/dist/src/lib/responses/GetRemoteSummary.d.ts +11 -0
  82. package/dist/node_modules/simple-git/dist/src/lib/responses/InitSummary.d.ts +9 -0
  83. package/dist/node_modules/simple-git/dist/src/lib/responses/MergeSummary.d.ts +16 -0
  84. package/dist/node_modules/simple-git/dist/src/lib/responses/PullSummary.d.ts +25 -0
  85. package/dist/node_modules/simple-git/dist/src/lib/responses/StatusSummary.d.ts +19 -0
  86. package/dist/node_modules/simple-git/dist/src/lib/responses/TagList.d.ts +7 -0
  87. package/dist/node_modules/simple-git/dist/src/lib/runners/git-executor-chain.d.ts +25 -0
  88. package/dist/node_modules/simple-git/dist/src/lib/runners/git-executor.d.ts +14 -0
  89. package/dist/node_modules/simple-git/dist/src/lib/runners/promise-wrapped.d.ts +2 -0
  90. package/dist/node_modules/simple-git/dist/src/lib/runners/scheduler.d.ts +11 -0
  91. package/dist/node_modules/simple-git/dist/src/lib/runners/tasks-pending-queue.d.ts +23 -0
  92. package/dist/node_modules/simple-git/dist/src/lib/simple-git-api.d.ts +20 -0
  93. package/dist/node_modules/simple-git/dist/src/lib/task-callback.d.ts +2 -0
  94. package/dist/node_modules/simple-git/dist/src/lib/tasks/apply-patch.d.ts +3 -0
  95. package/dist/node_modules/simple-git/dist/src/lib/tasks/branch.d.ts +7 -0
  96. package/dist/node_modules/simple-git/dist/src/lib/tasks/change-working-directory.d.ts +2 -0
  97. package/dist/node_modules/simple-git/dist/src/lib/tasks/check-ignore.d.ts +2 -0
  98. package/dist/node_modules/simple-git/dist/src/lib/tasks/check-is-repo.d.ts +9 -0
  99. package/dist/node_modules/simple-git/dist/src/lib/tasks/checkout.d.ts +2 -0
  100. package/dist/node_modules/simple-git/dist/src/lib/tasks/clean.d.ts +25 -0
  101. package/dist/node_modules/simple-git/dist/src/lib/tasks/clone.d.ts +9 -0
  102. package/dist/node_modules/simple-git/dist/src/lib/tasks/commit.d.ts +4 -0
  103. package/dist/node_modules/simple-git/dist/src/lib/tasks/config.d.ts +8 -0
  104. package/dist/node_modules/simple-git/dist/src/lib/tasks/count-objects.d.ts +12 -0
  105. package/dist/node_modules/simple-git/dist/src/lib/tasks/diff-name-status.d.ts +12 -0
  106. package/dist/node_modules/simple-git/dist/src/lib/tasks/diff.d.ts +5 -0
  107. package/dist/node_modules/simple-git/dist/src/lib/tasks/fetch.d.ts +4 -0
  108. package/dist/node_modules/simple-git/dist/src/lib/tasks/first-commit.d.ts +2 -0
  109. package/dist/node_modules/simple-git/dist/src/lib/tasks/grep.d.ts +12 -0
  110. package/dist/node_modules/simple-git/dist/src/lib/tasks/hash-object.d.ts +5 -0
  111. package/dist/node_modules/simple-git/dist/src/lib/tasks/init.d.ts +3 -0
  112. package/dist/node_modules/simple-git/dist/src/lib/tasks/log.d.ts +32 -0
  113. package/dist/node_modules/simple-git/dist/src/lib/tasks/merge.d.ts +4 -0
  114. package/dist/node_modules/simple-git/dist/src/lib/tasks/move.d.ts +3 -0
  115. package/dist/node_modules/simple-git/dist/src/lib/tasks/pull.d.ts +3 -0
  116. package/dist/node_modules/simple-git/dist/src/lib/tasks/push.d.ts +9 -0
  117. package/dist/node_modules/simple-git/dist/src/lib/tasks/remote.d.ts +8 -0
  118. package/dist/node_modules/simple-git/dist/src/lib/tasks/reset.d.ts +11 -0
  119. package/dist/node_modules/simple-git/dist/src/lib/tasks/show.d.ts +2 -0
  120. package/dist/node_modules/simple-git/dist/src/lib/tasks/stash-list.d.ts +4 -0
  121. package/dist/node_modules/simple-git/dist/src/lib/tasks/status.d.ts +3 -0
  122. package/dist/node_modules/simple-git/dist/src/lib/tasks/sub-module.d.ts +5 -0
  123. package/dist/node_modules/simple-git/dist/src/lib/tasks/tag.d.ts +18 -0
  124. package/dist/node_modules/simple-git/dist/src/lib/tasks/task.d.ts +14 -0
  125. package/dist/node_modules/simple-git/dist/src/lib/tasks/version.d.ts +9 -0
  126. package/dist/node_modules/simple-git/dist/src/lib/types/handlers.d.ts +21 -0
  127. package/dist/node_modules/simple-git/dist/src/lib/types/index.d.ts +136 -0
  128. package/dist/node_modules/simple-git/dist/src/lib/types/tasks.d.ts +19 -0
  129. package/dist/node_modules/simple-git/dist/src/lib/utils/argument-filters.d.ts +14 -0
  130. package/dist/node_modules/simple-git/dist/src/lib/utils/exit-codes.d.ts +10 -0
  131. package/dist/node_modules/simple-git/dist/src/lib/utils/git-output-streams.d.ts +7 -0
  132. package/dist/node_modules/simple-git/dist/src/lib/utils/index.d.ts +8 -0
  133. package/dist/node_modules/simple-git/dist/src/lib/utils/line-parser.d.ts +15 -0
  134. package/dist/node_modules/simple-git/dist/src/lib/utils/simple-git-options.d.ts +2 -0
  135. package/dist/node_modules/simple-git/dist/src/lib/utils/task-options.d.ts +13 -0
  136. package/dist/node_modules/simple-git/dist/src/lib/utils/task-parser.d.ts +5 -0
  137. package/dist/node_modules/simple-git/dist/src/lib/utils/util.d.ts +47 -0
  138. package/dist/node_modules/simple-git/dist/typings/errors.d.ts +5 -0
  139. package/dist/node_modules/simple-git/dist/typings/index.d.ts +14 -0
  140. package/dist/node_modules/simple-git/dist/typings/response.d.ts +556 -0
  141. package/dist/node_modules/simple-git/dist/typings/simple-git.d.ts +1033 -0
  142. package/dist/node_modules/simple-git/dist/typings/types.d.ts +22 -0
  143. package/dist/node_modules/simple-git/node_modules/debug/package.json +64 -0
  144. package/dist/node_modules/simple-git/node_modules/debug/src/browser.js +272 -0
  145. package/dist/node_modules/simple-git/node_modules/debug/src/common.js +292 -0
  146. package/dist/node_modules/simple-git/node_modules/debug/src/index.js +10 -0
  147. package/dist/node_modules/simple-git/node_modules/debug/src/node.js +263 -0
  148. package/dist/node_modules/simple-git/package.json +1 -0
  149. package/dist/node_modules/simple-git/promise.js +17 -0
  150. package/dist/server/collections/agent-execution-spans.d.ts +9 -0
  151. package/dist/server/collections/agent-execution-spans.js +152 -0
  152. package/dist/server/collections/orchestrator-config.js +6 -0
  153. package/dist/server/collections/skill-definitions.d.ts +3 -0
  154. package/dist/server/collections/skill-definitions.js +158 -0
  155. package/dist/server/collections/skill-executions.d.ts +3 -0
  156. package/dist/server/collections/skill-executions.js +123 -0
  157. package/dist/server/collections/skill-worker-configs.d.ts +3 -0
  158. package/dist/server/collections/skill-worker-configs.js +115 -0
  159. package/dist/server/migrations/20260423000000-add-progress-fields.d.ts +4 -0
  160. package/dist/server/migrations/20260423000000-add-progress-fields.js +69 -0
  161. package/dist/server/migrations/20260425000000-add-interaction-schema.d.ts +4 -0
  162. package/dist/server/migrations/20260425000000-add-interaction-schema.js +61 -0
  163. package/dist/server/migrations/20260427000000-change-packages-to-text.d.ts +4 -0
  164. package/dist/server/migrations/20260427000000-change-packages-to-text.js +70 -0
  165. package/dist/server/migrations/20260427000001-change-other-json-to-text.d.ts +4 -0
  166. package/dist/server/migrations/20260427000001-change-other-json-to-text.js +80 -0
  167. package/dist/server/migrations/20260429000000-add-llm-fields.js +8 -0
  168. package/dist/server/migrations/20260429000000-fix-inputargs-json-to-text.d.ts +16 -0
  169. package/dist/server/migrations/20260429000000-fix-inputargs-json-to-text.js +51 -0
  170. package/dist/server/migrations/20260503000000-add-orchestrator-trace-fields.d.ts +7 -0
  171. package/dist/server/migrations/20260503000000-add-orchestrator-trace-fields.js +57 -0
  172. package/dist/server/plugin.d.ts +3 -0
  173. package/dist/server/plugin.js +37 -1
  174. package/dist/server/resources/tracing.js +154 -11
  175. package/dist/server/services/CodeValidator.d.ts +32 -0
  176. package/dist/server/services/CodeValidator.js +205 -0
  177. package/dist/server/services/ExecutionSpanService.d.ts +44 -0
  178. package/dist/server/services/ExecutionSpanService.js +104 -0
  179. package/dist/server/services/FileManager.d.ts +28 -0
  180. package/dist/server/services/FileManager.js +151 -0
  181. package/dist/server/services/SandboxRunner.d.ts +41 -0
  182. package/dist/server/services/SandboxRunner.js +167 -0
  183. package/dist/server/services/SkillManager.d.ts +6 -0
  184. package/dist/server/services/SkillManager.js +640 -0
  185. package/dist/server/services/SkillRepositoryService.d.ts +22 -0
  186. package/dist/server/services/SkillRepositoryService.js +157 -0
  187. package/dist/server/services/WorkerEnvManager.d.ts +26 -0
  188. package/dist/server/services/WorkerEnvManager.js +120 -0
  189. package/dist/server/skill-hub/actions/git-import.d.ts +21 -0
  190. package/dist/server/skill-hub/actions/git-import.js +413 -0
  191. package/dist/server/skill-hub/mcp/McpController.d.ts +15 -0
  192. package/dist/server/skill-hub/mcp/McpController.js +111 -0
  193. package/dist/server/skill-hub/plugin.d.ts +58 -0
  194. package/dist/server/skill-hub/plugin.js +694 -0
  195. package/dist/server/skill-hub/sandbox-config.json +6 -0
  196. package/dist/server/skill-hub/tasks/SkillExecutionTask.d.ts +14 -0
  197. package/dist/server/skill-hub/tasks/SkillExecutionTask.js +267 -0
  198. package/dist/server/skill-hub/utils/json-fields.d.ts +7 -0
  199. package/dist/server/skill-hub/utils/json-fields.js +88 -0
  200. package/dist/server/tools/delegate-task.d.ts +4 -0
  201. package/dist/server/tools/delegate-task.js +606 -104
  202. package/dist/server/tools/skill-execute.d.ts +36 -0
  203. package/dist/server/tools/skill-execute.js +167 -0
  204. package/package.json +3 -1
  205. package/src/client/AIEmployeeSelect.tsx +1 -3
  206. package/src/client/AIEmployeesContext.tsx +28 -13
  207. package/src/client/OrchestratorSettings.tsx +43 -5
  208. package/src/client/RulesTab.tsx +253 -32
  209. package/src/client/TracingTab.tsx +277 -213
  210. package/src/client/plugin.tsx +39 -0
  211. package/src/client/skill-hub/components/ExecutionHistory.tsx +201 -0
  212. package/src/client/skill-hub/components/ExecutionProgress.tsx +55 -0
  213. package/src/client/skill-hub/components/GitSkillImport.tsx +555 -0
  214. package/src/client/skill-hub/components/SkillEditor.tsx +456 -0
  215. package/src/client/skill-hub/components/SkillManager.tsx +181 -0
  216. package/src/client/skill-hub/components/SkillMetrics.tsx +124 -0
  217. package/src/client/skill-hub/components/SkillTestPanel.tsx +144 -0
  218. package/src/client/skill-hub/index.tsx +75 -0
  219. package/src/client/skill-hub/locale.ts +16 -0
  220. package/src/client/skill-hub/tools/InteractionSchemasProvider.tsx +59 -0
  221. package/src/client/skill-hub/tools/SkillHubCard.tsx +78 -0
  222. package/src/client/skill-hub/utils/jsonFields.ts +37 -0
  223. package/src/server/collections/agent-execution-spans.ts +129 -0
  224. package/src/server/collections/orchestrator-config.ts +7 -0
  225. package/src/server/collections/skill-definitions.ts +128 -0
  226. package/src/server/collections/skill-executions.ts +94 -0
  227. package/src/server/collections/skill-worker-configs.ts +86 -0
  228. package/src/server/migrations/20260423000000-add-progress-fields.ts +50 -0
  229. package/src/server/migrations/20260425000000-add-interaction-schema.ts +35 -0
  230. package/src/server/migrations/20260427000000-change-packages-to-text.ts +47 -0
  231. package/src/server/migrations/20260427000001-change-other-json-to-text.ts +57 -0
  232. package/src/server/migrations/20260429000000-add-llm-fields.ts +9 -0
  233. package/src/server/migrations/20260429000000-fix-inputargs-json-to-text.ts +38 -0
  234. package/src/server/migrations/20260503000000-add-orchestrator-trace-fields.ts +32 -0
  235. package/src/server/plugin.ts +51 -3
  236. package/src/server/resources/tracing.ts +182 -15
  237. package/src/server/services/CodeValidator.ts +159 -0
  238. package/src/server/services/ExecutionSpanService.ts +106 -0
  239. package/src/server/services/FileManager.ts +144 -0
  240. package/src/server/services/SandboxRunner.ts +205 -0
  241. package/src/server/services/SkillManager.ts +623 -0
  242. package/src/server/services/SkillRepositoryService.ts +142 -0
  243. package/src/server/services/WorkerEnvManager.ts +113 -0
  244. package/src/server/skill-hub/actions/git-import.ts +486 -0
  245. package/src/server/skill-hub/mcp/McpController.ts +86 -0
  246. package/src/server/skill-hub/plugin.ts +771 -0
  247. package/src/server/skill-hub/sandbox-config.json +6 -0
  248. package/src/server/skill-hub/tasks/SkillExecutionTask.ts +297 -0
  249. package/src/server/skill-hub/utils/json-fields.ts +57 -0
  250. package/src/server/tools/delegate-task.ts +803 -127
  251. package/src/server/tools/skill-execute.ts +157 -0
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { createHash } from 'crypto';
2
3
  import { Context } from '@nocobase/actions';
3
4
  // @ts-ignore - subpath export types resolve at build time via NocoBase bundler
4
5
  import { createReactAgent } from '@langchain/langgraph/prebuilt';
@@ -6,6 +7,11 @@ import { DynamicStructuredTool } from '@langchain/core/tools';
6
7
  import { HumanMessage, SystemMessage } from '@langchain/core/messages';
7
8
  import type PluginAIServer from '@nocobase/plugin-ai/dist/server';
8
9
  import type { ToolsEntry } from '@nocobase/ai';
10
+ import {
11
+ ExecutionSpanService,
12
+ getOrchestratorTraceContext,
13
+ setOrchestratorTraceContext,
14
+ } from '../services/ExecutionSpanService';
9
15
 
10
16
  /**
11
17
  * Maximum delegation depth key stored in ctx metadata.
@@ -13,6 +19,13 @@ import type { ToolsEntry } from '@nocobase/ai';
13
19
  */
14
20
  const ORCHESTRATOR_DEPTH_KEY = '__orchestratorDepth';
15
21
 
22
+ /** Max sub-agents that the dispatch tool runs concurrently in one call. */
23
+ const MAX_DISPATCH_CONCURRENCY = 5;
24
+ /** Hard cap on tasks per dispatch call to keep output bounded. */
25
+ const MAX_DISPATCH_TASKS = 20;
26
+ /** OpenAI/Anthropic tool-name limit. Names exceeding this are silently rejected by providers. */
27
+ const MAX_TOOL_NAME_LENGTH = 64;
28
+
16
29
  type TraceEvent = {
17
30
  type: string;
18
31
  at: string;
@@ -28,6 +41,11 @@ type AgentExecutionResult = {
28
41
  messages: any[];
29
42
  };
30
43
 
44
+ type EmployeeSkillConfig = {
45
+ name: string;
46
+ autoCall: boolean;
47
+ };
48
+
31
49
  function sanitizeToolPart(value: string) {
32
50
  return (value || '').replace(/[^a-zA-Z0-9_-]/g, '_');
33
51
  }
@@ -36,8 +54,51 @@ function buildDelegateToolName(leaderUsername: string, subAgentUsername: string)
36
54
  return `delegate_${sanitizeToolPart(leaderUsername)}_to_${sanitizeToolPart(subAgentUsername)}`;
37
55
  }
38
56
 
39
- function isDelegateToolName(toolName: string) {
40
- return toolName.startsWith('delegate_to_') || (toolName.startsWith('delegate_') && toolName.includes('_to_'));
57
+ function buildDispatchToolName(leaderUsername: string) {
58
+ return `dispatch_subagents_${sanitizeToolPart(leaderUsername)}`;
59
+ }
60
+
61
+ function createRootRunId(seed = '') {
62
+ const hash = createHash('sha1')
63
+ .update(`${Date.now()}::${Math.random()}::${seed}`)
64
+ .digest('hex')
65
+ .slice(0, 10);
66
+ return `run_${Date.now()}_${hash}`;
67
+ }
68
+
69
+ /**
70
+ * Set of tool names this plugin actually registered in the most recent build.
71
+ * Sub-agents must not see these tools (would enable circular delegation), but
72
+ * we don't want to drop unrelated user tools whose names happen to start with
73
+ * "delegate_" — so we filter by the known registry, not a regex pattern.
74
+ */
75
+ let registeredDelegateNamesByPlugin: WeakMap<object, ReadonlySet<string>> = new WeakMap();
76
+
77
+ function isDelegateToolName(plugin: any, toolName: string) {
78
+ return registeredDelegateNamesByPlugin.get(plugin)?.has(toolName) ?? false;
79
+ }
80
+
81
+ /**
82
+ * Run async work over `items` with at most `limit` concurrent executions.
83
+ * Preserves input order in the returned array.
84
+ */
85
+ async function runWithConcurrency<T, R>(
86
+ items: T[],
87
+ limit: number,
88
+ fn: (item: T, index: number) => Promise<R>,
89
+ ): Promise<R[]> {
90
+ const results: R[] = new Array(items.length);
91
+ let cursor = 0;
92
+ const workerCount = Math.max(1, Math.min(limit, items.length));
93
+ const workers = Array.from({ length: workerCount }, async () => {
94
+ while (cursor < items.length) {
95
+ const i = cursor;
96
+ cursor += 1;
97
+ results[i] = await fn(items[i], i);
98
+ }
99
+ });
100
+ await Promise.all(workers);
101
+ return results;
41
102
  }
42
103
 
43
104
  function createDelegateToolOptions(
@@ -52,15 +113,31 @@ function createDelegateToolOptions(
52
113
  legacyAlias?: boolean;
53
114
  llmService?: string;
54
115
  model?: string;
116
+ recursionLimit?: number;
55
117
  },
56
118
  ) {
57
- const { leaderUsername, subAgentUsername, subAgentEmployee, maxDepth, timeout, toolName, legacyAlias, llmService, model } = options;
119
+ const {
120
+ leaderUsername,
121
+ subAgentUsername,
122
+ subAgentEmployee,
123
+ maxDepth,
124
+ timeout,
125
+ toolName,
126
+ legacyAlias,
127
+ llmService,
128
+ model,
129
+ recursionLimit,
130
+ } = options;
131
+ const dispatchToolName = buildDispatchToolName(leaderUsername);
58
132
  const toolDescription = [
59
133
  `Delegate a task from "${leaderUsername}" to the AI Employee "${subAgentEmployee.nickname || subAgentUsername}".`,
60
134
  legacyAlias ? 'This is a backward-compatible alias for existing skill assignments.' : '',
61
135
  subAgentEmployee.about ? `Specialist profile: ${subAgentEmployee.about.substring(0, 200)}` : '',
62
136
  'The sub-agent will execute the task independently and return its final answer.',
63
- ].filter(Boolean).join(' ');
137
+ `For multiple INDEPENDENT sub-tasks, prefer "${dispatchToolName}" to fan-out in one call (up to ${MAX_DISPATCH_CONCURRENCY} run in parallel), or emit several delegate_* calls in the SAME assistant turn so they run concurrently.`,
138
+ ]
139
+ .filter(Boolean)
140
+ .join(' ');
64
141
 
65
142
  return {
66
143
  scope: 'CUSTOM',
@@ -76,11 +153,32 @@ function createDelegateToolOptions(
76
153
  description: toolDescription,
77
154
  schema: z.object({
78
155
  task: z.string().describe('The detailed task description for the sub-agent to execute.'),
79
- context: z.string().optional().describe('Optional additional context to help the sub-agent understand the task better.'),
156
+ context: z
157
+ .string()
158
+ .optional()
159
+ .describe('Optional additional context to help the sub-agent understand the task better.'),
80
160
  }),
81
161
  },
82
162
  invoke: async (ctx: Context, args: { task: string; context?: string }, id: string) => {
83
- const callingEmployee = resolveCallingEmployee(ctx);
163
+ const callingEmployee = await resolveCallingEmployee(ctx, plugin);
164
+ if (!callingEmployee) {
165
+ await logDelegation(ctx, plugin, {
166
+ leaderUsername,
167
+ subAgentUsername,
168
+ toolName,
169
+ task: args.task,
170
+ context: args.context,
171
+ result: '',
172
+ status: 'error',
173
+ depth: (ctx as any)[ORCHESTRATOR_DEPTH_KEY] ?? 0,
174
+ durationMs: 0,
175
+ error: `Cannot determine calling AI employee for delegation tool "${toolName}".`,
176
+ });
177
+ return {
178
+ status: 'error' as const,
179
+ content: `Cannot determine calling AI employee for "${toolName}". Start the request from an AI Employee conversation so leader scoping can be enforced.`,
180
+ };
181
+ }
84
182
  if (callingEmployee && callingEmployee !== leaderUsername) {
85
183
  await logDelegation(ctx, plugin, {
86
184
  leaderUsername,
@@ -112,18 +210,280 @@ function createDelegateToolOptions(
112
210
  toolName,
113
211
  llmService,
114
212
  model,
213
+ recursionLimit,
115
214
  });
116
215
  },
117
216
  };
118
217
  }
119
218
 
120
- function resolveCallingEmployee(ctx: Context) {
219
+ type DispatchRuleEntry = {
220
+ rule: any;
221
+ employee: any;
222
+ };
223
+
224
+ type DispatchTaskResult = {
225
+ index: number;
226
+ subAgent: string;
227
+ status: 'success' | 'error';
228
+ content: string;
229
+ durationMs: number;
230
+ };
231
+
232
+ function formatDispatchResults(results: DispatchTaskResult[], rulesBySubAgent: Map<string, DispatchRuleEntry>) {
233
+ const total = results.length;
234
+ const ok = results.filter((r) => r.status === 'success').length;
235
+ const lines = [
236
+ `Dispatched ${total} sub-task(s) — ${ok} succeeded, ${
237
+ total - ok
238
+ } failed (max ${MAX_DISPATCH_CONCURRENCY} ran in parallel).`,
239
+ '',
240
+ ];
241
+ for (const r of results) {
242
+ const employee = rulesBySubAgent.get(r.subAgent)?.employee;
243
+ const displayName = employee?.nickname || r.subAgent;
244
+ const dur = `${(r.durationMs / 1000).toFixed(1)}s`;
245
+ lines.push(`--- [${r.index + 1}] ${displayName} (${r.subAgent}) [${r.status}] (${dur}) ---`);
246
+ lines.push(r.content || '(empty)');
247
+ lines.push('');
248
+ }
249
+ return lines.join('\n').trimEnd();
250
+ }
251
+
252
+ /**
253
+ * Build a single fan-out tool per leader. The leader passes a list of
254
+ * `{ subAgent, task, context? }` items; we run them concurrently (capped at
255
+ * MAX_DISPATCH_CONCURRENCY) and aggregate the results into one response.
256
+ *
257
+ * Each underlying execution still goes through `invokeDelegateTask`, so depth
258
+ * limits, per-rule timeouts, LLM overrides, and orchestratorLogs entries
259
+ * behave identically to a direct `delegate_*_to_*` call.
260
+ */
261
+ function createDispatchToolOptions(
262
+ plugin: any,
263
+ options: {
264
+ leaderUsername: string;
265
+ rulesBySubAgent: Map<string, DispatchRuleEntry>;
266
+ },
267
+ ) {
268
+ const { leaderUsername, rulesBySubAgent } = options;
269
+ const toolName = buildDispatchToolName(leaderUsername);
270
+ const subAgentNames = Array.from(rulesBySubAgent.keys());
271
+
272
+ const subAgentList = subAgentNames
273
+ .map((username) => {
274
+ const entry = rulesBySubAgent.get(username);
275
+ if (!entry) return `- ${username}`;
276
+ const profile = entry.employee?.about ? ` — ${String(entry.employee.about).substring(0, 120)}` : '';
277
+ const display = entry.employee?.nickname ? ` (${entry.employee.nickname})` : '';
278
+ return `- ${username}${display}${profile}`;
279
+ })
280
+ .join('\n');
281
+
282
+ const description = [
283
+ `Dispatch multiple tasks from "${leaderUsername}" to its configured sub-agents in one call.`,
284
+ `At most ${MAX_DISPATCH_CONCURRENCY} sub-tasks run in parallel; up to ${MAX_DISPATCH_TASKS} tasks per call.`,
285
+ 'Use this when you have already planned independent sub-tasks and want to fan-out, then aggregate the results.',
286
+ `Available sub-agents:\n${subAgentList}`,
287
+ ].join(' ');
288
+
289
+ return {
290
+ scope: 'CUSTOM',
291
+ execution: 'backend',
292
+ defaultPermission: 'ALLOW',
293
+ silence: false,
294
+ introduction: {
295
+ title: `[${leaderUsername}] Dispatch sub-agents`,
296
+ about: description,
297
+ },
298
+ definition: {
299
+ name: toolName,
300
+ description,
301
+ schema: z.object({
302
+ tasks: z
303
+ .array(
304
+ z.object({
305
+ subAgent: z
306
+ .enum(subAgentNames as [string, ...string[]])
307
+ .describe('Username of the sub-agent that should execute this task.'),
308
+ task: z.string().describe('Detailed task description for the sub-agent.'),
309
+ context: z.string().optional().describe('Optional additional context for the sub-agent.'),
310
+ }),
311
+ )
312
+ .min(1)
313
+ .max(MAX_DISPATCH_TASKS)
314
+ .describe(`List of sub-tasks to dispatch concurrently. Up to ${MAX_DISPATCH_CONCURRENCY} run in parallel.`),
315
+ }),
316
+ },
317
+ invoke: async (
318
+ ctx: Context,
319
+ args: { tasks: Array<{ subAgent: string; task: string; context?: string }> },
320
+ id: string,
321
+ ) => {
322
+ const callingEmployee = await resolveCallingEmployee(ctx, plugin);
323
+ if (!callingEmployee) {
324
+ const distinctSubs = Array.from(new Set((args.tasks ?? []).map((t) => t.subAgent).filter(Boolean)));
325
+ const reportedSub = distinctSubs.length === 1 ? distinctSubs[0] : '(multiple)';
326
+ await logDelegation(ctx, plugin, {
327
+ leaderUsername,
328
+ subAgentUsername: reportedSub,
329
+ toolName,
330
+ task: truncateText(args.tasks ?? [], 2000),
331
+ result: '',
332
+ status: 'error',
333
+ depth: (ctx as any)[ORCHESTRATOR_DEPTH_KEY] ?? 0,
334
+ durationMs: 0,
335
+ error: `Cannot determine calling AI employee for dispatch tool "${toolName}". Targets: ${
336
+ distinctSubs.join(', ') || '(empty)'
337
+ }.`,
338
+ });
339
+ return {
340
+ status: 'error' as const,
341
+ content: `Cannot determine calling AI employee for "${toolName}". Start the request from an AI Employee conversation so leader scoping can be enforced.`,
342
+ };
343
+ }
344
+ if (callingEmployee && callingEmployee !== leaderUsername) {
345
+ // Mirror the per-rule delegate tool: persist the rejection to
346
+ // orchestratorLogs so admins can investigate via the Tracing tab.
347
+ const distinctSubs = Array.from(new Set((args.tasks ?? []).map((t) => t.subAgent).filter(Boolean)));
348
+ const reportedSub = distinctSubs.length === 1 ? distinctSubs[0] : '(multiple)';
349
+ await logDelegation(ctx, plugin, {
350
+ leaderUsername,
351
+ subAgentUsername: reportedSub,
352
+ toolName,
353
+ task: truncateText(args.tasks ?? [], 2000),
354
+ result: '',
355
+ status: 'error',
356
+ depth: (ctx as any)[ORCHESTRATOR_DEPTH_KEY] ?? 0,
357
+ durationMs: 0,
358
+ error: `Employee "${callingEmployee}" is not authorized to dispatch sub-agents for leader "${leaderUsername}". Targets: ${
359
+ distinctSubs.join(', ') || '(empty)'
360
+ }.`,
361
+ });
362
+ return {
363
+ status: 'error' as const,
364
+ content: `Employee "${callingEmployee}" is not authorized to dispatch sub-agents for leader "${leaderUsername}".`,
365
+ };
366
+ }
367
+
368
+ const tasks = args.tasks ?? [];
369
+ if (!tasks.length) {
370
+ return {
371
+ status: 'error' as const,
372
+ content: 'No tasks provided. Pass at least one item in `tasks`.',
373
+ };
374
+ }
375
+
376
+ const dispatchRootRunId =
377
+ getOrchestratorTraceContext(ctx)?.rootRunId || createRootRunId(`${leaderUsername}:dispatch`);
378
+
379
+ const results = await runWithConcurrency<
380
+ { subAgent: string; task: string; context?: string },
381
+ DispatchTaskResult
382
+ >(tasks, MAX_DISPATCH_CONCURRENCY, async (item, i) => {
383
+ const startedAt = Date.now();
384
+ const entry = rulesBySubAgent.get(item.subAgent);
385
+ if (!entry) {
386
+ return {
387
+ index: i,
388
+ subAgent: item.subAgent,
389
+ status: 'error',
390
+ content: `Unknown sub-agent "${item.subAgent}". Allowed: ${subAgentNames.join(', ')}.`,
391
+ durationMs: 0,
392
+ };
393
+ }
394
+
395
+ try {
396
+ const res = await invokeDelegateTask(ctx, plugin, {
397
+ leaderUsername,
398
+ subAgentUsername: item.subAgent,
399
+ subAgentEmployee: entry.employee,
400
+ task: item.task,
401
+ context: item.context,
402
+ maxDepth: entry.rule.maxDepth ?? 1,
403
+ timeout: entry.rule.timeout ?? 120000,
404
+ toolCallId: `${id}-${i}`,
405
+ toolName,
406
+ llmService: entry.rule.llmService,
407
+ model: entry.rule.model,
408
+ recursionLimit: entry.rule.recursionLimit,
409
+ rootRunId: dispatchRootRunId,
410
+ });
411
+ return {
412
+ index: i,
413
+ subAgent: item.subAgent,
414
+ status: res.status,
415
+ content: res.content,
416
+ durationMs: Date.now() - startedAt,
417
+ };
418
+ } catch (e: any) {
419
+ return {
420
+ index: i,
421
+ subAgent: item.subAgent,
422
+ status: 'error',
423
+ content: e?.message || String(e),
424
+ durationMs: Date.now() - startedAt,
425
+ };
426
+ }
427
+ });
428
+
429
+ const successCount = results.filter((r) => r.status === 'success').length;
430
+ return {
431
+ status: (successCount > 0 ? 'success' : 'error') as 'success' | 'error',
432
+ content: formatDispatchResults(results, rulesBySubAgent),
433
+ };
434
+ },
435
+ };
436
+ }
437
+
438
+ type CtxSnapshot = {
439
+ userId?: number;
440
+ };
441
+
442
+ /**
443
+ * Read the few ctx fields we depend on once, before kicking off the long-running
444
+ * sub-agent. Avoids "ctx is destroyed" or stale-state issues when we later
445
+ * write the orchestratorLogs row from inside the agent's execution promise.
446
+ */
447
+ function captureCtxSnapshot(ctx: Context): CtxSnapshot {
448
+ let userId: number | undefined;
449
+ try {
450
+ userId = (ctx as any).auth?.user?.id || (ctx as any).state?.currentUser?.id;
451
+ } catch {
452
+ // ctx already disposed — nothing to capture.
453
+ }
454
+ return { userId };
455
+ }
456
+
457
+ function normalizeEmployeeUsername(raw: any) {
458
+ if (!raw) return null;
459
+ if (typeof raw === 'string') return raw;
460
+ return raw.username || raw.aiEmployeeUsername || raw.name || null;
461
+ }
462
+
463
+ async function resolveCallingEmployee(ctx: Context, plugin: any) {
464
+ const values = (ctx as any).action?.params?.values || {};
121
465
  const raw =
122
466
  (ctx as any)._currentAIEmployee ||
123
467
  (ctx as any).state?.currentAIEmployee ||
124
- (ctx as any).runtime?.context?.currentAIEmployee;
125
- if (!raw) return null;
126
- return typeof raw === 'string' ? raw : raw.username;
468
+ (ctx as any).runtime?.context?.currentAIEmployee ||
469
+ values.aiEmployee;
470
+
471
+ const direct = normalizeEmployeeUsername(raw);
472
+ if (direct) return direct;
473
+
474
+ const sessionId = values.sessionId || (ctx as any).action?.params?.sessionId;
475
+ if (!sessionId) return null;
476
+
477
+ try {
478
+ const repo = (ctx as any).db?.getRepository?.('aiConversations') || plugin.db.getRepository('aiConversations');
479
+ const conversation = await repo.findOne({
480
+ filter: { sessionId },
481
+ });
482
+ return normalizeEmployeeUsername(conversation?.aiEmployeeUsername || conversation?.get?.('aiEmployeeUsername'));
483
+ } catch (e) {
484
+ plugin.app.log.warn(`[AgentOrchestrator] Failed to resolve AI employee for session "${sessionId}"`, e);
485
+ return null;
486
+ }
127
487
  }
128
488
 
129
489
  function truncateText(value: any, maxLen: number) {
@@ -135,6 +495,202 @@ function nowIso() {
135
495
  return new Date().toISOString();
136
496
  }
137
497
 
498
+ function hasModelSettings(value: any): value is { llmService: string; model: string } {
499
+ return Boolean(value?.llmService && value?.model);
500
+ }
501
+
502
+ /**
503
+ * Cache for built delegate tool descriptors to avoid re-querying DB on every
504
+ * core toolsManager.listTools() call (which can fire many times per chat turn).
505
+ *
506
+ * - TTL is a safety net in case event hooks miss an external write path.
507
+ * - DB hooks invalidate immediately on rule/employee changes so admin edits
508
+ * take effect on the next request.
509
+ */
510
+ const TOOLS_CACHE_TTL_MS = 30_000;
511
+ let toolsCacheByPlugin: WeakMap<object, { tools: any[]; expiresAt: number }> = new WeakMap();
512
+ let hooksAttached: WeakSet<object> | null = null;
513
+
514
+ function attachInvalidationHooks(plugin: any) {
515
+ // Attach once per plugin instance (handles dev hot-reload safely).
516
+ if (!hooksAttached) hooksAttached = new WeakSet<object>();
517
+ if (hooksAttached.has(plugin)) return;
518
+ hooksAttached.add(plugin);
519
+
520
+ const invalidate = () => {
521
+ toolsCacheByPlugin.delete(plugin);
522
+ registeredDelegateNamesByPlugin.delete(plugin);
523
+ };
524
+ plugin.db.on('orchestratorConfig.afterCreate', invalidate);
525
+ plugin.db.on('orchestratorConfig.afterUpdate', invalidate);
526
+ plugin.db.on('orchestratorConfig.afterDestroy', invalidate);
527
+ plugin.db.on('aiEmployees.afterCreate', invalidate);
528
+ plugin.db.on('aiEmployees.afterUpdate', invalidate);
529
+ plugin.db.on('aiEmployees.afterDestroy', invalidate);
530
+ }
531
+
532
+ async function buildDelegateTools(plugin: any) {
533
+ const configRepo = plugin.db.getRepository('orchestratorConfig');
534
+ if (!configRepo) {
535
+ registeredDelegateNamesByPlugin.set(plugin, new Set());
536
+ return [];
537
+ }
538
+
539
+ const configs = await configRepo.find({
540
+ filter: { enabled: true },
541
+ });
542
+ if (!configs?.length) {
543
+ registeredDelegateNamesByPlugin.set(plugin, new Set());
544
+ return [];
545
+ }
546
+
547
+ const employeeCache = new Map<string, any>();
548
+ const tools: any[] = [];
549
+ // Track every generated tool name to surface sanitize() collisions
550
+ // (e.g. "pm-1" and "pm.1" both → "pm_1"). Collisions are skipped + logged.
551
+ const generatedNames = new Map<string, { leader: string; sub: string }>();
552
+ const configsBySubAgent = new Map<string, any[]>();
553
+ for (const config of configs) {
554
+ const items = configsBySubAgent.get(config.subAgentUsername) || [];
555
+ items.push(config);
556
+ configsBySubAgent.set(config.subAgentUsername, items);
557
+ }
558
+
559
+ for (const config of configs) {
560
+ const { leaderUsername, subAgentUsername, maxDepth, timeout, recursionLimit } = config;
561
+
562
+ let subAgentEmployee = employeeCache.get(subAgentUsername);
563
+ if (!subAgentEmployee) {
564
+ subAgentEmployee = await plugin.db.getRepository('aiEmployees').findOne({
565
+ filter: { username: subAgentUsername },
566
+ });
567
+ if (subAgentEmployee) {
568
+ employeeCache.set(subAgentUsername, subAgentEmployee);
569
+ }
570
+ }
571
+ if (!subAgentEmployee) continue;
572
+
573
+ const toolName = buildDelegateToolName(leaderUsername, subAgentUsername);
574
+ if (toolName.length > MAX_TOOL_NAME_LENGTH) {
575
+ plugin.app.log.error(
576
+ `[AgentOrchestrator] Tool name "${toolName}" exceeds the ${MAX_TOOL_NAME_LENGTH}-char limit enforced by most LLM providers. Skipping rule (${leaderUsername} → ${subAgentUsername}). Shorten one of the usernames.`,
577
+ );
578
+ continue;
579
+ }
580
+ const existing = generatedNames.get(toolName);
581
+ if (existing) {
582
+ const suffix = createHash('sha1').update(`${leaderUsername}::${subAgentUsername}`).digest('hex').slice(0, 6);
583
+ plugin.app.log.error(
584
+ `[AgentOrchestrator] Tool-name collision: rule (${leaderUsername} → ${subAgentUsername}) sanitizes to "${toolName}", same as (${existing.leader} → ${existing.sub}). Skipping duplicate registration. Rename one of the usernames or apply suffix "_${suffix}" manually.`,
585
+ );
586
+ continue;
587
+ }
588
+ generatedNames.set(toolName, { leader: leaderUsername, sub: subAgentUsername });
589
+ tools.push(
590
+ createDelegateToolOptions(plugin, {
591
+ leaderUsername,
592
+ subAgentUsername,
593
+ subAgentEmployee,
594
+ maxDepth,
595
+ timeout,
596
+ toolName,
597
+ llmService: config.llmService,
598
+ model: config.model,
599
+ recursionLimit,
600
+ }),
601
+ );
602
+ }
603
+
604
+ // Compatibility for existing single-parent setups that already assigned
605
+ // delegate_to_<sub> to the parent employee's skills.
606
+ for (const [subAgentUsername, items] of configsBySubAgent.entries()) {
607
+ if (items.length !== 1) {
608
+ // Multiple leaders for the same sub-agent ⇒ alias is ambiguous.
609
+ // Surface it so admins know why old skill assignments may stop working.
610
+ const leaders = items.map((c: any) => c.leaderUsername).join(', ');
611
+ plugin.app.log.warn(
612
+ `[AgentOrchestrator] Legacy alias "delegate_to_${sanitizeToolPart(
613
+ subAgentUsername,
614
+ )}" is NOT registered for sub-agent "${subAgentUsername}" because it has multiple leaders (${leaders}). Leaders must use the per-rule "delegate_<leader>_to_<sub>" tool name.`,
615
+ );
616
+ continue;
617
+ }
618
+ const config = items[0];
619
+ const subAgentEmployee = employeeCache.get(subAgentUsername);
620
+ if (!subAgentEmployee) continue;
621
+ const legacyToolName = `delegate_to_${sanitizeToolPart(subAgentUsername)}`;
622
+ if (legacyToolName.length > MAX_TOOL_NAME_LENGTH) {
623
+ plugin.app.log.error(
624
+ `[AgentOrchestrator] Legacy alias "${legacyToolName}" exceeds the ${MAX_TOOL_NAME_LENGTH}-char limit. Skipping alias for sub-agent "${subAgentUsername}".`,
625
+ );
626
+ continue;
627
+ }
628
+ const aliasExisting = generatedNames.get(legacyToolName);
629
+ if (aliasExisting) {
630
+ plugin.app.log.error(
631
+ `[AgentOrchestrator] Legacy alias "${legacyToolName}" collides with another rule (${aliasExisting.leader} → ${aliasExisting.sub}). Skipping alias registration.`,
632
+ );
633
+ continue;
634
+ }
635
+ generatedNames.set(legacyToolName, {
636
+ leader: config.leaderUsername,
637
+ sub: subAgentUsername,
638
+ });
639
+ tools.push(
640
+ createDelegateToolOptions(plugin, {
641
+ leaderUsername: config.leaderUsername,
642
+ subAgentUsername,
643
+ subAgentEmployee,
644
+ maxDepth: config.maxDepth,
645
+ timeout: config.timeout,
646
+ toolName: legacyToolName,
647
+ legacyAlias: true,
648
+ llmService: config.llmService,
649
+ model: config.model,
650
+ recursionLimit: config.recursionLimit,
651
+ }),
652
+ );
653
+ }
654
+
655
+ // One dispatch fan-out tool per leader.
656
+ const rulesByLeader = new Map<string, Map<string, DispatchRuleEntry>>();
657
+ for (const config of configs) {
658
+ const subAgentEmployee = employeeCache.get(config.subAgentUsername);
659
+ if (!subAgentEmployee) continue;
660
+ let bucket = rulesByLeader.get(config.leaderUsername);
661
+ if (!bucket) {
662
+ bucket = new Map<string, DispatchRuleEntry>();
663
+ rulesByLeader.set(config.leaderUsername, bucket);
664
+ }
665
+ bucket.set(config.subAgentUsername, { rule: config, employee: subAgentEmployee });
666
+ }
667
+ for (const [leaderUsername, rulesBySubAgent] of rulesByLeader.entries()) {
668
+ if (!rulesBySubAgent.size) continue;
669
+ const dispatchToolName = buildDispatchToolName(leaderUsername);
670
+ if (dispatchToolName.length > MAX_TOOL_NAME_LENGTH) {
671
+ plugin.app.log.error(
672
+ `[AgentOrchestrator] Dispatch tool "${dispatchToolName}" exceeds the ${MAX_TOOL_NAME_LENGTH}-char limit. Skipping for leader "${leaderUsername}".`,
673
+ );
674
+ continue;
675
+ }
676
+ const dispatchExisting = generatedNames.get(dispatchToolName);
677
+ if (dispatchExisting) {
678
+ plugin.app.log.error(
679
+ `[AgentOrchestrator] Dispatch tool "${dispatchToolName}" collides with another generated tool (${dispatchExisting.leader} → ${dispatchExisting.sub}). Skipping dispatch registration for leader "${leaderUsername}".`,
680
+ );
681
+ continue;
682
+ }
683
+ generatedNames.set(dispatchToolName, { leader: leaderUsername, sub: '(dispatch)' });
684
+ tools.push(createDispatchToolOptions(plugin, { leaderUsername, rulesBySubAgent }));
685
+ }
686
+
687
+ // Refresh the registry that `isDelegateToolName` consults so sub-agents
688
+ // running concurrently filter exactly the names we just registered.
689
+ registeredDelegateNamesByPlugin.set(plugin, new Set(generatedNames.keys()));
690
+
691
+ return tools;
692
+ }
693
+
138
694
  /**
139
695
  * Creates one dynamic tool per configured sub-agent for a given leader.
140
696
  * Uses Strategy B (Per-SubAgent Tool): each sub-agent becomes a separate tool
@@ -150,77 +706,19 @@ function nowIso() {
150
706
  * leaderUsername field, so scoping is enforced in the invoke callback)
151
707
  */
152
708
  export function createDelegateToolsProvider(plugin: any) {
709
+ attachInvalidationHooks(plugin);
710
+
153
711
  return async (register: any) => {
154
712
  try {
155
- const configRepo = plugin.db.getRepository('orchestratorConfig');
156
- if (!configRepo) return;
157
-
158
- const configs = await configRepo.find({
159
- filter: { enabled: true },
160
- });
161
- if (!configs?.length) return;
162
-
163
- const employeeCache = new Map<string, any>();
164
- const tools = [];
165
- const configsBySubAgent = new Map<string, any[]>();
166
- for (const config of configs) {
167
- const items = configsBySubAgent.get(config.subAgentUsername) || [];
168
- items.push(config);
169
- configsBySubAgent.set(config.subAgentUsername, items);
170
- }
171
-
172
- for (const config of configs) {
173
- const { leaderUsername, subAgentUsername, maxDepth, timeout } = config;
174
-
175
- // Fetch the sub-agent employee model for its description and LLM config
176
- let subAgentEmployee = employeeCache.get(subAgentUsername);
177
- if (!subAgentEmployee) {
178
- subAgentEmployee = await plugin.db.getRepository('aiEmployees').findOne({
179
- filter: { username: subAgentUsername },
180
- });
181
- if (subAgentEmployee) {
182
- employeeCache.set(subAgentUsername, subAgentEmployee);
183
- }
184
- }
185
- if (!subAgentEmployee) continue;
186
-
187
- const toolName = buildDelegateToolName(leaderUsername, subAgentUsername);
188
- tools.push(createDelegateToolOptions(plugin, {
189
- leaderUsername,
190
- subAgentUsername,
191
- subAgentEmployee,
192
- maxDepth,
193
- timeout,
194
- toolName,
195
- llmService: config.llmService,
196
- model: config.model,
197
- }));
198
- }
199
-
200
- // Compatibility for existing single-parent setups that already assigned
201
- // delegate_to_<sub> to the parent employee's skills.
202
- for (const [subAgentUsername, items] of configsBySubAgent.entries()) {
203
- if (items.length !== 1) continue;
204
- const config = items[0];
205
- const subAgentEmployee = employeeCache.get(subAgentUsername);
206
- if (!subAgentEmployee) continue;
207
- const legacyToolName = `delegate_to_${sanitizeToolPart(subAgentUsername)}`;
208
- if (tools.some((tool: any) => tool.definition.name === legacyToolName)) continue;
209
- tools.push(createDelegateToolOptions(plugin, {
210
- leaderUsername: config.leaderUsername,
211
- subAgentUsername,
212
- subAgentEmployee,
213
- maxDepth: config.maxDepth,
214
- timeout: config.timeout,
215
- toolName: legacyToolName,
216
- legacyAlias: true,
217
- llmService: config.llmService,
218
- model: config.model,
219
- }));
713
+ let toolsCache = toolsCacheByPlugin.get(plugin);
714
+ if (!toolsCache || toolsCache.expiresAt <= Date.now()) {
715
+ const tools = await buildDelegateTools(plugin);
716
+ toolsCache = { tools, expiresAt: Date.now() + TOOLS_CACHE_TTL_MS };
717
+ toolsCacheByPlugin.set(plugin, toolsCache);
220
718
  }
221
719
 
222
- if (tools.length) {
223
- register.registerTools(tools);
720
+ if (toolsCache.tools.length) {
721
+ register.registerTools(toolsCache.tools);
224
722
  }
225
723
  } catch (e) {
226
724
  plugin.app.log.error('[AgentOrchestrator] Failed to register delegate tools', e);
@@ -228,6 +726,14 @@ export function createDelegateToolsProvider(plugin: any) {
228
726
  };
229
727
  }
230
728
 
729
+ /**
730
+ * Test/internal helper to drop the in-memory tool cache (e.g., from a CLI op).
731
+ */
732
+ export function invalidateDelegateToolsCache() {
733
+ toolsCacheByPlugin = new WeakMap();
734
+ registeredDelegateNamesByPlugin = new WeakMap();
735
+ }
736
+
231
737
  /**
232
738
  * Core execution logic using createReactAgent (public LangGraph API).
233
739
  *
@@ -259,9 +765,34 @@ async function invokeDelegateTask(
259
765
  toolName: string;
260
766
  llmService?: string;
261
767
  model?: string;
768
+ recursionLimit?: number;
769
+ rootRunId?: string;
770
+ parentSpanId?: string;
262
771
  },
263
772
  ) {
264
- const { leaderUsername, subAgentUsername, subAgentEmployee, task, context, maxDepth, timeout, toolCallId, toolName, llmService, model } = options;
773
+ const {
774
+ leaderUsername,
775
+ subAgentUsername,
776
+ subAgentEmployee,
777
+ task,
778
+ context,
779
+ maxDepth,
780
+ timeout,
781
+ toolCallId,
782
+ toolName,
783
+ llmService,
784
+ model,
785
+ recursionLimit,
786
+ rootRunId: providedRootRunId,
787
+ parentSpanId: providedParentSpanId,
788
+ } = options;
789
+
790
+ // --- Snapshot ctx fields up-front ---
791
+ // Long-running agent execution (up to `timeout` ms) outlives the parent HTTP
792
+ // request, so middleware may have cleared `ctx.auth`, `ctx.state`, or even
793
+ // disposed the underlying socket by the time we finalize the log row.
794
+ // Capturing the values once here keeps log/audit fields stable.
795
+ const ctxSnapshot = captureCtxSnapshot(ctx);
265
796
 
266
797
  // --- P1: Depth enforcement ---
267
798
  const currentDepth: number = (ctx as any)[ORCHESTRATOR_DEPTH_KEY] ?? 0;
@@ -277,6 +808,7 @@ async function invokeDelegateTask(
277
808
  depth: currentDepth,
278
809
  durationMs: 0,
279
810
  error: `Delegation depth limit reached (${currentDepth}/${maxDepth}).`,
811
+ snapshot: ctxSnapshot,
280
812
  });
281
813
  return {
282
814
  status: 'error' as const,
@@ -284,6 +816,11 @@ async function invokeDelegateTask(
284
816
  };
285
817
  }
286
818
 
819
+ const spanService = new ExecutionSpanService(plugin);
820
+ const upstreamTraceContext = getOrchestratorTraceContext(ctx);
821
+ const rootRunId =
822
+ providedRootRunId || upstreamTraceContext?.rootRunId || createRootRunId(`${leaderUsername}:${subAgentUsername}`);
823
+ const parentSpanId = providedParentSpanId || upstreamTraceContext?.spanId || upstreamTraceContext?.parentSpanId;
287
824
  const startTime = Date.now();
288
825
  const trace: TraceEvent[] = [
289
826
  {
@@ -293,6 +830,25 @@ async function invokeDelegateTask(
293
830
  content: task,
294
831
  },
295
832
  ];
833
+ const executionSpan = await spanService.create({
834
+ rootRunId,
835
+ parentSpanId,
836
+ type: 'sub_agent',
837
+ status: 'running',
838
+ leaderUsername,
839
+ employeeUsername: subAgentUsername,
840
+ title: `Delegation: ${leaderUsername} -> ${subAgentUsername}`,
841
+ input: { task, context },
842
+ metadata: {
843
+ depth: currentDepth,
844
+ maxDepth,
845
+ toolName,
846
+ recursionLimit,
847
+ llmOverride: llmService && model ? { llmService, model } : undefined,
848
+ },
849
+ userId: ctxSnapshot.userId,
850
+ });
851
+ const executionSpanId = executionSpan?.id ? String(executionSpan.id) : undefined;
296
852
  const logRecord = await logDelegation(ctx, plugin, {
297
853
  leaderUsername,
298
854
  subAgentUsername,
@@ -304,7 +860,11 @@ async function invokeDelegateTask(
304
860
  depth: currentDepth,
305
861
  durationMs: 0,
306
862
  trace,
863
+ snapshot: ctxSnapshot,
307
864
  });
865
+ if (executionSpanId && logRecord?.id) {
866
+ await spanService.update(executionSpanId, { orchestratorLogId: logRecord.id });
867
+ }
308
868
 
309
869
  try {
310
870
  const aiPlugin = ctx.app.pm.get('ai') as PluginAIServer;
@@ -313,26 +873,32 @@ async function invokeDelegateTask(
313
873
  }
314
874
 
315
875
  // --- Step 1: Resolve LLM model from sub-agent's employee config ---
316
- let modelSettings = subAgentEmployee.modelSettings;
317
-
876
+ let modelSettings = hasModelSettings(subAgentEmployee.modelSettings) ? subAgentEmployee.modelSettings : undefined;
877
+
318
878
  // Override with orchestrator config if provided
319
879
  if (llmService && model) {
320
880
  modelSettings = { llmService, model };
321
881
  }
322
-
323
- if (!modelSettings?.llmService || !modelSettings?.model) {
882
+
883
+ if (!hasModelSettings(modelSettings)) {
324
884
  // Fallback to leader's LLM model if sub-agent doesn't have one
325
885
  const leaderEmployee = await plugin.db.getRepository('aiEmployees').findOne({
326
886
  filter: { username: leaderUsername },
327
887
  });
328
-
888
+
329
889
  // The leader's model might be empty in the DB if it relies on the dynamic system default.
330
890
  // In that case, we extract the dynamic `model` passed from the frontend request.
331
891
  const dynamicModel = ctx.action?.params?.values?.model;
332
- modelSettings = leaderEmployee?.modelSettings || dynamicModel;
333
-
334
- if (!modelSettings?.llmService || !modelSettings?.model) {
335
- throw new Error(`Sub-agent "${subAgentUsername}" has no LLM model configured (and leader fallback failed). Please configure a model in the Orchestrator Config or AI Employee settings.`);
892
+ modelSettings = hasModelSettings(leaderEmployee?.modelSettings)
893
+ ? leaderEmployee.modelSettings
894
+ : hasModelSettings(dynamicModel)
895
+ ? dynamicModel
896
+ : undefined;
897
+
898
+ if (!hasModelSettings(modelSettings)) {
899
+ throw new Error(
900
+ `Sub-agent "${subAgentUsername}" has no LLM model configured (and leader fallback failed). Please configure a model in the Orchestrator Config or AI Employee settings.`,
901
+ );
336
902
  }
337
903
  }
338
904
 
@@ -350,32 +916,77 @@ async function invokeDelegateTask(
350
916
 
351
917
  // skillSettings.skills is { name: string, autoCall: boolean }[]
352
918
  // (verified at ai-employee.ts:1028-1029)
353
- const employeeSkills: string[] = (subAgentEmployee.skillSettings?.skills ?? [])
354
- .map((s: any) => (typeof s === 'string' ? s : s.name))
355
- .filter(Boolean);
919
+ const employeeSkills: EmployeeSkillConfig[] = (subAgentEmployee.skillSettings?.skills ?? [])
920
+ .map((s: any) =>
921
+ typeof s === 'string'
922
+ ? { name: s, autoCall: false }
923
+ : { name: s?.name, autoCall: s?.autoCall === true },
924
+ )
925
+ .filter((s: EmployeeSkillConfig) => Boolean(s.name));
926
+ const employeeSkillMap = new Map(employeeSkills.map((skill) => [skill.name, skill]));
356
927
 
357
928
  const langchainTools: DynamicStructuredTool[] = [];
358
929
 
359
930
  for (const toolEntry of allTools) {
360
- const toolName = toolEntry.definition.name;
361
- if (!toolName) continue;
931
+ const entryName = toolEntry.definition.name;
932
+ if (!entryName) continue;
933
+ const employeeSkill = employeeSkillMap.get(entryName);
362
934
 
363
935
  // Only include tools that the sub-agent employee is configured to use.
364
- // Also skip our own delegate_to_* tools to prevent circular delegation
936
+ // Also skip our own orchestration tools to prevent circular delegation
365
937
  // (belt-and-suspenders with the depth check above).
366
- if (!employeeSkills.includes(toolName) || isDelegateToolName(toolName)) {
938
+ //
939
+ // Headless sub-agent execution has no human confirmation surface, so we
940
+ // require both the employee assignment and the tool definition to be
941
+ // explicitly auto-callable. This prevents ASK/interactionSchema Skill Hub
942
+ // tools from being executed silently by a delegated sub-agent.
943
+ if (
944
+ !employeeSkill ||
945
+ isDelegateToolName(plugin, entryName) ||
946
+ employeeSkill.autoCall !== true ||
947
+ toolEntry.defaultPermission !== 'ALLOW'
948
+ ) {
367
949
  continue;
368
950
  }
369
951
 
370
952
  langchainTools.push(
371
953
  new DynamicStructuredTool({
372
- name: toolName.replace(/[^a-zA-Z0-9_-]/g, '_'),
373
- description: toolEntry.definition.description || toolName,
954
+ name: entryName.replace(/[^a-zA-Z0-9_-]/g, '_'),
955
+ description: toolEntry.definition.description || entryName,
374
956
  schema: (toolEntry.definition.schema || z.object({})) as any,
375
957
  func: async (toolArgs) => {
376
958
  // Forward the invoke with depth tracking
377
959
  const invokeCtx = Object.create(ctx);
378
960
  (invokeCtx as any)[ORCHESTRATOR_DEPTH_KEY] = currentDepth + 1;
961
+ const toolStartedAt = Date.now();
962
+ const isSkillHubTool = entryName === 'skill_hub_execute' || entryName.startsWith('skill_hub_');
963
+ const toolSpan = await spanService.create({
964
+ rootRunId,
965
+ parentSpanId: executionSpanId,
966
+ type: isSkillHubTool ? 'skill' : 'tool',
967
+ status: 'running',
968
+ leaderUsername,
969
+ employeeUsername: subAgentUsername,
970
+ toolName: toolEntry.definition.name,
971
+ title: isSkillHubTool ? `Skill: ${toolEntry.definition.name}` : `Tool: ${toolEntry.definition.name}`,
972
+ input: toolArgs,
973
+ metadata: {
974
+ depth: currentDepth + 1,
975
+ toolCallId: `orch-${toolCallId}`,
976
+ defaultPermission: toolEntry.defaultPermission,
977
+ },
978
+ userId: ctxSnapshot.userId,
979
+ });
980
+ const toolSpanId = toolSpan?.id ? String(toolSpan.id) : undefined;
981
+ setOrchestratorTraceContext(invokeCtx, {
982
+ rootRunId,
983
+ spanId: toolSpanId,
984
+ parentSpanId: executionSpanId,
985
+ toolCallId: `orch-${toolCallId}`,
986
+ leaderUsername,
987
+ employeeUsername: subAgentUsername,
988
+ toolName: toolEntry.definition.name,
989
+ });
379
990
 
380
991
  trace.push({
381
992
  type: 'tool_call',
@@ -387,17 +998,26 @@ async function invokeDelegateTask(
387
998
 
388
999
  try {
389
1000
  const res = await toolEntry.invoke(invokeCtx, toolArgs, `orch-${toolCallId}`);
1001
+ const output = truncateText(res?.content ?? res?.result ?? res, 50000);
390
1002
  trace.push({
391
1003
  type: 'tool_result',
392
1004
  at: nowIso(),
393
1005
  title: `Tool finished: ${toolEntry.definition.name}`,
394
1006
  toolName: toolEntry.definition.name,
395
1007
  status: res?.status || 'success',
396
- content: truncateText(res?.content ?? res, 2000),
1008
+ content: truncateText(output, 2000),
397
1009
  });
398
1010
  if (res?.status === 'error') {
1011
+ await spanService.finish(toolSpanId, 'error', toolStartedAt, {
1012
+ output,
1013
+ error: truncateText(res.content || output, 10000),
1014
+ });
399
1015
  throw new Error(`Tool <${toolEntry.definition.name}> failed: ${res.content}`);
400
1016
  }
1017
+ await spanService.finish(toolSpanId, 'success', toolStartedAt, {
1018
+ output,
1019
+ skillExecutionId: res?.result?.execId || res?.execId,
1020
+ });
401
1021
  return typeof res?.content === 'string' ? res.content : JSON.stringify(res);
402
1022
  } catch (e: any) {
403
1023
  trace.push({
@@ -408,6 +1028,9 @@ async function invokeDelegateTask(
408
1028
  status: 'error',
409
1029
  content: e.message,
410
1030
  });
1031
+ await spanService.finish(toolSpanId, 'error', toolStartedAt, {
1032
+ error: truncateText(e.message, 10000),
1033
+ });
411
1034
  throw e;
412
1035
  }
413
1036
  },
@@ -423,23 +1046,36 @@ async function invokeDelegateTask(
423
1046
  });
424
1047
 
425
1048
  // --- Step 4: Construct messages ---
426
- const systemPrompt = subAgentEmployee.chatSettings?.systemPrompt
427
- || subAgentEmployee.bio
428
- || `You are an AI assistant named "${subAgentEmployee.nickname || subAgentUsername}". ${subAgentEmployee.about || ''}`;
1049
+ const systemPrompt =
1050
+ subAgentEmployee.chatSettings?.systemPrompt ||
1051
+ subAgentEmployee.bio ||
1052
+ `You are an AI assistant named "${subAgentEmployee.nickname || subAgentUsername}". ${
1053
+ subAgentEmployee.about || ''
1054
+ }`;
429
1055
 
430
- const combinedTask = context
431
- ? `Task: ${task}\n\nContext Provided:\n${context}`
432
- : `Task: ${task}`;
1056
+ const combinedTask = context ? `Task: ${task}\n\nContext Provided:\n${context}` : `Task: ${task}`;
433
1057
 
434
1058
  // --- Step 5: Execute with timeout + abort ---
435
1059
  // P3 FIX: AbortController signal cancels the in-flight stream on timeout,
436
1060
  // preventing continued token consumption after the timeout fires.
437
- const invokePromise = executeAgent(executor, systemPrompt, combinedTask, abortController.signal);
1061
+ const effectiveRecursionLimit =
1062
+ Number.isFinite(recursionLimit) && (recursionLimit as number) > 0 ? (recursionLimit as number) : 50;
1063
+ const invokePromise = executeAgent(
1064
+ executor,
1065
+ systemPrompt,
1066
+ combinedTask,
1067
+ abortController.signal,
1068
+ effectiveRecursionLimit,
1069
+ );
438
1070
 
439
- const result = await Promise.race([
440
- invokePromise,
441
- createTimeout(timeout, subAgentUsername, abortController),
442
- ]) as AgentExecutionResult;
1071
+ const timeoutHandle = createTimeout(timeout, subAgentUsername, abortController);
1072
+ let result: AgentExecutionResult;
1073
+ try {
1074
+ result = (await Promise.race([invokePromise, timeoutHandle.promise])) as AgentExecutionResult;
1075
+ } finally {
1076
+ // Always release the timer so it doesn't keep the event loop alive.
1077
+ timeoutHandle.cancel();
1078
+ }
443
1079
 
444
1080
  const content = result.content || 'Sub-agent completed the task but produced no output.';
445
1081
  trace.push({
@@ -464,6 +1100,18 @@ async function invokeDelegateTask(
464
1100
  durationMs: Date.now() - startTime,
465
1101
  trace,
466
1102
  messages: result.messages,
1103
+ snapshot: ctxSnapshot,
1104
+ });
1105
+ await spanService.finish(executionSpanId, 'success', startTime, {
1106
+ output: content,
1107
+ metadata: {
1108
+ depth: currentDepth,
1109
+ maxDepth,
1110
+ toolName,
1111
+ recursionLimit,
1112
+ messages: result.messages,
1113
+ traceCount: trace.length,
1114
+ },
467
1115
  });
468
1116
 
469
1117
  return {
@@ -496,9 +1144,20 @@ async function invokeDelegateTask(
496
1144
  content: e.message,
497
1145
  },
498
1146
  ],
1147
+ snapshot: ctxSnapshot,
499
1148
  }).catch((logErr) => {
500
1149
  plugin.app.log.warn('[AgentOrchestrator] Failed to save error log for delegation', logErr);
501
1150
  });
1151
+ await spanService.finish(executionSpanId, 'error', startTime, {
1152
+ error: truncateText(e.message, 10000),
1153
+ metadata: {
1154
+ depth: currentDepth,
1155
+ maxDepth,
1156
+ toolName,
1157
+ recursionLimit,
1158
+ traceCount: trace.length + 1,
1159
+ },
1160
+ });
502
1161
 
503
1162
  return {
504
1163
  status: 'error' as const,
@@ -527,6 +1186,7 @@ async function logDelegation(
527
1186
  error?: string;
528
1187
  trace?: TraceEvent[];
529
1188
  messages?: any[];
1189
+ snapshot?: CtxSnapshot;
530
1190
  },
531
1191
  ) {
532
1192
  try {
@@ -536,12 +1196,16 @@ async function logDelegation(
536
1196
  return;
537
1197
  }
538
1198
 
539
- // Safely resolve userId ctx may be stale after long-running agent execution
540
- let userId: number | undefined;
541
- try {
542
- userId = ctx.auth?.user?.id || ctx.state?.currentUser?.id;
543
- } catch {
544
- // ctx lifecycle ended — proceed without userId
1199
+ // Prefer the early snapshot captured in invokeDelegateTask by the time
1200
+ // the agent finishes, ctx may already be disposed. Fall back to ctx for
1201
+ // call sites that don't pass a snapshot (e.g. authz-failure short-circuit).
1202
+ let userId: number | undefined = data.snapshot?.userId;
1203
+ if (userId == null) {
1204
+ try {
1205
+ userId = ctx.auth?.user?.id || ctx.state?.currentUser?.id;
1206
+ } catch {
1207
+ // ctx lifecycle ended — proceed without userId
1208
+ }
545
1209
  }
546
1210
 
547
1211
  const values = {
@@ -591,8 +1255,9 @@ async function executeAgent(
591
1255
  systemPrompt: string,
592
1256
  task: string,
593
1257
  signal?: AbortSignal,
1258
+ recursionLimit = 50,
594
1259
  ): Promise<AgentExecutionResult> {
595
- const config: any = { recursionLimit: 50 };
1260
+ const config: any = { recursionLimit };
596
1261
  if (signal) {
597
1262
  config.signal = signal;
598
1263
  }
@@ -606,9 +1271,9 @@ async function executeAgent(
606
1271
 
607
1272
  // finalState.messages contains the entire conversation history of this delegation
608
1273
  const messages = finalState?.messages || [];
609
-
1274
+
610
1275
  // Find the last AI message in the chain
611
- const lastAIMessage = [...messages].reverse().find(m => m.getType() === 'ai');
1276
+ const lastAIMessage = [...messages].reverse().find((m) => m.getType() === 'ai');
612
1277
 
613
1278
  if (!lastAIMessage || !lastAIMessage.content) {
614
1279
  return { content: '', messages: serializeMessages(messages) };
@@ -618,9 +1283,7 @@ async function executeAgent(
618
1283
  if (typeof lastAIMessage.content === 'string') {
619
1284
  content = lastAIMessage.content;
620
1285
  } else if (Array.isArray(lastAIMessage.content)) {
621
- content = lastAIMessage.content
622
- .map((c: any) => c.text || JSON.stringify(c))
623
- .join('\n');
1286
+ content = lastAIMessage.content.map((c: any) => c.text || JSON.stringify(c)).join('\n');
624
1287
  } else {
625
1288
  content = String(lastAIMessage.content);
626
1289
  }
@@ -645,14 +1308,27 @@ function serializeMessages(messages: any[]) {
645
1308
  }
646
1309
 
647
1310
  /**
648
- * Create a timeout promise that rejects after the specified duration.
649
- * P3 FIX: Also triggers AbortController to cancel the in-flight stream.
1311
+ * Schedule a rejection-on-timeout that also aborts the in-flight stream.
1312
+ * Returns the promise plus a `cancel()` so callers can release the timer
1313
+ * when the race resolves successfully (otherwise the handle keeps the event
1314
+ * loop alive until `ms` elapses).
650
1315
  */
651
- function createTimeout(ms: number, agentName: string, abortController?: AbortController): Promise<never> {
652
- return new Promise((_, reject) =>
653
- setTimeout(() => {
1316
+ function createTimeout(
1317
+ ms: number,
1318
+ agentName: string,
1319
+ abortController?: AbortController,
1320
+ ): { promise: Promise<never>; cancel: () => void } {
1321
+ let timer: ReturnType<typeof setTimeout> | undefined;
1322
+ const promise = new Promise<never>((_resolve, reject) => {
1323
+ timer = setTimeout(() => {
654
1324
  abortController?.abort();
655
1325
  reject(new Error(`Sub-agent "${agentName}" timed out after ${ms / 1000}s`));
656
- }, ms),
657
- );
1326
+ }, ms);
1327
+ });
1328
+ return {
1329
+ promise,
1330
+ cancel: () => {
1331
+ if (timer) clearTimeout(timer);
1332
+ },
1333
+ };
658
1334
  }