Checks
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
InitializedEvent — agent.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
Checks
SDK Language
TypeScript
Strands Version
main@397176b71ea68ec57551960475279824824a3da2(monorepostrands-ts, package version0.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_initializedboolean, which is only set at the end (agent.ts:822). Concurrent callers all readfalseatagent.ts:781and 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-818InitializedEvent—agent.ts:820Repro (
tsx, run from the monorepo;SDKpoints at thestrands-tsworkspace):Any asynchronous work inside
initialize()opens the window — anasync initAgent(), or MCPlistTools()latency.invoke()/stream()are protected by the invocation lock (agent.ts:826+), so this is reachable through directinitialize()calls: a warm-up path racing the first request, an app that callsinitialize()in two places, orPromise.allover a startup sequence.Expected Behavior
InitializedEventfires exactly once per agent, and each intervention handler'sobserveAgent()is called exactly once, no matter how many times or how concurrentlyinitialize()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, 3observeAgent()calls. Any hook doing once-per-agent setup (registering resources, seeding state, emitting a startup metric or span) runs N times; anobserveAgent()implementation that attaches an observer attaches N of them.Additional Context
Found by
strandly-the-agentwhile 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: 1above. It cannot fix this, becauseagent.ts:813-820sits outside the registry. Verified againstmain@397176b71and against #3482's headc79ad41d: identical output on both, so this is neither caused nor fixed by that PR.One related observation on
mainthat #3482 does fix, recorded so it isn't filed twice: with several slow plugins, two concurrentinitialize()calls interleave over the plugin registry's shared_pendingqueue and one caller resolves while a plugin is still mid-init (orderp1: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 toPluginRegistry:That would fix this, the concurrent half of #3710, and make the
_initializedboolean redundant for concurrency purposes. The design question worth settling first is the failure semantic: should a failedAgent.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