@vellumai/assistant 0.8.12 → 0.9.0-staging.2

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 (442) hide show
  1. package/AGENTS.md +0 -14
  2. package/ARCHITECTURE.md +45 -45
  3. package/README.md +1 -1
  4. package/bun.lock +200 -154
  5. package/docs/architecture/integrations.md +3 -3
  6. package/docs/architecture/memory.md +2 -2
  7. package/docs/architecture/security.md +10 -10
  8. package/docs/runbook-trusted-contacts.md +12 -12
  9. package/docs/skills.md +6 -6
  10. package/docs/workflows-testing.md +221 -0
  11. package/docs/workflows.md +510 -0
  12. package/examples/plugins/echo/README.md +5 -5
  13. package/knip.json +2 -0
  14. package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +105 -0
  15. package/node_modules/@vellumai/gateway-client/src/index.ts +12 -0
  16. package/openapi.yaml +7197 -5708
  17. package/package.json +8 -4
  18. package/scripts/generate-openapi.ts +66 -114
  19. package/src/__tests__/access-request-seed-content-blocks.test.ts +213 -0
  20. package/src/__tests__/adaptive-thinking-repair.test.ts +32 -3
  21. package/src/__tests__/agent-loop-output-hooks.test.ts +183 -0
  22. package/src/__tests__/agent-loop-regrowth-guard.test.ts +506 -0
  23. package/src/__tests__/agent-wake-disk-pressure-callsite.test.ts +2 -0
  24. package/src/__tests__/agent-wake-override-profile.test.ts +77 -0
  25. package/src/__tests__/app-compiler.test.ts +7 -1
  26. package/src/__tests__/app-dir-path-guard.test.ts +27 -3
  27. package/src/__tests__/app-executors.test.ts +43 -0
  28. package/src/__tests__/approval-cascade.test.ts +0 -5
  29. package/src/__tests__/approval-routes-http.test.ts +91 -0
  30. package/src/__tests__/assistant-stream-state.test.ts +107 -0
  31. package/src/__tests__/browser-fill-credential.test.ts +3 -3
  32. package/src/__tests__/bundled-skill-retrieval-guard.test.ts +1 -1
  33. package/src/__tests__/compaction-events.test.ts +63 -7
  34. package/src/__tests__/compaction-trail-store.test.ts +74 -1
  35. package/src/__tests__/compaction.benchmark.test.ts +63 -41
  36. package/src/__tests__/compactor-low-watermark-cut.test.ts +349 -0
  37. package/src/__tests__/context-window-manager-compact-retry.test.ts +64 -0
  38. package/src/__tests__/conversation-abort-tool-results.test.ts +0 -5
  39. package/src/__tests__/conversation-confirmation-signals.test.ts +0 -5
  40. package/src/__tests__/conversation-history-web-search.test.ts +7 -0
  41. package/src/__tests__/conversation-process-callsite.test.ts +0 -5
  42. package/src/__tests__/conversation-provider-retry-repair.test.ts +0 -5
  43. package/src/__tests__/conversation-queue.test.ts +0 -5
  44. package/src/__tests__/conversation-slash-queue.test.ts +0 -5
  45. package/src/__tests__/conversation-slash-unknown.test.ts +0 -5
  46. package/src/__tests__/conversation-speed-override.test.ts +0 -5
  47. package/src/__tests__/conversation-surfaces-data-persist.test.ts +97 -0
  48. package/src/__tests__/conversation-surfaces-task-progress.test.ts +67 -0
  49. package/src/__tests__/conversation-usage.test.ts +2 -0
  50. package/src/__tests__/conversation-workspace-injection.test.ts +0 -5
  51. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +0 -5
  52. package/src/__tests__/credential-broker-browser-fill.test.ts +2 -2
  53. package/src/__tests__/credential-broker-server-use.test.ts +2 -2
  54. package/src/__tests__/credential-broker.test.ts +1 -1
  55. package/src/__tests__/credential-prompt-route.test.ts +417 -0
  56. package/src/__tests__/credential-security-invariants.test.ts +1 -0
  57. package/src/__tests__/credential-vault.test.ts +37 -0
  58. package/src/__tests__/db-schedule-syntax-migration.test.ts +24 -0
  59. package/src/__tests__/dynamic-page-surface.test.ts +219 -0
  60. package/src/__tests__/empty-state-greeting-cache.test.ts +94 -0
  61. package/src/__tests__/gateway-flag-listener.test.ts +24 -7
  62. package/src/__tests__/guardian-action-sweep.test.ts +56 -219
  63. package/src/__tests__/guardian-routing-invariants.test.ts +138 -0
  64. package/src/__tests__/helpers/channel-test-adapter.ts +0 -2
  65. package/src/__tests__/list-messages-hidden-metadata.test.ts +99 -0
  66. package/src/__tests__/llm-request-log-source-clickhouse.test.ts +87 -1
  67. package/src/__tests__/llm-resolver.test.ts +115 -0
  68. package/src/__tests__/managed-profile-guard.test.ts +6 -5
  69. package/src/__tests__/max-tokens-continue-hook.test.ts +184 -0
  70. package/src/__tests__/media-generate-image.test.ts +20 -9
  71. package/src/__tests__/mock-gateway-ipc.ts +23 -0
  72. package/src/__tests__/model-intents.test.ts +1 -1
  73. package/src/__tests__/normalize-onboarding.test.ts +26 -0
  74. package/src/__tests__/notification-decision-strategy.test.ts +4 -2
  75. package/src/__tests__/notification-telegram-adapter.test.ts +21 -3
  76. package/src/__tests__/pending-interactions-resolved-event.test.ts +62 -0
  77. package/src/__tests__/post-turn-tool-result-truncation.test.ts +72 -18
  78. package/src/__tests__/require-fresh-approval.test.ts +425 -1
  79. package/src/__tests__/resolve-app-id.test.ts +56 -0
  80. package/src/__tests__/runtime-events-sse-parity.test.ts +2 -0
  81. package/src/__tests__/schedule-routes-workflow-validation.test.ts +408 -0
  82. package/src/__tests__/schedule-routes.test.ts +257 -4
  83. package/src/__tests__/schedule-store.test.ts +60 -0
  84. package/src/__tests__/schedule-tools.test.ts +247 -2
  85. package/src/__tests__/skill-execute-input.test.ts +85 -0
  86. package/src/__tests__/skill-secret-handling-guard.test.ts +21 -20
  87. package/src/__tests__/skills.test.ts +3 -3
  88. package/src/__tests__/slack-app-setup-skill-regression.test.ts +1 -1
  89. package/src/__tests__/subagent-tool-filtering.test.ts +50 -0
  90. package/src/__tests__/subagent-tool-gate-mode.test.ts +547 -0
  91. package/src/__tests__/system-prompt.test.ts +1 -1
  92. package/src/__tests__/task-progress-nudge-hook.test.ts +372 -0
  93. package/src/__tests__/task-scheduler.test.ts +299 -0
  94. package/src/__tests__/tool-approval-seed-content-blocks.test.ts +209 -0
  95. package/src/__tests__/tool-result-spool.test.ts +3 -1
  96. package/src/__tests__/workspace-migration-102-preserve-heartbeat-enabled-for-existing-workspaces.test.ts +181 -0
  97. package/src/__tests__/workspace-migration-103-upgrade-quality-profile-to-opus-4-8.test.ts +174 -0
  98. package/src/agent/compaction-circuit.ts +11 -0
  99. package/src/agent/loop.ts +181 -12
  100. package/src/api/constants/call-sites.ts +12 -0
  101. package/src/api/events/assistant-thinking-delta.ts +10 -0
  102. package/src/api/events/usage-update.ts +7 -0
  103. package/src/api/index.ts +4 -1
  104. package/src/api/responses/memory-v3-selection-log.ts +18 -11
  105. package/src/approvals/approval-primitive.ts +2 -2
  106. package/src/background-wake/background-wake-routes.test.ts +5 -2
  107. package/src/bundler/compiler-tools.ts +1 -1
  108. package/src/bundler/package-resolver.ts +0 -1
  109. package/src/calls/call-domain.ts +1 -1
  110. package/src/calls/guardian-action-sweep.ts +16 -93
  111. package/src/cli/AGENTS.md +4 -0
  112. package/src/cli/commands/__tests__/schedules.test.ts +430 -1
  113. package/src/cli/commands/credentials.ts +28 -24
  114. package/src/cli/commands/image-generation.ts +23 -9
  115. package/src/cli/commands/notifications.ts +1 -1
  116. package/src/cli/commands/plugins.ts +89 -46
  117. package/src/cli/commands/schedules.ts +384 -11
  118. package/src/cli/lib/__tests__/inspect-plugin.test.ts +69 -5
  119. package/src/cli/lib/__tests__/install-from-github.test.ts +15 -0
  120. package/src/cli/lib/__tests__/upgrade-plugin.test.ts +81 -4
  121. package/src/cli/lib/inspect-plugin.ts +62 -1
  122. package/src/cli/lib/install-from-github.ts +52 -5
  123. package/src/cli/lib/upgrade-plugin.ts +18 -0
  124. package/src/config/__tests__/workflows-schema.test.ts +60 -0
  125. package/src/config/bundled-skills/acp/SKILL.md +2 -2
  126. package/src/config/bundled-skills/app-builder/SKILL.md +1 -1
  127. package/src/config/bundled-skills/app-builder/tools/app-create.ts +6 -1
  128. package/src/config/bundled-skills/app-builder/tools/app-generate-icon.ts +7 -1
  129. package/src/config/bundled-skills/app-builder/tools/app-refresh.ts +7 -1
  130. package/src/config/bundled-skills/app-builder/tools/app-update.ts +10 -1
  131. package/src/config/bundled-skills/image-studio/SKILL.md +66 -19
  132. package/src/config/bundled-skills/image-studio/TOOLS.json +1 -6
  133. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +22 -3
  134. package/src/config/bundled-skills/personal-page/SKILL.md +57 -0
  135. package/src/config/bundled-skills/personal-page/TOOLS.json +27 -0
  136. package/src/config/bundled-skills/personal-page/tools/app-refresh.ts +17 -0
  137. package/src/config/bundled-skills/schedule/SKILL.md +7 -2
  138. package/src/config/bundled-skills/schedule/TOOLS.json +48 -4
  139. package/src/config/bundled-skills/workflows/SKILL.md +214 -0
  140. package/src/config/bundled-skills/workflows/TOOLS.json +84 -0
  141. package/src/config/bundled-skills/workflows/tools/manage-workflows.ts +12 -0
  142. package/src/config/bundled-skills/workflows/tools/run-workflow.ts +12 -0
  143. package/src/config/bundled-tool-registry.ts +14 -2
  144. package/src/config/call-site-defaults.ts +5 -0
  145. package/src/config/feature-flag-registry.json +12 -4
  146. package/src/config/llm-context-resolution.ts +8 -0
  147. package/src/config/llm-resolver.ts +30 -0
  148. package/src/config/preloaded-apps/personal-page/src/components/About.tsx +22 -0
  149. package/src/config/preloaded-apps/personal-page/src/components/App.tsx +16 -0
  150. package/src/config/preloaded-apps/personal-page/src/components/Features.tsx +77 -0
  151. package/src/config/preloaded-apps/personal-page/src/components/Hero.tsx +57 -0
  152. package/src/config/preloaded-apps/personal-page/src/components/Pending.tsx +28 -0
  153. package/src/config/preloaded-apps/personal-page/src/components/animations.tsx +234 -0
  154. package/src/config/preloaded-apps/personal-page/src/components/icons.tsx +48 -0
  155. package/src/config/preloaded-apps/personal-page/src/components/media.ts +16 -0
  156. package/src/config/preloaded-apps/personal-page/src/index.html +20 -0
  157. package/src/config/preloaded-apps/personal-page/src/main.tsx +7 -0
  158. package/src/config/preloaded-apps/personal-page/src/profile-data.ts +82 -0
  159. package/src/config/preloaded-apps/personal-page/src/styles.css +759 -0
  160. package/src/config/schema.ts +2 -0
  161. package/src/config/schemas/call-site-catalog.ts +7 -0
  162. package/src/config/schemas/heartbeat.ts +4 -1
  163. package/src/config/schemas/llm.ts +33 -26
  164. package/src/config/schemas/memory-retrospective.ts +19 -0
  165. package/src/config/schemas/platform.ts +8 -0
  166. package/src/config/schemas/services.ts +5 -2
  167. package/src/config/schemas/workflows.ts +42 -0
  168. package/src/config/skills.ts +3 -3
  169. package/src/context/compactor.ts +273 -39
  170. package/src/context/post-turn-tool-result-truncation.ts +23 -6
  171. package/src/context/tool-result-spool.ts +12 -17
  172. package/src/credential-execution/executable-discovery.ts +1 -1
  173. package/src/credential-execution/process-manager.ts +37 -3
  174. package/src/credential-execution/prompted-credential.ts +205 -0
  175. package/src/daemon/conversation-agent-loop-handlers.ts +14 -0
  176. package/src/daemon/conversation-process.ts +11 -2
  177. package/src/daemon/conversation-surfaces.ts +167 -3
  178. package/src/daemon/conversation-tool-setup.ts +103 -26
  179. package/src/daemon/conversation-usage.ts +2 -0
  180. package/src/daemon/conversation.ts +115 -11
  181. package/src/daemon/handlers/shared.ts +26 -14
  182. package/src/daemon/host-cu-proxy.ts +15 -12
  183. package/src/daemon/host-file-proxy.ts +15 -12
  184. package/src/daemon/host-transfer-proxy.ts +30 -24
  185. package/src/daemon/lifecycle.ts +40 -3
  186. package/src/daemon/message-protocol.ts +3 -0
  187. package/src/daemon/message-types/messages.ts +2 -10
  188. package/src/daemon/message-types/workflows.ts +49 -0
  189. package/src/daemon/parse-actual-tokens-from-error.test.ts +62 -1
  190. package/src/daemon/parse-actual-tokens-from-error.ts +43 -4
  191. package/src/daemon/process-message.ts +6 -0
  192. package/src/daemon/tool-setup-types.ts +57 -0
  193. package/src/daemon/wake-conversation-ops.ts +18 -0
  194. package/src/heartbeat/heartbeat-run-store.ts +8 -2
  195. package/src/home/feed-types.ts +1 -1
  196. package/src/ipc/gateway-flag-listener.ts +28 -6
  197. package/src/mcp/mcp-auth-state.ts +8 -20
  198. package/src/media/__tests__/image-models.test.ts +57 -0
  199. package/src/media/image-models.ts +66 -0
  200. package/src/memory/__tests__/auto-analysis-enqueue.test.ts +38 -0
  201. package/src/memory/__tests__/find-most-recent-retrospective-for.test.ts +12 -2
  202. package/src/memory/__tests__/memory-retrospective-job.test.ts +911 -34
  203. package/src/memory/__tests__/memory-retrospective-startup-cleanup.test.ts +227 -5
  204. package/src/memory/__tests__/memory-retrospective-state.test.ts +195 -0
  205. package/src/memory/__tests__/preloaded-apps.test.ts +85 -0
  206. package/src/memory/auto-analysis-enqueue.ts +14 -1
  207. package/src/memory/compaction-log-store-clickhouse.ts +6 -4
  208. package/src/memory/conversation-crud.ts +9 -2
  209. package/src/memory/conversation-disk-view.ts +1 -1
  210. package/src/memory/conversation-queries.ts +22 -7
  211. package/src/memory/db-init.ts +20 -0
  212. package/src/memory/db-maintenance.ts +16 -0
  213. package/src/memory/embedding-runtime-manager.ts +1 -1
  214. package/src/memory/llm-request-log-source-clickhouse.ts +112 -14
  215. package/src/memory/llm-request-log-source-local.ts +19 -1
  216. package/src/memory/llm-request-log-source.ts +35 -6
  217. package/src/memory/llm-request-log-store.ts +90 -2
  218. package/src/memory/memory-retrospective-constants.ts +9 -0
  219. package/src/memory/memory-retrospective-enqueue.ts +3 -6
  220. package/src/memory/memory-retrospective-fork-boundary.ts +94 -0
  221. package/src/memory/memory-retrospective-job.ts +500 -208
  222. package/src/memory/memory-retrospective-startup-cleanup.ts +97 -19
  223. package/src/memory/memory-retrospective-state.ts +85 -2
  224. package/src/memory/migrations/281-memory-retrospective-remembered-log.ts +40 -0
  225. package/src/memory/migrations/282-schedule-inference-profile.test.ts +77 -0
  226. package/src/memory/migrations/282-schedule-inference-profile.ts +26 -0
  227. package/src/memory/migrations/283-memory-v3-selections-message-id-and-sections.test.ts +102 -0
  228. package/src/memory/migrations/283-memory-v3-selections-message-id-and-sections.ts +53 -0
  229. package/src/memory/migrations/284-workflow-runs.ts +51 -0
  230. package/src/memory/migrations/285-schedule-workflow-mode.ts +26 -0
  231. package/src/memory/migrations/286-workflow-run-trust.ts +27 -0
  232. package/src/memory/migrations/287-conversation-origin-channel-index.ts +15 -0
  233. package/src/memory/migrations/288-backfill-origin-channel-from-bindings.ts +43 -0
  234. package/src/memory/migrations/289-contact-channels-unique-ext-user.ts +115 -0
  235. package/src/memory/migrations/290-schedule-capabilities.test.ts +77 -0
  236. package/src/memory/migrations/290-schedule-capabilities.ts +25 -0
  237. package/src/memory/migrations/__tests__/281-memory-retrospective-remembered-log.test.ts +96 -0
  238. package/src/memory/migrations/__tests__/289-contact-channels-unique-ext-user.test.ts +571 -0
  239. package/src/memory/migrations/index.ts +10 -0
  240. package/src/memory/preloaded-apps.ts +116 -0
  241. package/src/memory/schema/infrastructure.ts +4 -0
  242. package/src/memory/schema/memory-core.ts +4 -0
  243. package/src/memory/v2/__tests__/concept-page-frontmatter-schema.test.ts +45 -0
  244. package/src/memory/v2/__tests__/frontmatter-sweep.test.ts +11 -7
  245. package/src/memory/v2/__tests__/page-store.test.ts +13 -2
  246. package/src/memory/v2/__tests__/qdrant.test.ts +24 -0
  247. package/src/memory/v2/frontmatter-sweep.ts +7 -6
  248. package/src/memory/v2/page-store.ts +4 -3
  249. package/src/memory/v2/qdrant.ts +42 -3
  250. package/src/memory/v2/types.ts +16 -10
  251. package/src/messaging/draft-store.ts +1 -1
  252. package/src/notifications/access-request-copy.ts +200 -113
  253. package/src/notifications/adapters/slack.ts +250 -111
  254. package/src/notifications/adapters/telegram.ts +7 -44
  255. package/src/notifications/approval-card-builder.ts +93 -0
  256. package/src/notifications/broadcaster.ts +74 -0
  257. package/src/notifications/conversation-pairing.ts +8 -6
  258. package/src/notifications/copy-composer.ts +32 -26
  259. package/src/notifications/decision-engine.ts +59 -7
  260. package/src/notifications/guardian-question-mode.ts +145 -155
  261. package/src/notifications/home-feed-side-effect.ts +28 -11
  262. package/src/notifications/notification-utils.ts +66 -0
  263. package/src/notifications/signal.ts +6 -0
  264. package/src/notifications/tool-approval-copy.ts +142 -0
  265. package/src/notifications/types.ts +19 -0
  266. package/src/permissions/threshold.ts +11 -0
  267. package/src/plugin-api/types.ts +16 -4
  268. package/src/plugins/defaults/compaction/window-manager.ts +44 -0
  269. package/src/plugins/defaults/index.ts +46 -0
  270. package/src/plugins/defaults/max-tokens-continue/continue-state-store.ts +53 -0
  271. package/src/plugins/defaults/max-tokens-continue/hooks/post-model-call.ts +80 -0
  272. package/src/plugins/defaults/max-tokens-continue/hooks/stop.ts +20 -0
  273. package/src/plugins/defaults/max-tokens-continue/package.json +14 -0
  274. package/src/plugins/defaults/memory-retrieval/hooks/__tests__/user-prompt-submit.test.ts +37 -0
  275. package/src/plugins/defaults/memory-retrieval/hooks/user-prompt-submit.ts +29 -1
  276. package/src/plugins/defaults/memory-v3-shadow/__tests__/carry-integration.test.ts +8 -3
  277. package/src/plugins/defaults/memory-v3-shadow/__tests__/injection.test.ts +4 -2
  278. package/src/plugins/defaults/memory-v3-shadow/__tests__/pool-select.test.ts +28 -18
  279. package/src/plugins/defaults/memory-v3-shadow/__tests__/section-dense-store.test.ts +67 -0
  280. package/src/plugins/defaults/memory-v3-shadow/__tests__/selection-log-store.test.ts +122 -22
  281. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-integration.test.ts +2 -0
  282. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-plugin.test.ts +63 -1
  283. package/src/plugins/defaults/memory-v3-shadow/injector.ts +61 -18
  284. package/src/plugins/defaults/memory-v3-shadow/orchestrate.ts +1 -1
  285. package/src/plugins/defaults/memory-v3-shadow/pool-select.ts +39 -10
  286. package/src/plugins/defaults/memory-v3-shadow/section-dense-store.ts +34 -1
  287. package/src/plugins/defaults/memory-v3-shadow/selection-log-store.ts +112 -47
  288. package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +78 -15
  289. package/src/plugins/defaults/task-progress-nudge/hooks/post-tool-use.ts +206 -0
  290. package/src/plugins/defaults/task-progress-nudge/package.json +15 -0
  291. package/src/prompts/__tests__/system-prompt.test.ts +100 -1
  292. package/src/prompts/__tests__/task-progress-hint-section.test.ts +5 -7
  293. package/src/prompts/normalize-onboarding.ts +2 -0
  294. package/src/prompts/persona-resolver.ts +3 -0
  295. package/src/prompts/system-prompt.ts +51 -2
  296. package/src/prompts/templates/BOOTSTRAP-ACTIVATION-RAIL.md +3 -1
  297. package/src/prompts/templates/system-sections.ts +8 -3
  298. package/src/providers/call-site-routing.ts +6 -3
  299. package/src/providers/fireworks/client.ts +3 -0
  300. package/src/providers/inference/auth.ts +52 -46
  301. package/src/providers/model-intents.ts +2 -2
  302. package/src/providers/openai/__tests__/coerce-object-args.test.ts +105 -0
  303. package/src/providers/openai/chat-completions-provider.ts +47 -9
  304. package/src/providers/openai/coerce-object-args.ts +104 -0
  305. package/src/providers/retry.ts +8 -5
  306. package/src/providers/types.ts +10 -0
  307. package/src/runtime/__tests__/agent-wake.test.ts +629 -7
  308. package/src/runtime/access-request-helper.ts +16 -9
  309. package/src/runtime/agent-wake.ts +302 -51
  310. package/src/runtime/assistant-stream-state.ts +141 -8
  311. package/src/runtime/background-job-runner.ts +9 -0
  312. package/src/runtime/channel-approval-types.ts +1 -0
  313. package/src/runtime/channel-invite-transports/telegram.ts +6 -5
  314. package/src/runtime/channel-invite-transports/voice.ts +2 -2
  315. package/src/runtime/channel-invite-types.ts +4 -2
  316. package/src/runtime/channel-retry-sweep.ts +19 -41
  317. package/src/runtime/finalize-event-delivery.ts +72 -0
  318. package/src/runtime/guardian-action-message-composer.ts +0 -54
  319. package/src/runtime/http-server.ts +6 -14
  320. package/src/runtime/http-types.ts +0 -1
  321. package/src/runtime/message-composer-types.ts +0 -9
  322. package/src/runtime/middleware/__tests__/rate-limiter.test.ts +63 -0
  323. package/src/runtime/middleware/auth.ts +27 -3
  324. package/src/runtime/middleware/rate-limiter.ts +28 -1
  325. package/src/runtime/migrations/vbundle-builder.ts +6 -5
  326. package/src/runtime/pending-interactions.ts +20 -1
  327. package/src/runtime/routes/__tests__/conversation-compaction-routes.test.ts +232 -173
  328. package/src/runtime/routes/__tests__/plugins-routes.test.ts +18 -0
  329. package/src/runtime/routes/__tests__/retrospective-routes.test.ts +436 -0
  330. package/src/runtime/routes/__tests__/surface-action-routes.test.ts +11 -0
  331. package/src/runtime/routes/approval-routes.ts +35 -8
  332. package/src/runtime/routes/approval-strategies/guardian-callback-strategy.ts +2 -1
  333. package/src/runtime/routes/btw-routes.ts +37 -1
  334. package/src/runtime/routes/channel-delivery-routes.ts +11 -7
  335. package/src/runtime/routes/channel-route-definitions.ts +3 -0
  336. package/src/runtime/routes/channel-route-shared.ts +3 -1
  337. package/src/runtime/routes/consolidation-routes.ts +17 -13
  338. package/src/runtime/routes/conversation-compaction-routes.ts +159 -119
  339. package/src/runtime/routes/conversation-list-routes.ts +41 -4
  340. package/src/runtime/routes/conversation-query-routes.ts +196 -11
  341. package/src/runtime/routes/conversation-routes.ts +15 -1
  342. package/src/runtime/routes/credential-prompt-routes.ts +39 -17
  343. package/src/runtime/routes/empty-state-greeting-cache.ts +65 -0
  344. package/src/runtime/routes/heartbeat-routes.ts +18 -13
  345. package/src/runtime/routes/image-generation-routes.ts +20 -2
  346. package/src/runtime/routes/inbound-message-handler.ts +13 -12
  347. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +32 -31
  348. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +7 -5
  349. package/src/runtime/routes/inbound-stages/background-dispatch.ts +7 -21
  350. package/src/runtime/routes/inbound-stages/escalation-intercept.ts +5 -5
  351. package/src/runtime/routes/inbound-stages/guardian-activation-intercept.ts +6 -15
  352. package/src/runtime/routes/inbound-stages/secret-ingress-check.ts +1 -1
  353. package/src/runtime/routes/index.ts +6 -0
  354. package/src/runtime/routes/log-export/AGENTS.md +1 -1
  355. package/src/runtime/routes/log-export/workspace-allowlist.ts +1 -1
  356. package/src/runtime/routes/migration-routes.ts +5 -9
  357. package/src/runtime/routes/plugins-routes.ts +26 -0
  358. package/src/runtime/routes/ps-routes.ts +10 -8
  359. package/src/runtime/routes/retrospective-routes.ts +235 -0
  360. package/src/runtime/routes/runs-pagination.ts +75 -0
  361. package/src/runtime/routes/schedule-routes.ts +367 -59
  362. package/src/runtime/routes/sounds-config-routes.ts +239 -0
  363. package/src/runtime/routes/surface-action-routes.ts +84 -4
  364. package/src/runtime/routes/workflow-routes.test.ts +372 -0
  365. package/src/runtime/routes/workflow-routes.ts +363 -0
  366. package/src/runtime/routes/workspace-routes.test.ts +61 -1
  367. package/src/runtime/routes/workspace-routes.ts +26 -1
  368. package/src/runtime/services/__tests__/analyze-conversation.test.ts +38 -0
  369. package/src/runtime/services/analyze-conversation.ts +26 -13
  370. package/src/schedule/inference-profile.ts +28 -0
  371. package/src/schedule/schedule-store.ts +152 -4
  372. package/src/schedule/scheduler-types.ts +6 -0
  373. package/src/schedule/scheduler.ts +96 -0
  374. package/src/security/secret-allowlist.ts +1 -1
  375. package/src/skills/path-classifier.ts +1 -1
  376. package/src/tools/apps/executors.ts +23 -0
  377. package/src/tools/apps/resolve-app-id.ts +42 -0
  378. package/src/tools/browser/browser-execution.ts +9 -11
  379. package/src/tools/credentials/broker.ts +4 -4
  380. package/src/tools/credentials/vault.ts +26 -137
  381. package/src/tools/executor.ts +69 -0
  382. package/src/tools/flag-gated-tools.test.ts +76 -0
  383. package/src/tools/permission-checker.ts +8 -1
  384. package/src/tools/registry.ts +51 -0
  385. package/src/tools/schedule/create.ts +77 -1
  386. package/src/tools/schedule/list.ts +1 -0
  387. package/src/tools/schedule/update.ts +73 -1
  388. package/src/tools/skills/execute.ts +56 -0
  389. package/src/tools/terminal/shell.ts +1 -1
  390. package/src/tools/ui-surface/definitions.ts +91 -2
  391. package/src/tools/workflows/manage-workflows.ts +183 -0
  392. package/src/tools/workflows/run-workflow.test.ts +442 -0
  393. package/src/tools/workflows/run-workflow.ts +88 -0
  394. package/src/types/onboarding-context.ts +2 -0
  395. package/src/usage/attribution.ts +24 -0
  396. package/src/util/canonicalize-identity.ts +12 -3
  397. package/src/util/platform.ts +17 -17
  398. package/src/watcher/__tests__/engine.test.ts +24 -0
  399. package/src/watcher/__tests__/telemetry.test.ts +135 -0
  400. package/src/watcher/engine.ts +7 -0
  401. package/src/watcher/telemetry.ts +74 -0
  402. package/src/workflows/capabilities.test.ts +365 -0
  403. package/src/workflows/capabilities.ts +359 -0
  404. package/src/workflows/deterministic-stringify.ts +27 -0
  405. package/src/workflows/engine-integration.test.ts +656 -0
  406. package/src/workflows/engine.test.ts +1144 -0
  407. package/src/workflows/engine.ts +1078 -0
  408. package/src/workflows/fanout-load.test.ts +168 -0
  409. package/src/workflows/journal-store.test.ts +369 -0
  410. package/src/workflows/journal-store.ts +470 -0
  411. package/src/workflows/leaf-runner.test.ts +704 -0
  412. package/src/workflows/leaf-runner.ts +589 -0
  413. package/src/workflows/library.test.ts +134 -0
  414. package/src/workflows/library.ts +124 -0
  415. package/src/workflows/run-manager.test.ts +711 -0
  416. package/src/workflows/run-manager.ts +593 -0
  417. package/src/workflows/sandbox-escape.test.ts +339 -0
  418. package/src/workflows/sandbox.test.ts +251 -0
  419. package/src/workflows/sandbox.ts +447 -0
  420. package/src/workspace/adaptive-thinking-repair.ts +33 -11
  421. package/src/workspace/migrations/021-move-signals-to-workspace.ts +1 -1
  422. package/src/workspace/migrations/022-move-hooks-to-workspace.ts +1 -1
  423. package/src/workspace/migrations/026-backfill-install-meta.ts +1 -1
  424. package/src/workspace/migrations/030-seed-pkb-autoinject.ts +2 -1
  425. package/src/workspace/migrations/031-drop-user-md.ts +1 -4
  426. package/src/workspace/migrations/048-remove-workspace-hooks.ts +1 -1
  427. package/src/workspace/migrations/056-release-notes-inference-profile-reordering.ts +5 -2
  428. package/src/workspace/migrations/061-move-backup-key-to-workspace.ts +1 -1
  429. package/src/workspace/migrations/082-backfill-managed-profile-labels.ts +8 -2
  430. package/src/workspace/migrations/097-enable-adaptive-thinking-managed-profiles.ts +41 -14
  431. package/src/workspace/migrations/102-preserve-heartbeat-enabled-for-existing-workspaces.ts +69 -0
  432. package/src/workspace/migrations/103-upgrade-quality-profile-to-opus-4-8.ts +83 -0
  433. package/src/workspace/migrations/104-recheck-adaptive-thinking-model-implied-anthropic.ts +133 -0
  434. package/src/workspace/migrations/registry.ts +6 -0
  435. package/src/workspace/migrations/runner.ts +1 -1
  436. package/tsconfig.json +1 -1
  437. package/src/__tests__/guardian-action-copy-generator.test.ts +0 -200
  438. package/src/__tests__/guardian-action-grant-mint-consume.test.ts +0 -579
  439. package/src/__tests__/guardian-action-store.test.ts +0 -106
  440. package/src/daemon/guardian-action-generators.ts +0 -71
  441. package/src/memory/guardian-action-store.ts +0 -484
  442. package/src/runtime/guardian-action-grant-minter.ts +0 -150
@@ -0,0 +1,1078 @@
1
+ /**
2
+ * Workflow orchestration engine — `executeWorkflow`.
3
+ *
4
+ * Runs an assistant-authored workflow script in the QuickJS sandbox
5
+ * ({@link createWorkflowSandbox}) and fans it out to many parallel ephemeral
6
+ * leaf agents ({@link runLeaf}). The engine ties together four already-merged
7
+ * building blocks:
8
+ *
9
+ * - **sandbox** (`sandbox.ts`) — runs the script SYNCHRONOUSLY. Host functions
10
+ * are asyncified: a host fn may return a promise, the VM suspends until it
11
+ * settles, and the script gets the value back directly (authors write
12
+ * `const r = agent(...)`, never `await agent(...)`). The VM is single-
13
+ * threaded: it is never inside two script callbacks at once, and a host
14
+ * function CANNOT synchronously re-enter the VM to invoke a script callback
15
+ * while it is itself suspended in asyncify. This is why `map`/`pipeline` are
16
+ * JS prelude helpers (built on `parallel` purely in the VM), not host fns.
17
+ * - **journal-store** (`journal-store.ts`) — `(runId, seq)` append-only log of
18
+ * every leaf call, for crash-resume replay.
19
+ * - **capabilities** (`capabilities.ts`) — the resolved tools/persona/
20
+ * host-function grants for the run (the single consent point).
21
+ * - **leaf-runner** (`leaf-runner.ts`) — the single-leaf primitive. Injected as
22
+ * a dependency so tests can pass a fake.
23
+ *
24
+ * ### Host API exposed to the script (synchronous from the script's view)
25
+ *
26
+ * - `agent(prompt, opts?) -> result` — runs ONE leaf and returns its output.
27
+ * - `leaf(prompt, opts?) -> Spec` — a tagged descriptor (runs nothing); used
28
+ * inside `parallel`/`map` fan-out callbacks.
29
+ * - `parallel(specs) -> results[]` — runs the specs concurrently, capped at
30
+ * `config.maxConcurrentLeaves`, results in spec-array order; a failed leaf
31
+ * yields `null` (never throws). The core fan-out primitive.
32
+ * - `map(items, build) -> results[]` and
33
+ * `pipeline(items, ...stages) -> results[]` — JS prelude helpers over
34
+ * `parallel`. `pipeline` has a PER-STAGE BARRIER in v1: all of stage N
35
+ * completes before stage N+1 is built (the single-threaded VM cannot stream
36
+ * across stages).
37
+ * - `phase(title)`, `log(msg)` — forwarded to `onProgress`.
38
+ * - `args` — the verbatim run input.
39
+ * - `usage() -> { agentsSpawned, inputTokens, outputTokens }` — a read-only
40
+ * snapshot so scripts can self-moderate.
41
+ *
42
+ * Declared host functions from the manifest are injected by name; undeclared
43
+ * ones are absent.
44
+ *
45
+ * ### Determinism & resume
46
+ *
47
+ * Each leaf call is assigned a `seq` from a monotonic counter incremented in
48
+ * deterministic call order. For `parallel`, seqs are assigned across the spec
49
+ * array in array order BEFORE any concurrency is launched, so completion order
50
+ * cannot perturb seq. The `call_hash = sha256(deterministicStringify({ prompt,
51
+ * opts }))`. On resume, a journal entry whose `(runId, seq)` is present and
52
+ * whose `call_hash` matches is replayed from cache WITHOUT calling the leaf
53
+ * runner (longest-unchanged-prefix replay).
54
+ */
55
+
56
+ import { createHash } from "node:crypto";
57
+
58
+ import type { WorkflowsConfig } from "../config/schemas/workflows.js";
59
+ import type { TrustContext } from "../daemon/trust-context.js";
60
+ import { getLogger } from "../util/logger.js";
61
+ import type { ResolvedCapabilities } from "./capabilities.js";
62
+ import { deterministicStringify } from "./deterministic-stringify.js";
63
+ import type * as JournalStore from "./journal-store.js";
64
+ import type { WorkflowRunStatus } from "./journal-store.js";
65
+ import type { runLeaf } from "./leaf-runner.js";
66
+ import * as library from "./library.js";
67
+ import { createWorkflowSandbox, WorkflowScriptError } from "./sandbox.js";
68
+
69
+ const log = getLogger("workflow-engine");
70
+
71
+ /** A progress event forwarded from the script's `phase`/`log` host calls. */
72
+ export type WorkflowProgressEvent =
73
+ | { type: "phase"; title: string }
74
+ | { type: "log"; message: string };
75
+
76
+ /** The journal-store surface the engine depends on (injectable for tests). */
77
+ export interface WorkflowJournal {
78
+ appendJournalEntry: typeof JournalStore.appendJournalEntry;
79
+ getJournalEntry: typeof JournalStore.getJournalEntry;
80
+ getRun: typeof JournalStore.getRun;
81
+ createRun: typeof JournalStore.createRun;
82
+ updateRun: typeof JournalStore.updateRun;
83
+ finishRun: typeof JournalStore.finishRun;
84
+ }
85
+
86
+ /** Static `meta` extracted from a workflow script. */
87
+ export interface WorkflowMeta {
88
+ name: string;
89
+ description: string;
90
+ }
91
+
92
+ export interface ExecuteWorkflowOptions {
93
+ /** Stable run id; also the journal/resume key. */
94
+ runId: string;
95
+ /** The workflow script source (JS or TS). */
96
+ scriptSource: string;
97
+ /** Verbatim run input, exposed to the script as `args`. */
98
+ args: unknown;
99
+ /** Resolved capabilities (tools/persona/host fns) — the single consent point. */
100
+ capabilities: ResolvedCapabilities;
101
+ /** Engine caps and concurrency knobs. */
102
+ config: WorkflowsConfig;
103
+ /** Journal-store functions (injected so tests use a real store + temp DB). */
104
+ journal: WorkflowJournal;
105
+ /** Leaf runner (injected so tests pass a fake — no real provider call). */
106
+ leafRunner: typeof runLeaf;
107
+ /** Trust/auth context forwarded to every leaf. */
108
+ trustContext: TrustContext;
109
+ /** Receives `phase`/`log` progress events from the script. */
110
+ onProgress?: (event: WorkflowProgressEvent) => void;
111
+ /** Cooperative cancellation for the whole run. */
112
+ signal?: AbortSignal;
113
+ }
114
+
115
+ export interface ExecuteWorkflowResult {
116
+ status: WorkflowRunStatus;
117
+ result: unknown;
118
+ agentsSpawned: number;
119
+ inputTokens: number;
120
+ outputTokens: number;
121
+ }
122
+
123
+ /** Options passed by a script to `agent(...)` / `leaf(...)`. */
124
+ interface LeafCallOptions {
125
+ schema?: unknown;
126
+ label?: string;
127
+ profile?: string;
128
+ persona?: boolean;
129
+ phase?: string;
130
+ }
131
+
132
+ /** Tagged descriptor returned by `leaf(...)` — runs nothing on its own. */
133
+ interface LeafSpec {
134
+ __workflowSpec: true;
135
+ prompt: string;
136
+ opts: LeafCallOptions;
137
+ }
138
+
139
+ /** Sentinel thrown internally to unwind the script when the agent cap trips. */
140
+ class CapExceededSignal extends Error {
141
+ constructor() {
142
+ super("Workflow agent cap exceeded");
143
+ this.name = "CapExceededSignal";
144
+ }
145
+ }
146
+
147
+ /** Sentinel thrown internally to unwind the script on abort. */
148
+ class AbortedSignal extends Error {
149
+ constructor() {
150
+ super("Workflow aborted");
151
+ this.name = "AbortedSignal";
152
+ }
153
+ }
154
+
155
+ /**
156
+ * True if `err` is a cancellation rejection — the engine's own
157
+ * {@link AbortedSignal}, or the canonical `AbortError` a fetch/provider/
158
+ * agent-loop call rejects with when its `AbortSignal` fires (both `DOMException`
159
+ * and plain-`Error` shapes). Such an error means the leaf was CANCELLED, not
160
+ * that it failed, so the engine ends the whole run as `aborted` rather than
161
+ * journaling a failed leaf and letting `parallel` continue with `null`.
162
+ *
163
+ * Detection is name-based only (`AbortError`) — deliberately NOT a message
164
+ * substring match — so a genuine leaf failure whose text merely contains
165
+ * "aborted" is not mis-classified. The catch site checks `signal.aborted`
166
+ * FIRST, so an in-flight abort is caught regardless of the rejection's shape;
167
+ * this helper just covers a stray late `AbortError` after the signal cleared.
168
+ */
169
+ function isAbortError(err: unknown): boolean {
170
+ if (err instanceof AbortedSignal) return true;
171
+ if (typeof DOMException !== "undefined" && err instanceof DOMException) {
172
+ return err.name === "AbortError";
173
+ }
174
+ return err instanceof Error && err.name === "AbortError";
175
+ }
176
+
177
+ /**
178
+ * Thrown into the script when `workflow(name)` references a saved workflow that
179
+ * does not exist in the library. Surfaces as a catchable VM exception (or, if
180
+ * uncaught, fails the run).
181
+ */
182
+ export class WorkflowNotFoundError extends Error {
183
+ readonly code = "workflow_not_found" as const;
184
+ constructor(readonly name: string) {
185
+ super(`No saved workflow named "${name}".`);
186
+ this.name = "WorkflowNotFoundError";
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Thrown into the script when a leaf requests `persona: true` but the run's
192
+ * capability manifest did not declare `persona`. Persona access — like tool
193
+ * access — is consent-gated at the manifest (the single consent point); a leaf
194
+ * cannot opt itself in. Surfaces as a catchable VM exception (or, uncaught,
195
+ * fails the run), matching the tool-denial model: be loud, never silently
196
+ * downgrade to anonymous.
197
+ */
198
+ export class WorkflowPersonaNotDeclaredError extends Error {
199
+ readonly code = "persona_not_declared" as const;
200
+ constructor() {
201
+ super(
202
+ "persona leaves require declaring `persona` in the workflow capabilities.",
203
+ );
204
+ this.name = "WorkflowPersonaNotDeclaredError";
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Thrown into the script when `workflow()` is called from inside a nested
210
+ * workflow. Nesting is limited to ONE level: a top-level script may call
211
+ * `workflow()`, but a child workflow may not.
212
+ */
213
+ export class WorkflowNestingDepthError extends Error {
214
+ readonly code = "workflow_nesting_too_deep" as const;
215
+ constructor() {
216
+ super("workflow() may only be called from a top-level workflow (depth 1).");
217
+ this.name = "WorkflowNestingDepthError";
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Prelude evaluated in the VM before the user script. `map` and `pipeline` are
223
+ * pure script-side helpers over the `parallel` host function — the VM is
224
+ * single-threaded and a host fn cannot re-enter it mid-asyncify, so these
225
+ * cannot be host functions. `pipeline` reduces over its stages with one
226
+ * `parallel` per stage; because each `parallel` fully settles before the next
227
+ * stage is built, v1 pipelining has a PER-STAGE BARRIER (no cross-stage
228
+ * streaming). Each helper is wrapped in a getter-free assignment so a script
229
+ * cannot accidentally shadow it before use.
230
+ */
231
+ const SCRIPT_PRELUDE = `
232
+ const map = (items, build) => parallel(items.map((it, i) => __toSpec(build(it, i))));
233
+ const pipeline = (items, ...stages) =>
234
+ stages.reduce((acc, stage) => {
235
+ const staged = acc.map((it, i) => stage(it, i));
236
+ const specs = staged.filter(__isSpec);
237
+ if (specs.length === 0) return staged;
238
+ const ran = parallel(specs);
239
+ let k = 0;
240
+ return staged.map((v) => (__isSpec(v) ? ran[k++] : v));
241
+ }, items);
242
+ `;
243
+
244
+ /**
245
+ * `map`/`pipeline` helpers, evaluated before the user script.
246
+ *
247
+ * - `__isSpec` recognizes a {@link LeafSpec} produced by `leaf(...)`.
248
+ * - `__toSpec` normalizes a `map` build callback's return into a leaf spec:
249
+ * a {@link LeafSpec} (idiomatic) or a bare prompt string (sugar). `map` ALWAYS
250
+ * runs a leaf per item, so a non-spec is wrapped via `leaf(v)`.
251
+ *
252
+ * `pipeline` differs DELIBERATELY: a stage that returns a leaf spec runs it, but
253
+ * a stage that returns any PLAIN value (string, number, object, null) passes it
254
+ * through unchanged to the next stage — so a stage can filter/transform/skip
255
+ * locally without spending an agent. This matches the documented contract
256
+ * ("each stage returns a leaf(...) descriptor OR a plain value"); only explicit
257
+ * `leaf(...)` results consume agent budget.
258
+ */
259
+ const SCRIPT_PRELUDE_HELPERS = `
260
+ const __isSpec = (v) =>
261
+ !!v && typeof v === "object" && v.__workflowSpec === true;
262
+ const __toSpec = (v) => (__isSpec(v) ? v : leaf(v));
263
+ `;
264
+
265
+ /**
266
+ * Strip leading `export` keywords from top-level declarations WITHOUT touching
267
+ * `export` that appears inside string literals, template literals, or comments.
268
+ *
269
+ * The sandbox runs the script inside a synchronous function body where a
270
+ * top-level `export` is a syntax error, so an authored `export const meta = ...`
271
+ * (and any other top-level `export const/let/var/function/class`) must become a
272
+ * plain local. A naive line-anchored regex over the whole source would ALSO
273
+ * rewrite an `export const ...` line that lives inside a multiline template
274
+ * literal or block comment — e.g. a workflow that hands a leaf a TypeScript
275
+ * snippet to inspect or generate — silently corrupting the prompt/result (and
276
+ * perturbing resume call-hashes). {@link lineStartsInCode} tracks lexical state
277
+ * so the strip fires only on lines that genuinely begin in code.
278
+ */
279
+ function stripTopLevelExports(scriptSource: string): string {
280
+ const lines = scriptSource.split("\n");
281
+ const inCode = lineStartsInCode(scriptSource, lines.length);
282
+ return lines
283
+ .map((line, i) =>
284
+ inCode[i]
285
+ ? line.replace(
286
+ /^(\s*)export\s+(const|let|var|function|class|async\s+function)\b/,
287
+ "$1$2",
288
+ )
289
+ : line,
290
+ )
291
+ .join("\n");
292
+ }
293
+
294
+ /**
295
+ * For each line of `source`, whether the line BEGINS in code context (vs inside
296
+ * a multiline template literal or block comment). A single forward scan tracks
297
+ * the lexical state that can carry across a newline: only template literals and
298
+ * block comments do. A single/double-quoted string terminates at an unescaped
299
+ * newline and a line comment ends at the newline, so a line always re-enters
300
+ * code after them unless a template/block-comment is still open.
301
+ *
302
+ * This is a focused lexer, not a full parser: it recognizes strings, template
303
+ * literals (incl. `${}` interpolation), line/block comments, and regex literals
304
+ * — enough that `export` inside literal text is never mistaken for a real
305
+ * declaration, and a `` ` ``/quote/`/*` inside a regex never flips the state.
306
+ */
307
+ function lineStartsInCode(source: string, lineCount: number): boolean[] {
308
+ // result[0] is always true (a file begins in code); later entries are set as
309
+ // each newline is crossed.
310
+ const result: boolean[] = new Array<boolean>(lineCount).fill(true);
311
+
312
+ // Nesting stack: the base frame is code; a backtick pushes a template frame;
313
+ // a `${` inside a template pushes a code frame (interpolation). `braceDepth`
314
+ // on a code frame distinguishes an interpolation-closing `}` from a block `}`.
315
+ type Frame = { kind: "code"; braceDepth: number } | { kind: "template" };
316
+ const stack: Frame[] = [{ kind: "code", braceDepth: 0 }];
317
+ let inLineComment = false;
318
+ let inBlockComment = false;
319
+ let stringQuote: "'" | '"' | null = null;
320
+ // True when the previous char was an unconsumed backslash inside a string or
321
+ // template (escapes the next char, including a line-continuation newline).
322
+ let escaped = false;
323
+ // Last significant (non-space, non-comment) code char — disambiguates a `/`
324
+ // that starts a regex literal from one that means division.
325
+ let prevSignificant = "";
326
+ let line = 0;
327
+
328
+ const carryIsCode = (): boolean =>
329
+ !inBlockComment &&
330
+ stringQuote === null &&
331
+ stack[stack.length - 1]!.kind === "code";
332
+
333
+ for (let i = 0; i < source.length; i++) {
334
+ const c = source[i]!;
335
+ const next = source[i + 1];
336
+
337
+ if (c === "\n") {
338
+ // A string survives the newline only if it was line-continued (`\`); an
339
+ // otherwise-unterminated string is a syntax error the transpiler catches.
340
+ if (stringQuote !== null && !escaped) stringQuote = null;
341
+ inLineComment = false;
342
+ escaped = false;
343
+ line++;
344
+ if (line < lineCount) result[line] = carryIsCode();
345
+ continue;
346
+ }
347
+
348
+ if (escaped) {
349
+ escaped = false;
350
+ continue;
351
+ }
352
+ if (inLineComment) continue;
353
+ if (inBlockComment) {
354
+ if (c === "*" && next === "/") {
355
+ inBlockComment = false;
356
+ i++;
357
+ }
358
+ continue;
359
+ }
360
+ if (stringQuote !== null) {
361
+ if (c === "\\") escaped = true;
362
+ else if (c === stringQuote) stringQuote = null;
363
+ continue;
364
+ }
365
+
366
+ const top = stack[stack.length - 1]!;
367
+ if (top.kind === "template") {
368
+ if (c === "\\") escaped = true;
369
+ else if (c === "`") stack.pop();
370
+ else if (c === "$" && next === "{") {
371
+ stack.push({ kind: "code", braceDepth: 0 });
372
+ i++;
373
+ }
374
+ continue;
375
+ }
376
+
377
+ // --- code frame ---
378
+ if (c === " " || c === "\t" || c === "\r") continue; // not significant
379
+ if (c === "/" && next === "/") {
380
+ inLineComment = true;
381
+ i++;
382
+ continue;
383
+ }
384
+ if (c === "/" && next === "*") {
385
+ inBlockComment = true;
386
+ i++;
387
+ continue;
388
+ }
389
+ if (c === "/" && regexAllowedAfter(prevSignificant)) {
390
+ // Skip the regex body so a `` ` ``, quote, or `/*` inside it cannot flip
391
+ // the lexer into a bogus string/template/comment that carries across
392
+ // lines. Regex literals cannot span a raw newline, so this is bounded.
393
+ i = skipRegexLiteral(source, i);
394
+ prevSignificant = "/"; // a regex is a value; `/`/`.` after it is division/member
395
+ continue;
396
+ }
397
+ if (c === "'" || c === '"') {
398
+ stringQuote = c;
399
+ prevSignificant = c;
400
+ continue;
401
+ }
402
+ if (c === "`") {
403
+ stack.push({ kind: "template" });
404
+ prevSignificant = "`";
405
+ continue;
406
+ }
407
+ if (c === "{") {
408
+ top.braceDepth++;
409
+ } else if (c === "}") {
410
+ if (top.braceDepth === 0 && stack.length > 1) stack.pop();
411
+ else if (top.braceDepth > 0) top.braceDepth--;
412
+ }
413
+ prevSignificant = c;
414
+ }
415
+
416
+ return result;
417
+ }
418
+
419
+ /**
420
+ * Whether a `/` following `prev` (the last significant code char) begins a
421
+ * regex literal rather than division. Regex is allowed at an expression
422
+ * position: the start of input, or after an operator/punctuator — not after an
423
+ * identifier char, closing bracket/paren, or `.`. A miss is harmless here (an
424
+ * undetected regex is scanned as plain code chars, which only matters if it
425
+ * contains a backtick or `/*`), so this conservative table suffices.
426
+ */
427
+ function regexAllowedAfter(prev: string): boolean {
428
+ if (prev === "") return true;
429
+ return "(,=:[!&|?{};+-*/%^~<>".includes(prev);
430
+ }
431
+
432
+ /**
433
+ * Skip a regex literal whose opening `/` is at `source[start]`. Returns the
434
+ * index of the closing `/` (or the last char before a newline / EOF if the
435
+ * literal is malformed). Handles `\` escapes and `[...]` character classes,
436
+ * inside which `/` does not close the literal.
437
+ */
438
+ function skipRegexLiteral(source: string, start: number): number {
439
+ let inClass = false;
440
+ for (let j = start + 1; j < source.length; j++) {
441
+ const c = source[j]!;
442
+ if (c === "\n") return j - 1; // regex can't span a newline; bail before it
443
+ if (c === "\\") {
444
+ j++;
445
+ continue;
446
+ }
447
+ if (c === "[") inClass = true;
448
+ else if (c === "]") inClass = false;
449
+ else if (c === "/" && !inClass) return j;
450
+ }
451
+ return source.length - 1;
452
+ }
453
+
454
+ function callHashOf(prompt: string, opts: LeafCallOptions): string {
455
+ return createHash("sha256")
456
+ .update(deterministicStringify({ prompt, opts }))
457
+ .digest("hex");
458
+ }
459
+
460
+ /** Journal call-hash for a nested `workflow(name)` resolution. */
461
+ function workflowCallHashOf(name: string): string {
462
+ return createHash("sha256")
463
+ .update(deterministicStringify({ workflow: name }))
464
+ .digest("hex");
465
+ }
466
+
467
+ /**
468
+ * Extract the pure-literal `export const meta = { name, description }` from a
469
+ * script. Rejects a computed/missing meta. The literal is parsed via
470
+ * {@link JSON.parse} after light normalization — NOT `eval`/`Function` — so an
471
+ * author cannot run code in the host process at extraction time (the script
472
+ * source is untrusted; only the QuickJS sandbox may execute it). Anything that
473
+ * isn't a plain `{ "name": "...", "description": "..." }` literal (template
474
+ * strings, identifiers, function calls) fails to JSON-parse and is rejected.
475
+ */
476
+ export function extractWorkflowMeta(scriptSource: string): WorkflowMeta {
477
+ const match = scriptSource.match(
478
+ /export\s+const\s+meta\s*=\s*(\{[\s\S]*?\})\s*;?/,
479
+ );
480
+ if (!match) {
481
+ throw new WorkflowScriptError(
482
+ "Workflow script must begin with a literal `export const meta = { name, description }`.",
483
+ );
484
+ }
485
+
486
+ let parsed: unknown;
487
+ try {
488
+ parsed = JSON.parse(literalToJson(match[1]!));
489
+ } catch {
490
+ throw new WorkflowScriptError(
491
+ "Workflow script `meta` must be a plain object literal with string " +
492
+ "`name` and `description` (no computed values, template strings, or calls).",
493
+ );
494
+ }
495
+
496
+ if (
497
+ !parsed ||
498
+ typeof parsed !== "object" ||
499
+ typeof (parsed as Record<string, unknown>).name !== "string" ||
500
+ typeof (parsed as Record<string, unknown>).description !== "string"
501
+ ) {
502
+ throw new WorkflowScriptError(
503
+ "Workflow script `meta` must have string `name` and `description` fields.",
504
+ );
505
+ }
506
+ const m = parsed as Record<string, unknown>;
507
+ return { name: m.name as string, description: m.description as string };
508
+ }
509
+
510
+ /**
511
+ * Normalize a restricted JS object literal into strict JSON without executing
512
+ * it: convert single-quoted strings to double-quoted, quote bare identifier
513
+ * keys, and drop a trailing comma. Any construct outside this grammar (a call,
514
+ * a template literal, an identifier value) survives normalization as invalid
515
+ * JSON and makes the subsequent `JSON.parse` throw — which is the rejection
516
+ * path. This is a deliberately narrow normalizer, not a JS parser.
517
+ */
518
+ function literalToJson(literal: string): string {
519
+ return literal
520
+ .replace(/'((?:[^'\\]|\\.)*)'/g, (_m, body: string) => {
521
+ // Re-encode the unescaped string body as a JSON string literal.
522
+ return JSON.stringify(body.replace(/\\'/g, "'"));
523
+ })
524
+ .replace(/([{,]\s*)([A-Za-z_$][\w$]*)\s*:/g, '$1"$2":')
525
+ .replace(/,(\s*})/g, "$1");
526
+ }
527
+
528
+ /**
529
+ * Run a workflow script and fan it out to parallel leaf agents, with journaled
530
+ * resume and an agent cap. See the module doc for the host API and invariants.
531
+ */
532
+ export async function executeWorkflow(
533
+ opts: ExecuteWorkflowOptions,
534
+ ): Promise<ExecuteWorkflowResult> {
535
+ const {
536
+ runId,
537
+ scriptSource,
538
+ args,
539
+ capabilities,
540
+ config,
541
+ journal,
542
+ leafRunner,
543
+ trustContext,
544
+ onProgress,
545
+ signal,
546
+ } = opts;
547
+
548
+ const meta = extractWorkflowMeta(scriptSource);
549
+ const scriptHash = createHash("sha256").update(scriptSource).digest("hex");
550
+
551
+ // Idempotent run row: createRun on first execution, reuse on resume.
552
+ const existing = journal.getRun(runId);
553
+ if (!existing) {
554
+ journal.createRun({
555
+ id: runId,
556
+ name: meta.name,
557
+ scriptSource,
558
+ scriptHash,
559
+ args,
560
+ capabilities,
561
+ status: "running",
562
+ });
563
+ } else {
564
+ // Re-running: re-open the row as running and reset terminal fields.
565
+ journal.updateRun(runId, { status: "running" });
566
+ }
567
+
568
+ // --- Run-scoped mutable accounting ---------------------------------------
569
+ // On a RESUME (an existing run row from a prior, crashed execution), SEED the
570
+ // ACCOUNTING counters from persisted state so the agent cap and token/agent
571
+ // totals carry across the restart instead of resetting to zero. Without this,
572
+ // replayed leaves return at the journal short-circuit BEFORE the
573
+ // `agentsSpawned += 1` increment, so the fresh-from-0 counter would exclude
574
+ // everything spawned before the crash — and the first `flushCounters()` would
575
+ // overwrite the persisted total with that smaller value, handing a resumed
576
+ // run a full fresh cap budget and defeating the runaway guard.
577
+ //
578
+ // The `seq` counter is DELIBERATELY NOT seeded: it must restart at 0 on every
579
+ // execution. The script re-runs from the top and re-derives the SAME
580
+ // deterministic seq sequence (0, 1, 2, …); replay matches a journaled entry
581
+ // by `(runId, seq)`, so a `seq` that did not restart at 0 would miss every
582
+ // cached entry and re-run the whole prefix. The persisted `agentsSpawned`
583
+ // carries the real spawn total; `seq` is purely the in-execution call index.
584
+ let nextSeq = 0;
585
+ let agentsSpawned = existing ? existing.agentsSpawned : 0;
586
+ let inputTokens = existing ? existing.inputTokens : 0;
587
+ let outputTokens = existing ? existing.outputTokens : 0;
588
+ let capExceeded = false;
589
+
590
+ /** Persist live counters so `getRun` reports them mid-flight. */
591
+ const flushCounters = (): void => {
592
+ journal.updateRun(runId, { agentsSpawned, inputTokens, outputTokens });
593
+ };
594
+
595
+ /**
596
+ * Run (or replay) a single leaf at a pre-assigned `seq`. Returns the leaf
597
+ * output. On the agent cap or an abort, throws the corresponding sentinel to
598
+ * unwind the whole script. A leaf-runner failure does NOT throw here — the
599
+ * caller (`agent`/`parallel`) decides whether to surface or null it.
600
+ */
601
+ const runLeafAtSeq = async (
602
+ seq: number,
603
+ prompt: string,
604
+ leafOpts: LeafCallOptions,
605
+ leafSignal?: AbortSignal,
606
+ ): Promise<{ output: unknown; failed: boolean }> => {
607
+ if (signal?.aborted) throw new AbortedSignal();
608
+
609
+ const hash = callHashOf(prompt, leafOpts);
610
+
611
+ // Resume: replay a cached entry whose hash matches. Failures are journaled
612
+ // with status "failed", so they are re-run rather than replayed as a hit.
613
+ const cached = journal.getJournalEntry(runId, seq);
614
+ if (cached && cached.callHash === hash && cached.status === "completed") {
615
+ return { output: cached.result, failed: false };
616
+ }
617
+
618
+ // Persona is consent-gated by the manifest, exactly like tools: a leaf may
619
+ // opt into persona ONLY if the run declared `persona`. An undeclared
620
+ // request fails loudly (never silently downgrades to anonymous), matching
621
+ // the tool-denial model.
622
+ if (leafOpts.persona && !capabilities.persona) {
623
+ throw new WorkflowPersonaNotDeclaredError();
624
+ }
625
+
626
+ // Agent cap: trip BEFORE launching, abort the whole run.
627
+ if (agentsSpawned >= config.maxAgentsPerRun) {
628
+ capExceeded = true;
629
+ throw new CapExceededSignal();
630
+ }
631
+ agentsSpawned += 1;
632
+
633
+ try {
634
+ // A leaf is EITHER a schema leaf (structured output via forced
635
+ // tool-choice — `schema` set, no tools) OR a tool leaf (free-form with
636
+ // tools — no schema). `runLeaf` hard-errors if BOTH are passed. The
637
+ // resolved capabilities always include a non-empty read-only baseline, so
638
+ // forward `tools` only on the tool-leaf path; a schema leaf runs with none.
639
+ const isSchemaLeaf = leafOpts.schema !== undefined;
640
+ const result = await leafRunner({
641
+ prompt,
642
+ ...(leafOpts.label !== undefined ? { label: leafOpts.label } : {}),
643
+ ...(isSchemaLeaf ? { schema: leafOpts.schema as never } : {}),
644
+ ...(leafOpts.profile !== undefined
645
+ ? { profile: leafOpts.profile }
646
+ : {}),
647
+ ...(isSchemaLeaf ? {} : { tools: capabilities.tools }),
648
+ ...(leafOpts.persona ? { persona: true } : {}),
649
+ trustContext,
650
+ // Cancel the leaf on EITHER the run's external abort or the fan-out's
651
+ // internal unwind (`leafSignal`, set by `parallel` so a tripped agent
652
+ // cap / sibling error cancels in-flight leaves instead of orphaning
653
+ // them). The status-classification checks below stay on the EXTERNAL
654
+ // `signal` only, so a fan-out cancel surfaces via `isAbortError(err)`
655
+ // and the FIRST sentinel (cap) still decides the run status.
656
+ ...((leafSignal ?? signal) ? { signal: leafSignal ?? signal } : {}),
657
+ });
658
+ inputTokens += result.inputTokens;
659
+ outputTokens += result.outputTokens;
660
+ flushCounters();
661
+ journal.appendJournalEntry({
662
+ runId,
663
+ seq,
664
+ callHash: hash,
665
+ kind: "agent",
666
+ request: { prompt, opts: leafOpts },
667
+ result: result.output,
668
+ status: "completed",
669
+ });
670
+ return { output: result.output, failed: false };
671
+ } catch (err) {
672
+ // An ABORT that fired while the leaf provider/tool call was in flight is
673
+ // NOT a leaf failure — it unwinds the WHOLE run. Detect it FIRST and
674
+ // rethrow the abort sentinel so the top-level catch ends the run with
675
+ // status `aborted`, `parallel` terminates (rather than null-coalescing a
676
+ // cancelled leaf and continuing), and the journal is NOT polluted with a
677
+ // spurious "failed" entry for a leaf that was merely cancelled.
678
+ if (signal?.aborted || isAbortError(err)) {
679
+ throw new AbortedSignal();
680
+ }
681
+ // A genuine leaf failure is journaled as failed (so it is NOT replayed as
682
+ // a hit) and surfaced to the caller, which decides to null or rethrow it.
683
+ journal.appendJournalEntry({
684
+ runId,
685
+ seq,
686
+ callHash: hash,
687
+ kind: "agent",
688
+ request: { prompt, opts: leafOpts },
689
+ result: { error: err instanceof Error ? err.message : String(err) },
690
+ status: "failed",
691
+ });
692
+ log.warn(
693
+ { err, runId, seq, label: leafOpts.label },
694
+ "Workflow leaf failed",
695
+ );
696
+ return { output: null, failed: true };
697
+ }
698
+ };
699
+
700
+ // --- Host functions ------------------------------------------------------
701
+ // `agent` runs one sequential leaf and surfaces failures (throws into the
702
+ // script). `parallel` is the fan-out primitive: it null-coalesces failures.
703
+ //
704
+ // The host API is built by a FACTORY so it can be re-bound for a nested
705
+ // `workflow()` child: the child reuses the SAME shared run-state (seq counter,
706
+ // agent cap, journal, signal, capabilities) but applies a `labelPrefix` so its
707
+ // leaf labels are attributed to the child workflow. The single-level nesting
708
+ // guard lives in the `workflow()` host fn, gated on `depth`.
709
+
710
+ /** Prefix a leaf label with the (optional) nested-workflow attribution. */
711
+ const withLabelPrefix = (
712
+ labelPrefix: string,
713
+ leafOpts: LeafCallOptions,
714
+ ): LeafCallOptions => {
715
+ if (!labelPrefix) return leafOpts;
716
+ const base = leafOpts.label ?? "";
717
+ return {
718
+ ...leafOpts,
719
+ label: base ? `${labelPrefix}/${base}` : labelPrefix,
720
+ };
721
+ };
722
+
723
+ /**
724
+ * Build the host API bound to the shared run-state. `labelPrefix` attributes a
725
+ * nested child's leaves; `depth` (0 = top-level, 1 = nested) gates `workflow()`.
726
+ */
727
+ const buildHostFunctions = (
728
+ labelPrefix: string,
729
+ depth: number,
730
+ ): Record<string, (...a: unknown[]) => unknown | Promise<unknown>> => {
731
+ const hostAgent = async (
732
+ promptArg: unknown,
733
+ optsArg?: unknown,
734
+ ): Promise<unknown> => {
735
+ const prompt = String(promptArg);
736
+ const leafOpts = withLabelPrefix(labelPrefix, normalizeLeafOpts(optsArg));
737
+ const seq = nextSeq++;
738
+ const { output, failed } = await runLeafAtSeq(seq, prompt, leafOpts);
739
+ if (failed) {
740
+ throw new WorkflowScriptError(
741
+ `Workflow agent leaf${leafOpts.label ? ` "${leafOpts.label}"` : ""} failed.`,
742
+ );
743
+ }
744
+ return output;
745
+ };
746
+
747
+ const hostLeaf = (promptArg: unknown, optsArg?: unknown): LeafSpec => ({
748
+ __workflowSpec: true,
749
+ prompt: String(promptArg),
750
+ opts: withLabelPrefix(labelPrefix, normalizeLeafOpts(optsArg)),
751
+ });
752
+
753
+ const hostParallel = async (specsArg: unknown): Promise<unknown[]> => {
754
+ // A spec built by `leaf()` already carries the child label prefix; a
755
+ // bare-string spec (sugar) bypassed `leaf()`, so prefix it here. Either
756
+ // way the prefix is applied EXACTLY once.
757
+ const specs = toSpecArray(specsArg).map((spec, i) =>
758
+ typeof (specsArg as unknown[])[i] === "string"
759
+ ? { ...spec, opts: withLabelPrefix(labelPrefix, spec.opts) }
760
+ : spec,
761
+ );
762
+ // Assign seqs in array order BEFORE launching concurrency, so completion
763
+ // order cannot perturb the deterministic seq mapping.
764
+ const assigned = specs.map((spec) => ({ spec, seq: nextSeq++ }));
765
+ // Fan-out unwind controller. When the agent cap trips or a sibling throws,
766
+ // `runWithConcurrency` fires `onUnwind` to abort this — cancelling every
767
+ // in-flight leaf — then drains them before the error propagates. Without
768
+ // it, the early rejection would mark the run terminal and `finishRun`
769
+ // while orphaned leaves kept running: appending journal entries and
770
+ // performing granted side-effecting tools after the run was reported done.
771
+ const fanoutAbort = new AbortController();
772
+ const leafSignal = mergeSignals(signal, fanoutAbort.signal);
773
+ return runWithConcurrency(
774
+ assigned,
775
+ config.maxConcurrentLeaves,
776
+ async ({ spec, seq }) => {
777
+ const { output } = await runLeafAtSeq(
778
+ seq,
779
+ spec.prompt,
780
+ spec.opts,
781
+ leafSignal,
782
+ );
783
+ // `parallel` never throws on a single leaf failure — it yields null.
784
+ return output;
785
+ },
786
+ () => fanoutAbort.abort(),
787
+ );
788
+ };
789
+
790
+ const hostPhase = (titleArg: unknown): void => {
791
+ onProgress?.({ type: "phase", title: String(titleArg) });
792
+ };
793
+ const hostLog = (msgArg: unknown): void => {
794
+ onProgress?.({ type: "log", message: String(msgArg) });
795
+ };
796
+ const hostUsage = (): {
797
+ agentsSpawned: number;
798
+ inputTokens: number;
799
+ outputTokens: number;
800
+ } => ({ agentsSpawned, inputTokens, outputTokens });
801
+
802
+ /**
803
+ * Run a saved workflow by name INLINE as part of this run: the child draws
804
+ * `seq` from the same counter, counts against the same agent cap, and shares
805
+ * the same journal/signal/leaf-runner — so determinism and journaled resume
806
+ * carry across the nesting boundary. Nesting is depth-1 only: a `workflow()`
807
+ * call from inside a child throws {@link WorkflowNestingDepthError}.
808
+ */
809
+ const hostWorkflow = async (
810
+ nameArg: unknown,
811
+ childArgs?: unknown,
812
+ ): Promise<unknown> => {
813
+ if (depth >= 1) throw new WorkflowNestingDepthError();
814
+ if (signal?.aborted) throw new AbortedSignal();
815
+ const childName = String(nameArg);
816
+
817
+ // Snapshot the resolved child source under its own journal `seq` so a
818
+ // resumed run executes the SAME child code the original launch did. The
819
+ // child resolves from a workspace file that may have been EDITED or
820
+ // DELETED since; without a snapshot, resume would run different child code
821
+ // against the original run's journal/cap accounting (mixing new
822
+ // orchestration with replayed leaf results) or fail outright if the file
823
+ // is gone. The `seq` is drawn in deterministic call order, exactly like a
824
+ // leaf, so replay realigns. The child's own leaves draw the seqs AFTER it.
825
+ const seq = nextSeq++;
826
+ const hash = workflowCallHashOf(childName);
827
+ const cached = journal.getJournalEntry(runId, seq);
828
+ let source: string;
829
+ if (
830
+ cached &&
831
+ cached.callHash === hash &&
832
+ cached.status === "completed" &&
833
+ cached.result &&
834
+ typeof (cached.result as { source?: unknown }).source === "string"
835
+ ) {
836
+ source = (cached.result as { source: string }).source;
837
+ } else {
838
+ const saved = library.getWorkflow(childName);
839
+ if (!saved) throw new WorkflowNotFoundError(childName);
840
+ source = saved.source;
841
+ journal.appendJournalEntry({
842
+ runId,
843
+ seq,
844
+ callHash: hash,
845
+ kind: "workflow",
846
+ request: { name: childName, args: childArgs ?? null },
847
+ result: { source },
848
+ status: "completed",
849
+ });
850
+ }
851
+
852
+ // The child runs in its OWN sandbox VM (the parent VM is suspended in
853
+ // asyncify and cannot be re-entered), but with host functions bound to the
854
+ // SAME shared run-state and a child label prefix, at depth 1.
855
+ const childHostFns = buildHostFunctions(childName, depth + 1);
856
+ return runScriptInSandbox(source, childArgs ?? null, childHostFns);
857
+ };
858
+
859
+ // Always-available host functions.
860
+ const hostFunctions: Record<
861
+ string,
862
+ (...a: unknown[]) => unknown | Promise<unknown>
863
+ > = {
864
+ agent: (p, o) => hostAgent(p, o),
865
+ leaf: (p, o) => hostLeaf(p, o),
866
+ parallel: (s) => hostParallel(s),
867
+ workflow: (n, a) => hostWorkflow(n, a),
868
+ phase: (t) => hostPhase(t),
869
+ log: (m) => hostLog(m),
870
+ usage: () => hostUsage(),
871
+ };
872
+
873
+ // Manifest-declared host functions are injected by name as no-op-safe stubs
874
+ // only when explicitly granted. (Their concrete impls are bound by later
875
+ // PRs; here we expose the names so an undeclared call is a ReferenceError and
876
+ // a declared-but-unbound call fails loudly rather than silently.)
877
+ for (const name of capabilities.hostFunctions) {
878
+ if (name in hostFunctions) continue;
879
+ hostFunctions[name] = () => {
880
+ throw new WorkflowScriptError(
881
+ `Host function "${name}" is declared but not bound in this engine build.`,
882
+ );
883
+ };
884
+ }
885
+ return hostFunctions;
886
+ };
887
+
888
+ /**
889
+ * Run one workflow script source in a fresh sandbox VM, wired to the given
890
+ * host functions. Shared by the top-level run and every nested `workflow()`
891
+ * child. The prelude defines `map`/`pipeline` over `parallel`; prepend it so
892
+ * the script can call them. The sandbox runs the script as a SYNCHRONOUS
893
+ * function body, where a top-level `export` is a syntax error — strip the
894
+ * `export` keyword(s) so `export const meta = ...` becomes a plain local.
895
+ */
896
+ const runScriptInSandbox = (
897
+ source: string,
898
+ scriptArgs: unknown,
899
+ hostFunctions: Record<
900
+ string,
901
+ (...a: unknown[]) => unknown | Promise<unknown>
902
+ >,
903
+ ): Promise<unknown> => {
904
+ const fullScript = `${SCRIPT_PRELUDE_HELPERS}${SCRIPT_PRELUDE}\n${stripTopLevelExports(source)}`;
905
+ const sandbox = createWorkflowSandbox({
906
+ hostFunctions,
907
+ ...(onProgress
908
+ ? { onLog: (m) => onProgress({ type: "log", message: m }) }
909
+ : {}),
910
+ ...(signal ? { signal } : {}),
911
+ });
912
+ return sandbox.run(fullScript, scriptArgs);
913
+ };
914
+
915
+ let status: WorkflowRunStatus;
916
+ let result: unknown = null;
917
+
918
+ try {
919
+ result = await runScriptInSandbox(
920
+ scriptSource,
921
+ args,
922
+ buildHostFunctions("", 0),
923
+ );
924
+ // A host CapExceededSignal/AbortedSignal crosses into the VM as a catchable
925
+ // exception, so a script can wrap agent()/parallel() in try/catch, SWALLOW
926
+ // the sentinel, and return a partial result normally. The run still hit its
927
+ // safety cap (or was aborted), so trust the host-side flags — which the
928
+ // sandboxed script cannot reach or reset — over the script's return: mark
929
+ // cap_exceeded/aborted (cap takes precedence, matching the catch below) so
930
+ // callers learn the run was stopped, not completed. finishRun discards the
931
+ // partial result for any non-completed status.
932
+ if (capExceeded) {
933
+ status = "cap_exceeded";
934
+ } else if (signal?.aborted) {
935
+ status = "aborted";
936
+ } else {
937
+ status = "completed";
938
+ }
939
+ } catch (err) {
940
+ if (capExceeded || err instanceof CapExceededSignal) {
941
+ status = "cap_exceeded";
942
+ } else if (signal?.aborted || err instanceof AbortedSignal) {
943
+ status = "aborted";
944
+ } else {
945
+ status = "failed";
946
+ }
947
+ if (status === "failed") {
948
+ log.warn({ err, runId }, "Workflow run failed");
949
+ }
950
+ }
951
+
952
+ flushCounters();
953
+ journal.finishRun(runId, {
954
+ status,
955
+ result: status === "completed" ? result : null,
956
+ error:
957
+ status === "failed"
958
+ ? "Workflow script error"
959
+ : status === "cap_exceeded"
960
+ ? `Agent cap of ${config.maxAgentsPerRun} exceeded`
961
+ : null,
962
+ });
963
+
964
+ return {
965
+ status,
966
+ result: status === "completed" ? result : null,
967
+ agentsSpawned,
968
+ inputTokens,
969
+ outputTokens,
970
+ };
971
+ }
972
+
973
+ function normalizeLeafOpts(optsArg: unknown): LeafCallOptions {
974
+ if (!optsArg || typeof optsArg !== "object") return {};
975
+ const o = optsArg as Record<string, unknown>;
976
+ const out: LeafCallOptions = {};
977
+ if (o.schema !== undefined) out.schema = o.schema;
978
+ if (typeof o.label === "string") out.label = o.label;
979
+ if (typeof o.profile === "string") out.profile = o.profile;
980
+ if (typeof o.persona === "boolean") out.persona = o.persona;
981
+ if (typeof o.phase === "string") out.phase = o.phase;
982
+ return out;
983
+ }
984
+
985
+ function toSpecArray(specsArg: unknown): LeafSpec[] {
986
+ if (!Array.isArray(specsArg)) {
987
+ throw new WorkflowScriptError(
988
+ "parallel(specs) requires an array of leaf specs.",
989
+ );
990
+ }
991
+ return specsArg.map((spec, i) => {
992
+ if (
993
+ spec &&
994
+ typeof spec === "object" &&
995
+ (spec as Record<string, unknown>).__workflowSpec === true
996
+ ) {
997
+ return spec as LeafSpec;
998
+ }
999
+ // Sugar: a bare string is treated as a prompt-only spec.
1000
+ if (typeof spec === "string") {
1001
+ return { __workflowSpec: true, prompt: spec, opts: {} };
1002
+ }
1003
+ throw new WorkflowScriptError(
1004
+ `parallel(specs)[${i}] must be a leaf(...) spec or a prompt string.`,
1005
+ );
1006
+ });
1007
+ }
1008
+
1009
+ /**
1010
+ * Merge an optional external abort signal with a fan-out's internal controller
1011
+ * so an in-flight leaf is cancelled by EITHER the caller's cancellation or the
1012
+ * fan-out unwinding (agent cap / sibling error). Returns the internal signal
1013
+ * alone when there is no external one.
1014
+ */
1015
+ function mergeSignals(
1016
+ external: AbortSignal | undefined,
1017
+ internal: AbortSignal,
1018
+ ): AbortSignal {
1019
+ return external ? AbortSignal.any([external, internal]) : internal;
1020
+ }
1021
+
1022
+ /**
1023
+ * Run `tasks` with at most `limit` in flight, preserving INPUT ORDER in the
1024
+ * returned results array. A simple index-cursor worker pool: each worker pulls
1025
+ * the next index, runs it, and writes the result back at that index.
1026
+ *
1027
+ * Fan-out unwind safety: if a task throws (the agent-cap or abort sentinel, or
1028
+ * an unexpected error) the pool does NOT reject while siblings are still in
1029
+ * flight. It captures the FIRST error, stops workers from pulling NEW tasks,
1030
+ * fires `onUnwind` (so the caller can cancel the in-flight leaves), then awaits
1031
+ * every worker to settle before re-throwing that first error. Rejecting eagerly
1032
+ * via a fail-fast `Promise.all` would let `executeWorkflow` mark the run
1033
+ * terminal and call `finishRun` while orphaned leaves keep running — appending
1034
+ * journal entries and performing granted side-effecting tools after the run was
1035
+ * already reported done (and a user, seeing the terminal status, may retry and
1036
+ * duplicate the work).
1037
+ */
1038
+ async function runWithConcurrency<T, R>(
1039
+ tasks: T[],
1040
+ limit: number,
1041
+ run: (task: T) => Promise<R>,
1042
+ onUnwind?: () => void,
1043
+ ): Promise<R[]> {
1044
+ const results: R[] = new Array(tasks.length);
1045
+ let cursor = 0;
1046
+ let failed = false;
1047
+ let firstError: unknown;
1048
+ const width = Math.max(1, Math.min(limit, tasks.length || 1));
1049
+
1050
+ const worker = async (): Promise<void> => {
1051
+ for (;;) {
1052
+ // Once any task has thrown, stop pulling NEW work; let the in-flight
1053
+ // leaves (which `onUnwind` has asked to cancel) drain to settlement.
1054
+ if (failed) return;
1055
+ const index = cursor++;
1056
+ if (index >= tasks.length) return;
1057
+ try {
1058
+ results[index] = await run(tasks[index]!);
1059
+ } catch (err) {
1060
+ // Capture the FIRST error and signal the caller to cancel siblings.
1061
+ // Later errors (e.g. an in-flight leaf rejecting from that very
1062
+ // cancellation) are discarded so the original cause wins.
1063
+ if (!failed) {
1064
+ failed = true;
1065
+ firstError = err;
1066
+ onUnwind?.();
1067
+ }
1068
+ return;
1069
+ }
1070
+ }
1071
+ };
1072
+
1073
+ // Each worker catches its own errors and resolves, so this awaits ALL of them
1074
+ // to settle — an all-settled drain, not a fail-fast `Promise.all`.
1075
+ await Promise.all(Array.from({ length: width }, () => worker()));
1076
+ if (failed) throw firstError;
1077
+ return results;
1078
+ }