Skip to content

[BUG] InitializedEvent and observeAgent() fire once per concurrent Agent.initialize() call instead of once per agent #3711

Description

@strandly-the-agent

Checks

  • I have updated to the lastest minor and patch version of Strands
  • I have checked the documentation and this is not expected behavior
  • I have searched ./issues and there are no duplicates of my issue

SDK Language

TypeScript

Strands Version

main @ 397176b71ea68ec57551960475279824824a3da2 (monorepo strands-ts, package version 0.0.1-development)

Language Runtime Version

Node.js 22.14.0

Operating System

Linux 6.1.161-18.298.amzn2023.aarch64 (aarch64)

Installation Method

git clone

Steps to Reproduce

Agent.initialize() (strands-ts/src/agent/agent.ts:780-823) guards re-entry with the _initialized boolean, which is only set at the end (agent.ts:822). Concurrent callers all read false at agent.ts:781 and all run the whole body — so the once-per-agent lifecycle steps at the tail fire once per call:

  • observeAgent() on every intervention handler — agent.ts:813-818
  • InitializedEventagent.ts:820

Repro (tsx, run from the monorepo; SDK points at the strands-ts workspace):

/** InitializedEvent fires once per concurrent Agent.initialize() call, not once per agent. */
const SDK = process.env.SDK ?? '/path/to/harness-sdk/strands-ts'
const { Agent } = await import(`${SDK}/src/agent/agent.js`)
const { MockMessageModel } = await import(`${SDK}/src/__fixtures__/mock-message-model.js`)
const { InitializedEvent } = await import(`${SDK}/src/hooks/events.js`)

const model = new MockMessageModel()
model.addTurn({ type: 'textBlock', text: 'x' }, { stopReason: 'endTurn' })

// A plugin that just takes a moment to initialize — any async initAgent() will do.
class SlowPlugin {
  name = 'slow-plugin'
  calls = 0
  async initAgent(): Promise<void> {
    this.calls++
    await new Promise((r) => setTimeout(r, 30))
  }
}
const plugin = new SlowPlugin()

let initializedEvents = 0
let observeAgentCalls = 0
const agent = new Agent({
  model,
  plugins: [plugin as never],
  printer: false,
  interventions: [{ observeAgent: () => { observeAgentCalls++ } } as never],
})
agent.addHook(InitializedEvent, () => {
  initializedEvents++
})

await Promise.all([agent.initialize(), agent.initialize(), agent.initialize()])

console.log('initialize() calls:            3')
console.log('InitializedEvent fired:       ', initializedEvents, '(expected 1)')
console.log('observeAgent() calls:         ', observeAgentCalls, '(expected 1)')
console.log('plugin.initAgent calls:       ', plugin.calls)
$ SDK=.../strands-ts npx tsx initialized-event-twice.ts
initialize() calls:            3
InitializedEvent fired:        3 (expected 1)
observeAgent() calls:          3 (expected 1)
plugin.initAgent calls:        1

Any asynchronous work inside initialize() opens the window — an async initAgent(), or MCP listTools() latency. invoke()/stream() are protected by the invocation lock (agent.ts:826+), so this is reachable through direct initialize() calls: a warm-up path racing the first request, an app that calls initialize() in two places, or Promise.all over a startup sequence.

Expected Behavior

InitializedEvent fires exactly once per agent, and each intervention handler's observeAgent() is called exactly once, no matter how many times or how concurrently initialize() is called. initialize() should be idempotent, not merely "idempotent if you never call it twice at once".

Actual Behavior

Both fire once per concurrent call — 3 calls, 3 InitializedEvents, 3 observeAgent() calls. Any hook doing once-per-agent setup (registering resources, seeding state, emitting a startup metric or span) runs N times; an observeAgent() implementation that attaches an observer attaches N of them.

Additional Context

Found by strandly-the-agent while reviewing #3482. Deliberately filed separately from #3710: that one is about tool re-registration on a retry and produces a hard error, this one is about lifecycle hooks double-firing on a concurrent call and is silent. They do share a root cause — Agent.initialize() has no real re-entrancy guard — so one fix may close both.

Relationship to #3482: that PR memoizes the plugin registry's init promise, which is why plugin.initAgent calls: 1 above. It cannot fix this, because agent.ts:813-820 sits outside the registry. Verified against main @ 397176b71 and against #3482's head c79ad41d: identical output on both, so this is neither caused nor fixed by that PR.

One related observation on main that #3482 does fix, recorded so it isn't filed twice: with several slow plugins, two concurrent initialize() calls interleave over the plugin registry's shared _pending queue and one caller resolves while a plugin is still mid-init (order p1:start,p2:start,p1:done,p3:start,p2:done). #3482's memoization serializes that; only the hook double-firing above survives it.

Possible Solution

Give Agent.initialize() a real re-entrancy guard by memoizing its promise instead of relying on a boolean set at the end — the same shape #3482 applies to PluginRegistry:

public async initialize(): Promise<void> {
  this._initializationPromise ??= this._initialize()
  return this._initializationPromise
}

private async _initialize(): Promise<void> {
  // current body of initialize(), minus the `if (this._initialized) return` guard
}

That would fix this, the concurrent half of #3710, and make the _initialized boolean redundant for concurrency purposes. The design question worth settling first is the failure semantic: should a failed Agent.initialize() stay failed for the agent's lifetime (as #3482 chose for plugins) or remain retriable? Happy to open a PR once a maintainer confirms the intended shape.

Related Issues

#3710, #3482, #3477

Metadata

Metadata

Assignees

No one assigned

    Labels

    area-agentRelated to the agent class or general agent questionsarea-asyncRelated to asynchronous flows or multi-threadingbugSomething isn't workingtypescriptPull requests that update typescript code

    Type

    Fields

    Language

    TypeScript

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions