Skip to content

contrib: add @temporalio/strands-agents plugin#2091

Open
brianstrauch wants to merge 24 commits into
mainfrom
strands
Open

contrib: add @temporalio/strands-agents plugin#2091
brianstrauch wants to merge 24 commits into
mainfrom
strands

Conversation

@brianstrauch

Copy link
Copy Markdown
Member

Summary

Adds @temporalio/strands-agents, a Temporal plugin that runs Strands Agents inside Temporal Workflows. Model invocations, MCP tool calls, and activityAsTool/activityAsHook calls all dispatch through Temporal Activities for durable execution and Temporal-managed retries.

API

// Workflow side
import { TemporalAgent, TemporalMCPClient, workflow as strandsWorkflow } from '@temporalio/strands-agents';

const agent = new TemporalAgent({
  model: 'bedrock',
  tools: [
    strandsWorkflow.activityAsTool('getWeather', { description: '…', inputSchema: {} }),
    new TemporalMCPClient({ server: 'filesystem' }),
  ],
});
const result = await agent.invoke(prompt);

// Worker side
new StrandsPlugin({
  models: { bedrock: () => new BedrockModel({}) },
  mcpClients: { filesystem: () => new McpClient({}) },
});

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 pulls fs, node:*, and MCP transport modules into any workflow that imports Agent/McpClient. StrandsPlugin.configureBundler handles this by:

  • Ignoring fs (statically imported from vended-plugins/skills and vended-tools/file-editor, both unreachable from workflow code).
  • Replacing node:fs/promises/os/path/process/stream and @modelcontextprotocol/sdk/client/{stdio,sse}.js with an empty stub via NormalModuleReplacementPlugin (dynamic imports inside mcp-config.js's server-only code paths).
  • Disabling async chunks / code-splitting so webpack's JSONP chunk loader (which uses self/document) never ships in the bundle.

load-polyfills.ts installs Headers, web-streams-polyfill, @ungap/structured-clone, and a deterministic crypto.randomUUID backed by workflow.uuid4()@ungap/structured-clone calls crypto.randomUUID() internally and Temporal's sink path uses structuredClone for log payloads, so without the polyfill the first logger.warn from inside an agent crashes the workflow.

TemporalMCPClient.listTools() returns lightweight TemporalMCPTool wrappers (lazy-required to break the cycle) because McpTool isn't re-exported from @strands-agents/sdk's public index. If strands-agents/sdk-typescript#1108 merges, temporal-mcp-tool.ts can be deleted entirely and TemporalMCPClient.listTools() can return real McpTool instances 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:

  • Model dispatch via invokeModel activity
  • activityAsTool end-to-end
  • TemporalMCPClient listTools + callTool through per-server activities
  • In-workflow tool() (Strands FunctionTool) running inside the sandbox
  • activityAsHook on AfterToolCallEvent
  • structuredOutputSchema via the strands_structured_output tool
  • Activity-tool interrupt round-trip through the failure converter + workflow-signal resume
  • streamingTopic events consumed via WorkflowStreamClient.subscribe
  • Captured-history replay (validates the deterministic crypto.randomUUID polyfill stays replay-safe)

Test plan

  • pnpm build clean
  • pnpm test in contrib/strands — 9/9 passing
  • Reviewer-side smoke: install in a downstream project and run a real agent against Bedrock

🤖 Generated with Claude Code

brianstrauch and others added 3 commits May 26, 2026 14:45
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>
@brianstrauch brianstrauch requested a review from a team as a code owner May 27, 2026 21:12
Comment thread pnpm-workspace.yaml Outdated
…ler ignores

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread contrib/strands/src/__tests__/test-strands.ts Outdated
Comment thread contrib/strands/src/__tests__/test-strands.ts
Comment thread contrib/strands/src/__tests__/test-strands.ts
Comment thread contrib/strands/src/model-activity.ts Outdated
Comment thread contrib/strands/src/temporal-activity-tool.ts Outdated
Comment thread contrib/strands/src/temporal-activity-tool.ts Outdated
Comment thread contrib/strands/src/plugin.ts Outdated
Comment thread contrib/strands/src/temporal-mcp-client.ts Outdated
Comment thread contrib/strands/src/temporal-mcp-client.ts Outdated
Comment thread contrib/strands/src/temporal-mcp-tool.ts Outdated
brianstrauch and others added 8 commits May 28, 2026 17:57
@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>
Comment thread contrib/strands/src/__tests__/test-strands.ts Outdated
Comment thread contrib/strands/src/__tests__/test-strands.ts
Comment thread contrib/strands/src/temporal-mcp-client.ts
Comment thread contrib/strands/src/temporal-mcp-client.ts Outdated
Comment thread contrib/strands/src/temporal-mcp-client.ts Outdated
brianstrauch and others added 4 commits June 1, 2026 09:48
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>
brianstrauch and others added 3 commits June 1, 2026 12:54
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>
brianstrauch and others added 3 commits June 2, 2026 09:25
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>

Copilot AI 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.

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-agents plugin + 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.

Comment thread contrib/strands/package.json
Comment thread contrib/strands/src/temporal-mcp-client.ts
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>
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.

4 participants