contrib: add @temporalio/strands-agents plugin#2091
Open
brianstrauch wants to merge 24 commits into
Open
Conversation
Adds a Strands Agents plugin mirroring the Python sdk's strands branch: models, MCP, activityAsTool/activityAsHook helpers, an interrupt-aware failure converter, and streaming via @temporalio/workflow-streams. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds an ava test suite (9 tests) covering the model dispatch path, activityAsTool / activityAsHook, in-workflow tool(), TemporalMCPClient, structured output, the activity-tool interrupt round-trip, the streamingTopic publisher, and a deterministic-history replay assertion. Wiring required to bundle @strands-agents/sdk 1.3.0 in a workflow: - load-polyfills installs crypto.randomUUID using workflow.uuid4 so the @ungap/structured-clone polyfill used by Temporal's sink path is replay safe. - StrandsPlugin.configureBundler ignores `fs`, replaces the SDK's dynamic node:* and MCP-transport imports with an empty stub, and disables async chunks so webpack's JSONP runtime never tries to resolve `self`. - TemporalMCPClient.listTools returns lightweight TemporalMCPTool wrappers (lazy-required to break the cycle) so the workflow bundle never needs the unexported McpTool from @strands-agents/sdk's index. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Aligns with sdk-python's `temporalio[strands-agents]` extras and with the sibling @temporalio/openai-agents contrib package. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
mjameswh
reviewed
May 27, 2026
…ler ignores Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@strands-agents/sdk is a third-party package, so there's no reason to bypass the 2-week supply-chain age gate for it (unlike @temporalio/* and nexus-rpc, which we publish and immediately consume). The ^1.3.0 constraint resolves to an already-aged 1.x version. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The activityAsTool, TemporalMCPClient, and in-workflow tool() tests asserted only the stub model's scripted final string, which can't prove the tool actually ran. Capture the conversation the second stream() call sees and assert the tool's output round-tripped back into the loop. The echo test counts occurrences since echo returns its input verbatim and the input already appears in the tool-use block. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Type the tool/MCP input schemas as JSONSchema, the dynamic activity proxies by their real input types, and JsonBlock payloads as JSONValue, dropping the corresponding `as never` casts. Let the terminal-error table infer its constructor types instead of casting each through never. Left in place the casts that sit on the payload-converter wire boundary or work around SDK types that aren't re-exported (e.g. McpTool). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ON Schema activityAsTool's inputSchema now takes a JSON Schema or a Zod schema, mirroring Strands' own tool() ergonomics. A shared toJsonSchema helper converts Zod via zod 4's native z.toJSONSchema (pure/deterministic, so sandbox-safe) and passes literal JSON Schemas through. TemporalMCPTool routes its schema through the same helper for symmetry; in practice MCP schemas arrive from the server as JSON across the listTools activity boundary, so the Zod branch is only exercised by activityAsTool. Conversion only -- input is not validated against the schema. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Use listToolsActivityName/callToolActivityName instead of re-deriving the
`${server}-listTools`/`${server}-callTool` convention inline, so the
registration keys can't drift from the workflow-side lookup.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The per-server callTool activity reconnected on every invocation (connect + listTools + callTool + disconnect), so an agent making several successive MCP calls paid a full handshake — and a redundant listTools round-trip — per call. Hold a lazily-opened MCP session per server in the activity worker process so successive callTool activities reuse one connection, evicting it after an idle timeout (or on a call error, so a broken session reconnects). Scope the plugin's shutdown eviction to the servers it registered rather than every cached connection. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The activity/MCP tool tests asserted only on the stub model's scripted reply, so they exercised StubModel rather than proving the plugin dispatches the tool. Record real invocation args and assert on them: getWeather via a module-level sink, MCP callTool via capturing client subclasses. The in-workflow tool runs in the sandbox, so its callback emits an `echoed:` marker the model never produces, making the follow-up-message check prove the callback ran. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The per-server callTool activity keeps a worker-process MCP connection open between calls and evicts it after a fixed 5-minute idle window. Expose that window as a `mcpConnectionIdleTimeout` plugin option, accepting a millisecond number or a duration string (e.g. '5 minutes') like `startToCloseTimeout`. MCP_CONNECTION_IDLE_MS stays the default and remains internal to the package. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The callTool activity listed tools on every cached connection just to
recover the McpTool object it passed to client.callTool, but that method
only reads tool.name. Drop the per-connection listTools() and dispatch
by a minimal { name } descriptor, mirroring the Python SDK's by-name
session.call_tool. Startup discovery still populates the tool cache the
agent reads. The cache now holds the connected McpClient directly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A worker-lifetime tool cache went stale if an MCP server was redeployed
mid-workflow. Drop TOOL_CACHE and the startup populateMcpCache; the
{server}-listTools activity now enumerates tools live over the shared
worker-process connection on every call, so the agent always sees the
server's current tools. Connection reuse and idle eviction are unchanged
— only the tool list is no longer cached. The connection is now opened
lazily on first use rather than at worker startup.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Strands lists an MCP client's tools once at agent init and reuses that list for the whole workflow, so a mid-workflow server restart was never reflected. TemporalMCPClient now accepts `cacheTools` (default false): when off, TemporalAgent registers an AfterToolsEvent hook that re-lists and reconciles the tool registry each turn; when true, it lists once. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
JasonSteving99
approved these changes
Jun 2, 2026
Strands' agent loop uses a downleveled `using` lock that references Symbol.dispose/Symbol.asyncDispose, which Node <22 lacks in the workflow isolate. Every agent.invoke() failed the workflow task with "Symbol.dispose is not defined." on Node 20, retrying until the test timed out. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Downleveled `using`/`await using` declarations reference Symbol.dispose/Symbol.asyncDispose, which Node <22 lacks. Strands' agent loop ships such a downleveled lock, so every agent.invoke() failed the workflow task with "Symbol.dispose is not defined." on Node 20, retrying until the test timed out. The workflow sandbox can't be polyfilled from workflow code: the global scope is deep-frozen before any workflow module imports, so a late assignment throws "Cannot add property dispose, object is not extensible". Inject the symbols in injectGlobals(), which runs before the freeze and is shared by both the reuse and non-reuse VM paths. Symbol.for yields a stable registry symbol, keeping replay deterministic. Also drops the equivalent (and ineffective) polyfill attempt from the strands load-polyfills shim. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The previous attempt read `context.Symbol` from the outer scope, but a vm context's built-in globals aren't reliably exposed there — `context.Symbol` is undefined — so injectGlobals threw "Cannot read properties of undefined (reading 'dispose')" at workflow creation on every runtime, breaking all integration tests. Run the polyfill assignment inside the context with vm.runInContext instead, which still executes before the global scope is frozen. No-op on Node 22+ and Bun, where the symbols already exist. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Port sections from the Python README that were missing: a continue-as-new chat-loop pattern, an Observability section for OpenTelemetryPlugin, and the per-server cacheTools toggle in the MCP section. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 tasks
Contributor
There was a problem hiding this comment.
Pull request overview
Adds a new contrib package, @temporalio/strands-agents, to run Strands Agents inside Temporal Workflows by routing model/tool/MCP operations through Temporal Activities (durable execution + retries), and includes workflow-bundle plumbing/polyfills to keep workflow code sandbox-safe.
Changes:
- Introduces the
@temporalio/strands-agentsplugin + workflow-side wrappers (TemporalAgent,TemporalMCPClient,activityAsTool,activityAsHook) and worker-side activities/failure-converter support. - Extends workflow bundling behavior to avoid pulling non-workflow-safe modules into workflow bundles and to prevent webpack code-splitting in workflow bundles.
- Adds comprehensive AVA integration tests and documentation for the new contrib package.
Reviewed changes
Copilot reviewed 24 out of 25 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| pnpm-workspace.yaml | Adds contrib/strands workspace package. |
| pnpm-lock.yaml | Locks new dependency graph for contrib/strands and transitive deps. |
| packages/worker/src/workflow/vm-shared.ts | Injects Symbol.dispose / Symbol.asyncDispose into the workflow VM context for downleveled using support. |
| contrib/strands/tsconfig.json | Adds TS build configuration and project references for the new contrib package. |
| contrib/strands/src/index.ts | Package entrypoint: loads workflow polyfills and exports plugin + workflow helpers. |
| contrib/strands/src/plugin.ts | Implements StrandsPlugin (activities, bundler configuration, failure converter wiring). |
| contrib/strands/src/workflow.ts | Exposes workflow-side helpers to wrap activities as Strands tools/hooks. |
| contrib/strands/src/temporal-agent.ts | Adds TemporalAgent wrapper over Strands Agent (model calls via activities, tool refresh hooks). |
| contrib/strands/src/temporal-model.ts | Adds TemporalModel that dispatches model streaming/invocation through activities. |
| contrib/strands/src/model-activity.ts | Worker-side activity implementation for model invocation + streaming publication. |
| contrib/strands/src/temporal-activity-tool.ts | Workflow-side Strands Tool that executes an activity and supports interrupt round-trips. |
| contrib/strands/src/temporal-mcp-client.ts | Workflow handle + worker-side activity builders for MCP list/call with connection caching. |
| contrib/strands/src/temporal-mcp-tool.ts | Workflow-side MCP tool wrapper mapping MCP results into Strands blocks. |
| contrib/strands/src/failure-converter.ts | Converts Strands interrupts and terminal model errors into non-retryable ApplicationFailures. |
| contrib/strands/src/load-polyfills.ts | Installs workflow-only polyfills needed by Strands/MCP usage in the sandbox. |
| contrib/strands/src/json-schema.ts | Converts Zod schemas to JSON Schema for tool input schemas. |
| contrib/strands/src/empty-module.ts | Stub module used by bundler replacement to satisfy named imports safely. |
| contrib/strands/src/heartbeat.ts | Adds activity auto-heartbeating helper for long-running model activities. |
| contrib/strands/src/tests/workflows/strands.ts | Workflow fixtures used by integration tests. |
| contrib/strands/src/tests/activities/strands.ts | Test activities used by integration tests (tools/hooks/interrupt). |
| contrib/strands/src/tests/test-strands.ts | AVA integration test suite for Temporal/Strands end-to-end behavior. |
| contrib/strands/src/tests/test-json-schema.ts | Unit tests for toJsonSchema. |
| contrib/strands/README.md | Adds end-user documentation for install, usage, and design details. |
| contrib/strands/package.json | Declares the new published package, deps, scripts, and engine constraints. |
| .github/CODEOWNERS | Adds ownership rule for contrib/strands. |
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
1.3.0 is deprecated with a Potential CWE-502 advisory. Bump to ^1.3.1 to pick up the security fix and refresh the lockfile. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds
@temporalio/strands-agents, a Temporal plugin that runs Strands Agents inside Temporal Workflows. Model invocations, MCP tool calls, andactivityAsTool/activityAsHookcalls all dispatch through Temporal Activities for durable execution and Temporal-managed retries.API
Mirrors
temporalio[strands-agents]from sdk-python (TemporalAgent,TemporalMCPClient,activity_as_tool,activity_as_hook,auto_heartbeater).Workflow-bundle plumbing
@strands-agents/sdk@1.3.0's index transitively pullsfs,node:*, and MCP transport modules into any workflow that importsAgent/McpClient.StrandsPlugin.configureBundlerhandles this by:fs(statically imported fromvended-plugins/skillsandvended-tools/file-editor, both unreachable from workflow code).node:fs/promises/os/path/process/streamand@modelcontextprotocol/sdk/client/{stdio,sse}.jswith an empty stub viaNormalModuleReplacementPlugin(dynamic imports insidemcp-config.js's server-only code paths).self/document) never ships in the bundle.load-polyfills.tsinstallsHeaders,web-streams-polyfill,@ungap/structured-clone, and a deterministiccrypto.randomUUIDbacked byworkflow.uuid4()—@ungap/structured-clonecallscrypto.randomUUID()internally and Temporal's sink path usesstructuredClonefor log payloads, so without the polyfill the firstlogger.warnfrom inside an agent crashes the workflow.TemporalMCPClient.listTools()returns lightweightTemporalMCPToolwrappers (lazy-required to break the cycle) becauseMcpToolisn't re-exported from@strands-agents/sdk's public index. If strands-agents/sdk-typescript#1108 merges,temporal-mcp-tool.tscan be deleted entirely andTemporalMCPClient.listTools()can return realMcpToolinstances bound to itself — picking up the built-in content mapping for images, embedded resources, and URL-elicitation errors that our wrapper currently elides.Tests
9 ava integration tests in
contrib/strands/src/__tests__/test-strands.ts:invokeModelactivityactivityAsToolend-to-endTemporalMCPClientlistTools + callTool through per-server activitiestool()(StrandsFunctionTool) running inside the sandboxactivityAsHookonAfterToolCallEventstructuredOutputSchemavia thestrands_structured_outputtoolstreamingTopicevents consumed viaWorkflowStreamClient.subscribecrypto.randomUUIDpolyfill stays replay-safe)Test plan
pnpm buildcleanpnpm testincontrib/strands— 9/9 passing🤖 Generated with Claude Code