Skip to content

chore(detectors): added async detectors execution (WIP) - #277

Open
poshinchen wants to merge 1 commit into
strands-agents:mainfrom
poshinchen:chore/detectors-async
Open

chore(detectors): added async detectors execution (WIP)#277
poshinchen wants to merge 1 commit into
strands-agents:mainfrom
poshinchen:chore/detectors-async

Conversation

@poshinchen

@poshinchen poshinchen commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Description

Adds async support to the detectors/ module so failure detection, root cause analysis, and session diagnosis can run concurrently and integrate cleanly with async callers (Experiment._run_diagnosis, FastAPI handlers, Jupyter, async tests).

New async API surface (re-exported from strands_evals.detectors):

  • detect_failures_async(..., max_workers=5)failure_detector.py
  • analyze_root_cause_async(..., max_workers=5)root_cause_analyzer.py
  • diagnose_session_async(..., max_workers=5)diagnosis.py
  • diagnose_sessions_async(sessions, ..., max_workers=5, per_session_workers=5) — batch helper for many sessions

Sync APIs preserved. detect_failures / analyze_root_cause / diagnose_session remain available with their original signatures and now route through run_async(lambda: …_async(…)). That means they're safe to call from sync code and from inside a running event loop (Jupyter, FastAPI, async tests) — the work runs on a dedicated worker thread with its own loop, so no RuntimeError: asyncio.run() cannot be called from a running event loop.

Internal fan-out. _detect_chunked_async and _rca_chunked_async run chunks/windows concurrently under a max_workers semaphore via two new helpers in _async.py:

  • bounded_gather(coros, max_workers, return_exceptions=True) — sibling isolation; used by diagnose_sessions_async so one bad session yields None instead of poisoning the batch.
  • bounded_gather_fail_fast(coros, max_workers) — uses asyncio.wait(..., FIRST_EXCEPTION), cancels still-running siblings, drains, and re-raises. Used by chunked detection / RCA. Both already swallow ContextWindowOverflowException / _is_context_exceeded as a successful empty result, so only genuine errors trigger the cancel — which is exactly when we want to stop spending tokens on doomed sibling work.

Removed dead bridges. failure_detector._call_model is gone; the old run_async thread bridge is no longer needed there because model.stream(...) is awaited natively. RCA's three sync Agent(...)(prompt, structured_output_model=...) call sites now use await agent.invoke_async(...).

Experiment integration.

  • Experiment._run_diagnosis (experiment.py:474) swapped await asyncio.to_thread(diagnose_session, ...)await diagnose_session_async(...) — drops the redundant thread hop.
  • DiagnosisConfig gains max_workers: int = Field(default=5, ge=1). The value is forwarded into diagnose_session_async so users can tune per-session concurrency without monkey-patching.

Related Issues

n/a

Documentation PR

n/a

Type of Change

New feature (additive — sync API and existing behavior unchanged).

Testing

hatch run prepare — passes (ruff format, ruff lint, mypy, full pytest).

New tests:

  • tests/strands_evals/test_async.pybounded_gather order/isolation + bounded_gather_fail_fast order, sibling cancellation on first error, first-exception-wins semantics, empty/invalid inputs.
  • tests/strands_evals/detectors/test_failure_detector.py — async happy path, max_workers forwarded, chunk fan-out actually overlaps (peak ≥ 2 in flight), one-overflow-doesn't-poison-batch, non-context error propagates, fail-fast cancels slow sibling, sync wrapper works inside a running event loop.
  • tests/strands_evals/detectors/test_root_cause_analyzer.py — async direct path, empty failures shortcut, auto-detection threads max_workers, sync wrapper works inside a running event loop.
  • tests/strands_evals/detectors/test_diagnosis.pydiagnose_session_async happy path, max_workers thread-through; diagnose_sessions_async batch order, one-bad-session→None, per_session_workers thread-through; sync wrapper works inside a running event loop.
  • tests/strands_evals/test_experiment.pyDiagnosisConfig.max_workers reaches diagnose_session_async. The pre-existing test_diagnosis_exception_returns_none mock target updated to the async name.

Existing detector tests that mocked the old sync hooks were updated to use AsyncMock against _call_model_async / Agent.invoke_async / detect_failures_async.

  • I ran hatch run prepare

Checklist

  • I have read the CONTRIBUTING document
  • I have reviewed and understand every line of code in this PR, including any generated by AI tools, and I can explain why it works
  • My change is focused and reasonably small; I have split unrelated work into separate PRs
  • I have added any necessary tests that prove my fix is effective or my feature works
  • I have updated the documentation accordingly
  • I have added an appropriate example to the documentation to outline the feature, or no new docs are needed
  • My changes generate no new warnings
  • Any dependent changes have been merged and published

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

@github-actions github-actions Bot added area-detectors Failure detection and root cause analysis of agent sessions chore Maintenance tasks, dependency updates, CI changes, refactoring with no user-facing impact strands-running labels Jun 17, 2026
@poshinchen poshinchen changed the title chore(detectors): added async detectors execution chore(detectors): added async detectors execution (WIP) Jun 17, 2026
*,
model: Model | str | None = None,
confidence_threshold: ConfidenceLevel = ConfidenceLevel.LOW,
max_workers: int = 5,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: max_workers carries two different meanings across the public async API. In detect_failures_async, analyze_root_cause_async, and diagnose_session_async, max_workers caps inner per-session LLM concurrency. Here in diagnose_sessions_async, max_workers caps session-level concurrency, and the inner cap is a separate per_session_workers arg. A caller who learned the meaning from diagnose_session_async will misread this signature.

Suggestion: Rename the batch-level knob to something unambiguous (e.g. max_concurrent_sessions) so the two axes of concurrency read clearly, keeping per_session_workers for the inner cap. This also makes the obvious path the happy path (CONTRIBUTING tenet #4).

def _detect_direct(user_prompt: str, model: Model, template: object) -> list[FailureItem]:
"""Attempt direct LLM detection on the full session."""
text = _call_model(model, system_prompt=template.SYSTEM_PROMPT, user_prompt=user_prompt)
async def _detect_direct_async(user_prompt: str, model: Model, template: object) -> list[FailureItem]:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: template: object discards type information — _detect_direct_async / _detect_chunked_async then access template.SYSTEM_PROMPT and template.build_prompt(...), which only type-checks because attr-defined is globally disabled in the mypy config. This loses the safety net for these calls.

Suggestion: Annotate with the concrete template type returned by get_template(...) (and add a return annotation to get_template if it lacks one). That restores attribute checking and documents the contract for readers.

*,
model: Model | str | None = None,
confidence_threshold: ConfidenceLevel = ConfidenceLevel.LOW,
max_workers: int = 5,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: The default max_workers=5 (and per_session_workers=5) is hard-coded as a literal in four separate functions across diagnosis.py, failure_detector.py, and root_cause_analyzer.py. If the sensible default ever needs tuning, it has to be changed in several places and can drift out of sync.

Suggestion: Define a single shared constant (e.g. DEFAULT_MAX_WORKERS in detectors/constants.py) and reference it from each signature.

for t in pending:
t.cancel()
await asyncio.gather(*pending, return_exceptions=True)
for t in tasks:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: The docstring says the first observed exception surfaces, but asyncio.wait(FIRST_EXCEPTION) can return with several tasks already done, and the subsequent loop re-raises the first task in list order that has an exception — not necessarily the one that completed first. The test test_bounded_gather_fail_fast_first_exception_wins only passes because the list-first task also fails first (delay 0.0), so it doesn't actually distinguish the two semantics.

Suggestion: Either tighten the wording to "the first failing task in submission order" or, if temporal ordering matters, capture the exception from the task that triggered the FIRST_EXCEPTION return rather than scanning in list order.

`return_exceptions=True`, failed tasks appear as `BaseException`
instances rather than raising.
"""
if max_workers < 1:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: bounded_gather accepts an Iterable[Awaitable] and, when max_workers < 1, raises after the caller has already created the coroutines (the coros generator/list is materialized by the caller). Any already-constructed coroutines that are never awaited will emit "coroutine was never awaited" warnings. The same applies to bounded_gather_fail_fast. The tests only exercise the invalid case with an empty iterable, so this leak path is untested.

Suggestion: Either validate max_workers before constructing coroutines is the caller's responsibility (document it), or close/drain the passed-in awaitables before raising. At minimum, add a test that passes non-empty coros with max_workers=0 to lock in the intended behavior.


await diagnose_sessions_async([_make_session("x")], per_session_workers=7)

assert mock_detect.call_args.kwargs["max_workers"] == 7

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: This suite covers per_session_workers threading nicely, but there's no test asserting the session-level concurrency cap (max_workers) actually bounds how many sessions run at once. Given that this PR's whole purpose is bounded concurrency, that cap is the core contract for this function and is currently unverified.

Suggestion: Add a test (mirroring test_detect_failures_chunked_concurrent_calls) that tracks peak in-flight sessions and asserts it never exceeds max_workers.

@github-actions

Copy link
Copy Markdown

Issue (process / API review): This PR adds several new public APIs (detect_failures_async, analyze_root_cause_async, diagnose_session_async, diagnose_sessions_async, plus bounded_gather/bounded_gather_fail_fast) and exports the detector ones from detectors/__init__, but the PR description is empty and the checklist is unchecked. New public async surface that customers will depend on warrants documented use cases, example snippets, and complete signatures so the API contract can be reviewed.

Suggestion: Before marking ready-for-review, fill in the description with: motivation, the new public signatures (with defaults), example usage for the common batch case (diagnose_sessions_async), and whether bounded_gather* are meant to be public or internal (the leading _async module name suggests internal — if so, they shouldn't be treated as a supported API).

@github-actions

Copy link
Copy Markdown

Assessment: Comment (PR is marked WIP)

Solid, well-tested async refactor — the sync→async split is clean, run_async correctly isolates execution so the sync wrappers stay safe inside a running loop, and the concurrency primitives are thoroughly exercised (ordering, failure isolation, sibling cancellation, real overlap). Full suite passes (200 tests), ruff and mypy are clean. Feedback below is about API clarity and a couple of edge cases, not correctness of the happy path.

Review Categories
  • API clarity: max_workers means two different things between diagnose_sessions_async (session-level) and the other async fns (inner LLM-level) — the biggest readability risk in the change.
  • API surface / docs: Several new public *_async functions added with an empty PR description; clarify intended public vs internal surface (esp. bounded_gather*) and document the common batch use case.
  • Typing: template: object relies on globally-disabled attr-defined to pass; a concrete template type would restore safety.
  • Maintainability: max_workers=5 default duplicated across four signatures — extract a shared constant.
  • Edge cases / tests: coroutine-leak path when max_workers < 1 with non-empty input is untested; session-level concurrency cap for diagnose_sessions_async is not asserted; fail-fast "first observed" wording vs list-order re-raise.

Nice work on the concurrency test coverage — the peak-in-flight and cancellation assertions are exactly the right things to lock in.

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

Labels

area-detectors Failure detection and root cause analysis of agent sessions chore Maintenance tasks, dependency updates, CI changes, refactoring with no user-facing impact

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant