Files
beaver_project/app-instance/frontend/lib/task-ui-model.ts
steven_li 520a21a027 feat(coordinator): 添加团队节点默认最大工具迭代次数配置
添加 DEFAULT_TEAM_NODE_MAX_TOOL_ITERATIONS 配置项以控制团队节点的最大工具迭代次数,
并修改 LocalAgentRunner 中的逻辑来使用此默认值当 envelope 中未指定时。

fix(runtime): 修复团队节点运行成功判断逻辑

更新运行成功判断条件,将 finish_reason 为 "max_tool_iterations_finalized" 的情况
视为运行失败,并添加对原始工具调用输出的检测,避免将其误判为成功完成。

feat(mcp): 添加团队工作流MCP工具类别支持

增加新的本地MCP工具类别 "team_workflow" 及其对应的工具创建功能,
为团队工作流提供本地工具支持。

refactor(engine): 调整AgentLoop最大工具迭代次数设置

将 AgentProfile 中的默认 max_tool_iterations 从 30 增加到 100,
同时移除 TaskExecutionPlanner 构造函数中的重复参数传递。

perf(mcp): 优化MCP连接管理避免重复连接

添加 mcp_connected 标志来跟踪MCP连接状态,确保 connect_all 只执行一次,
提高性能并避免不必要的重复连接。

refactor(skills): 移除技能团队模板相关功能

移除与技能团队模板相关的代码,包括解析、存储和处理逻辑,
简化技能记录结构和加载流程。

feat(process): 增强会话过程投影器功能

添加技能激活快照事件处理,改进团队运行完成消息显示,
并增强技能激活事件的时间戳记录功能。

refactor(tasks): 简化任务尝试编排器团队执行逻辑

移除团队执行相关代码,将所有任务统一按单步执行处理,
简化任务编排器的复杂度并提升执行效率。

fix(evidence): 修复节点证据评估中需求验证逻辑

更新节点证据评估逻辑,跳过自然语言证据需求的确定性验证,
只执行机器可读的需求验证,避免因自然语言需求导致的节点失败。
2026-06-26 16:36:29 +08:00

693 lines
23 KiB
TypeScript

import { pickAppText, type AppLocale } from '@/lib/i18n/core';
import type { BackendTask, ProcessArtifact, ProcessRun, SessionProcessProjection, TaskTimelineCard } from '@/types';
export type TaskUiStatus = 'done' | 'running' | 'waiting' | 'error' | 'cancelled';
export type TaskUiStep = {
id: string;
title: string;
summary: string;
status: TaskUiStatus;
createdAt: string;
kind: 'task' | 'skill' | 'tool' | 'agent' | 'artifact' | 'result';
};
export type TaskUiSkill = {
id: string;
name: string;
summary: string;
status: TaskUiStatus;
createdAt: string;
};
export type TaskUiToolCall = {
id: string;
runId?: string | null;
toolCallId?: string | null;
toolName: string;
actorName: string;
summary: string;
status: TaskUiStatus;
createdAt: string;
finishedAt?: string;
durationMs?: number | null;
query?: string;
url?: string;
quality?: string;
resultCount?: number;
};
export type TaskUiAttemptRun = {
runId: string;
title: string;
actorName: string;
source?: string | null;
status: TaskUiStatus;
startedAt: string;
finishedAt?: string | null;
};
export type TaskUiAttempt = {
id: string;
index: number;
title: string;
status: TaskUiStatus;
startedAt: string;
finishedAt?: string | null;
runs: TaskUiAttemptRun[];
tools: TaskUiToolCall[];
result?: {
title: string;
summary: string;
status: TaskUiStatus;
createdAt: string;
};
};
export type TaskUiAgentNode = {
runId: string;
parentRunId: string | null;
name: string;
title: string;
summary: string;
status: TaskUiStatus;
progress: number;
children: TaskUiAgentNode[];
};
export type TaskUiArtifact = {
id: string;
runId: string;
actorName?: string;
title: string;
type: ProcessArtifact['artifact_type'];
summary: string;
createdAt: string;
fileId?: string;
url?: string;
status: TaskUiStatus;
sizeLabel?: string;
};
export type TaskUiModel = {
executionMode: 'single' | 'team';
team: {
hasTeam: boolean;
status: TaskUiStatus;
outcome: string;
nodeIds: string[];
incompleteNodeIds: string[];
summary: string;
};
summary: TaskUiStep;
skills: TaskUiSkill[];
tools: TaskUiToolCall[];
attempts: TaskUiAttempt[];
agentTree: TaskUiAgentNode[];
artifacts: TaskUiArtifact[];
steps: TaskUiStep[];
result: {
status: TaskUiStatus;
title: string;
summary: string;
bullets: string[];
};
};
const WAITING_TASK_STATUSES = new Set(['open', 'queued', 'awaiting_acceptance', 'needs_revision']);
const RUNNING_TASK_STATUSES = new Set(['running']);
const DONE_TASK_STATUSES = new Set(['closed', 'done', 'completed', 'satisfied']);
const ERROR_TASK_STATUSES = new Set(['error', 'failed']);
const CANCELLED_TASK_STATUSES = new Set(['cancelled', 'abandoned']);
function normalizeStatus(status?: string | null): TaskUiStatus {
const value = String(status || '').toLowerCase();
if (DONE_TASK_STATUSES.has(value) || value === 'done') return 'done';
if (RUNNING_TASK_STATUSES.has(value)) return 'running';
if (ERROR_TASK_STATUSES.has(value)) return 'error';
if (CANCELLED_TASK_STATUSES.has(value)) return 'cancelled';
if (WAITING_TASK_STATUSES.has(value) || value === 'waiting' || value === 'queued' || !value) return 'waiting';
return 'running';
}
export function taskUiStatusLabel(status: TaskUiStatus, locale: AppLocale | string): string {
const labels: Record<TaskUiStatus, [string, string]> = {
done: ['已完成', 'Done'],
running: ['进行中', 'Running'],
waiting: ['等待中', 'Waiting'],
error: ['失败', 'Failed'],
cancelled: ['已取消', 'Cancelled'],
};
const label = labels[status];
return pickAppText(locale, label[0], label[1]);
}
export function taskUiStatusClass(status: TaskUiStatus): string {
if (status === 'done') return 'border-[#D6E2D5] bg-[#F4F8F3] text-[#557052]';
if (status === 'running') return 'border-[#E8D7B2] bg-[#FFF8EA] text-[#9B6B12]';
if (status === 'error') return 'border-[#E8C8C2] bg-[#FFF4F2] text-[#9D3D2F]';
if (status === 'cancelled') return 'border-[#DED9D5] bg-[#F4F2F0] text-[#756A64]';
return 'border-[#DDD8D4] bg-[#F8F6F4] text-[#746C67]';
}
function titleForTask(task: BackendTask): string {
return task.short_title || String(task.metadata?.short_title || '') || task.description || task.goal || task.task_id;
}
function summarizeTask(task: BackendTask): string {
return task.description || task.goal || String(task.metadata?.summary || '') || task.task_id;
}
function statusRank(status: TaskUiStatus): number {
if (status === 'error') return 5;
if (status === 'running') return 4;
if (status === 'waiting') return 3;
if (status === 'cancelled') return 2;
return 1;
}
function mergeStatus(current: TaskUiStatus, next: TaskUiStatus): TaskUiStatus {
return statusRank(next) > statusRank(current) ? next : current;
}
function mergeToolStatus(current: TaskUiStatus, next: TaskUiStatus): TaskUiStatus {
if (next === 'error' || current === 'error') return 'error';
if (next === 'done' || current === 'done') return 'done';
if (next === 'running' || current === 'running') return 'running';
if (next === 'cancelled' || current === 'cancelled') return 'cancelled';
return 'waiting';
}
function firstString(...values: unknown[]): string {
for (const value of values) {
if (typeof value === 'string' && value.trim()) {
return value.trim();
}
}
return '';
}
function stringList(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.map((item) => String(item || '').trim()).filter(Boolean);
}
function parseJsonObject(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'string') return null;
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record<string, unknown> : null;
} catch {
return null;
}
}
function countArray(value: unknown): number | undefined {
return Array.isArray(value) ? value.length : undefined;
}
function summarizeToolPayload(toolName: string, card: TaskTimelineCard): {
summary: string;
query?: string;
url?: string;
quality?: string;
resultCount?: number;
} {
const details = card.details ?? {};
const args = parseJsonObject(firstString(details.arguments));
const result = parseJsonObject(firstString(details.result_summary, card.summary));
const source = result ?? args ?? {};
const query = firstString(source.query, args?.query);
const url = firstString(source.url, args?.url);
const quality = firstString(source.quality);
const resultCount = countArray(source.results) ?? countArray(source.links);
if (toolName === 'web_search') {
return {
query,
quality,
resultCount,
summary: [
query ? `Query: ${query}` : 'Search query',
typeof resultCount === 'number' ? `${resultCount} result(s)` : '',
].filter(Boolean).join(' · '),
};
}
if (toolName === 'web_fetch') {
return {
url,
resultCount,
summary: [
firstString(source.title) || (url ? `Fetch ${url}` : 'Fetch web page'),
firstString(source.status_code) ? `HTTP ${String(source.status_code)}` : '',
typeof resultCount === 'number' ? `${resultCount} link(s)` : '',
].filter(Boolean).join(' · '),
};
}
return {
query,
url,
quality,
resultCount,
summary: firstString(card.summary, details.result_summary, card.title) || toolName,
};
}
function toolNameFromCard(card: TaskTimelineCard): string {
return firstString(
card.details?.tool_name,
card.details?.tool,
card.details?.name,
card.actorName,
card.title
);
}
function buildSkills(task: BackendTask, cards: TaskTimelineCard[], locale: AppLocale | string): TaskUiSkill[] {
const createdAt = task.created_at || task.updated_at;
const names = new Set(task.skill_names.filter(Boolean));
for (const card of cards) {
if (card.type !== 'skill') continue;
const detailNames = card.details?.skill_names;
if (Array.isArray(detailNames)) {
detailNames.forEach((item) => {
if (typeof item === 'string' && item.trim()) names.add(item.trim());
});
}
}
return Array.from(names).map((name, index) => {
const card = cards.find((item) => item.type === 'skill' && (item.title === name || item.summary?.includes(name)));
return {
id: `${name}:${index}`,
name,
summary: card?.summary || '',
status: normalizeStatus(card?.status || (task.status === 'running' || task.status === 'open' ? 'running' : 'done')),
createdAt: card?.createdAt || createdAt,
};
});
}
function toolAggregateStatus(toolCards: TaskTimelineCard[]): TaskUiStatus {
const calls = new Map<string, { started: boolean; finished: boolean; error: boolean }>();
for (const card of toolCards) {
const toolName = toolNameFromCard(card) || card.title;
const toolCallId = firstString(card.details?.tool_call_id);
const key = toolCallId ? `${card.runId || '-'}:${toolCallId}` : `${card.runId || '-'}:${toolName}:${card.id}`;
const item = calls.get(key) ?? { started: false, finished: false, error: false };
if (card.type === 'tool_call') item.started = true;
if (card.type === 'tool_result') item.finished = true;
if (normalizeStatus(card.status) === 'error') item.error = true;
calls.set(key, item);
}
const values = Array.from(calls.values());
if (values.some((item) => item.error)) return 'error';
if (values.length > 0 && values.every((item) => item.finished)) return 'done';
if (values.some((item) => item.started)) return 'running';
return 'waiting';
}
function toolActorNameForRun(run: ProcessRun | undefined): string {
if (!run) return 'Agent';
if (run.source === 'task_team' || run.metadata?.node_id) {
return run.actor_name || run.title || String(run.metadata?.node_id || 'Agent');
}
return 'Agent';
}
function buildTools(cards: TaskTimelineCard[], runs: ProcessRun[]): TaskUiToolCall[] {
const map = new Map<string, TaskUiToolCall>();
const runsById = new Map(runs.map((run) => [run.run_id, run]));
for (const card of cards) {
if (card.type !== 'tool_call' && card.type !== 'tool_result') continue;
const toolName = toolNameFromCard(card) || card.title;
const toolCallId = firstString(card.details?.tool_call_id);
const key = toolCallId ? `${card.runId || '-'}:${toolCallId}` : card.id;
const status = normalizeStatus(card.status || (card.type === 'tool_result' ? 'done' : 'running'));
const summary = summarizeToolPayload(toolName, card);
const existing = map.get(key);
if (existing) {
const finishedAt = card.type === 'tool_result' ? card.createdAt : existing.finishedAt;
map.set(key, {
...existing,
summary: summary.summary || existing.summary,
status: mergeToolStatus(existing.status, status),
finishedAt,
durationMs: calculateDurationMs(existing.createdAt, finishedAt),
query: summary.query || existing.query,
url: summary.url || existing.url,
quality: summary.quality || existing.quality,
resultCount: summary.resultCount ?? existing.resultCount,
});
continue;
}
map.set(key, {
id: key,
runId: card.runId,
toolCallId,
toolName,
actorName: toolActorNameForRun(card.runId ? runsById.get(card.runId) : undefined),
summary: summary.summary || card.title,
status,
createdAt: card.createdAt,
finishedAt: card.type === 'tool_result' ? card.createdAt : undefined,
durationMs: calculateDurationMs(card.createdAt, card.type === 'tool_result' ? card.createdAt : undefined),
query: summary.query,
url: summary.url,
quality: summary.quality,
resultCount: summary.resultCount,
});
}
return Array.from(map.values()).sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
}
function calculateDurationMs(start?: string | null, end?: string | null): number | null {
if (!start || !end) return null;
const startMs = new Date(start).getTime();
const endMs = new Date(end).getTime();
if (Number.isNaN(startMs) || Number.isNaN(endMs) || endMs < startMs) return null;
return endMs - startMs;
}
function attemptIndexForRun(run: ProcessRun, fallback: number): number {
const metadataIndex = run.metadata?.attempt_index;
if (typeof metadataIndex === 'number' && Number.isFinite(metadataIndex)) return metadataIndex;
if (typeof metadataIndex === 'string' && /^\d+$/.test(metadataIndex)) return Number(metadataIndex);
const match = run.run_id.match(/:attempt:(\d+)/);
return match ? Number(match[1]) : fallback;
}
function runStartedAt(run: ProcessRun): string {
return run.started_at || run.finished_at || '';
}
function runFinishedAt(run: ProcessRun): string | null | undefined {
return run.finished_at;
}
function maxTime(values: Array<string | null | undefined>): string | null {
let selected: string | null = null;
let selectedMs = -Infinity;
for (const value of values) {
if (!value) continue;
const time = new Date(value).getTime();
if (!Number.isNaN(time) && time > selectedMs) {
selected = value;
selectedMs = time;
}
}
return selected;
}
function attemptStatus(runs: ProcessRun[], planner?: ProcessRun): TaskUiStatus {
const statuses = [...runs.map((run) => normalizeStatus(run.status)), normalizeStatus(planner?.status)];
if (statuses.some((status) => status === 'running')) return 'running';
if (statuses.some((status) => status === 'error')) return 'error';
if (statuses.some((status) => status === 'cancelled')) return 'cancelled';
if (statuses.some((status) => status === 'done')) return 'done';
return 'waiting';
}
function collectChildRunIds(rootRunId: string, runs: ProcessRun[]): Set<string> {
const ids = new Set([rootRunId]);
let changed = true;
while (changed) {
changed = false;
for (const run of runs) {
if (run.parent_run_id && ids.has(run.parent_run_id) && !ids.has(run.run_id)) {
ids.add(run.run_id);
changed = true;
}
}
}
return ids;
}
function buildAttempts(
process: SessionProcessProjection,
cards: TaskTimelineCard[],
tools: TaskUiToolCall[],
locale: AppLocale | string,
): TaskUiAttempt[] {
const runs = process.runs ?? [];
const plannerRuns = runs
.filter((run) => run.source === 'task_mode' || /:attempt:\d+/.test(run.run_id))
.sort((a, b) => new Date(runStartedAt(a)).getTime() - new Date(runStartedAt(b)).getTime());
const attemptRuns = plannerRuns.length > 0 ? plannerRuns : [];
const attempts = attemptRuns.map((planner, index) => {
const runIds = collectChildRunIds(planner.run_id, runs);
const groupedRuns = runs
.filter((run) => runIds.has(run.run_id))
.sort((a, b) => new Date(runStartedAt(a)).getTime() - new Date(runStartedAt(b)).getTime());
const groupedTools = tools.filter((tool) => tool.runId && runIds.has(tool.runId));
const resultCard = [...cards].reverse().find((card) => card.type === 'result' && card.runId && runIds.has(card.runId));
const attemptIndex = attemptIndexForRun(planner, index + 1);
return {
id: planner.run_id,
index: attemptIndex,
title: pickAppText(locale, `${attemptIndex} 次执行`, `Attempt ${attemptIndex}`),
status: attemptStatus(groupedRuns, planner),
startedAt: runStartedAt(planner),
finishedAt: maxTime(groupedRuns.map(runFinishedAt)) ?? planner.finished_at,
runs: groupedRuns.map((run) => ({
runId: run.run_id,
title: run.title || run.actor_name || run.actor_id,
actorName: run.actor_name || run.actor_id,
source: run.source,
status: normalizeStatus(run.status),
startedAt: runStartedAt(run),
finishedAt: run.finished_at,
})),
tools: groupedTools,
result: resultCard
? {
title: resultCard.title,
summary: resultCard.summary || '',
status: normalizeStatus(resultCard.status),
createdAt: resultCard.createdAt,
}
: undefined,
};
});
if (attempts.length > 0) return attempts;
return [
{
id: 'single-run',
index: 1,
title: pickAppText(locale, '本次执行', 'Current run'),
status: tools.some((tool) => tool.status === 'running') ? 'running' : tools.length > 0 ? 'done' : 'waiting',
startedAt: cards[0]?.createdAt || '',
runs: runs.map((run) => ({
runId: run.run_id,
title: run.title || run.actor_name || run.actor_id,
actorName: run.actor_name || run.actor_id,
source: run.source,
status: normalizeStatus(run.status),
startedAt: runStartedAt(run),
finishedAt: run.finished_at,
})),
tools,
result: undefined,
},
];
}
function progressForStatus(status: TaskUiStatus): number {
if (status === 'done') return 100;
if (status === 'running') return 58;
if (status === 'error' || status === 'cancelled') return 100;
return 12;
}
function buildAgentTree(runs: ProcessRun[]): TaskUiAgentNode[] {
const nodes = new Map<string, TaskUiAgentNode>();
const teamRuns = runs.filter((run) => run.source === 'task_team' || Boolean(run.metadata?.node_id));
for (const run of teamRuns) {
const status = normalizeStatus(run.status);
nodes.set(run.run_id, {
runId: run.run_id,
parentRunId: run.parent_run_id ?? null,
name: run.actor_name || run.actor_id || run.title,
title: run.title || run.actor_name || run.actor_id,
summary: run.summary || String(run.metadata?.summary || ''),
status,
progress: progressForStatus(status),
children: [],
});
}
const roots: TaskUiAgentNode[] = [];
for (const node of Array.from(nodes.values())) {
if (node.parentRunId && nodes.has(node.parentRunId)) {
nodes.get(node.parentRunId)!.children.push(node);
} else {
roots.push(node);
}
}
return roots;
}
function buildTeam(cards: TaskTimelineCard[]): TaskUiModel['team'] {
const teamCard = [...cards].reverse().find((card) => card.type === 'agent_team');
if (!teamCard) {
return {
hasTeam: false,
status: 'waiting',
outcome: 'single',
nodeIds: [],
incompleteNodeIds: [],
summary: '',
};
}
const nodeIds = stringList(teamCard.details?.node_ids);
const incompleteNodeIds = stringList(teamCard.details?.incomplete_node_ids);
const outcome = firstString(teamCard.details?.task_outcome) || (teamCard.status === 'error' ? 'incomplete' : 'complete');
return {
hasTeam: true,
status: normalizeStatus(teamCard.status),
outcome,
nodeIds,
incompleteNodeIds,
summary: teamCard.summary || (outcome === 'complete' ? 'Agent Team completed' : 'Team 执行未完成 / 子节点失败'),
};
}
function buildArtifacts(process: SessionProcessProjection): TaskUiArtifact[] {
return process.artifacts.map((artifact) => ({
id: artifact.artifact_id,
runId: artifact.run_id,
actorName: artifact.actor_name,
title: artifact.title || artifact.artifact_id,
type: artifact.artifact_type,
summary: firstString(artifact.metadata?.summary, artifact.content, artifact.url) || artifact.artifact_type,
createdAt: artifact.created_at,
fileId: artifact.file_id,
url: artifact.url,
status: normalizeStatus(firstString(artifact.metadata?.status, artifact.metadata?.state) || 'done'),
sizeLabel: firstString(artifact.metadata?.size_label, artifact.metadata?.size, artifact.metadata?.file_size),
}));
}
function stepKind(card: TaskTimelineCard): TaskUiStep['kind'] {
if (card.type === 'skill') return 'skill';
if (card.type === 'tool_call' || card.type === 'tool_result') return 'tool';
if (card.type === 'agent_team' || card.type === 'agent_progress' || card.type === 'agent_handoff') return 'agent';
if (card.type === 'artifact') return 'artifact';
if (card.type === 'result' || card.type === 'acceptance') return 'result';
return 'task';
}
function buildSteps(task: BackendTask, cards: TaskTimelineCard[], locale: AppLocale | string): TaskUiStep[] {
const taskStep: TaskUiStep = {
id: `summary:${task.task_id}`,
title: titleForTask(task),
summary: summarizeTask(task),
status: normalizeStatus(task.status),
createdAt: task.created_at,
kind: 'task',
};
const skillCard = cards.find((card) => card.type === 'skill');
const teamCard = cards.find((card) => card.type === 'agent_team');
const resultCard = [...cards].reverse().find((card) => card.type === 'result');
const toolCards = cards.filter((card) => card.type === 'tool_call' || card.type === 'tool_result');
const toolNames = new Set(toolCards.map(toolNameFromCard).filter(Boolean));
const cardSteps: Array<TaskUiStep | null> = [
skillCard
? {
id: `${skillCard.id}:step`,
title: pickAppText(locale, '选择 Skill', 'Skill selected'),
summary: skillCard.summary || '',
status: normalizeStatus(skillCard.status),
createdAt: skillCard.createdAt,
kind: 'skill' as const,
}
: null,
toolCards.length
? {
id: `tools:${task.task_id}`,
title: pickAppText(locale, '调用工具', 'Tool calls'),
summary: `${toolCards.filter((card) => card.type === 'tool_result').length || toolCards.length} calls · ${Array.from(toolNames).slice(0, 3).join(', ')}`,
status: toolAggregateStatus(toolCards),
createdAt: toolCards[0].createdAt,
kind: 'tool' as const,
}
: null,
teamCard
? {
id: `${teamCard.id}:step`,
title: pickAppText(locale, 'Agent Team 执行', 'Agent Team execution'),
summary: teamCard.summary || '',
status: normalizeStatus(teamCard.status),
createdAt: teamCard.createdAt,
kind: 'agent' as const,
}
: null,
resultCard
? {
id: `${resultCard.id}:step`,
title: pickAppText(locale, '生成结果', 'Result ready'),
summary: resultCard.summary || '',
status: normalizeStatus(resultCard.status),
createdAt: resultCard.createdAt,
kind: 'result' as const,
}
: null,
];
return [taskStep, ...cardSteps.filter((step): step is TaskUiStep => step !== null)];
}
function buildResult(task: BackendTask, cards: TaskTimelineCard[], locale: AppLocale | string): TaskUiModel['result'] {
const resultCard = [...cards].reverse().find((card) => card.type === 'result');
const summary = resultCard?.summary || firstString(task.metadata?.result_summary, task.close_reason);
const bullets = summary
.replace(/[。.!?]\s+/g, '\n')
.split(/\n+/)
.map((item) => item.trim())
.filter(Boolean)
.slice(0, 3);
return {
status: normalizeStatus(resultCard?.status || task.status),
title: resultCard?.title || pickAppText(locale, '本轮结果', 'Current result'),
summary: summary || '',
bullets,
};
}
export function buildTaskUiModel({
task,
process,
cards,
locale,
}: {
task: BackendTask;
process: SessionProcessProjection;
cards: TaskTimelineCard[];
locale: AppLocale | string;
}): TaskUiModel {
const steps = buildSteps(task, cards, locale);
const team = buildTeam(cards);
const tools = buildTools(cards, process.runs);
return {
executionMode: team.hasTeam ? 'team' : 'single',
team,
summary: steps[0],
skills: buildSkills(task, cards, locale),
tools,
attempts: buildAttempts(process, cards, tools, locale),
agentTree: buildAgentTree(process.runs),
artifacts: buildArtifacts(process),
steps,
result: buildResult(task, cards, locale),
};
}