Nan Gao 4fcb4bc366
feat(subagents): persist and display subagent step history (#3779) (#3845)
* 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.
2026-07-02 07:43:09 +08:00

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,
);
}