@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.
- package/AGENTS.md +0 -14
- package/ARCHITECTURE.md +45 -45
- package/README.md +1 -1
- package/bun.lock +200 -154
- package/docs/architecture/integrations.md +3 -3
- package/docs/architecture/memory.md +2 -2
- package/docs/architecture/security.md +10 -10
- package/docs/runbook-trusted-contacts.md +12 -12
- package/docs/skills.md +6 -6
- package/docs/workflows-testing.md +221 -0
- package/docs/workflows.md +510 -0
- package/examples/plugins/echo/README.md +5 -5
- package/knip.json +2 -0
- package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +105 -0
- package/node_modules/@vellumai/gateway-client/src/index.ts +12 -0
- package/openapi.yaml +7197 -5708
- package/package.json +8 -4
- package/scripts/generate-openapi.ts +66 -114
- package/src/__tests__/access-request-seed-content-blocks.test.ts +213 -0
- package/src/__tests__/adaptive-thinking-repair.test.ts +32 -3
- package/src/__tests__/agent-loop-output-hooks.test.ts +183 -0
- package/src/__tests__/agent-loop-regrowth-guard.test.ts +506 -0
- package/src/__tests__/agent-wake-disk-pressure-callsite.test.ts +2 -0
- package/src/__tests__/agent-wake-override-profile.test.ts +77 -0
- package/src/__tests__/app-compiler.test.ts +7 -1
- package/src/__tests__/app-dir-path-guard.test.ts +27 -3
- package/src/__tests__/app-executors.test.ts +43 -0
- package/src/__tests__/approval-cascade.test.ts +0 -5
- package/src/__tests__/approval-routes-http.test.ts +91 -0
- package/src/__tests__/assistant-stream-state.test.ts +107 -0
- package/src/__tests__/browser-fill-credential.test.ts +3 -3
- package/src/__tests__/bundled-skill-retrieval-guard.test.ts +1 -1
- package/src/__tests__/compaction-events.test.ts +63 -7
- package/src/__tests__/compaction-trail-store.test.ts +74 -1
- package/src/__tests__/compaction.benchmark.test.ts +63 -41
- package/src/__tests__/compactor-low-watermark-cut.test.ts +349 -0
- package/src/__tests__/context-window-manager-compact-retry.test.ts +64 -0
- package/src/__tests__/conversation-abort-tool-results.test.ts +0 -5
- package/src/__tests__/conversation-confirmation-signals.test.ts +0 -5
- package/src/__tests__/conversation-history-web-search.test.ts +7 -0
- package/src/__tests__/conversation-process-callsite.test.ts +0 -5
- package/src/__tests__/conversation-provider-retry-repair.test.ts +0 -5
- package/src/__tests__/conversation-queue.test.ts +0 -5
- package/src/__tests__/conversation-slash-queue.test.ts +0 -5
- package/src/__tests__/conversation-slash-unknown.test.ts +0 -5
- package/src/__tests__/conversation-speed-override.test.ts +0 -5
- package/src/__tests__/conversation-surfaces-data-persist.test.ts +97 -0
- package/src/__tests__/conversation-surfaces-task-progress.test.ts +67 -0
- package/src/__tests__/conversation-usage.test.ts +2 -0
- package/src/__tests__/conversation-workspace-injection.test.ts +0 -5
- package/src/__tests__/conversation-workspace-tool-tracking.test.ts +0 -5
- package/src/__tests__/credential-broker-browser-fill.test.ts +2 -2
- package/src/__tests__/credential-broker-server-use.test.ts +2 -2
- package/src/__tests__/credential-broker.test.ts +1 -1
- package/src/__tests__/credential-prompt-route.test.ts +417 -0
- package/src/__tests__/credential-security-invariants.test.ts +1 -0
- package/src/__tests__/credential-vault.test.ts +37 -0
- package/src/__tests__/db-schedule-syntax-migration.test.ts +24 -0
- package/src/__tests__/dynamic-page-surface.test.ts +219 -0
- package/src/__tests__/empty-state-greeting-cache.test.ts +94 -0
- package/src/__tests__/gateway-flag-listener.test.ts +24 -7
- package/src/__tests__/guardian-action-sweep.test.ts +56 -219
- package/src/__tests__/guardian-routing-invariants.test.ts +138 -0
- package/src/__tests__/helpers/channel-test-adapter.ts +0 -2
- package/src/__tests__/list-messages-hidden-metadata.test.ts +99 -0
- package/src/__tests__/llm-request-log-source-clickhouse.test.ts +87 -1
- package/src/__tests__/llm-resolver.test.ts +115 -0
- package/src/__tests__/managed-profile-guard.test.ts +6 -5
- package/src/__tests__/max-tokens-continue-hook.test.ts +184 -0
- package/src/__tests__/media-generate-image.test.ts +20 -9
- package/src/__tests__/mock-gateway-ipc.ts +23 -0
- package/src/__tests__/model-intents.test.ts +1 -1
- package/src/__tests__/normalize-onboarding.test.ts +26 -0
- package/src/__tests__/notification-decision-strategy.test.ts +4 -2
- package/src/__tests__/notification-telegram-adapter.test.ts +21 -3
- package/src/__tests__/pending-interactions-resolved-event.test.ts +62 -0
- package/src/__tests__/post-turn-tool-result-truncation.test.ts +72 -18
- package/src/__tests__/require-fresh-approval.test.ts +425 -1
- package/src/__tests__/resolve-app-id.test.ts +56 -0
- package/src/__tests__/runtime-events-sse-parity.test.ts +2 -0
- package/src/__tests__/schedule-routes-workflow-validation.test.ts +408 -0
- package/src/__tests__/schedule-routes.test.ts +257 -4
- package/src/__tests__/schedule-store.test.ts +60 -0
- package/src/__tests__/schedule-tools.test.ts +247 -2
- package/src/__tests__/skill-execute-input.test.ts +85 -0
- package/src/__tests__/skill-secret-handling-guard.test.ts +21 -20
- package/src/__tests__/skills.test.ts +3 -3
- package/src/__tests__/slack-app-setup-skill-regression.test.ts +1 -1
- package/src/__tests__/subagent-tool-filtering.test.ts +50 -0
- package/src/__tests__/subagent-tool-gate-mode.test.ts +547 -0
- package/src/__tests__/system-prompt.test.ts +1 -1
- package/src/__tests__/task-progress-nudge-hook.test.ts +372 -0
- package/src/__tests__/task-scheduler.test.ts +299 -0
- package/src/__tests__/tool-approval-seed-content-blocks.test.ts +209 -0
- package/src/__tests__/tool-result-spool.test.ts +3 -1
- package/src/__tests__/workspace-migration-102-preserve-heartbeat-enabled-for-existing-workspaces.test.ts +181 -0
- package/src/__tests__/workspace-migration-103-upgrade-quality-profile-to-opus-4-8.test.ts +174 -0
- package/src/agent/compaction-circuit.ts +11 -0
- package/src/agent/loop.ts +181 -12
- package/src/api/constants/call-sites.ts +12 -0
- package/src/api/events/assistant-thinking-delta.ts +10 -0
- package/src/api/events/usage-update.ts +7 -0
- package/src/api/index.ts +4 -1
- package/src/api/responses/memory-v3-selection-log.ts +18 -11
- package/src/approvals/approval-primitive.ts +2 -2
- package/src/background-wake/background-wake-routes.test.ts +5 -2
- package/src/bundler/compiler-tools.ts +1 -1
- package/src/bundler/package-resolver.ts +0 -1
- package/src/calls/call-domain.ts +1 -1
- package/src/calls/guardian-action-sweep.ts +16 -93
- package/src/cli/AGENTS.md +4 -0
- package/src/cli/commands/__tests__/schedules.test.ts +430 -1
- package/src/cli/commands/credentials.ts +28 -24
- package/src/cli/commands/image-generation.ts +23 -9
- package/src/cli/commands/notifications.ts +1 -1
- package/src/cli/commands/plugins.ts +89 -46
- package/src/cli/commands/schedules.ts +384 -11
- package/src/cli/lib/__tests__/inspect-plugin.test.ts +69 -5
- package/src/cli/lib/__tests__/install-from-github.test.ts +15 -0
- package/src/cli/lib/__tests__/upgrade-plugin.test.ts +81 -4
- package/src/cli/lib/inspect-plugin.ts +62 -1
- package/src/cli/lib/install-from-github.ts +52 -5
- package/src/cli/lib/upgrade-plugin.ts +18 -0
- package/src/config/__tests__/workflows-schema.test.ts +60 -0
- package/src/config/bundled-skills/acp/SKILL.md +2 -2
- package/src/config/bundled-skills/app-builder/SKILL.md +1 -1
- package/src/config/bundled-skills/app-builder/tools/app-create.ts +6 -1
- package/src/config/bundled-skills/app-builder/tools/app-generate-icon.ts +7 -1
- package/src/config/bundled-skills/app-builder/tools/app-refresh.ts +7 -1
- package/src/config/bundled-skills/app-builder/tools/app-update.ts +10 -1
- package/src/config/bundled-skills/image-studio/SKILL.md +66 -19
- package/src/config/bundled-skills/image-studio/TOOLS.json +1 -6
- package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +22 -3
- package/src/config/bundled-skills/personal-page/SKILL.md +57 -0
- package/src/config/bundled-skills/personal-page/TOOLS.json +27 -0
- package/src/config/bundled-skills/personal-page/tools/app-refresh.ts +17 -0
- package/src/config/bundled-skills/schedule/SKILL.md +7 -2
- package/src/config/bundled-skills/schedule/TOOLS.json +48 -4
- package/src/config/bundled-skills/workflows/SKILL.md +214 -0
- package/src/config/bundled-skills/workflows/TOOLS.json +84 -0
- package/src/config/bundled-skills/workflows/tools/manage-workflows.ts +12 -0
- package/src/config/bundled-skills/workflows/tools/run-workflow.ts +12 -0
- package/src/config/bundled-tool-registry.ts +14 -2
- package/src/config/call-site-defaults.ts +5 -0
- package/src/config/feature-flag-registry.json +12 -4
- package/src/config/llm-context-resolution.ts +8 -0
- package/src/config/llm-resolver.ts +30 -0
- package/src/config/preloaded-apps/personal-page/src/components/About.tsx +22 -0
- package/src/config/preloaded-apps/personal-page/src/components/App.tsx +16 -0
- package/src/config/preloaded-apps/personal-page/src/components/Features.tsx +77 -0
- package/src/config/preloaded-apps/personal-page/src/components/Hero.tsx +57 -0
- package/src/config/preloaded-apps/personal-page/src/components/Pending.tsx +28 -0
- package/src/config/preloaded-apps/personal-page/src/components/animations.tsx +234 -0
- package/src/config/preloaded-apps/personal-page/src/components/icons.tsx +48 -0
- package/src/config/preloaded-apps/personal-page/src/components/media.ts +16 -0
- package/src/config/preloaded-apps/personal-page/src/index.html +20 -0
- package/src/config/preloaded-apps/personal-page/src/main.tsx +7 -0
- package/src/config/preloaded-apps/personal-page/src/profile-data.ts +82 -0
- package/src/config/preloaded-apps/personal-page/src/styles.css +759 -0
- package/src/config/schema.ts +2 -0
- package/src/config/schemas/call-site-catalog.ts +7 -0
- package/src/config/schemas/heartbeat.ts +4 -1
- package/src/config/schemas/llm.ts +33 -26
- package/src/config/schemas/memory-retrospective.ts +19 -0
- package/src/config/schemas/platform.ts +8 -0
- package/src/config/schemas/services.ts +5 -2
- package/src/config/schemas/workflows.ts +42 -0
- package/src/config/skills.ts +3 -3
- package/src/context/compactor.ts +273 -39
- package/src/context/post-turn-tool-result-truncation.ts +23 -6
- package/src/context/tool-result-spool.ts +12 -17
- package/src/credential-execution/executable-discovery.ts +1 -1
- package/src/credential-execution/process-manager.ts +37 -3
- package/src/credential-execution/prompted-credential.ts +205 -0
- package/src/daemon/conversation-agent-loop-handlers.ts +14 -0
- package/src/daemon/conversation-process.ts +11 -2
- package/src/daemon/conversation-surfaces.ts +167 -3
- package/src/daemon/conversation-tool-setup.ts +103 -26
- package/src/daemon/conversation-usage.ts +2 -0
- package/src/daemon/conversation.ts +115 -11
- package/src/daemon/handlers/shared.ts +26 -14
- package/src/daemon/host-cu-proxy.ts +15 -12
- package/src/daemon/host-file-proxy.ts +15 -12
- package/src/daemon/host-transfer-proxy.ts +30 -24
- package/src/daemon/lifecycle.ts +40 -3
- package/src/daemon/message-protocol.ts +3 -0
- package/src/daemon/message-types/messages.ts +2 -10
- package/src/daemon/message-types/workflows.ts +49 -0
- package/src/daemon/parse-actual-tokens-from-error.test.ts +62 -1
- package/src/daemon/parse-actual-tokens-from-error.ts +43 -4
- package/src/daemon/process-message.ts +6 -0
- package/src/daemon/tool-setup-types.ts +57 -0
- package/src/daemon/wake-conversation-ops.ts +18 -0
- package/src/heartbeat/heartbeat-run-store.ts +8 -2
- package/src/home/feed-types.ts +1 -1
- package/src/ipc/gateway-flag-listener.ts +28 -6
- package/src/mcp/mcp-auth-state.ts +8 -20
- package/src/media/__tests__/image-models.test.ts +57 -0
- package/src/media/image-models.ts +66 -0
- package/src/memory/__tests__/auto-analysis-enqueue.test.ts +38 -0
- package/src/memory/__tests__/find-most-recent-retrospective-for.test.ts +12 -2
- package/src/memory/__tests__/memory-retrospective-job.test.ts +911 -34
- package/src/memory/__tests__/memory-retrospective-startup-cleanup.test.ts +227 -5
- package/src/memory/__tests__/memory-retrospective-state.test.ts +195 -0
- package/src/memory/__tests__/preloaded-apps.test.ts +85 -0
- package/src/memory/auto-analysis-enqueue.ts +14 -1
- package/src/memory/compaction-log-store-clickhouse.ts +6 -4
- package/src/memory/conversation-crud.ts +9 -2
- package/src/memory/conversation-disk-view.ts +1 -1
- package/src/memory/conversation-queries.ts +22 -7
- package/src/memory/db-init.ts +20 -0
- package/src/memory/db-maintenance.ts +16 -0
- package/src/memory/embedding-runtime-manager.ts +1 -1
- package/src/memory/llm-request-log-source-clickhouse.ts +112 -14
- package/src/memory/llm-request-log-source-local.ts +19 -1
- package/src/memory/llm-request-log-source.ts +35 -6
- package/src/memory/llm-request-log-store.ts +90 -2
- package/src/memory/memory-retrospective-constants.ts +9 -0
- package/src/memory/memory-retrospective-enqueue.ts +3 -6
- package/src/memory/memory-retrospective-fork-boundary.ts +94 -0
- package/src/memory/memory-retrospective-job.ts +500 -208
- package/src/memory/memory-retrospective-startup-cleanup.ts +97 -19
- package/src/memory/memory-retrospective-state.ts +85 -2
- package/src/memory/migrations/281-memory-retrospective-remembered-log.ts +40 -0
- package/src/memory/migrations/282-schedule-inference-profile.test.ts +77 -0
- package/src/memory/migrations/282-schedule-inference-profile.ts +26 -0
- package/src/memory/migrations/283-memory-v3-selections-message-id-and-sections.test.ts +102 -0
- package/src/memory/migrations/283-memory-v3-selections-message-id-and-sections.ts +53 -0
- package/src/memory/migrations/284-workflow-runs.ts +51 -0
- package/src/memory/migrations/285-schedule-workflow-mode.ts +26 -0
- package/src/memory/migrations/286-workflow-run-trust.ts +27 -0
- package/src/memory/migrations/287-conversation-origin-channel-index.ts +15 -0
- package/src/memory/migrations/288-backfill-origin-channel-from-bindings.ts +43 -0
- package/src/memory/migrations/289-contact-channels-unique-ext-user.ts +115 -0
- package/src/memory/migrations/290-schedule-capabilities.test.ts +77 -0
- package/src/memory/migrations/290-schedule-capabilities.ts +25 -0
- package/src/memory/migrations/__tests__/281-memory-retrospective-remembered-log.test.ts +96 -0
- package/src/memory/migrations/__tests__/289-contact-channels-unique-ext-user.test.ts +571 -0
- package/src/memory/migrations/index.ts +10 -0
- package/src/memory/preloaded-apps.ts +116 -0
- package/src/memory/schema/infrastructure.ts +4 -0
- package/src/memory/schema/memory-core.ts +4 -0
- package/src/memory/v2/__tests__/concept-page-frontmatter-schema.test.ts +45 -0
- package/src/memory/v2/__tests__/frontmatter-sweep.test.ts +11 -7
- package/src/memory/v2/__tests__/page-store.test.ts +13 -2
- package/src/memory/v2/__tests__/qdrant.test.ts +24 -0
- package/src/memory/v2/frontmatter-sweep.ts +7 -6
- package/src/memory/v2/page-store.ts +4 -3
- package/src/memory/v2/qdrant.ts +42 -3
- package/src/memory/v2/types.ts +16 -10
- package/src/messaging/draft-store.ts +1 -1
- package/src/notifications/access-request-copy.ts +200 -113
- package/src/notifications/adapters/slack.ts +250 -111
- package/src/notifications/adapters/telegram.ts +7 -44
- package/src/notifications/approval-card-builder.ts +93 -0
- package/src/notifications/broadcaster.ts +74 -0
- package/src/notifications/conversation-pairing.ts +8 -6
- package/src/notifications/copy-composer.ts +32 -26
- package/src/notifications/decision-engine.ts +59 -7
- package/src/notifications/guardian-question-mode.ts +145 -155
- package/src/notifications/home-feed-side-effect.ts +28 -11
- package/src/notifications/notification-utils.ts +66 -0
- package/src/notifications/signal.ts +6 -0
- package/src/notifications/tool-approval-copy.ts +142 -0
- package/src/notifications/types.ts +19 -0
- package/src/permissions/threshold.ts +11 -0
- package/src/plugin-api/types.ts +16 -4
- package/src/plugins/defaults/compaction/window-manager.ts +44 -0
- package/src/plugins/defaults/index.ts +46 -0
- package/src/plugins/defaults/max-tokens-continue/continue-state-store.ts +53 -0
- package/src/plugins/defaults/max-tokens-continue/hooks/post-model-call.ts +80 -0
- package/src/plugins/defaults/max-tokens-continue/hooks/stop.ts +20 -0
- package/src/plugins/defaults/max-tokens-continue/package.json +14 -0
- package/src/plugins/defaults/memory-retrieval/hooks/__tests__/user-prompt-submit.test.ts +37 -0
- package/src/plugins/defaults/memory-retrieval/hooks/user-prompt-submit.ts +29 -1
- package/src/plugins/defaults/memory-v3-shadow/__tests__/carry-integration.test.ts +8 -3
- package/src/plugins/defaults/memory-v3-shadow/__tests__/injection.test.ts +4 -2
- package/src/plugins/defaults/memory-v3-shadow/__tests__/pool-select.test.ts +28 -18
- package/src/plugins/defaults/memory-v3-shadow/__tests__/section-dense-store.test.ts +67 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/selection-log-store.test.ts +122 -22
- package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-integration.test.ts +2 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-plugin.test.ts +63 -1
- package/src/plugins/defaults/memory-v3-shadow/injector.ts +61 -18
- package/src/plugins/defaults/memory-v3-shadow/orchestrate.ts +1 -1
- package/src/plugins/defaults/memory-v3-shadow/pool-select.ts +39 -10
- package/src/plugins/defaults/memory-v3-shadow/section-dense-store.ts +34 -1
- package/src/plugins/defaults/memory-v3-shadow/selection-log-store.ts +112 -47
- package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +78 -15
- package/src/plugins/defaults/task-progress-nudge/hooks/post-tool-use.ts +206 -0
- package/src/plugins/defaults/task-progress-nudge/package.json +15 -0
- package/src/prompts/__tests__/system-prompt.test.ts +100 -1
- package/src/prompts/__tests__/task-progress-hint-section.test.ts +5 -7
- package/src/prompts/normalize-onboarding.ts +2 -0
- package/src/prompts/persona-resolver.ts +3 -0
- package/src/prompts/system-prompt.ts +51 -2
- package/src/prompts/templates/BOOTSTRAP-ACTIVATION-RAIL.md +3 -1
- package/src/prompts/templates/system-sections.ts +8 -3
- package/src/providers/call-site-routing.ts +6 -3
- package/src/providers/fireworks/client.ts +3 -0
- package/src/providers/inference/auth.ts +52 -46
- package/src/providers/model-intents.ts +2 -2
- package/src/providers/openai/__tests__/coerce-object-args.test.ts +105 -0
- package/src/providers/openai/chat-completions-provider.ts +47 -9
- package/src/providers/openai/coerce-object-args.ts +104 -0
- package/src/providers/retry.ts +8 -5
- package/src/providers/types.ts +10 -0
- package/src/runtime/__tests__/agent-wake.test.ts +629 -7
- package/src/runtime/access-request-helper.ts +16 -9
- package/src/runtime/agent-wake.ts +302 -51
- package/src/runtime/assistant-stream-state.ts +141 -8
- package/src/runtime/background-job-runner.ts +9 -0
- package/src/runtime/channel-approval-types.ts +1 -0
- package/src/runtime/channel-invite-transports/telegram.ts +6 -5
- package/src/runtime/channel-invite-transports/voice.ts +2 -2
- package/src/runtime/channel-invite-types.ts +4 -2
- package/src/runtime/channel-retry-sweep.ts +19 -41
- package/src/runtime/finalize-event-delivery.ts +72 -0
- package/src/runtime/guardian-action-message-composer.ts +0 -54
- package/src/runtime/http-server.ts +6 -14
- package/src/runtime/http-types.ts +0 -1
- package/src/runtime/message-composer-types.ts +0 -9
- package/src/runtime/middleware/__tests__/rate-limiter.test.ts +63 -0
- package/src/runtime/middleware/auth.ts +27 -3
- package/src/runtime/middleware/rate-limiter.ts +28 -1
- package/src/runtime/migrations/vbundle-builder.ts +6 -5
- package/src/runtime/pending-interactions.ts +20 -1
- package/src/runtime/routes/__tests__/conversation-compaction-routes.test.ts +232 -173
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +18 -0
- package/src/runtime/routes/__tests__/retrospective-routes.test.ts +436 -0
- package/src/runtime/routes/__tests__/surface-action-routes.test.ts +11 -0
- package/src/runtime/routes/approval-routes.ts +35 -8
- package/src/runtime/routes/approval-strategies/guardian-callback-strategy.ts +2 -1
- package/src/runtime/routes/btw-routes.ts +37 -1
- package/src/runtime/routes/channel-delivery-routes.ts +11 -7
- package/src/runtime/routes/channel-route-definitions.ts +3 -0
- package/src/runtime/routes/channel-route-shared.ts +3 -1
- package/src/runtime/routes/consolidation-routes.ts +17 -13
- package/src/runtime/routes/conversation-compaction-routes.ts +159 -119
- package/src/runtime/routes/conversation-list-routes.ts +41 -4
- package/src/runtime/routes/conversation-query-routes.ts +196 -11
- package/src/runtime/routes/conversation-routes.ts +15 -1
- package/src/runtime/routes/credential-prompt-routes.ts +39 -17
- package/src/runtime/routes/empty-state-greeting-cache.ts +65 -0
- package/src/runtime/routes/heartbeat-routes.ts +18 -13
- package/src/runtime/routes/image-generation-routes.ts +20 -2
- package/src/runtime/routes/inbound-message-handler.ts +13 -12
- package/src/runtime/routes/inbound-stages/acl-enforcement.ts +32 -31
- package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +7 -5
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +7 -21
- package/src/runtime/routes/inbound-stages/escalation-intercept.ts +5 -5
- package/src/runtime/routes/inbound-stages/guardian-activation-intercept.ts +6 -15
- package/src/runtime/routes/inbound-stages/secret-ingress-check.ts +1 -1
- package/src/runtime/routes/index.ts +6 -0
- package/src/runtime/routes/log-export/AGENTS.md +1 -1
- package/src/runtime/routes/log-export/workspace-allowlist.ts +1 -1
- package/src/runtime/routes/migration-routes.ts +5 -9
- package/src/runtime/routes/plugins-routes.ts +26 -0
- package/src/runtime/routes/ps-routes.ts +10 -8
- package/src/runtime/routes/retrospective-routes.ts +235 -0
- package/src/runtime/routes/runs-pagination.ts +75 -0
- package/src/runtime/routes/schedule-routes.ts +367 -59
- package/src/runtime/routes/sounds-config-routes.ts +239 -0
- package/src/runtime/routes/surface-action-routes.ts +84 -4
- package/src/runtime/routes/workflow-routes.test.ts +372 -0
- package/src/runtime/routes/workflow-routes.ts +363 -0
- package/src/runtime/routes/workspace-routes.test.ts +61 -1
- package/src/runtime/routes/workspace-routes.ts +26 -1
- package/src/runtime/services/__tests__/analyze-conversation.test.ts +38 -0
- package/src/runtime/services/analyze-conversation.ts +26 -13
- package/src/schedule/inference-profile.ts +28 -0
- package/src/schedule/schedule-store.ts +152 -4
- package/src/schedule/scheduler-types.ts +6 -0
- package/src/schedule/scheduler.ts +96 -0
- package/src/security/secret-allowlist.ts +1 -1
- package/src/skills/path-classifier.ts +1 -1
- package/src/tools/apps/executors.ts +23 -0
- package/src/tools/apps/resolve-app-id.ts +42 -0
- package/src/tools/browser/browser-execution.ts +9 -11
- package/src/tools/credentials/broker.ts +4 -4
- package/src/tools/credentials/vault.ts +26 -137
- package/src/tools/executor.ts +69 -0
- package/src/tools/flag-gated-tools.test.ts +76 -0
- package/src/tools/permission-checker.ts +8 -1
- package/src/tools/registry.ts +51 -0
- package/src/tools/schedule/create.ts +77 -1
- package/src/tools/schedule/list.ts +1 -0
- package/src/tools/schedule/update.ts +73 -1
- package/src/tools/skills/execute.ts +56 -0
- package/src/tools/terminal/shell.ts +1 -1
- package/src/tools/ui-surface/definitions.ts +91 -2
- package/src/tools/workflows/manage-workflows.ts +183 -0
- package/src/tools/workflows/run-workflow.test.ts +442 -0
- package/src/tools/workflows/run-workflow.ts +88 -0
- package/src/types/onboarding-context.ts +2 -0
- package/src/usage/attribution.ts +24 -0
- package/src/util/canonicalize-identity.ts +12 -3
- package/src/util/platform.ts +17 -17
- package/src/watcher/__tests__/engine.test.ts +24 -0
- package/src/watcher/__tests__/telemetry.test.ts +135 -0
- package/src/watcher/engine.ts +7 -0
- package/src/watcher/telemetry.ts +74 -0
- package/src/workflows/capabilities.test.ts +365 -0
- package/src/workflows/capabilities.ts +359 -0
- package/src/workflows/deterministic-stringify.ts +27 -0
- package/src/workflows/engine-integration.test.ts +656 -0
- package/src/workflows/engine.test.ts +1144 -0
- package/src/workflows/engine.ts +1078 -0
- package/src/workflows/fanout-load.test.ts +168 -0
- package/src/workflows/journal-store.test.ts +369 -0
- package/src/workflows/journal-store.ts +470 -0
- package/src/workflows/leaf-runner.test.ts +704 -0
- package/src/workflows/leaf-runner.ts +589 -0
- package/src/workflows/library.test.ts +134 -0
- package/src/workflows/library.ts +124 -0
- package/src/workflows/run-manager.test.ts +711 -0
- package/src/workflows/run-manager.ts +593 -0
- package/src/workflows/sandbox-escape.test.ts +339 -0
- package/src/workflows/sandbox.test.ts +251 -0
- package/src/workflows/sandbox.ts +447 -0
- package/src/workspace/adaptive-thinking-repair.ts +33 -11
- package/src/workspace/migrations/021-move-signals-to-workspace.ts +1 -1
- package/src/workspace/migrations/022-move-hooks-to-workspace.ts +1 -1
- package/src/workspace/migrations/026-backfill-install-meta.ts +1 -1
- package/src/workspace/migrations/030-seed-pkb-autoinject.ts +2 -1
- package/src/workspace/migrations/031-drop-user-md.ts +1 -4
- package/src/workspace/migrations/048-remove-workspace-hooks.ts +1 -1
- package/src/workspace/migrations/056-release-notes-inference-profile-reordering.ts +5 -2
- package/src/workspace/migrations/061-move-backup-key-to-workspace.ts +1 -1
- package/src/workspace/migrations/082-backfill-managed-profile-labels.ts +8 -2
- package/src/workspace/migrations/097-enable-adaptive-thinking-managed-profiles.ts +41 -14
- package/src/workspace/migrations/102-preserve-heartbeat-enabled-for-existing-workspaces.ts +69 -0
- package/src/workspace/migrations/103-upgrade-quality-profile-to-opus-4-8.ts +83 -0
- package/src/workspace/migrations/104-recheck-adaptive-thinking-model-implied-anthropic.ts +133 -0
- package/src/workspace/migrations/registry.ts +6 -0
- package/src/workspace/migrations/runner.ts +1 -1
- package/tsconfig.json +1 -1
- package/src/__tests__/guardian-action-copy-generator.test.ts +0 -200
- package/src/__tests__/guardian-action-grant-mint-consume.test.ts +0 -579
- package/src/__tests__/guardian-action-store.test.ts +0 -106
- package/src/daemon/guardian-action-generators.ts +0 -71
- package/src/memory/guardian-action-store.ts +0 -484
- 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
|
+
}
|