Skip to content

[Bug] strands-agents (TypeScript): a plugin Activity silently replaces a caller Activity with the same name #201

Closed

Description

@campoy

What happens

StrandsPlugin (@temporalio/strands-agents) passes its Activities to SimplePlugin, whose
configureWorker merges them into the Worker's activities with { ...existing, ...param }.
When the Worker already has an Activity with one of the plugin's names, the plugin's function
replaces it. Worker.create succeeds, nothing is logged, and Workflows that call that name run
the plugin's handler instead of the caller's.

The plugin registers invokeModel and invokeModelStreaming, and <server>-listTools and
<server>-callTool for each MCP client in mcpClients. SimplePlugin documents plugin
Activities as "appended", and the Temporal docs tell callers to register their own Activities in
the Worker's activities. Neither reserves these names.

Reproduction

One file, no server. It calls the plugin's configureWorker, as Worker.create does. It exits 1
when a caller Activity is replaced.

// repro.cjs
// A caller Activity with one of StrandsPlugin's Activity names. No server: it calls the plugin's
// configureWorker, as Worker.create does. Exits 1 when the plugin replaces a caller Activity.
const { StrandsPlugin } = require('@temporalio/strands-agents');

const notCalled = () => {
  throw new Error('not called');
};
const caller = async () => 'caller handler';
const plugin = new StrandsPlugin({ models: { claude: notCalled }, mcpClients: { docs: notCalled } });
let replaced = 0;
for (const name of ['invokeModel', 'docs-callTool']) {
  try {
    const { activities } = plugin.configureWorker({ taskQueue: 'tq', activities: { [name]: caller } });
    const kept = activities[name] === caller;
    console.log(`${name}: Worker options built; the caller's handler was ${kept ? 'kept' : "replaced by the plugin's"}`);
    if (!kept) replaced += 1;
  } catch (err) {
    console.log(`${name}: ${err.name}: ${err.message}`);
  }
}
process.exitCode = replaced > 0 ? 1 : 0;

With @temporalio/strands-agents 1.24.0 (npm, with @strands-agents/sdk 1.6.0) and with the
package built from this repository's main (c664a34, with its locked @strands-agents/sdk
1.19.0), both with @temporalio/* 1.24.0 and Node 24.22:

$ node repro.cjs
invokeModel: Worker options built; the caller's handler was replaced by the plugin's
docs-callTool: Worker options built; the caller's handler was replaced by the plugin's
exit=1

On a real Worker and dev server, with both versions, a caller Workflow that calls its own
invokeModel Activity next to new StrandsPlugin({ models: { claude } }) got the plugin model's
stream events (answer from model claude) instead of the caller's result. The caller's handler ran
0 times, and nothing was logged at WARN or above. Control: on a Worker without the plugin, the
same Workflow gets caller handler got {"question":"caller data"}.

Expected

The caller's Activity runs, or Worker.create refuses the duplicate name with a clear error.
The Temporal Python and Go SDKs refuse a second Activity with the same name.

Impact

A silent wrong result: the Workflow gets the plugin's answer for a call to the caller's own
Activity, with no error or warning. The trigger needs a caller Activity with one of the plugin's
names. That is uncommon, but the names are not prefixed, so a caller invokeModel is not
far-fetched.

Proposed fix

In StrandsPlugin.configureWorker, before calling super.configureWorker, throw a TypeError
when one of the plugin's Activity names is already registered with a function that no
StrandsPlugin built. Activities a StrandsPlugin built stay replaceable, so one instance on both
the NativeConnection and the Worker still starts. This matches the fixes proposed for the same
bug in GoogleAdkPlugin (#159) and OpenAIAgentsPlugin (#165).

This is a breaking change: Workers with such a collision start today and would fail in
Worker.create. Activity names and History don't change. A PR follows.

Related: #160 reports the same bug in AiSdkPlugin, which also registers invokeModel and
invokeModelStreaming. On one Worker with StrandsPlugin, whichever plugin comes last owns those
names.

Activity

  1. campoy commented on Oct 27, 2026

    @campoy
    ContributorAuthor

    One change to the proposed fix above: #202 now also refuses a second StrandsPlugin with different options on one Worker, as #161 does for GoogleAdkPlugin, instead of letting any StrandsPlugin Activity replace another. The same instance on the NativeConnection and the Worker, and instances built from one options object, still start.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions