mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-01 10:56:02 +00:00
* feat(subagents): persist and display subagent step history (#3779) Capture both assistant turns and tool outputs during subagent execution, stream them in task_running events, and persist them as subagent.* run events so the subtask card's step timeline survives a reload. Backend: - step_events.py: pure layer (capture_step_message, build_subagent_step, subagent_run_event) shared by streaming and persistence - executor.py: capture ToolMessage outputs, not just AIMessage turns - worker.py: persist task_* custom events to RunEventStore (category "subagent" keeps them out of the thread feed; list_events backfills) Frontend: - core/tasks/steps.ts + api.ts: SubtaskStep model, messageToStep, eventsToSteps, mergeSteps, fetchSubtaskSteps - subtask card accumulates live steps and backfills on expand - carry run_id onto history content messages for the events endpoint * fix(subagents): show AI turns in subtask card + paginate step backfill (#3779) Two follow-ups to the subagent step-history feature: Problem 1 — reload backfill could silently truncate the step timeline because list_events capped at 500 events (seq-ASC) across the whole run. Add task_id filtering + an after_seq forward cursor to list_events (all three stores + abstract base + the /events route), and make fetchSubtaskSteps page through one task's subagent.step events until a short page. No schema migration: the DB filter rides the existing run-scoped index via event_metadata["task_id"]. Problem 2 — the card only rendered tool steps, so persisted AI turns were never shown. Replace toolStepsForDisplay with stepsForDisplay: interleave AI reasoning turns (with text) and tool steps by message_index, drop blank-text AI turns, and drop the trailing final-answer AI turn when completed (already shown as result). Card renders AI steps as muted clamped markdown with a sparkles icon. Tests: store task_id/after_seq filtering + pagination across memory/db/jsonl, the /events route forwarding, stepsForDisplay rules, and fetchSubtaskSteps pagination. Docs updated in both AGENTS.md. * make format * fix(subagents): capture full multi-tool step tail, batch step persistence, cap tool-call args (#3779) Address PR review findings on the subagent step-history feature: 1. executor.py streamed on stream_mode="values" and captured only messages[-1] per chunk, so a multi-tool-call turn (ToolNode appends one ToolMessage per call in a single super-step) lost all but the last tool output in both the live task_running stream and the persisted history. Replace with capture_new_step_messages, which walks the newly-appended tail (and still re-checks the trailing message on no-growth chunks so id-less in-place replacements survive). 2. worker.py persisted each step with the store's low-frequency put() (a per-thread advisory lock per call); a deep subagent (max_turns=150) emits hundreds of steps on the hot stream loop. Replace with _SubagentEventBuffer, which batches via put_batch (flush on terminal subagent.end, at FLUSH_THRESHOLD, and in the worker finally). 3. build_subagent_step capped only text; tool_calls[].args were copied verbatim, so a large write_file/bash payload produced an unbounded subagent.step row. Cap each call's serialized args at SUBAGENT_STEP_MAX_CHARS, flagged args_truncated. Tests updated/added for all three; AGENTS.md refreshed. * fix(subagents): merge backfill into latest subtask state; reuse message_content_to_text (#3779) Address the remaining two PR review findings: 4. subtask-card's fetchSubtaskSteps().then(updateSubtask) closed over a stale tasks snapshot: a late-resolving backfill wrote setTasks({...stale}), clobbering SSE steps/status and sibling subtasks that arrived during the fetch. useUpdateSubtask now reads/writes through a tasksRef mirroring the latest state (ref-to-latest), and the pure per-subtask transition is extracted to core/tasks/subtask-update.ts::computeNextSubtask (unit-tested). 5. step_events._content_to_text duplicated deerflow.utils.messages. message_content_to_text; call the shared helper instead (guarding None content with 'or ""' so a tool-call-only turn still renders as ""). Tests added for computeNextSubtask and the None-content case; AGENTS.md docs updated.
327 lines
14 KiB
Python
327 lines
14 KiB
Python
"""SQLAlchemy-backed RunEventStore implementation.
|
|
|
|
Persists events to the ``run_events`` table. Trace content is truncated
|
|
at ``max_trace_content`` bytes to avoid bloating the database.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from datetime import UTC, datetime
|
|
from typing import Any
|
|
|
|
from sqlalchemy import delete, func, select, text
|
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
|
|
from deerflow.persistence.models.run_event import RunEventRow
|
|
from deerflow.runtime.events.store.base import RunEventStore
|
|
from deerflow.runtime.user_context import AUTO, _AutoSentinel, get_current_user, resolve_user_id
|
|
from deerflow.utils.time import coerce_iso
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class DbRunEventStore(RunEventStore):
|
|
def __init__(self, session_factory: async_sessionmaker[AsyncSession], *, max_trace_content: int = 10240):
|
|
self._sf = session_factory
|
|
self._max_trace_content = max_trace_content
|
|
|
|
@staticmethod
|
|
def _row_to_dict(row: RunEventRow) -> dict:
|
|
d = row.to_dict()
|
|
d["metadata"] = d.pop("event_metadata", {})
|
|
val = d.get("created_at")
|
|
if isinstance(val, datetime):
|
|
# SQLite drops tzinfo on read despite ``DateTime(timezone=True)``;
|
|
# ``coerce_iso`` normalizes naive datetimes as UTC.
|
|
d["created_at"] = coerce_iso(val)
|
|
d.pop("id", None)
|
|
# Restore structured content that was JSON-serialized on write.
|
|
raw = d.get("content", "")
|
|
metadata = d.get("metadata", {})
|
|
if isinstance(raw, str) and (metadata.get("content_is_json") or metadata.get("content_is_dict")):
|
|
try:
|
|
d["content"] = json.loads(raw)
|
|
except (json.JSONDecodeError, ValueError):
|
|
# Content looked like JSON but failed to parse;
|
|
# keep the raw string as-is.
|
|
logger.debug("Failed to deserialize content as JSON for event seq=%s", d.get("seq"))
|
|
return d
|
|
|
|
def _truncate_trace(self, category: str, content: Any, metadata: dict | None) -> tuple[Any, dict]:
|
|
if category == "trace":
|
|
text = content if isinstance(content, str) else json.dumps(content, default=str, ensure_ascii=False)
|
|
encoded = text.encode("utf-8")
|
|
if len(encoded) > self._max_trace_content:
|
|
# Truncate by bytes, then decode back (may cut a multi-byte char, so use errors="ignore")
|
|
content = encoded[: self._max_trace_content].decode("utf-8", errors="ignore")
|
|
metadata = {**(metadata or {}), "content_truncated": True, "original_byte_length": len(encoded)}
|
|
return content, metadata or {}
|
|
|
|
@staticmethod
|
|
def _content_to_db(content: Any, metadata: dict | None) -> tuple[str, dict]:
|
|
metadata = metadata or {}
|
|
if isinstance(content, str):
|
|
return content, metadata
|
|
|
|
db_content = json.dumps(content, default=str, ensure_ascii=False)
|
|
metadata = {**metadata, "content_is_json": True}
|
|
if isinstance(content, dict):
|
|
metadata["content_is_dict"] = True
|
|
return db_content, metadata
|
|
|
|
@staticmethod
|
|
def _user_id_from_context() -> str | None:
|
|
"""Soft read of user_id from contextvar for write paths.
|
|
|
|
Returns ``None`` (no filter / no stamp) if contextvar is unset,
|
|
which is the expected case for background worker writes. HTTP
|
|
request writes will have the contextvar set by auth middleware
|
|
and get their user_id stamped automatically.
|
|
|
|
Coerces ``user.id`` to ``str`` at the boundary: ``User.id`` is
|
|
typed as ``UUID`` by the auth layer, but ``run_events.user_id``
|
|
is ``VARCHAR(64)`` and aiosqlite cannot bind a raw UUID object
|
|
to a VARCHAR column ("type 'UUID' is not supported") — the
|
|
INSERT would silently roll back and the worker would hang.
|
|
"""
|
|
user = get_current_user()
|
|
return str(user.id) if user is not None else None
|
|
|
|
@staticmethod
|
|
async def _max_seq_for_thread(session: AsyncSession, thread_id: str) -> int | None:
|
|
"""Return the current max seq while serializing writers per thread.
|
|
|
|
PostgreSQL rejects ``SELECT max(...) FOR UPDATE`` because aggregate
|
|
results are not lockable rows. As a release-safe workaround, take a
|
|
transaction-level advisory lock keyed by thread_id before reading the
|
|
aggregate. Other dialects keep the existing row-locking statement.
|
|
"""
|
|
stmt = select(func.max(RunEventRow.seq)).where(RunEventRow.thread_id == thread_id)
|
|
bind = session.get_bind()
|
|
dialect_name = bind.dialect.name if bind is not None else ""
|
|
|
|
if dialect_name == "postgresql":
|
|
await session.execute(
|
|
text("SELECT pg_advisory_xact_lock(hashtext(CAST(:thread_id AS text))::bigint)"),
|
|
{"thread_id": thread_id},
|
|
)
|
|
return await session.scalar(stmt)
|
|
|
|
return await session.scalar(stmt.with_for_update())
|
|
|
|
async def put(self, *, thread_id, run_id, event_type, category, content="", metadata=None, created_at=None): # noqa: D401
|
|
"""Write a single event — low-frequency path only.
|
|
|
|
This opens a dedicated transaction with a FOR UPDATE lock to
|
|
assign a monotonic *seq*. For high-throughput writes use
|
|
:meth:`put_batch`, which acquires the lock once for the whole
|
|
batch. Currently the only caller is ``worker.run_agent`` for
|
|
the initial ``human_message`` event (once per run).
|
|
"""
|
|
content, metadata = self._truncate_trace(category, content, metadata)
|
|
db_content, metadata = self._content_to_db(content, metadata)
|
|
user_id = self._user_id_from_context()
|
|
async with self._sf() as session:
|
|
async with session.begin():
|
|
max_seq = await self._max_seq_for_thread(session, thread_id)
|
|
seq = (max_seq or 0) + 1
|
|
row = RunEventRow(
|
|
thread_id=thread_id,
|
|
run_id=run_id,
|
|
user_id=user_id,
|
|
event_type=event_type,
|
|
category=category,
|
|
content=db_content,
|
|
event_metadata=metadata,
|
|
seq=seq,
|
|
created_at=datetime.fromisoformat(created_at) if created_at else datetime.now(UTC),
|
|
)
|
|
session.add(row)
|
|
return self._row_to_dict(row)
|
|
|
|
async def put_batch(self, events):
|
|
if not events:
|
|
return []
|
|
thread_ids = {e["thread_id"] for e in events}
|
|
if len(thread_ids) > 1:
|
|
raise ValueError(f"put_batch requires all events to belong to the same thread; got {thread_ids!r}")
|
|
user_id = self._user_id_from_context()
|
|
async with self._sf() as session:
|
|
async with session.begin():
|
|
# All events belong to the same thread (validated above).
|
|
thread_id = events[0]["thread_id"]
|
|
max_seq = await self._max_seq_for_thread(session, thread_id)
|
|
seq = max_seq or 0
|
|
rows = []
|
|
for e in events:
|
|
seq += 1
|
|
content = e.get("content", "")
|
|
category = e.get("category", "trace")
|
|
metadata = e.get("metadata")
|
|
content, metadata = self._truncate_trace(category, content, metadata)
|
|
db_content, metadata = self._content_to_db(content, metadata)
|
|
row = RunEventRow(
|
|
thread_id=e["thread_id"],
|
|
run_id=e["run_id"],
|
|
user_id=e.get("user_id", user_id),
|
|
event_type=e["event_type"],
|
|
category=category,
|
|
content=db_content,
|
|
event_metadata=metadata,
|
|
seq=seq,
|
|
created_at=datetime.fromisoformat(e["created_at"]) if e.get("created_at") else datetime.now(UTC),
|
|
)
|
|
session.add(row)
|
|
rows.append(row)
|
|
return [self._row_to_dict(r) for r in rows]
|
|
|
|
async def list_messages(
|
|
self,
|
|
thread_id,
|
|
*,
|
|
limit=50,
|
|
before_seq=None,
|
|
after_seq=None,
|
|
user_id: str | None | _AutoSentinel = AUTO,
|
|
):
|
|
resolved_user_id = resolve_user_id(user_id, method_name="DbRunEventStore.list_messages")
|
|
stmt = select(RunEventRow).where(RunEventRow.thread_id == thread_id, RunEventRow.category == "message")
|
|
if resolved_user_id is not None:
|
|
stmt = stmt.where(RunEventRow.user_id == resolved_user_id)
|
|
if before_seq is not None:
|
|
stmt = stmt.where(RunEventRow.seq < before_seq)
|
|
if after_seq is not None:
|
|
stmt = stmt.where(RunEventRow.seq > after_seq)
|
|
|
|
if after_seq is not None:
|
|
# Forward pagination: first `limit` records after cursor
|
|
stmt = stmt.order_by(RunEventRow.seq.asc()).limit(limit)
|
|
async with self._sf() as session:
|
|
result = await session.execute(stmt)
|
|
return [self._row_to_dict(r) for r in result.scalars()]
|
|
else:
|
|
# before_seq or default (latest): take last `limit` records, return ascending
|
|
stmt = stmt.order_by(RunEventRow.seq.desc()).limit(limit)
|
|
async with self._sf() as session:
|
|
result = await session.execute(stmt)
|
|
rows = list(result.scalars())
|
|
return [self._row_to_dict(r) for r in reversed(rows)]
|
|
|
|
async def list_events(
|
|
self,
|
|
thread_id,
|
|
run_id,
|
|
*,
|
|
event_types=None,
|
|
task_id=None,
|
|
limit=500,
|
|
after_seq=None,
|
|
user_id: str | None | _AutoSentinel = AUTO,
|
|
):
|
|
resolved_user_id = resolve_user_id(user_id, method_name="DbRunEventStore.list_events")
|
|
stmt = select(RunEventRow).where(RunEventRow.thread_id == thread_id, RunEventRow.run_id == run_id)
|
|
if resolved_user_id is not None:
|
|
stmt = stmt.where(RunEventRow.user_id == resolved_user_id)
|
|
if event_types:
|
|
stmt = stmt.where(RunEventRow.event_type.in_(event_types))
|
|
if task_id is not None:
|
|
# Filter on metadata["task_id"] in SQL (before LIMIT) so cursor
|
|
# pagination over a single subagent task stays correct (#3779). The
|
|
# query is already scoped to (thread_id, run_id), so the JSON probe
|
|
# only runs over this run's small candidate set; ``.as_string()``
|
|
# renders to json_extract (SQLite) / ->> (Postgres).
|
|
stmt = stmt.where(RunEventRow.event_metadata["task_id"].as_string() == task_id)
|
|
if after_seq is not None:
|
|
stmt = stmt.where(RunEventRow.seq > after_seq)
|
|
stmt = stmt.order_by(RunEventRow.seq.asc()).limit(limit)
|
|
async with self._sf() as session:
|
|
result = await session.execute(stmt)
|
|
return [self._row_to_dict(r) for r in result.scalars()]
|
|
|
|
async def list_messages_by_run(
|
|
self,
|
|
thread_id,
|
|
run_id,
|
|
*,
|
|
limit=50,
|
|
before_seq=None,
|
|
after_seq=None,
|
|
user_id: str | None | _AutoSentinel = AUTO,
|
|
):
|
|
resolved_user_id = resolve_user_id(user_id, method_name="DbRunEventStore.list_messages_by_run")
|
|
stmt = select(RunEventRow).where(
|
|
RunEventRow.thread_id == thread_id,
|
|
RunEventRow.run_id == run_id,
|
|
RunEventRow.category == "message",
|
|
)
|
|
if resolved_user_id is not None:
|
|
stmt = stmt.where(RunEventRow.user_id == resolved_user_id)
|
|
if before_seq is not None:
|
|
stmt = stmt.where(RunEventRow.seq < before_seq)
|
|
if after_seq is not None:
|
|
stmt = stmt.where(RunEventRow.seq > after_seq)
|
|
|
|
if after_seq is not None:
|
|
stmt = stmt.order_by(RunEventRow.seq.asc()).limit(limit)
|
|
async with self._sf() as session:
|
|
result = await session.execute(stmt)
|
|
return [self._row_to_dict(r) for r in result.scalars()]
|
|
else:
|
|
stmt = stmt.order_by(RunEventRow.seq.desc()).limit(limit)
|
|
async with self._sf() as session:
|
|
result = await session.execute(stmt)
|
|
rows = list(result.scalars())
|
|
return [self._row_to_dict(r) for r in reversed(rows)]
|
|
|
|
async def count_messages(
|
|
self,
|
|
thread_id,
|
|
*,
|
|
user_id: str | None | _AutoSentinel = AUTO,
|
|
):
|
|
resolved_user_id = resolve_user_id(user_id, method_name="DbRunEventStore.count_messages")
|
|
stmt = select(func.count()).select_from(RunEventRow).where(RunEventRow.thread_id == thread_id, RunEventRow.category == "message")
|
|
if resolved_user_id is not None:
|
|
stmt = stmt.where(RunEventRow.user_id == resolved_user_id)
|
|
async with self._sf() as session:
|
|
return await session.scalar(stmt) or 0
|
|
|
|
async def delete_by_thread(
|
|
self,
|
|
thread_id,
|
|
*,
|
|
user_id: str | None | _AutoSentinel = AUTO,
|
|
):
|
|
resolved_user_id = resolve_user_id(user_id, method_name="DbRunEventStore.delete_by_thread")
|
|
async with self._sf() as session:
|
|
count_conditions = [RunEventRow.thread_id == thread_id]
|
|
if resolved_user_id is not None:
|
|
count_conditions.append(RunEventRow.user_id == resolved_user_id)
|
|
count_stmt = select(func.count()).select_from(RunEventRow).where(*count_conditions)
|
|
count = await session.scalar(count_stmt) or 0
|
|
if count > 0:
|
|
await session.execute(delete(RunEventRow).where(*count_conditions))
|
|
await session.commit()
|
|
return count
|
|
|
|
async def delete_by_run(
|
|
self,
|
|
thread_id,
|
|
run_id,
|
|
*,
|
|
user_id: str | None | _AutoSentinel = AUTO,
|
|
):
|
|
resolved_user_id = resolve_user_id(user_id, method_name="DbRunEventStore.delete_by_run")
|
|
async with self._sf() as session:
|
|
count_conditions = [RunEventRow.thread_id == thread_id, RunEventRow.run_id == run_id]
|
|
if resolved_user_id is not None:
|
|
count_conditions.append(RunEventRow.user_id == resolved_user_id)
|
|
count_stmt = select(func.count()).select_from(RunEventRow).where(*count_conditions)
|
|
count = await session.scalar(count_stmt) or 0
|
|
if count > 0:
|
|
await session.execute(delete(RunEventRow).where(*count_conditions))
|
|
await session.commit()
|
|
return count
|