Skip to content

[Bug] strands-agents (TypeScript): OpenAIModel calls fail in the model Activity on Strands 1.15.0 and later #296

Open

Description

@campoy

What are you really trying to do?

Use a Strands OpenAIModel (Responses or Chat Completions) as a Worker model of the Strands plugin:
new StrandsPlugin({ models: { openai: () => new OpenAIModel({ ... }) } }) and
new TemporalAgent({ model: 'openai' }), with the plugin's locked @strands-agents/sdk (1.19.0) or the
latest release (1.22.0).

Describe the bug

Every OpenAIModel call through the plugin fails in the model Activity before any request is sent.
The agent gets this error chain, from the OpenAI client's Connection error.:

ModelError: Activity task failed <- ActivityFailure: Activity task failed <- ApplicationFailure: Connection error. <- TemporalFailure: signal.addEventListener is not a function

Strands puts the agent's cancelSignal, an AbortSignal, in the model's stream options
(agent.ts L2277),
documented as a signal a provider can forward to abort an in-flight request
(model.ts L223-L227).
TemporalModel.stream() sends those options to the invokeModel (or invokeModelStreaming)
Activity, and the payload converter turns the signal into {}. From @strands-agents/sdk 1.15.0,
OpenAIModel passes it to the OpenAI client as { signal: options?.cancelSignal }
(openai/model.ts L185, L243),
and the client calls signal.addEventListener. With 1.14.0 the same plugin code works.

The Activity fails as retryable, so with the plugin's default Activity options (no attempt limit)
a run retries the call without end. Bedrock, the plugin's default model, does not fail (from
source, not run): the AWS SDK's HTTP handler sets onabort on a signal without
addEventListener. But that object never aborts, so on the plugin route cancelling the model
Activity cannot abort the provider call, whatever the model.

Minimal Reproduction

Two files. The real OpenAIModel runs natively and through the plugin; only its fetch is a
recording fake. The script runs one agent turn each way and exits 0 when the plugin route sends the
request and returns the reply as natively, and 1 otherwise. node reproduce.cjs chat uses Chat
Completions.

npm install ./temporalio-strands-agents-0.0.0.tgz  # main at 1e8ae93, packed with make build
npm install @temporalio/worker@1.24.0 @temporalio/testing@1.24.0 @temporalio/activity@1.24.0 \
  @temporalio/client@1.24.0 @strands-agents/sdk@1.19.0 openai@6 zod@4
node reproduce.cjs
reproduce.cjs
'use strict';
// A Strands OpenAIModel through the Temporal plugin: one agent turn natively, then the same turn in
// a Workflow. The real OpenAIModel runs in both; only its `fetch` is a recording fake. The script
// prints both routes and exits 0 when the plugin route sends the request and returns the reply, as
// natively, and 1 when it does not. `node reproduce.cjs chat` uses the Chat Completions API instead
// of the default Responses API.
const crypto = require('node:crypto');
const fs = require('node:fs');
const path = require('node:path');
const { Agent } = require('@strands-agents/sdk');
const { OpenAIModel } = require('@strands-agents/sdk/models/openai');
const { StrandsPlugin } = require('@temporalio/strands-agents');
const { Worker, Runtime, DefaultLogger } = require('@temporalio/worker');
const { TestWorkflowEnvironment } = require('@temporalio/testing');

const api = process.argv[2] ?? 'responses';
const requests = [];

const sse = (events) => events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join('');

// A text reply in the streaming format of the API the request went to.
function reply(url, text) {
  if (url.endsWith('/chat/completions')) {
    const chunk = (delta, finish) => ({ id: 'chat_1', object: 'chat.completion.chunk', created: 1, model: 'gpt-test', choices: [{ index: 0, delta, finish_reason: finish }] });
    return sse([chunk({ role: 'assistant', content: text }, null), chunk({}, 'stop')]) + 'data: [DONE]\n\n';
  }
  const base = { id: 'resp_1', object: 'response', created_at: 1, model: 'gpt-test', output: [], error: null, incomplete_details: null, instructions: null, metadata: {}, parallel_tool_calls: true, temperature: 1, tool_choice: 'auto', tools: [], top_p: 1 };
  const message = { type: 'message', id: 'msg_1', role: 'assistant', status: 'completed', content: [{ type: 'output_text', text, annotations: [] }] };
  return sse([
    { type: 'response.created', response: { ...base, status: 'in_progress' } },
    { type: 'response.output_item.added', item: { ...message, status: 'in_progress', content: [] }, output_index: 0 },
    { type: 'response.output_text.delta', item_id: message.id, output_index: 0, content_index: 0, delta: text, logprobs: [] },
    { type: 'response.output_item.done', item: message, output_index: 0 },
    { type: 'response.completed', response: { ...base, status: 'completed', output: [message] } },
  ].map((e, n) => ({ ...e, sequence_number: n })));
}

async function recordingFetch(url, init) {
  requests.push(String(url).replace('http://fake-openai.test/v1', ''));
  return new Response(reply(String(url), 'answer'), { status: 200, headers: { 'content-type': 'text/event-stream' } });
}

const model = () => new OpenAIModel({ api, modelId: 'gpt-test', apiKey: 'test-key', clientConfig: { baseURL: 'http://fake-openai.test/v1', fetch: recordingFetch } });

async function main() {
  const versions = {};
  for (const p of ['@temporalio/strands-agents', '@temporalio/worker', '@strands-agents/sdk', 'openai']) {
    versions[p] = JSON.parse(fs.readFileSync(path.join(__dirname, 'node_modules', p, 'package.json'), 'utf8')).version;
  }
  console.log(JSON.stringify({ api, versions }));

  // Native control: the same model and turn, outside Temporal.
  const native = { reply: (await new Agent({ model: model(), printer: false }).invoke('hello')).toString() };
  console.log(JSON.stringify({ route: 'native', result: native, requests: requests.splice(0) }));

  Runtime.install({ logger: new DefaultLogger('ERROR') });
  const env = await TestWorkflowEnvironment.createLocal();
  let plugin;
  try {
    const taskQueue = `openai-${crypto.randomUUID()}`;
    const worker = await Worker.create({
      connection: env.nativeConnection,
      taskQueue,
      workflowsPath: require.resolve('./workflows.js'),
      plugins: [new StrandsPlugin({ models: { openai: model } })],
    });
    plugin = await worker.runUntil(env.client.workflow.execute('oneTurn', { taskQueue, workflowId: crypto.randomUUID() }));
  } finally {
    await env.teardown();
  }
  const pluginRequests = requests.splice(0);
  console.log(JSON.stringify({ route: 'plugin', result: plugin, requests: pluginRequests }));

  const same = plugin.reply === native.reply && pluginRequests.length === 1;
  console.log(JSON.stringify({ result: same ? 'plugin route matches native' : 'plugin route differs from native' }));
  process.exitCode = same ? 0 : 1;
}

main().catch((err) => {
  console.error(err);
  process.exitCode = 2;
});
workflows.js
'use strict';
const { TemporalAgent } = require('@temporalio/strands-agents');

// One turn on the `openai` Worker model. A failed model Activity ends the run after two attempts, so
// the script does not wait on endless retries.
async function oneTurn() {
  const agent = new TemporalAgent({
    model: 'openai',
    printer: false,
    activityOptions: { startToCloseTimeout: '10 seconds', retry: { maximumAttempts: 2 } },
  });
  try {
    return { reply: (await agent.invoke('hello')).toString() };
  } catch (err) {
    const chain = [];
    for (let e = err; e; e = e.cause) chain.push(`${e.name}: ${e.message}`);
    return { error: chain.join(' <- ') };
  }
}

module.exports = { oneTurn };

Output on main with @strands-agents/sdk 1.19.0:

{"api":"responses","versions":{"@temporalio/strands-agents":"0.0.0","@temporalio/worker":"1.24.0","@strands-agents/sdk":"1.19.0","openai":"6.49.0"}}
{"route":"native","result":{"reply":"answer"},"requests":["/responses"]}
{"route":"plugin","result":{"error":"ModelError: Activity task failed <- ActivityFailure: Activity task failed <- ApplicationFailure: Connection error. <- TemporalFailure: signal.addEventListener is not a function"},"requests":[]}
{"result":"plugin route differs from native"}

The same with 1.14.0, the release before OpenAIModel forwards the signal:

{"api":"responses","versions":{"@temporalio/strands-agents":"0.0.0","@temporalio/worker":"1.24.0","@strands-agents/sdk":"1.14.0","openai":"6.49.0"}}
{"route":"native","result":{"reply":"answer"},"requests":["/responses"]}
{"route":"plugin","result":{"reply":"answer"},"requests":["/responses"]}
{"result":"plugin route matches native"}
@strands-agents/sdk Responses Chat Completions
1.14.0 sends the request (exit 0) sends the request (exit 0)
1.15.0 fails before the request (exit 1) fails before the request (exit 1)
1.19.0 (locked) fails before the request (exit 1) fails before the request (exit 1)
1.22.0 (latest) fails before the request (exit 1) fails before the request (exit 1)

With @temporalio/* 1.25.0 and Strands 1.22.0 the result is the same.

Environment/Versions

  • @temporalio/strands-agents on main at 1e8ae93
  • @temporalio/worker, @temporalio/testing, @temporalio/client 1.24.0 (also 1.25.0)
  • @strands-agents/sdk 1.14.0, 1.15.0, 1.19.0 and 1.22.0; openai 6.49.0
  • Node 24.22.0 on Linux, Temporal CLI 1.9.1 (Server 1.32.0)

Additional context

Expected: the model in the Activity gets a working AbortSignal, the request is sent, and the turn
returns the reply, as natively.

Proposed fix: in ModelActivity, give the model the Activity's own cancellation signal
(Context.current().cancellationSignal) in place of the agent's, which cannot cross the Activity
boundary. OpenAIModel then works, and cancelling the model Activity aborts the provider call, as
cancelling the agent does natively. Workflow code and the Activity input do not change. A PR
follows.

This also blocks the stateful model fix in #285, which reaches the same failure on 1.15.0 and later.

Activity

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