Files
pikasTech-agentrun/src/mgr/result.ts
T
Lyon 17918cda0a Merge pull request #116 from pikasTech/fix/issue-113-command-result-summary
修复 command-result 长 trace 摘要过期
2026-06-09 21:43:48 +08:00

301 lines
13 KiB
TypeScript

import type { AgentRunStore } from "./store.js";
import type { CommandRecord, JsonRecord, JsonValue, RunEvent, RunRecord, RunnerJobRecord, TerminalStatus } from "../common/types.js";
import { boundedTextSummary, outputBytesFromPayload, outputTruncatedFromPayload } from "../common/output.js";
const maxToolCallSummaryItems = 40;
const toolCallCommandLimitChars = 600;
const toolCallFieldLimitChars = 200;
const RESULT_EVENT_PAGE_LIMIT = 500;
const RESULT_EVENT_MAX_PAGES = 200;
interface ResultEventPage {
events: RunEvent[];
capped: boolean;
nextAfterSeq: number | null;
}
interface AssistantReplySummary {
text: string;
seq: number | null;
source: string | null;
final: boolean;
replyAuthority: boolean;
textTruncated: boolean;
outputTruncated: boolean;
}
export async function buildRunResult(store: AgentRunStore, runId: string, commandId?: string): Promise<JsonRecord> {
const run = await store.getRun(runId);
const command = await selectCommand(store, runId, commandId);
const eventPage = await listResultEvents(store, runId);
const events = eventPage.events;
const scopedEvents = command ? eventsForCommand(events, command.id) : events;
const jobs = await store.listRunnerJobs(runId, command?.id);
const latestJob = jobs.at(-1) ?? null;
const commandTerminal = command ? terminalFromCommand(command) : null;
const terminalEventStatus = terminalFromEvents(scopedEvents);
const terminal = commandTerminal ?? terminalEventStatus ?? run.terminalStatus;
const terminalSource = commandTerminal ? "command-record" : terminalEventStatus ? "terminal_status-event" : run.terminalStatus ? "run-record" : "none";
const failureKind = command ? failureKindFromEvents(scopedEvents) : run.failureKind ?? failureKindFromEvents(scopedEvents);
const failureMessage = command ? messageFromEvents(scopedEvents) : run.failureMessage ?? messageFromEvents(scopedEvents);
const reply = assistantReply(scopedEvents);
const blocker = terminal === "blocked" || terminal === "failed" ? { failureKind, message: failureMessage } : null;
return {
runId: run.id,
commandId: command?.id ?? commandId ?? null,
attemptId: latestJob?.attemptId ?? attemptFromEvents(events),
runnerId: latestJob?.runnerId ?? null,
jobName: latestJob?.jobName ?? null,
namespace: latestJob?.namespace ?? null,
status: command?.state ?? run.status,
runStatus: run.status,
commandState: command?.state ?? null,
terminalStatus: terminal,
terminalSource,
completed: terminal === "completed",
reply: reply.text,
finalResponse: {
text: reply.text,
seq: reply.seq,
source: reply.source,
final: reply.final,
replyAuthority: reply.replyAuthority,
textTruncated: reply.textTruncated,
outputTruncated: reply.outputTruncated,
},
finalAssistantSeq: reply.seq,
finalAssistantSource: reply.source,
finalAssistantTextTruncated: reply.textTruncated,
finalAssistantOutputTruncated: reply.outputTruncated,
failureKind,
failureMessage,
blocker,
lastSeq: events.at(-1)?.seq ?? 0,
eventCount: events.length,
eventsCapped: eventPage.capped,
nextAfterSeq: eventPage.nextAfterSeq,
scopedEventCount: scopedEvents.length,
scopedLastSeq: scopedEvents.at(-1)?.seq ?? 0,
artifactSummary: artifactSummary(scopedEvents),
toolCallSummary: toolCallSummary(scopedEvents),
sessionRef: sessionSummary(run),
resourceBundleRef: resourceBundleSummary(run, events),
runnerJobCount: jobs.length,
};
}
async function listResultEvents(store: AgentRunStore, runId: string): Promise<ResultEventPage> {
const events: RunEvent[] = [];
let afterSeq = 0;
for (let pageIndex = 0; pageIndex < RESULT_EVENT_MAX_PAGES; pageIndex += 1) {
const page = await store.listEvents(runId, afterSeq, RESULT_EVENT_PAGE_LIMIT);
if (page.length === 0) return { events, capped: false, nextAfterSeq: null };
events.push(...page);
const nextAfterSeq = page.at(-1)?.seq ?? afterSeq;
if (nextAfterSeq <= afterSeq) return { events, capped: true, nextAfterSeq: afterSeq };
afterSeq = nextAfterSeq;
if (page.length < RESULT_EVENT_PAGE_LIMIT) return { events, capped: false, nextAfterSeq: null };
}
return { events, capped: true, nextAfterSeq: events.at(-1)?.seq ?? afterSeq };
}
async function selectCommand(store: AgentRunStore, runId: string, commandId?: string): Promise<CommandRecord | null> {
if (commandId) {
const command = await store.getCommand(commandId);
return command.runId === runId ? command : null;
}
const commands = await store.listCommands(runId, 0, 100);
return commands.at(-1) ?? null;
}
function terminalFromEvents(events: RunEvent[]): TerminalStatus | null {
for (const event of [...events].reverse()) {
if (event.type !== "terminal_status") continue;
const value = event.payload.terminalStatus;
if (value === "completed" || value === "failed" || value === "blocked" || value === "cancelled") return value;
}
return null;
}
function terminalFromCommand(command: CommandRecord): TerminalStatus | null {
if (command.state === "completed") return "completed";
if (command.state === "failed") return "failed";
if (command.state === "cancelled") return "cancelled";
return null;
}
function eventsForCommand(events: RunEvent[], commandId: string): RunEvent[] {
const scoped = events.filter((event) => event.payload.commandId === commandId || typeof event.payload.commandId !== "string");
return scoped.length > 0 ? scoped : events;
}
function failureKindFromEvents(events: RunEvent[]): string | null {
for (const event of [...events].reverse()) {
const value = event.payload.failureKind;
if (typeof value === "string") return value;
}
return null;
}
function messageFromEvents(events: RunEvent[]): string | null {
for (const event of [...events].reverse()) {
const value = event.payload.message;
if (typeof value === "string" && value.length > 0) return value;
}
return null;
}
function assistantReply(events: RunEvent[]): AssistantReplySummary {
const assistantEvents = events.filter((event) => event.type === "assistant_message");
const latestAuthoritative = [...assistantEvents].reverse().find((event) => (event.payload.replyAuthority === true || event.payload.final === true) && textPayload(event.payload).length > 0);
if (latestAuthoritative) return assistantReplySummary(latestAuthoritative);
const latestAssistant = [...assistantEvents].reverse().find((event) => textPayload(event.payload).length > 0);
if (latestAssistant) return assistantReplySummary(latestAssistant);
return { text: "", seq: null, source: null, final: false, replyAuthority: false, textTruncated: false, outputTruncated: false };
}
function assistantReplySummary(event: RunEvent): AssistantReplySummary {
return {
text: textPayload(event.payload),
seq: event.seq,
source: typeof event.payload.source === "string" ? event.payload.source : null,
final: event.payload.final === true,
replyAuthority: event.payload.replyAuthority === true,
textTruncated: event.payload.textTruncated === true,
outputTruncated: event.payload.outputTruncated === true,
};
}
function textPayload(payload: JsonRecord): string {
for (const key of ["text", "content", "delta"]) {
const value = payload[key];
if (typeof value === "string") return value;
}
return "";
}
function artifactSummary(events: RunEvent[]): JsonRecord {
let commandOutputEvents = 0;
let diffEvents = 0;
let toolCallEvents = 0;
let outputChars = 0;
let outputBytes = 0;
let outputTruncatedEvents = 0;
const streamSummary: Record<string, { events: number; outputBytes: number; outputTruncated: boolean }> = {
stdout: { events: 0, outputBytes: 0, outputTruncated: false },
stderr: { events: 0, outputBytes: 0, outputTruncated: false },
};
for (const event of events) {
if (event.type === "command_output") {
commandOutputEvents += 1;
const text = textPayload(event.payload);
outputChars += text.length;
const bytes = outputBytesFromPayload(event.payload);
outputBytes += bytes;
const truncated = outputTruncatedFromPayload(event.payload);
if (truncated) outputTruncatedEvents += 1;
const stream = event.payload.stream === "stderr" ? "stderr" : "stdout";
streamSummary[stream].events += 1;
streamSummary[stream].outputBytes += bytes;
streamSummary[stream].outputTruncated ||= truncated;
}
if (event.type === "diff") diffEvents += 1;
if (event.type === "tool_call") toolCallEvents += 1;
}
return { commandOutputEvents, diffEvents, toolCallEvents, outputChars, outputBytes, truncatedEvents: outputTruncatedEvents, outputTruncatedEvents, stdoutSummary: streamSummary.stdout, stderrSummary: streamSummary.stderr };
}
function toolCallSummary(events: RunEvent[]): JsonRecord {
const toolCallEvents = events.filter((event) => event.type === "tool_call");
const statusCounts: Record<string, number> = {};
const exitCodeCounts: Record<string, number> = {};
for (const event of toolCallEvents) {
const status = typeof event.payload.status === "string" && event.payload.status.length > 0 ? event.payload.status : "unknown";
statusCounts[status] = (statusCounts[status] ?? 0) + 1;
const exitCode = normalizedExitCode(event.payload.exitCode);
if (exitCode !== null) exitCodeCounts[String(exitCode)] = (exitCodeCounts[String(exitCode)] ?? 0) + 1;
}
const window = toolCallEvents.slice(-maxToolCallSummaryItems);
return {
count: toolCallEvents.length,
statusCounts,
exitCodeCounts,
items: window.map((event) => toolCallItemSummary(event)),
itemsOmitted: Math.max(0, toolCallEvents.length - window.length),
itemWindow: "latest",
valuesPrinted: false,
};
}
function toolCallItemSummary(event: RunEvent): JsonRecord {
const payload = event.payload;
return {
seq: event.seq,
createdAt: event.createdAt,
method: boundedOptionalString(payload.method, toolCallFieldLimitChars),
toolName: boundedOptionalString(payload.toolName, toolCallFieldLimitChars),
type: boundedOptionalString(payload.type, toolCallFieldLimitChars),
itemId: boundedOptionalString(payload.itemId, toolCallFieldLimitChars),
status: boundedOptionalString(payload.status, toolCallFieldLimitChars),
exitCode: normalizedExitCode(payload.exitCode),
processId: boundedOptionalString(payload.processId, toolCallFieldLimitChars),
cwd: boundedOptionalString(payload.cwd, toolCallFieldLimitChars),
command: boundedOptionalString(payload.command, toolCallCommandLimitChars),
commandTruncated: optionalStringTruncated(payload.command, toolCallCommandLimitChars),
valuesPrinted: false,
};
}
function boundedOptionalString(value: JsonValue | undefined, limitChars: number): string | null {
if (typeof value !== "string") return null;
return boundedTextSummary(value, { limitChars }).text as string;
}
function optionalStringTruncated(value: JsonValue | undefined, limitChars: number): boolean {
if (typeof value !== "string") return false;
return boundedTextSummary(value, { limitChars }).outputTruncated === true;
}
function normalizedExitCode(value: JsonValue | undefined): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function attemptFromEvents(events: RunEvent[]): string | null {
for (const event of [...events].reverse()) {
const value = event.payload.attemptId;
if (typeof value === "string") return value;
}
return null;
}
function sessionSummary(run: RunRecord): JsonRecord | null {
if (!run.sessionRef) return null;
return {
sessionId: run.sessionRef.sessionId,
conversationId: run.sessionRef.conversationId ?? null,
threadId: run.sessionRef.threadId ?? null,
expiresAt: run.sessionRef.expiresAt ?? null,
metadataKeys: Object.keys(run.sessionRef.metadata ?? {}).sort(),
valuesPrinted: false,
};
}
function resourceBundleSummary(run: RunRecord, events: RunEvent[]): JsonRecord | null {
if (!run.resourceBundleRef) return null;
const materialized = events.find((event) => event.type === "backend_status" && event.payload.phase === "resource-bundle-materialized")?.payload ?? null;
return {
kind: run.resourceBundleRef.kind,
repoUrl: run.resourceBundleRef.repoUrl,
commitId: run.resourceBundleRef.commitId ?? null,
ref: run.resourceBundleRef.ref ?? null,
bundles: {
count: run.resourceBundleRef.bundles.length,
items: run.resourceBundleRef.bundles.map((item) => ({ name: item.name ?? null, repoUrl: item.repoUrl ?? run.resourceBundleRef?.repoUrl ?? null, commitId: item.commitId ?? run.resourceBundleRef?.commitId ?? null, ref: item.ref ?? run.resourceBundleRef?.ref ?? null, subpath: item.subpath, targetPath: item.targetPath, valuesPrinted: false })),
valuesPrinted: false,
},
promptRefs: run.resourceBundleRef.promptRefs ? { count: run.resourceBundleRef.promptRefs.length, names: run.resourceBundleRef.promptRefs.map((item) => item.name), required: run.resourceBundleRef.promptRefs.filter((item) => item.required === true).map((item) => item.name), valuesPrinted: false } : { count: 0, names: [], required: [], valuesPrinted: false },
materialized: materialized as JsonValue,
};
}