Skip to content

feat(think): dynamic ThinkWorkflow support via Dynamic Workers#1786

Draft
thomasgauvin wants to merge 5 commits into
mainfrom
dynamic-think-workflows
Draft

feat(think): dynamic ThinkWorkflow support via Dynamic Workers#1786
thomasgauvin wants to merge 5 commits into
mainfrom
dynamic-think-workflows

Conversation

@thomasgauvin

@thomasgauvin thomasgauvin commented Jun 19, 2026

Copy link
Copy Markdown

Summary

Add the ability to run generated TypeScript as a ThinkWorkflow at 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 __dispatcherMetadata in the params envelope. The DynamicThinkWorkflow entrypoint 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 createDynamicWorkflowEntrypoint from @cloudflare/dynamic-workflows. When the Workflows engine calls run():

  1. Unwraps __dispatcherMetadata from the event payload
  2. Fetches generated code from the agent via RPC
  3. Bundles it with npm deps (@cloudflare/think, zod) via createWorker()
  4. Loads as a Dynamic Worker via env.LOADER.get()
  5. Dispatches run(event, step) to the generated ThinkWorkflow

The generated code runs as a real ThinkWorkflowstep.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 check passes (sherif + exports + oxfmt + oxlint + typecheck — 112/112 projects)
  • @cloudflare/think builds successfully
  • Manual test with wrangler dev (requires Workers Paid plan for Dynamic Workers)

Open in Devin Review

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-bot

changeset-bot Bot commented Jun 19, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 48970f0

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@cloudflare/think Minor

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

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 4 potential issues.

View 2 additional findings in Devin Review.

Open in Devin Review

Comment on lines +70 to +75
const worker = env.LOADER.get(`dwt-${meta.wfId}`, async () => ({
mainModule,
modules,
compatibilityDate: "2026-01-01",
env: { [meta.agentBinding]: env[meta.agentBinding] }
}));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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.

Suggested change
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] }
}));
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +77 to +79
return worker.getEntrypoint(
"GeneratedWorkflow"
) as unknown as WorkflowRunner;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 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.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +70 to +75
const worker = env.LOADER.get(`dwt-${meta.wfId}`, async () => ({
mainModule,
modules,
compatibilityDate: "2026-01-01",
env: { [meta.agentBinding]: env[meta.agentBinding] }
}));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 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.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +58 to +68
const { mainModule, modules } = await createWorker({
files: {
"workflow.ts": code,
"package.json": JSON.stringify({
dependencies: {
"@cloudflare/think": "*",
zod: "*"
}
})
}
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 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.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@pkg-pr-new

pkg-pr-new Bot commented Jun 19, 2026

Copy link
Copy Markdown

Open in StackBlitz

agents

npm i https://pkg.pr.new/agents@1786

@cloudflare/ai-chat

npm i https://pkg.pr.new/@cloudflare/ai-chat@1786

@cloudflare/codemode

npm i https://pkg.pr.new/@cloudflare/codemode@1786

create-think

npm i https://pkg.pr.new/create-think@1786

hono-agents

npm i https://pkg.pr.new/hono-agents@1786

@cloudflare/shell

npm i https://pkg.pr.new/@cloudflare/shell@1786

@cloudflare/think

npm i https://pkg.pr.new/@cloudflare/think@1786

@cloudflare/voice

npm i https://pkg.pr.new/@cloudflare/voice@1786

@cloudflare/worker-bundler

npm i https://pkg.pr.new/@cloudflare/worker-bundler@1786

commit: 48970f0

@thomasgauvin thomasgauvin marked this pull request as draft June 19, 2026 21:13
- 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant