Checks
SDK Language
TypeScript
Strands Version
main @ 2c6c531779538ec75957cb114f4514a4e0addc3e
Language Runtime Version
Node.js 26.5.0
Operating System
macOS 26.6.1
Installation Method
git clone
Steps to Reproduce
execute_agent_loop_cycle's span (cycleSpan in strands-ts/src/agent/agent.ts) is only ended
by explicit this._tracer.endAgentLoopSpan(cycleSpan) calls at each of the loop body's exit
points (currently 6, plus one in the catch) — there is no finally around the try that wraps
the cycle body (agent.ts:1508-1719).
The loop body is an async generator, and it has unguarded yield/yield* points ahead of every
one of those explicit end-span calls, e.g.:
agent.ts:1513 — yield this._appendMessage(...)
agent.ts:1528 — yield* this._invokeModel(...)
agent.ts:1624 — yield* this.executeTools(...)
Breaking out of a for await loop over this generator (which is exactly what a caller does to
cancel a run, e.g. agent.cancel() + break) calls the generator's .return(). .return()
unwinds through a pending yield the same way a return statement would: it runs finally
blocks, but does not enter catch. Since there is no finally here, .return() landing on
any of those yield points skips every span-ending call entirely.
Minimal repro shape (mirrors how a consumer cancels a run):
const gen = agent.streamAsync(input) // or whatever the public async-generator entry point is
for await (const event of gen) {
// some cancellation condition
break // <- triggers gen.return(), unwinding through the unguarded yield inside the try
}
Expected Behavior
The execute_agent_loop_cycle span (and its ancestor invoke_agent span) is still ended —
ideally marked with a distinct status/attribute for "cancelled mid-cycle" — even when the
consuming for await breaks or otherwise triggers .return() on the loop generator.
Actual Behavior
The cycle span (and therefore its invoke_agent root, since OTel can't export a parent before
its children finish and the parent's own end() never runs either) is never ended and never
exported. Downstream, this deletes the entire trace's identity — everything that already ended
(earlier cycles, chats, tool spans) still exports, but there's no way to attribute the run to a
session/user/etc. because that attribution lives on the un-exported root.
We hit this in production via agent.cancel() during a graceful-shutdown drain timeout racing a
long-running tool call; a multi-day sample showed roughly 9% of exported trace volume missing
its invoke_agent root, consistently showing the same signature (one more chat span than
execute_agent_loop_cycle spans, i.e. the last cycle's span never closed). We're currently
working around it downstream with a periodic sweep that force-ends spans past an age threshold,
but that's a mitigation, not a fix — it can't recover attribution promptly (sweep runs on a timer)
and it records a fictional span duration.
Additional Context
This looks like the TypeScript-SDK counterpart to #3609 (spans dropped on cancellation in the
Python SDK) — same class of symptom, but a different mechanism: #3609 is about except Exception
not catching BaseException-derived cancellation signals, whereas this is about a try with no
finally combined with async-generator .return() semantics, which skips catch but would have
still gone through a finally had one existed.
Possible Solution
Wrap the cycle body in try { ... } finally { ... } and move the span-ending (and
this._meter.endCycle(cycleStartTime)) into the finally, guarding against double-ending since
several explicit call sites already end the span before returning/continuing. Track "already
ended" via a local flag or by making endAgentLoopSpan idempotent, so the finally only fires
the end call when none of the explicit call sites already ran.
Related Issues
#3609
Checks
SDK Language
TypeScript
Strands Version
main@2c6c531779538ec75957cb114f4514a4e0addc3eLanguage Runtime Version
Node.js 26.5.0
Operating System
macOS 26.6.1
Installation Method
git clone
Steps to Reproduce
execute_agent_loop_cycle's span (cycleSpaninstrands-ts/src/agent/agent.ts) is only endedby explicit
this._tracer.endAgentLoopSpan(cycleSpan)calls at each of the loop body's exitpoints (currently 6, plus one in the
catch) — there is nofinallyaround thetrythat wrapsthe cycle body (
agent.ts:1508-1719).The loop body is an async generator, and it has unguarded
yield/yield*points ahead of everyone of those explicit end-span calls, e.g.:
agent.ts:1513—yield this._appendMessage(...)agent.ts:1528—yield* this._invokeModel(...)agent.ts:1624—yield* this.executeTools(...)Breaking out of a
for awaitloop over this generator (which is exactly what a caller does tocancel a run, e.g.
agent.cancel()+break) calls the generator's.return()..return()unwinds through a pending
yieldthe same way areturnstatement would: it runsfinallyblocks, but does not enter
catch. Since there is nofinallyhere,.return()landing onany of those yield points skips every span-ending call entirely.
Minimal repro shape (mirrors how a consumer cancels a run):
Expected Behavior
The
execute_agent_loop_cyclespan (and its ancestorinvoke_agentspan) is still ended —ideally marked with a distinct status/attribute for "cancelled mid-cycle" — even when the
consuming
for awaitbreaks or otherwise triggers.return()on the loop generator.Actual Behavior
The cycle span (and therefore its
invoke_agentroot, since OTel can't export a parent beforeits children finish and the parent's own
end()never runs either) is never ended and neverexported. Downstream, this deletes the entire trace's identity — everything that already ended
(earlier cycles, chats, tool spans) still exports, but there's no way to attribute the run to a
session/user/etc. because that attribution lives on the un-exported root.
We hit this in production via
agent.cancel()during a graceful-shutdown drain timeout racing along-running tool call; a multi-day sample showed roughly 9% of exported trace volume missing
its
invoke_agentroot, consistently showing the same signature (one morechatspan thanexecute_agent_loop_cyclespans, i.e. the last cycle's span never closed). We're currentlyworking around it downstream with a periodic sweep that force-ends spans past an age threshold,
but that's a mitigation, not a fix — it can't recover attribution promptly (sweep runs on a timer)
and it records a fictional span duration.
Additional Context
This looks like the TypeScript-SDK counterpart to #3609 (spans dropped on cancellation in the
Python SDK) — same class of symptom, but a different mechanism: #3609 is about
except Exceptionnot catching
BaseException-derived cancellation signals, whereas this is about atrywith nofinallycombined with async-generator.return()semantics, which skipscatchbut would havestill gone through a
finallyhad one existed.Possible Solution
Wrap the cycle body in
try { ... } finally { ... }and move the span-ending (andthis._meter.endCycle(cycleStartTime)) into thefinally, guarding against double-ending sinceseveral explicit call sites already end the span before returning/continuing. Track "already
ended" via a local flag or by making
endAgentLoopSpanidempotent, so thefinallyonly firesthe end call when none of the explicit call sites already ran.
Related Issues
#3609