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.
223 lines
9.6 KiB
Python
223 lines
9.6 KiB
Python
"""JSONL file-backed RunEventStore implementation.
|
|
|
|
Each run's events are stored in a single file:
|
|
``.deer-flow/threads/{thread_id}/runs/{run_id}.jsonl``
|
|
|
|
All categories (message, trace, lifecycle) are in the same file.
|
|
This backend is suitable for lightweight single-node deployments.
|
|
|
|
**Single-process guarantee**: the in-memory seq counter is process-local.
|
|
Multi-process deployments sharing the same directory will produce duplicate
|
|
or non-monotonic seq values. Use ``DbRunEventStore`` for multi-process or
|
|
high-concurrency deployments.
|
|
|
|
File I/O is offloaded to a thread pool via ``asyncio.to_thread`` so the
|
|
event loop is never blocked. Per-thread ``asyncio.Lock`` objects serialise
|
|
writes within a single process to prevent interleaved JSONL lines.
|
|
|
|
Known trade-off: ``list_messages()`` must scan all run files for a
|
|
thread since messages from multiple runs need unified seq ordering.
|
|
``list_events()`` reads only one file -- the fast path.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import re
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
|
|
from deerflow.runtime.events.store.base import RunEventStore
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_SAFE_ID_PATTERN = re.compile(r"^[A-Za-z0-9_\-]+$")
|
|
|
|
|
|
class JsonlRunEventStore(RunEventStore):
|
|
def __init__(self, base_dir: str | Path | None = None):
|
|
self._base_dir = Path(base_dir) if base_dir else Path(".deer-flow")
|
|
self._seq_counters: dict[str, int] = {} # thread_id -> current max seq
|
|
# Per-thread asyncio.Lock — serialises concurrent writes within one process.
|
|
self._write_locks: dict[str, asyncio.Lock] = {}
|
|
|
|
def _get_write_lock(self, thread_id: str) -> asyncio.Lock:
|
|
return self._write_locks.setdefault(thread_id, asyncio.Lock())
|
|
|
|
@staticmethod
|
|
def _validate_id(value: str, label: str) -> str:
|
|
"""Validate that an ID is safe for use in filesystem paths."""
|
|
if not value or not _SAFE_ID_PATTERN.match(value):
|
|
raise ValueError(f"Invalid {label}: must be alphanumeric/dash/underscore, got {value!r}")
|
|
return value
|
|
|
|
def _thread_dir(self, thread_id: str) -> Path:
|
|
self._validate_id(thread_id, "thread_id")
|
|
return self._base_dir / "threads" / thread_id / "runs"
|
|
|
|
def _run_file(self, thread_id: str, run_id: str) -> Path:
|
|
self._validate_id(run_id, "run_id")
|
|
return self._thread_dir(thread_id) / f"{run_id}.jsonl"
|
|
|
|
def _next_seq(self, thread_id: str) -> int:
|
|
self._seq_counters[thread_id] = self._seq_counters.get(thread_id, 0) + 1
|
|
return self._seq_counters[thread_id]
|
|
|
|
def _compute_max_seq(self, thread_id: str) -> int:
|
|
"""Scan all run files for a thread and return the current max seq (blocking I/O)."""
|
|
max_seq = 0
|
|
thread_dir = self._thread_dir(thread_id)
|
|
if thread_dir.exists():
|
|
for f in thread_dir.glob("*.jsonl"):
|
|
for line in f.read_text(encoding="utf-8").strip().splitlines():
|
|
try:
|
|
record = json.loads(line)
|
|
max_seq = max(max_seq, record.get("seq", 0))
|
|
except json.JSONDecodeError:
|
|
logger.debug("Skipping malformed JSONL line in %s", f)
|
|
return max_seq
|
|
|
|
async def _ensure_seq_loaded(self, thread_id: str) -> None:
|
|
"""Load max seq from existing files into the in-memory counter (non-blocking)."""
|
|
if thread_id in self._seq_counters:
|
|
return
|
|
max_seq = await asyncio.to_thread(self._compute_max_seq, thread_id)
|
|
self._seq_counters[thread_id] = max_seq
|
|
|
|
def _write_record(self, record: dict) -> None:
|
|
path = self._run_file(record["thread_id"], record["run_id"])
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
with open(path, "a", encoding="utf-8") as f:
|
|
f.write(json.dumps(record, default=str, ensure_ascii=False) + "\n")
|
|
|
|
def _read_thread_events(self, thread_id: str) -> list[dict]:
|
|
"""Read all events for a thread, sorted by seq (blocking I/O)."""
|
|
events = []
|
|
thread_dir = self._thread_dir(thread_id)
|
|
if not thread_dir.exists():
|
|
return events
|
|
for f in sorted(thread_dir.glob("*.jsonl")):
|
|
for line in f.read_text(encoding="utf-8").strip().splitlines():
|
|
if not line:
|
|
continue
|
|
try:
|
|
events.append(json.loads(line))
|
|
except json.JSONDecodeError:
|
|
logger.debug("Skipping malformed JSONL line in %s", f)
|
|
events.sort(key=lambda e: e.get("seq", 0))
|
|
return events
|
|
|
|
def _read_run_events(self, thread_id: str, run_id: str) -> list[dict]:
|
|
"""Read events for a specific run file (blocking I/O)."""
|
|
path = self._run_file(thread_id, run_id)
|
|
if not path.exists():
|
|
return []
|
|
events = []
|
|
for line in path.read_text(encoding="utf-8").strip().splitlines():
|
|
if not line:
|
|
continue
|
|
try:
|
|
events.append(json.loads(line))
|
|
except json.JSONDecodeError:
|
|
logger.debug("Skipping malformed JSONL line in %s", path)
|
|
events.sort(key=lambda e: e.get("seq", 0))
|
|
return events
|
|
|
|
def _delete_thread_files(self, thread_id: str) -> None:
|
|
thread_dir = self._thread_dir(thread_id)
|
|
if thread_dir.exists():
|
|
for f in thread_dir.glob("*.jsonl"):
|
|
f.unlink()
|
|
|
|
def _delete_run_file(self, thread_id: str, run_id: str) -> None:
|
|
path = self._run_file(thread_id, run_id)
|
|
if path.exists():
|
|
path.unlink()
|
|
|
|
async def put(self, *, thread_id, run_id, event_type, category, content="", metadata=None, created_at=None):
|
|
async with self._get_write_lock(thread_id):
|
|
await self._ensure_seq_loaded(thread_id)
|
|
seq = self._next_seq(thread_id)
|
|
record = {
|
|
"thread_id": thread_id,
|
|
"run_id": run_id,
|
|
"event_type": event_type,
|
|
"category": category,
|
|
"content": content,
|
|
"metadata": metadata or {},
|
|
"seq": seq,
|
|
"created_at": created_at or datetime.now(UTC).isoformat(),
|
|
}
|
|
await asyncio.to_thread(self._write_record, record)
|
|
return record
|
|
|
|
async def put_batch(self, events):
|
|
if not events:
|
|
return []
|
|
results = []
|
|
for ev in events:
|
|
record = await self.put(**ev)
|
|
results.append(record)
|
|
return results
|
|
|
|
async def list_messages(self, thread_id, *, limit=50, before_seq=None, after_seq=None):
|
|
all_events = await asyncio.to_thread(self._read_thread_events, thread_id)
|
|
messages = [e for e in all_events if e.get("category") == "message"]
|
|
|
|
if before_seq is not None:
|
|
messages = [e for e in messages if e["seq"] < before_seq]
|
|
return messages[-limit:]
|
|
elif after_seq is not None:
|
|
messages = [e for e in messages if e["seq"] > after_seq]
|
|
return messages[:limit]
|
|
else:
|
|
return messages[-limit:]
|
|
|
|
async def list_events(self, thread_id, run_id, *, event_types=None, task_id=None, limit=500, after_seq=None):
|
|
events = await asyncio.to_thread(self._read_run_events, thread_id, run_id)
|
|
if event_types is not None:
|
|
events = [e for e in events if e.get("event_type") in event_types]
|
|
if task_id is not None:
|
|
events = [e for e in events if (e.get("metadata") or {}).get("task_id") == task_id]
|
|
if after_seq is not None:
|
|
events = [e for e in events if e.get("seq", 0) > after_seq]
|
|
return events[:limit]
|
|
|
|
async def list_messages_by_run(self, thread_id, run_id, *, limit=50, before_seq=None, after_seq=None):
|
|
events = await asyncio.to_thread(self._read_run_events, thread_id, run_id)
|
|
filtered = [e for e in events if e.get("category") == "message"]
|
|
if before_seq is not None:
|
|
filtered = [e for e in filtered if e.get("seq", 0) < before_seq]
|
|
if after_seq is not None:
|
|
filtered = [e for e in filtered if e.get("seq", 0) > after_seq]
|
|
if after_seq is not None:
|
|
return filtered[:limit]
|
|
else:
|
|
return filtered[-limit:] if len(filtered) > limit else filtered
|
|
|
|
async def count_messages(self, thread_id):
|
|
all_events = await asyncio.to_thread(self._read_thread_events, thread_id)
|
|
return sum(1 for e in all_events if e.get("category") == "message")
|
|
|
|
async def delete_by_thread(self, thread_id):
|
|
async with self._get_write_lock(thread_id):
|
|
all_events = await asyncio.to_thread(self._read_thread_events, thread_id)
|
|
count = len(all_events)
|
|
await asyncio.to_thread(self._delete_thread_files, thread_id)
|
|
self._seq_counters.pop(thread_id, None)
|
|
# Pop the lock inside the held scope to minimise the window where a new caller
|
|
# could obtain a fresh lock while a waiting coroutine still holds the old one.
|
|
# Note: coroutines that already acquired a reference to this lock before the
|
|
# delete will still proceed after we release — this is an accepted narrow race.
|
|
self._write_locks.pop(thread_id, None)
|
|
return count
|
|
|
|
async def delete_by_run(self, thread_id, run_id):
|
|
async with self._get_write_lock(thread_id):
|
|
events = await asyncio.to_thread(self._read_run_events, thread_id, run_id)
|
|
count = len(events)
|
|
await asyncio.to_thread(self._delete_run_file, thread_id, run_id)
|
|
return count
|