Shxiao 572744975d
fix(tools): run tool assembly off-loop at async entry points (#5224)
* fix(tools): run tool assembly off-loop at async entry points

get_available_tools() may block on MCP cache initialization while it is
called on async agent-assembly paths (task_tool, durable batch execution),
stalling the calling event loop for the full discovery duration.

Dispatch the (unchanged, synchronous) assembly call to a worker thread via
asyncio.to_thread at the two async entry points so the loop keeps processing
requests, SSE frames, cancellations, and timers.

Fixes #5172

* fix(tools): offload lead-agent assembly off-loop and pin with blocking-io anchors

Review follow-up for #5224:

- run_agent now dispatches agent_factory(...) through asyncio.to_thread, so
  lead-agent assembly (including both get_available_tools call sites in
  _assemble_lead_agent) runs off the event loop — the Gateway headline
  scenario from issue #5172.
- _ensure_sync_invocable_tool takes a double-checked threading.Lock, making
  the in-place tool.func wrap on the shared tool singletons explicitly
  single-shot now that assembly can run concurrently on worker threads.
- Add backend/tests/blocking_io/test_tool_assembly_offloop.py: blocking-probe
  anchors for task_tool and SubagentBatchService._execute_item under the
  strict Blockbuster gate, plus a meta-check proving the gate trips on the
  exact syscall class (ExtensionsConfig.from_file on the loop). Verified the
  anchor goes red when the offload is flattened back to a plain call.

* fix(gateway): build checkpoint state accessor off-loop; anchor run_agent offload

Review follow-up for #5224:

- Add abuild_checkpoint_state_accessor (asyncio.to_thread around the
  unchanged sync builder) and switch every async call site to it: the
  stateless_wait route, thread_runs, both threads call sites, and the
  build_thread_checkpoint_state_accessor boundary. The agent-factory
  assembly re-enters get_available_tools() and may block on MCP cache
  initialization; repeat calls hit _state_accessor_graph_cache and only
  pay the thread hop.
- Add a third blocking-io anchor driving the real run_agent with minimal
  RunManager/bridge stubs; the factory performs a real production blocking
  read (ExtensionsConfig.from_file()) and the test asserts assembly never
  runs on the main thread. Verified the anchor goes red when the run_agent
  offload is flattened back to a plain call.
- Adapt the test_threads_router checkpoint-builder patch sites to the new
  async name.

* refactor(tools): carry assembly offloads on a dedicated bounded pool

Review follow-up for #5224:

- Add utils/assembly_io.py: a dedicated ThreadPoolExecutor (default 8
  workers, DEER_FLOW_ASSEMBLY_WORKERS-overridable, mirroring
  utils/file_io.py and tools/sync.py) with run_assembly(), which copies
  contextvars explicitly. A hung stdio MCP server parks its worker for
  the full MCP timeout; carrying assembly hops on the loop's default
  executor would let a few parked assemblies queue every other
  to_thread/run_in_executor(None, ...) caller behind them.
- Switch all four offloads (run_agent, task_tool, batch _execute_item,
  abuild_checkpoint_state_accessor) to run_assembly().
- State the cold-path behavior in the accessor docstring: the graph
  cache validates factory identity, so non-identity-stable factories may
  duplicate lead-agent assembly across concurrent readers (MCP discovery
  stays process-wide single-flight); the pool bounds the duplicates.
- Add a fourth blocking-io anchor driving build_thread_checkpoint_state_
  accessor with a per-resolution fresh factory (always a cache miss) and
  the real production blocking read; enumerate all four offloads in the
  gate's module docstring. Verified the anchor goes red when
  abuild_checkpoint_state_accessor is flattened back to a plain call.

* fix(subagents): revalidate batch item before launch; make assembly pool observable

Review follow-up for #5224:

- _execute_item() revalidates the durable state right after assembly and
  before executor.execute_async(): renew_item_lease() returns valid=False
  when cancel_batch() terminalized the item or the lease was lost while
  assembly was parked, and the launch is skipped (the canceller already
  finalized the item). Previously the launch was unconditional and the
  poll loop's cancellation checks only started after execution began.
- Regression test driving the real SQLite repository: a blocking assembly
  probe parks _execute_item, cancel_batch() lands, and the launch is
  skipped with the item staying cancelled. Verified the test goes red
  when the revalidation is removed.
- run_assembly() tracks pending assemblies and logs a throttled WARNING
  once the pending count exceeds the worker count, so assembly starvation
  (workers parked on a hung MCP server) is distinguishable from idle.
- The run_agent blocking-io anchor now binds a sentinel extension
  snapshot via ctx.extensions and asserts the factory observed it through
  get_agent_build_extensions(), pinning run_assembly()'s ContextVar
  propagation. Verified red when ctx.run is dropped.
- Document the assembly pool in backend/AGENTS.md.

* fix(utils): decrement the assembly pending count on the pool thread

The pending-assembly counter behind the starvation warning decremented
from the asyncio future's done callback, which never fires once the
submitting loop is closed while its worker is still running: the count
ratcheted up permanently and eventually fired the starvation warning
with no starvation behind it (reproduced at 97dc9bec by review).

Decrement instead from the dispatched work item: run_assembly() wraps
func so a finally drops the count under the pending lock on the pool
thread, and the done callback is gone.

Pin the counter with tests/test_assembly_io.py: a healthy call returns
the count to zero, and an abandoned loop (stopped while the worker is
parked) does not wedge it — the abandoned case goes red against the old
done-callback decrement.

* docs(utils): fix the pending-counter comment after the decrement move

The comment still described the removed done-callback decrement,
contradicting _work()'s own comment; state the actual mechanism
(increment on the loop before dispatch, decrement from the dispatched
work item's finally on a pool thread).

* test(gateway): retarget checkpoint-accessor stubs to the services seam

thread_runs and runs now call abuild_checkpoint_state_accessor, so the
upstream wait-reader, regenerate-prepare, and idempotency tests must stub
the sync builder where abuild resolves it (app.gateway.services); stubbing
the removed router re-exports fails with AttributeError at setup. The
async seam semantics are unchanged: run_assembly invokes the stubbed
sync builder off-loop and propagates its return values and exceptions.

Move the agent/tool assembly off-load note from backend/AGENTS.md to
deerflow/utils/AGENTS.md (next to assembly_io.py) so the effective
instruction chain for agents/middlewares no longer grows past the AG002
hard limit.

* fix(runtime): serialize same-key accessor assembly and release queued-cancel slots

Address the three review follow-ups on the assembly off-load:

- assembly_io: a job cancelled while still queued never runs its work
  item, so the dispatched finally never fired and _pending_assemblies
  stayed elevated until a false starvation warning. Exactly-once cleanup
  now rides the concurrent future's cancelled() state — cancel() only
  succeeds before the executor starts the item, so cancelled() is true
  precisely when the finally will never run — plus a submit-failure
  release; the one-worker queued-cancellation case is pinned red/green.
- services: overlapping cold readers sharing one cache key could both
  run full agent assembly. _state_accessor_graph now serializes per key
  through a thread-side KeyedLockTable (pool threads, no running loop)
  and re-validates factory/app-config identity under the lock, so the
  factory runs exactly once while identity changes still rebuild. Cache
  dict access is lock-guarded now that construction runs off-loop.
- guidance inventory: register deerflow/utils/AGENTS.md in
  EXPECTED_GUIDANCE_PATHS so test_repository_has_the_approved_scoped_
  guidance_shape matches the relocated assembly note (CI shard 4).

* test(keyed-lock): pin KeyedLockTable reclamation and waiter bypass directly

Thread-side counterparts of the async table's own tests: overlapping
hold() calls serialize (a late arrival joins the live entry instead of
creating a second lock that bypasses a queued waiter), the last check-in
pops the entry, and many unique keys leave the registry empty. Both
regressions verified red — popping unconditionally trips the late-arrival
test, never reclaiming trips the many-keys test.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-09-12 07:41:24 +08:00

391 lines
16 KiB
Python

from __future__ import annotations
import asyncio
import logging
import socket
import uuid
from datetime import UTC, datetime
from typing import Any
from deerflow.config.app_config import AppConfig, get_app_config
from deerflow.config.subagent_batches_config import SubagentBatchesConfig
from deerflow.config.subagent_runtime_config import SubagentRuntimeConfig
from deerflow.subagents.batch_acceptance import check_batch_acceptance
from deerflow.subagents.batch_runtime import BatchSubmitRequest
from deerflow.subagents.capacity import SubagentExecutionCapacity
from deerflow.subagents.config import SubagentConfig, resolve_subagent_model_name
from deerflow.subagents.executor import (
SubagentExecutor,
SubagentStatus,
cleanup_background_task,
get_background_task_result,
request_cancel_background_task,
)
from deerflow.utils.assembly_io import run_assembly
logger = logging.getLogger(__name__)
def _usage(records: list[dict[str, Any]] | None) -> dict[str, int] | None:
if not records:
return None
return {
"input_tokens": sum(int(row.get("input_tokens") or 0) for row in records),
"output_tokens": sum(int(row.get("output_tokens") or 0) for row in records),
"total_tokens": sum(int(row.get("total_tokens") or 0) for row in records),
}
class SubagentBatchService:
"""Lease, execute, and recover durable native-subagent batch items."""
def __init__(
self,
*,
repository,
config: SubagentBatchesConfig,
runtime_config: SubagentRuntimeConfig,
app_config: AppConfig | None = None,
execution_capacity: SubagentExecutionCapacity | None = None,
) -> None:
self._repository = repository
self._config = config
self._runtime_config = runtime_config
self._app_config = app_config
self._execution_capacity = execution_capacity
self._lease_owner = f"{socket.gethostname()}:{uuid.uuid4().hex}"
self._stop = asyncio.Event()
self._poller: asyncio.Task[None] | None = None
self._executions: dict[str, asyncio.Task[None]] = {}
self._execution_ids: dict[str, str] = {}
self._item_batches: dict[str, str] = {}
async def start(self) -> None:
if self._poller is not None:
return
self._stop.clear()
self._poller = asyncio.create_task(self._run(), name="subagent-batch-poller")
async def stop(self) -> None:
self._stop.set()
poller = self._poller
self._poller = None
if poller is not None:
poller.cancel()
await asyncio.gather(poller, return_exceptions=True)
execution_ids = list(self._execution_ids.values())
for execution_id in execution_ids:
request_cancel_background_task(execution_id)
tasks = list(self._executions.values())
for task in tasks:
task.cancel()
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
self._executions.clear()
self._execution_ids.clear()
self._item_batches.clear()
async def _run(self) -> None:
while not self._stop.is_set():
try:
await self.run_once(now=datetime.now(UTC))
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Subagent batch scheduler pass failed")
try:
await asyncio.wait_for(
self._stop.wait(),
timeout=self._config.poll_interval_seconds,
)
except TimeoutError:
pass
async def run_once(self, *, now: datetime) -> None:
available = max(0, self._runtime_config.max_running - len(self._executions))
if available <= 0:
return
items = await self._repository.claim_items(
now=now,
lease_owner=self._lease_owner,
lease_seconds=self._config.lease_seconds,
limit=available,
)
for item in items:
item_id = item["id"]
if item_id in self._executions:
continue
task = asyncio.create_task(
self._execute_item(item),
name=f"subagent-batch-item-{item_id}",
)
self._executions[item_id] = task
task.add_done_callback(
lambda _task, current_id=item_id: self._executions.pop(
current_id,
None,
)
)
async def submit(self, request: BatchSubmitRequest) -> dict[str, Any]:
total = len(request.items)
if total < 1 or total > self._config.max_items_per_batch:
raise ValueError(f"Batch item count must be between 1 and {self._config.max_items_per_batch}")
max_live = request.max_live_items or self._config.default_max_live_items
max_running = request.max_running_items or self._config.default_max_running_items
if not 1 <= max_live <= self._config.max_live_items_per_batch:
raise ValueError(f"max_live_items must be between 1 and {self._config.max_live_items_per_batch}")
if not 1 <= max_running <= self._config.max_running_items_per_batch:
raise ValueError(f"max_running_items must be between 1 and {self._config.max_running_items_per_batch}")
if max_running > max_live:
raise ValueError("max_running_items must not exceed max_live_items")
return await self._repository.create_batch(
batch_id=f"subagent-batch-{uuid.uuid4().hex}",
user_id=request.user_id,
thread_id=request.thread_id,
run_id=request.run_id,
tool_call_id=request.tool_call_id,
submission_key=request.submission_key,
title=request.title,
subagent_type=request.subagent_type,
items=request.items,
max_live_items=max_live,
max_running_items=max_running,
max_attempts=self._config.max_attempts,
execution_spec=request.execution_spec,
)
async def get_batch(
self,
*,
batch_id: str,
user_id: str,
) -> dict[str, Any] | None:
return await self._repository.get_batch(batch_id, user_id=user_id)
async def cancel_batch(
self,
*,
batch_id: str,
user_id: str,
) -> dict[str, Any] | None:
batch = await self._repository.cancel_batch(batch_id, user_id=user_id)
if batch is None:
return None
for item_id, execution_id in list(self._execution_ids.items()):
if self._item_batches.get(item_id) == batch_id:
request_cancel_background_task(execution_id)
# Normal ids are not prefixed; the renew loop observes the durable
# cancellation within lease_seconds/3. Keeping cancellation durable is
# what lets another worker own the HTTP control request safely.
return batch
async def _execute_item(self, item: dict[str, Any]) -> None:
item_id = item["id"]
execution_id: str | None = None
try:
batch = item["batch"]
self._item_batches[item_id] = batch["id"]
spec = batch["execution_spec"]
config = SubagentConfig(**spec["subagent_config"])
app_config = self._app_config or get_app_config()
from deerflow.tools import get_available_tools
effective_model = resolve_subagent_model_name(
config,
spec.get("parent_model"),
app_config=app_config,
)
# Assemble off-loop: tool assembly may block on MCP cache
# initialization, which must not stall the calling event loop (issue #5172).
tools = await run_assembly(
get_available_tools,
groups=spec.get("tool_groups"),
model_name=effective_model,
subagent_enabled=False,
include_upload_tool=False,
app_config=app_config,
)
# Revalidate durable state before launching: cancel_batch may have
# terminalized this item (or its lease may have been lost) while
# assembly blocked in the worker thread — the poll loop's checks
# only start after execute_async(), so launching without this
# check would run work the user already cancelled.
lease = await self._repository.renew_item_lease(
item_id,
lease_owner=self._lease_owner,
lease_seconds=self._config.lease_seconds,
now=datetime.now(UTC),
)
if not lease["valid"]:
logger.info(
"Durable batch item %s cancelled or lease lost during tool assembly; skipping launch",
item_id,
)
return
executor = SubagentExecutor(
config=config,
tools=tools,
app_config=app_config,
parent_model=spec.get("parent_model"),
thread_id=batch["thread_id"],
user_id=batch["user_id"],
user_role=spec.get("user_role"),
oauth_provider=spec.get("oauth_provider"),
oauth_id=spec.get("oauth_id"),
run_id=batch.get("run_id"),
channel_user_id=spec.get("channel_user_id"),
is_internal=spec.get("is_internal") is True,
authz_attributes=spec.get("authz_attributes"),
execution_capacity=self._execution_capacity,
acceptance_criteria=item.get("acceptance_criteria"),
)
prompt = f"Durable batch item key: {item['item_key']}\nThis item may be retried after a worker crash. Keep side effects idempotent and use the item key as the idempotency identity.\n\n{item['prompt']}"
execution_id = executor.execute_async(prompt, task_id=item_id)
self._execution_ids[item_id] = execution_id
marked_running = False
renew_every = max(1.0, self._config.lease_seconds / 3)
status_poll_every = min(
self._config.poll_interval_seconds,
renew_every,
)
loop = asyncio.get_running_loop()
next_renew_at = loop.time() + renew_every
while True:
result = get_background_task_result(execution_id)
if result is None:
raise RuntimeError("Native subagent execution disappeared")
if result.status is SubagentStatus.RUNNING and not marked_running:
marked_running = await self._repository.mark_item_running(
item_id,
lease_owner=self._lease_owner,
now=datetime.now(UTC),
)
if not marked_running:
request_cancel_background_task(execution_id)
if result.status.is_terminal:
break
now_monotonic = loop.time()
if now_monotonic >= next_renew_at:
lease = await self._repository.renew_item_lease(
item_id,
lease_owner=self._lease_owner,
lease_seconds=self._config.lease_seconds,
now=datetime.now(UTC),
)
next_renew_at = loop.time() + renew_every
if not lease["valid"]:
request_cancel_background_task(execution_id)
try:
until_renew = max(0.0, next_renew_at - loop.time())
await asyncio.wait_for(
self._stop.wait(),
timeout=min(status_poll_every, until_renew),
)
if self._stop.is_set():
raise asyncio.CancelledError
except TimeoutError:
pass
raw_result = result.result or ""
if getattr(result, "admission_failure", False):
await self._repository.requeue_item_after_admission_failure(
item_id,
lease_owner=self._lease_owner,
error=result.error,
now=datetime.now(UTC),
)
return
truncated = len(raw_result) > self._config.max_result_chars
stored_result = raw_result[: self._config.max_result_chars] if raw_result else None
preview = raw_result[: self._config.result_preview_max_chars] if raw_result else None
acceptance_verdict = None
if result.status is SubagentStatus.COMPLETED and item.get("acceptance_criteria"):
try:
valid, acceptance_verdict = await self._check_acceptance_with_lease(item, result, app_config)
if not valid:
return
except Exception:
# Advisory like ordinary task acceptance: an unavailable
# checker must not discard useful work or trigger a retry.
logger.warning("Batch acceptance check failed; result remains unchecked (item_id=%s)", item_id, exc_info=True)
await self._repository.finalize_item(
item_id,
lease_owner=self._lease_owner,
succeeded=result.status is SubagentStatus.COMPLETED,
result=stored_result,
result_preview=preview,
result_truncated=truncated,
error=result.error,
stop_reason=result.stop_reason,
token_usage=_usage(result.token_usage_records),
model_name=effective_model,
completed_at=datetime.now(UTC),
acceptance_verdict=acceptance_verdict,
)
except asyncio.CancelledError:
if execution_id is not None:
request_cancel_background_task(execution_id)
# Do not finalize on process shutdown. The durable lease expires and
# another worker reclaims the same stable item key.
raise
except Exception as exc:
logger.exception(
"Durable subagent batch item failed (item_id=%s)",
item_id,
)
await self._repository.finalize_item(
item_id,
lease_owner=self._lease_owner,
succeeded=False,
result=None,
result_preview=None,
result_truncated=False,
error=str(exc)[:4_000],
stop_reason=None,
token_usage=None,
model_name=None,
completed_at=datetime.now(UTC),
)
finally:
self._execution_ids.pop(item_id, None)
self._item_batches.pop(item_id, None)
if execution_id is not None:
cleanup_background_task(execution_id)
async def _check_acceptance_with_lease(self, item, result, app_config):
"""Keep a completed execution leased until its advisory check drains."""
async def renew():
lease = await self._repository.renew_item_lease(
item["id"],
lease_owner=self._lease_owner,
lease_seconds=self._config.lease_seconds,
now=datetime.now(UTC),
)
return lease["valid"]
if not await renew():
return False, None
check = asyncio.create_task(
check_batch_acceptance(
item["acceptance_criteria"],
batch=item["batch"],
app_config=app_config,
bash_executions=getattr(result, "bash_executions", None),
)
)
try:
while True:
done, _ = await asyncio.wait({check}, timeout=max(1.0, self._config.lease_seconds / 3))
if done:
return True, check.result()
if not await renew():
return False, None
finally:
if not check.done():
check.cancel()
# The checklist's sandbox offload drains before releasing its
# holder, even when shutdown or a lost lease cancels this task.
await asyncio.gather(check, return_exceptions=True)