feat(think): dynamic ThinkWorkflow support via Dynamic Workers#1786
feat(think): dynamic ThinkWorkflow support via Dynamic Workers#1786thomasgauvin wants to merge 5 commits into
Conversation
Add ability to run generated TypeScript as a ThinkWorkflow at runtime: - Think.runDynamicWorkflow() stores generated code in SQLite and starts a Dynamic Workflow instance with dispatcher metadata - DynamicThinkWorkflow entrypoint (from @cloudflare/think/dynamic-workflows) loads code from the agent via RPC, bundles it with @cloudflare/worker-bundler, and dispatches execution as a Dynamic Worker - Generated code extends ThinkWorkflow with full step.prompt() support - Uses @cloudflare/dynamic-workflows for engine-to-Worker-Loader routing - Includes example at examples/dynamic-think-workflows/
🦋 Changeset detectedLatest commit: 48970f0 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
| const worker = env.LOADER.get(`dwt-${meta.wfId}`, async () => ({ | ||
| mainModule, | ||
| modules, | ||
| compatibilityDate: "2026-01-01", | ||
| env: { [meta.agentBinding]: env[meta.agentBinding] } | ||
| })); |
There was a problem hiding this comment.
🔴 Dynamic worker missing nodejs_compat flag and uses stale compatibility date
The dynamic worker created in the loader at packages/think/src/dynamic-workflows/loader.ts:70-75 sets compatibilityDate: "2026-01-01" but omits compatibilityFlags. The parent worker (and every wrangler.jsonc in the repo) uses compatibilityDate: "2026-06-11" with compatibilityFlags: ["nodejs_compat"]. The generated workflow code bundles @cloudflare/think and zod, which may rely on Node.js built-in APIs (e.g. crypto, Buffer, streams) that are only available when nodejs_compat is enabled. Without the flag, the dynamic worker will fail at runtime if any bundled dependency references a Node.js built-in. The stale date (2026-01-01 vs 2026-06-11) could also cause behavioral differences between the host worker and the dynamic worker.
| const worker = env.LOADER.get(`dwt-${meta.wfId}`, async () => ({ | |
| mainModule, | |
| modules, | |
| compatibilityDate: "2026-01-01", | |
| env: { [meta.agentBinding]: env[meta.agentBinding] } | |
| })); | |
| const worker = env.LOADER.get(`dwt-${meta.wfId}`, async () => ({ | |
| mainModule, | |
| modules, | |
| compatibilityDate: "2026-06-11", | |
| compatibilityFlags: ["nodejs_compat"], | |
| env: { [meta.agentBinding]: env[meta.agentBinding] } | |
| })); |
Was this helpful? React with 👍 or 👎 to provide feedback.
| return worker.getEntrypoint( | ||
| "GeneratedWorkflow" | ||
| ) as unknown as WorkflowRunner; |
There was a problem hiding this comment.
🚩 Hardcoded entrypoint name "GeneratedWorkflow" creates undocumented contract
The loader at packages/think/src/dynamic-workflows/loader.ts:77-78 calls worker.getEntrypoint("GeneratedWorkflow"), which means ALL generated workflow code must export a class named exactly GeneratedWorkflow. This requirement is not documented in the runDynamicWorkflow JSDoc (packages/think/src/think.ts:7449-7463), which only says "The generated code must export a default class that extends ThinkWorkflow." If a user names their class differently (e.g., ResearchWorkflow), or uses an anonymous default export, the entrypoint lookup will fail at runtime. Consider either documenting this constraint clearly in the JSDoc, or making the entrypoint name configurable.
Was this helpful? React with 👍 or 👎 to provide feedback.
| const worker = env.LOADER.get(`dwt-${meta.wfId}`, async () => ({ | ||
| mainModule, | ||
| modules, | ||
| compatibilityDate: "2026-01-01", | ||
| env: { [meta.agentBinding]: env[meta.agentBinding] } | ||
| })); |
There was a problem hiding this comment.
🚩 Dynamic worker env only forwards the agent binding — other bindings are unavailable
At packages/think/src/dynamic-workflows/loader.ts:74, the dynamic worker's env is set to { [meta.agentBinding]: env[meta.agentBinding] }, forwarding only the agent's DurableObjectNamespace. If a generated workflow's run() method needs other bindings (e.g., AI for Workers AI, KV, R2, or other DO namespaces), they won't be available. The example at examples/dynamic-think-workflows/src/index.ts:70-74 uses step.prompt() which invokes LLM calls — whether this resolves through the agent stub (via RPC) or needs a direct AI binding determines if this is a problem. Worth verifying that step.prompt() in ThinkWorkflow delegates LLM calls through the agent's DO rather than requiring a local AI binding.
Was this helpful? React with 👍 or 👎 to provide feedback.
| const { mainModule, modules } = await createWorker({ | ||
| files: { | ||
| "workflow.ts": code, | ||
| "package.json": JSON.stringify({ | ||
| dependencies: { | ||
| "@cloudflare/think": "*", | ||
| zod: "*" | ||
| } | ||
| }) | ||
| } | ||
| }); |
There was a problem hiding this comment.
🚩 Bundler package.json missing agents dependency used by generated code
The loader's hardcoded package.json at packages/think/src/dynamic-workflows/loader.ts:61-66 only lists @cloudflare/think and zod as dependencies. However, the example's generated code (examples/dynamic-think-workflows/src/index.ts:54) imports import type { AgentWorkflowEvent } from "agents/workflows". While import type is erased at runtime, if any generated code does a value import from agents, the bundler would fail to resolve it. Since agents is a peer dependency of @cloudflare/think, whether it's transitively available depends on how @cloudflare/worker-bundler resolves dependencies. Users generating code with runtime imports from agents would hit bundling errors.
Was this helpful? React with 👍 or 👎 to provide feedback.
agents
@cloudflare/ai-chat
@cloudflare/codemode
create-think
hono-agents
@cloudflare/shell
@cloudflare/think
@cloudflare/voice
@cloudflare/worker-bundler
commit: |
- Resolve agent binding name via parent-class constructor (consistent with base _findAgentBindingName; avoids minifier-erased this.constructor.name) and cache the result - Move createWorker/code-fetch into the LOADER.get factory so TypeScript bundling only runs on cache miss, not every step dispatch - Validate metadata at runtime in the loader instead of an unchecked cast - Rename getWorkflowCode -> _getWorkflowCode to signal internal RPC use - Add validateWorkflowCode() hook + MAX_DYNAMIC_WORKFLOW_CODE_BYTES limit - Validate agentBinding resolves to a Durable Object namespace - Add unit tests and a Dynamic Workflows docs section
Agent.name is a getter-only accessor (partyserver base), so define it as an own data property instead of assigning through Object.assign.
- Mirror base Agent.runWorkflow contract on the tracking INSERT: re-throw non-UNIQUE errors and surface duplicate-ID collisions instead of swallowing - Reject caller-supplied IDs that are already tracked before storing code, and clean up the stored code row if workflow.create() fails (no orphan rows) - Validate generated code declares a GeneratedWorkflow class (the loader entrypoint); extract the entrypoint name to a named constant in the loader - Fast-path the size check before TextEncoder allocation - Document RPC reachability of _getWorkflowCode and the class-name requirement - Extend tests for class-name validation and create-failure cleanup
Summary
Add the ability to run generated TypeScript as a
ThinkWorkflowat runtime using@cloudflare/dynamic-workflows+@cloudflare/worker-bundler+ Dynamic Workers.What's new
Think.runDynamicWorkflow(workflowName, code, params?, options?)Stores generated TypeScript source in SQLite, then starts a Workflow instance with
__dispatcherMetadatain the params envelope. TheDynamicThinkWorkflowentrypoint reads this metadata to load, bundle, and execute the code.Think.getWorkflowCode(wfId)RPC method used by the loader to retrieve stored generated code by ID.
DynamicThinkWorkflow(from@cloudflare/think/dynamic-workflows)Created via
createDynamicWorkflowEntrypointfrom@cloudflare/dynamic-workflows. When the Workflows engine callsrun():__dispatcherMetadatafrom the event payload@cloudflare/think,zod) viacreateWorker()env.LOADER.get()run(event, step)to the generatedThinkWorkflowThe generated code runs as a real
ThinkWorkflow—step.prompt(),this.agent,step.do(),step.sleep(),step.waitForEvent()all work natively.New dependencies
@cloudflare/dynamic-workflows— runtime dependency (small library, ~300 lines)@cloudflare/worker-bundler— optional peer dependency (dynamically imported)Example
examples/dynamic-think-workflows/— server-only example showing a Think agent that generates a ThinkWorkflow class as a string and runs it as a Dynamic Workflow.Test plan
pnpm run checkpasses (sherif + exports + oxfmt + oxlint + typecheck — 112/112 projects)@cloudflare/thinkbuilds successfullywrangler dev(requires Workers Paid plan for Dynamic Workers)