mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-13 00:08:42 +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.
170 lines
5.1 KiB
TypeScript
170 lines
5.1 KiB
TypeScript
/**
|
|
* Subtask step model shared by the live (SSE) and reload (fetched) paths.
|
|
*
|
|
* Issue #3779: the subtask card used to keep only the latest subagent message,
|
|
* so earlier steps flashed by and nothing survived a reload. A `SubtaskStep` is
|
|
* the normalized, renderable unit of subagent progress — one assistant turn
|
|
* (`kind: "ai"`, carrying its tool-call requests) or one tool result
|
|
* (`kind: "tool"`, carrying the tool's output). The backend persists the same
|
|
* shape as `subagent.step` run-event content; `messageToStep` mirrors that
|
|
* shaping for the live `task_running` event, which still carries the raw message.
|
|
*/
|
|
|
|
export interface SubtaskStepToolCall {
|
|
name?: string;
|
|
args?: unknown;
|
|
}
|
|
|
|
export interface SubtaskStep {
|
|
message_index: number;
|
|
kind: "ai" | "tool";
|
|
text: string;
|
|
truncated?: boolean;
|
|
tool_calls?: SubtaskStepToolCall[];
|
|
tool_name?: string;
|
|
}
|
|
|
|
type RawMessage = {
|
|
type?: string;
|
|
content?: unknown;
|
|
name?: string;
|
|
tool_calls?: { name?: string; args?: unknown; [key: string]: unknown }[];
|
|
[key: string]: unknown;
|
|
};
|
|
|
|
function contentToText(content: unknown): string {
|
|
if (typeof content === "string") {
|
|
return content;
|
|
}
|
|
if (Array.isArray(content)) {
|
|
return content
|
|
.map((block) => {
|
|
if (typeof block === "string") {
|
|
return block;
|
|
}
|
|
if (block && typeof block === "object" && "text" in block) {
|
|
const text = (block as { text?: unknown }).text;
|
|
return typeof text === "string" ? text : "";
|
|
}
|
|
return "";
|
|
})
|
|
.filter(Boolean)
|
|
.join("\n");
|
|
}
|
|
return "";
|
|
}
|
|
|
|
/** Normalize a raw subagent message (live `task_running` payload) into a step. */
|
|
export function messageToStep(
|
|
message: RawMessage,
|
|
messageIndex: number,
|
|
): SubtaskStep {
|
|
const kind = message.type === "tool" ? "tool" : "ai";
|
|
const step: SubtaskStep = {
|
|
message_index: messageIndex,
|
|
kind,
|
|
text: contentToText(message.content),
|
|
};
|
|
|
|
if (kind === "tool") {
|
|
step.tool_name = message.name;
|
|
} else {
|
|
step.tool_calls = (message.tool_calls ?? []).map((call) => ({
|
|
name: call.name,
|
|
args: call.args,
|
|
}));
|
|
}
|
|
|
|
return step;
|
|
}
|
|
|
|
/**
|
|
* Steps to render in the subtask card timeline (#3779). Interleaves the
|
|
* subagent's assistant turns and tool steps, ordered by `message_index`:
|
|
*
|
|
* - tool steps are always kept (one "the subagent ran <tool>" row each);
|
|
* - AI steps are kept only when they carry visible reasoning text — a turn that
|
|
* only requests tools (blank text) adds no information beyond the tool rows
|
|
* that follow it, so it is dropped;
|
|
* - when the task is `completed`, a trailing AI step with no tool_calls is the
|
|
* subagent's final answer, which the card already renders as `task.result`,
|
|
* so it is dropped here to avoid showing the answer twice.
|
|
*/
|
|
export function stepsForDisplay(
|
|
steps: SubtaskStep[] | undefined,
|
|
status: "in_progress" | "completed" | "failed",
|
|
): SubtaskStep[] {
|
|
const visible = (steps ?? [])
|
|
.filter((step) => step.kind === "tool" || step.text.trim() !== "")
|
|
.sort((a, b) => a.message_index - b.message_index);
|
|
|
|
if (status === "completed") {
|
|
const last = visible[visible.length - 1];
|
|
if (last?.kind === "ai" && !last?.tool_calls?.length) {
|
|
return visible.slice(0, -1);
|
|
}
|
|
}
|
|
return visible;
|
|
}
|
|
|
|
type RunEvent = {
|
|
event_type?: string;
|
|
content?: unknown;
|
|
metadata?: { task_id?: string } & Record<string, unknown>;
|
|
};
|
|
|
|
/**
|
|
* Map persisted run events (from `GET /{rid}/events`) into the subtask's steps,
|
|
* keeping only `subagent.step` events for `taskId` and ordering by message_index.
|
|
* The persisted `content` already matches the step shape (it is what the backend
|
|
* `build_subagent_step` produced), so this filters, projects, and sorts (#3779).
|
|
*/
|
|
export function eventsToSteps(
|
|
events: RunEvent[],
|
|
taskId: string,
|
|
): SubtaskStep[] {
|
|
const steps: SubtaskStep[] = [];
|
|
for (const event of events) {
|
|
if (event.event_type !== "subagent.step") {
|
|
continue;
|
|
}
|
|
const content = event.content as
|
|
| (SubtaskStep & { task_id?: string })
|
|
| undefined;
|
|
const eventTaskId = content?.task_id ?? event.metadata?.task_id;
|
|
if (!content || eventTaskId !== taskId) {
|
|
continue;
|
|
}
|
|
steps.push({
|
|
message_index: content.message_index,
|
|
kind: content.kind,
|
|
text: content.text ?? "",
|
|
truncated: content.truncated,
|
|
tool_calls: content.tool_calls,
|
|
tool_name: content.tool_name,
|
|
});
|
|
}
|
|
return steps.sort((a, b) => a.message_index - b.message_index);
|
|
}
|
|
|
|
/**
|
|
* Merge `incoming` steps into `existing`, deduping by `message_index` (incoming
|
|
* wins) and keeping the result ordered. Used to reconcile live SSE steps with
|
|
* steps fetched on expand without double-rendering shared indices.
|
|
*/
|
|
export function mergeSteps(
|
|
existing: SubtaskStep[],
|
|
incoming: SubtaskStep[],
|
|
): SubtaskStep[] {
|
|
const byIndex = new Map<number, SubtaskStep>();
|
|
for (const step of existing) {
|
|
byIndex.set(step.message_index, step);
|
|
}
|
|
for (const step of incoming) {
|
|
byIndex.set(step.message_index, step);
|
|
}
|
|
return [...byIndex.values()].sort(
|
|
(a, b) => a.message_index - b.message_index,
|
|
);
|
|
}
|